More Effective c++ 总结

 基础议题

条款1:仔细区别pointers和references

指针和引用都是一种间接访问对象的方式,但它们有一些重要的区别:

  • 指针可以为空,表示不指向任何对象,而引用必须总是绑定到一个对象,不能为空。
  • 指针可以被重新赋值,指向不同的对象,而引用一旦绑定到一个对象,就不能再改变。
  • 指针需要用*运算符来解引用,才能访问所指对象的值,而引用可以直接访问绑定对象的值。
  • 指针有自己的地址和大小,而引用不占用内存空间,也没有自己的地址。

作者给出了一些选择指针或引用的原则:

  • 如果你有一个变量,其目的是用来指向(代表)另一个对象,但是也有可能它不指向(代表)任何对象,那么你应该使用指针,因为你可以将指针设为null。
  • 如果你确定这个变量总是会代表某个对象,而且一旦代表了该对象就不能够再改变,那么你应该选用引用。
  • 如果你需要考虑“不指向任何对象”的可能性,或是考虑“在不同时间指向不同对象”的能力,你就应该采用指针。
  • 当你实现一个操作符而其语法需求无法由指针达成,你就应该选择引用。例如,重载[]或->操作符时,必须返回一个引用,而不能返回一个指针。

条款2:最好使用c++转型操作符

        转型操作符是一种用于在不同类型之间进行转换的运算符,例如将一个int转换为一个double,或者将一个指向基类对象的指针转换为指向派生类对象的指针。C语言中有一种旧式的转型方式,就是在要转换的表达式前加上目标类型,用括号括起来,例如:

int x = 1;
double y = (double) x; // C旧式转型

这种转型方式有两个缺点:

  • 它几乎允许你将任何类型转换为任何其他类型,即使这样的转换是不合法或不安全的,例如将一个结构体转换为一个整数,或者将一个常量指针转换为一个非常量指针。
  • 它很难识别,因为它和普通的函数调用很相似,而且没有明确的标识,例如关键字或运算符。

为了解决这些问题,C++提供了四种新的转型操作符,分别是:

static_cast、const_cast、dynamic_cast、reinterpret_cast

条款3:绝对不要以多态(polymorphically)方式处理数组

        多态是指通过基类的指针或引用来操作派生类的对象,从而实现不同类型的对象有不同的行为。多态是C++的一个重要特性,但是它和数组是不兼容的。

        原因是,数组对象几乎总是会涉及到指针的算术运算,而指针的算术运算是根据指针所指对象的大小来进行的。如果指针指向的是基类对象,那么它的大小就是基类对象的大小;如果指针指向的是派生类对象,那么它的大小就是派生类对象的大小。如果基类和派生类的对象大小不同,那么指针的算术运算就会出错,导致访问到错误的内存地址,或者调用到错误的析构函数,或者释放到错误的内存空间。

        解决方法是,避免将指向派生类对象数组的指针转换为指向基类对象数组的指针,或者避免用new运算符动态分配对象数组,而是使用标准库容器,如vector或array,来管理对象数组的内存分配和释放。

条款4:非必要不提供default constructor

为了避免一些类的对象在没有外来信息的情况下无法正确初始化,或者为了提高类的效率,避免不必要的测试和处理。这个条款的主要观点有以下几点:

但是这些限制也可以通过一些方法来解决,例如使用指针数组,或者使用内存池和placement new等技术。这些方法的原理是:

  • default constructor是指在没有任何参数的情况下将对象初始化的构造函数。有些类的数据成员需要在创建时就指定其值,否则会导致对象的不完整或不一致。如果这样的类提供了default constructor,那么就需要在使用对象时检查数据成员是否被正确赋值,这会增加时间和空间的开销,以及可能的错误处理。
  • 如果一个类需要提供default constructor,那么最好将其声明为explicit,以避免编译器因隐式类型转换的需要而调用它。这样可以防止一些意料之外的转换发生,提高代码的可读性和安全性。
  • 创建一个类的数组时,编译器会为每个数组元素调用default constructor,以初始化它们的值。如果一个类没有default constructor,那么编译器就无法创建这样的数组,会报错。
  • 使用一些要求default constructor的模板类时,例如std::vectorstd::map等,编译器会在一些情况下调用default constructor,例如在分配内存时,或者在使用resize()方法时。如果一个类没有default constructor,那么编译器就无法使用这些模板类,会报错。
  • 使用指针数组时,不需要为每个数组元素调用default constructor,只需要为每个指针分配内存,然后再用指针指向已经初始化好的对象。这样就可以避免调用default constructor的问题,但是需要注意内存管理的问题,避免内存泄漏或野指针。
    Class **a=new Class*[10];
    for(int i=0;i!=10;i++)
        a[i]=new Class(值);
    for(int i=0;i!=10;i++)
        delete a[i];
    delete []a;
  • 使用内存池和placement new时,可以先为一组对象分配一块连续的内存,然后再用placement new在这块内存上调用有参数的构造函数,以初始化对象。这样就可以避免调用default constructor的问题,但是需要注意内存对齐的问题,以及在销毁对象时调用析构函数的问题。

操作符

条款5:对定制的“类型转换函数”保持警觉

        该条款讲述了用户自定义的类型转换函数(user-defined conversion functions)的优缺点,以及如何避免它们带来的潜在问题。

  • 类型转换函数是一种特殊的成员函数,它可以让一个类的对象被隐式地转换为另一种类型的值。例如,如果一个类有一个类型转换函数,它可以让该类的对象被当作一个整数使用。
  • 类型转换函数的优点是,它可以提供方便的语法,让不同类型的对象可以互相操作。例如,如果一个类有一个类型转换函数,它可以让该类的对象和一个整数相加,而不需要显式地调用一个函数或者使用一个转换操作符。
  • 类型转换函数的缺点是,它可能导致意料之外的转换发生,从而引起逻辑错误或者效率损失。例如,如果一个类有一个类型转换函数,它可能让该类的对象被当作一个布尔值使用,而这可能不是用户的本意,或者会造成额外的开销。
    class Base
    {
    public:
        Base(int s):s(s){};
        operator int()const { return s; }
        int s;
    };
    Base b(10);
    cout<<b; //b会发生隐式转换输出10
  • 为了避免类型转换函数的缺点,作者建议以下几点:
    • 尽量不要使用类型转换函数,除非它们能提供明显的好处,而且不会引起歧义或者低效。
    • 如果必须使用类型转换函数,尽量让它们只能被显式地调用,而不是隐式地调用。这可以通过在类型转换函数前加上关键字 explicit 来实现。
    • 如果必须使用隐式的类型转换函数,尽量让它们只能转换为一个类型,而不是多个类型。这可以通过在类型转换函数前加上关键字 operator 来实现。
    • 如果必须使用隐式的类型转换函数,尽量让它们只能转换为一个内置类型,而不是一个用户自定义类型。这可以避免类型转换函数之间的相互调用,从而造成复杂的转换链。

条款6:区别increment / decrement操作符的前置(prefix)和后置(postfix)形式

        该条款讲述了如何区别和重载前置(prefix)和后置(postfix)形式的自增(increment)和自减(decrement)操作符,以及它们的优缺点和注意点。

  • 前置形式的操作符是指在操作数之前加上++或–,例如++x或–y。后置形式的操作符是指在操作数之后加上++或–,例如x++或y–。
  • 前置形式的操作符的返回类型是类的引用,而后置形式的操作符的返回类型是类的常量对象。这是为了保持与内置类型的行为一致,以及避免一些逻辑错误或效率损失。
    重载前置形式的操作符没有形参,而重载后置形式的操作符有一个int形参,但是该形参并没有被使用。这是为了绕过语法的限制,因为重载函数必须拥有不同的函数原型,而返回类型不属于函数原型。所以,为了区分前置和后置形式的操作符,就给后置形式的操作符增加了一个int形参。
    Class T
    {
    public:
        T& operator++ () //前置++
        {
            *this+=1;
            return *this;
        }
        const T operator++ (int) //后置++
        {
            T old=*this;
            ++(*this);
            return old;
        }
        T& operator--(); //前置--
        const T operator--(int); //后置--
    };
  • 重载前置形式的操作符的实现比较简单,只需要对对象的数据成员进行自增或自减,然后返回对象的引用即可。重载后置形式的操作符的实现稍微复杂一些,需要先将对象拷贝一份,然后对原对象进行自增或自减,最后返回拷贝的对象。这样做的目的是为了返回自增或自减之前的对象,以符合后置形式的操作符的语义。
  • 如果不需要返回自增或自减之前的值,那么前置和后置形式的操作符的计算效果都一样。但是,我们仍然应该优先使用前置形式的操作符,尤其是对于用户自定义类型的自增或自减操作。前置形式的操作符的效率更高,因为后置形式的操作符会生成临时对象,造成一次构造函数和一次析构函数的额外开销。虽然编译器在某些情况下可以优化掉这些开销,但是我们最好不要依赖编译器的行为。

条款7:千万不要重载&&,||和,操作符

该条款讲述了为什么不要重载&&,||和逗号操作符,以及它们可能带来的问题和风险。

  • &&和||是逻辑运算符,它们具有短路特性,即如果第一个操作数已经能够确定表达式的结果,就不会再计算第二个操作数。这样可以提高效率,也可以避免一些副作用。但是,如果重载了&&和||,那么它们就会变成普通的函数调用,就会失去短路特性,而且会按照函数参数的计算顺序来计算操作数,这可能与用户的预期不符,导致逻辑错误或者效率损失。
  • 逗号是顺序运算符,它的作用是按照从左到右的顺序计算操作数,并返回最右边的操作数的值。例如,a = (b = 3, b + 2)就相当于b = 3; a = b + 2;。但是,如果重载了逗号,那么它就会变成普通的函数调用,就会失去顺序特性,而且会按照函数参数的计算顺序来计算操作数,这可能与用户的预期不符,导致逻辑错误或者效率损失。
  • 该条款的建议是,除非有非常强烈的理由,否则不要重载&&,||和逗号操作符,因为它们会改变语言的基本语义,造成混淆和错误。如果真的需要重载它们,那么应该遵循以下几点:
    • 尽量保持与内置类型的行为一致,不要改变短路或顺序特性。
    • 尽量避免使用有副作用的表达式作为操作数,因为副作用的发生时机可能不确定。
    • 尽量在文档中清楚地说明重载操作符的行为和用法,以便用户了解其特点和风险。

条款8:了解各种不同意义的new和delete

条款8的主要内容是:了解各种不同意义的new和delete。这个条款解释了C++中有四种不同的new和delete,它们分别是:

  • new operator和delete operator:这是语言内置的操作符,用来创建和销毁对象。它们的行为是先调用operator new或operator delete函数来分配或释放原始内存,然后调用对象的构造函数或析构函数来初始化或清理内存。这些操作符不能被重载,但是可以使用定位new或定位delete来指定对象的内存位置。
  • operator new和operator delete:这是全局的函数,用来分配或释放原始内存。它们的行为类似于C语言中的malloc和free函数,但是可以被重载,以实现自定义的内存分配策略。重载这些函数时要注意保持一致性,避免内存泄漏或重复释放。
  • new表达式和delete表达式:这是由编译器生成的代码,用来调用new operator或delete operator,并传递相应的参数。它们的行为是根据对象的类型和数量来确定要分配或释放的内存大小,以及要调用的构造函数或析构函数。这些表达式不能被重载,但是可以使用explicit关键字来防止隐式类型转换。
  • new[]和delete[]:这是一种特殊的new表达式和delete表达式,用来创建和销毁数组对象。它们的行为是在分配或释放内存时,额外记录数组的元素个数,以便在调用构造函数或析构函数时,正确地遍历数组。这些表达式不能被重载,也不能使用定位new或定位delete。

了解这些不同的new和delete的意义和用法,可以帮助我们更好地管理内存,避免内存错误,提高程序的效率和可维护性。

异常

条款9:利用destructors避免泄露资源

        利用析构函数避免资源泄漏。资源泄漏是指在程序运行过程中,动态分配的内存或其他资源没有被及时释放,导致系统资源的浪费或不足。为了防止资源泄漏,一种有效的方法是将资源的管理封装在类的对象中,利用对象的生命周期来控制资源的释放。当对象被创建时,它可以分配资源,并在其析构函数中释放资源。这样,当对象超出作用域或被删除时,它所管理的资源也会自动被释放,无需手动操作。这种技术称为RAII(Resource Acquisition Is Initialization),即资源获取即初始化。

        下面的代码中,res是一个unique_ptr,它指向一个动态分配的Resource对象。当main函数结束时,res会被销毁,同时它所管理的Resource对象也会被销毁,输出"Resource destroyed"。

#include <iostream>
#include <memory> // for std::unique_ptr

struct Resource {
  Resource() { std::cout << "Resource acquired\n"; }
  ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main() {
  // allocate a Resource object and have it owned by std::unique_ptr
  std::unique_ptr<Resource> res{ new Resource() };
  return 0;
} // the allocated Resource is destroyed here

输出:

Resource acquired
Resource destroyed

条款10:在constructors内阻止资源泄漏(resource leak)

在构造函数中分配资源的过程可能会抛出异常,导致构造函数提前终止,而对象没有被完全构造。在这种情况下,C++不会为这个未完成的对象调用析构函数,因此它所分配的资源也不会被释放,从而造成资源泄漏。为了避免这种情况,构造函数必须设计得能够在遇到异常时自我清理,即释放已经分配的资源,并传递异常给上层调用者。

有两种常用的方法可以实现构造函数的自我清理:

  • 一种是在构造函数中使用try-catch语句,捕获所有可能的异常,并在catch块中释放已经分配的资源,然后重新抛出异常。这种方法的缺点是需要编写额外的代码,而且可能会影响异常的类型和信息。
  • 另一种是使用智能指针(smart pointer)来管理动态分配的对象,例如std::unique_ptr或std::shared_ptr。这些智能指针会在自己的析构函数中自动调用delete操作符释放内存,因此无论构造函数是否正常完成,它们所管理的对象都会被正确地销毁。这种方法的优点是简洁和安全,而且不会改变异常的类型和信息。
    class Base
    {
    public:
        Base(const int &a,const Person &b):a(a),
            b(b!=""? new Person(b):0){}; //实参如果为空,指针就为空
        
        int a;
        unique_ptr<Person>b; //用智能指针管理对象,防止构造函数时异常
    };

条款11:禁止异常(exceptions)流出destructors之外

        异常是指在程序运行过程中发生的一些不正常的情况,例如内存不足、除零错误、无效参数等。当异常发生时,程序会抛出一个异常对象,然后寻找一个能够捕获并处理该异常的catch子句。如果没有找到合适的catch子句,程序会终止并调用std::terminate函数。

        析构函数是指在对象被销毁时自动调用的成员函数,它的作用是释放对象占用的资源,如内存、文件、锁等。析构函数通常不应该抛出异常,因为这样会导致两个严重的问题:

  • 第一个问题是,如果析构函数在栈展开(stack unwinding)过程中抛出异常,会导致程序终止。栈展开是指在异常传播的过程中,程序会自动销毁已经构造的局部对象,并调用它们的析构函数,以释放它们占用的资源。如果在这个过程中,某个析构函数抛出了另一个异常,那么就会出现两个未被处理的异常,这时C++的规则是调用std::terminate函数,结束程序的执行。这样会使得程序失去对异常的控制,无法进行恢复或记录。
  • 第二个问题是,如果析构函数在正常情况下抛出异常,会导致对象的状态不一致。如果析构函数在释放资源的过程中遇到了异常,那么它可能无法完成所有的清理工作,导致对象的部分资源被释放,而部分资源仍然被占用。这样会使得对象的状态不一致,可能导致内存泄漏、资源浪费或其他错误。

因此,为了避免这些问题,析构函数应该遵循以下原则:

  • 析构函数应该尽量避免调用可能抛出异常的函数,或者在调用时使用try-catch语句来捕获并处理异常,确保不让异常传出析构函数。
  • 析构函数应该保证无论是否发生异常,都能正确地释放对象占用的所有资源,确保对象的状态一致。
  • 析构函数应该使用noexcept规格来声明自己不会抛出异常,这样可以提高编译器的优化效率,也可以向调用者传递明确的信息。

条款12:了解:“抛出一个exception”与“传递一个参数”或“调用一个虚函数”之间的差异

抛出一个异常与传递一个参数或调用一个虚函数有以下几个方面的差异:

  • 异常对象的复制次数。当抛出一个异常时,异常对象会被复制一次,然后传递给catch子句。如果catch子句以值方式捕获异常对象,那么异常对象会被复制第二次。这可能会影响程序的性能和正确性,特别是当异常对象是一个类类型时。而传递一个参数或调用一个虚函数时,参数对象的复制次数取决于参数的类型和传递方式,可以是零次、一次或多次。
  • 异常对象的类型。当抛出一个异常时,异常对象的类型会被编译器记录下来,然后传递给catch子句。如果catch子句以引用方式捕获异常对象,那么异常对象的类型不会改变。如果catch子句以值方式捕获异常对象,那么异常对象的类型会被切割(sliced),只保留catch子句指定的类型部分。这可能会导致多态性(polymorphism)的丢失,特别是当异常对象是一个派生类类型时。而传递一个参数或调用一个虚函数时,参数对象的类型取决于参数的类型和传递方式,可以是原始类型、基类类型或派生类类型。
  • 异常对象的生命周期。当抛出一个异常时,异常对象的生命周期会延续到catch子句结束为止,然后被销毁。如果catch子句没有处理异常,而是重新抛出异常,那么异常对象的生命周期会延续到下一个catch子句结束为止,依此类推。如果没有任何catch子句能够捕获异常,那么异常对象的生命周期会延续到程序终止为止。而传递一个参数或调用一个虚函数时,参数对象的生命周期取决于参数的类型和传递方式,可以是临时的、局部的或全局的。

条款13:以by reference 方式捕捉exceptions

  • 可以避免对象删除问题,即如果以 by pointer 方式捕捉 exceptions,可能会导致内存泄漏或者重复删除的问题。
  • 可以避免切割问题,即如果以 by value 方式捕捉 exceptions,可能会丢失派生类的信息或者虚函数的功能。
  • 可以保留捕捉标准 exceptions 的能力,即如果以 by reference 方式捕捉 exceptions,可以使用基类的引用来捕捉所有派生类的 exceptions。
  • 可以约束 exception objects 需要被复制的次数,即如果以 by reference 方式捕捉 exceptions,只需要复制一次 exception object,而不是每次 catch 都复制一次。

        因此,条款13建议我们在 catch 语句中使用引用类型,而不是指针或者值类型,以提高异常处理的安全性和效率。

条款14:明智运用exception specifications

  • 可以提高代码的可读性和可维护性,通过在函数声明中指定可能抛出的异常类型,可以让调用者知道需要处理哪些异常,以及函数的设计者对异常的预期。
  • 可以提高代码的性能,通过使用 noexcept 或 noexcept(true) 来指定函数不会抛出任何异常,可以让编译器进行更多的优化,比如省略异常处理的开销,或者使用移动语义而不是复制语义。
  • 可以提高代码的安全性,通过使用 noexcept(false) 或 throw(…) 来指定函数可能抛出任何异常,可以避免在不确定的情况下使用具体的异常类型,以免造成异常类型不匹配或者异常规范不一致的问题。

因此,条款14建议我们在编写函数时,根据函数的实现和需求,合理地选择使用 noexcept 或 throw 来指定异常规范,以提高代码的质量和效率。

条款15:了解异常处理(exception handling)的成本

  • 编译时的成本:使用异常会增加编译器的工作量,需要生成额外的数据结构来记录try…catch结构,以及异常处理的逻辑。这会导致编译时间变长,以及生成的二进制文件体积变大。
  • 运行时的成本:使用异常会影响程序的运行速度,因为每次抛出和捕获异常都需要进行栈展开,查找匹配的catch块,以及调用析构函数等操作。这些操作都会消耗一定的时间和空间。另外,如果异常没有被妥善处理,还可能导致程序崩溃或退出。
  • 设计时的成本:使用异常会增加程序的设计复杂度,因为需要考虑异常安全的问题,如何避免内存泄漏、死锁等问题。这需要使用一些技巧,如RAII,以及合理地使用异常规格说明。
  • 维护时的成本:使用异常会影响程序的可读性和可维护性,因为异常会改变程序的控制流,使得代码的逻辑不易跟踪。这需要使用一些规范,如尽量减少异常的使用,只在必要的情况下使用异常,以及使用标准的异常类或自定义的异常类。

因此,使用异常的原则是:如果能使用参数传递、返回值等方式处理错误,就尽量减少对异常的使用。只有在无法恢复的错误或者不可预期的错误时,才使用异常来处理。

效率

条款16:谨记80-20法则

        80-20法则是一种指导性的原则,用于提高程序的性能和效率。它的基本思想是,一个程序的80%的资源(如运行时间、内存、磁盘访问等)都被20%的代码所消耗,而剩下的80%的代码只占用了20%的资源。因此,如果要优化程序的性能,就应该找出那些占用资源最多的20%的代码,并尽可能地提高它们的效率。这样可以减少程序的资源消耗,提高程序的运行速度和稳定性。

        80-20法则的好处是,它可以帮助程序员在编写代码时,区分出哪些部分是性能的关键点,哪些部分是可以忽略的细节。这样可以避免在不重要的地方浪费时间和精力,而把重点放在提高性能的地方。80-20法则也可以帮助程序员在调试和优化代码时,使用一些工具(如程序分析器)来定位程序的瓶颈,从而找出最有效的优化方案。

        80-20法则的局限性是,它并不是一条精确的定律,而是一种经验性的规律。它并不适用于所有的程序和情况,有时候也可能出现其他的比例,如90-10、70-30等。因此,程序员在使用80-20法则时,不能盲目地套用,而要根据实际的数据和情况进行分析和判断,以达到最佳的效果。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值