《Effective C++》学习笔记

让自己习惯C++

条款01:视C++为一个语言联邦

C++可视为:

  • C:以C为基础。
  • 面向对象的C++:添加面向对象特性。
  • 模板C++:泛型编程概念,使用模板。
  • STL:使用STL的容器、迭代器、算法、及函数对象。

四者的集合。

条款02:尽量以const、enum、inline替换 #define

对于单纯常量,尽量以const对象或enums枚举来代替#define。
对于函数宏,用inline函数代替#define(define是死板的替换,容易产生传递计算式类似累加多次的问题)

条款03:尽可能使用const

声明const可以帮助编译器侦测错误用法,避免改变不应改变的对象、参数、返回类型等。
编译器对const是“像素级”的不变检查,但编程时应该以“逻辑级”的不变思路来做,对于一些可能变化的变量,使用mutable修饰让编译器允许其变化。
由于函数有重载特性,当const和non-const成员函数有实质等价的实现时,用non-const版本调用const版本来避免代码重复,但不要反过来调用,这不符合逻辑。

条款04:确定对象被使用前已先被初始化

确定对象在使用前已经初始化,避免一些难以预测的问题。
为内置类型手动做初始化,C++不保证初始化它们。
构造函数使用成员初始化列表来赋值,而不是在构造函数里去赋值(会导致赋值两次,浪费了),列表的排列次序保持和class中声明次序一致。
对于一些可能在被别的类直接调用其成员函数、值的类,最好改为暴露一个返回其类对象的引用的函数的形式,而不是暴露其类对象本身,这可以保证在函数内完成初始化,避免被调用时还没有初始化。

构造/析构/赋值运算

条款05:了解C++默默编写并调用哪些函数

当没有声明时,编译器会自动为类创建默认构造函数、析构函数、复制构造函数和赋值构造函数,但如果成员变量中包含引用、const这些不能被改变的值,则不会去生成赋值构造函数,因为无法修改引用对象和const的值,除非我们自己去定义赋值构造函数的行为。

条款06:若不想使用编译器自动生成的函数,就该明确拒绝

若不想使用编译器自动生成的函数,可将相应的成员函数申明为private并且不予实现。或者继承一个类似uncopyable的基类,该基类的相应函数为private且不予实现,这样子类调用时会去调用基类的该函数,从而被编译器拒绝。

条款07:为多态基类声明虚析构函数

如果一个基类可能有多态子类,那么就该声明一个虚析构函数。
如果一个类有任何虚函数,那么它就应该有虚析构函数。
如果一个类不被用来做基类,那么就不该声明虚析构函数。

条款08:别让异常逃离析构函数

析构函数不要抛出异常,如果析构函数中调用的函数可能抛出异常,析构函数应该捕捉并记录下来然后吞掉他(不传播)或结束程序。同时最好提供一个普通函数用来供用户执行可能异常的该操作。

条款09:绝不在构造和析构过程中调用虚函数

在构造函数和析构函数中不要去调用虚函数,因为子类在构造/析构时,会调用父类的构造/析构函数,此时其中的虚函数是调用父类的实现,但这是父类的虚函数可能是纯虚函数,即使不是,也可能不符合你想要的目的(是父类的结果不是子类的结果)。
如果想调用父类的构造函数来做一些事情,替换做法是:在子类调用父类构造函数时,向上传递一个值给父类的构造函数。

条款10:令 operator= 返回一个*this 引用

赋值操作符要反回一个 *this 的引用,如:

TheClass& operator=(const TheClass& rhs) {
    ...
    return *this;
}

条款11:在 operator= 中处理“自我赋值”

由于变量有别名的存在(多个指针或引用只想一个对象),所以可能出现自我赋值的情况。比如 a[i] = a[j],可能是同一个对象赋值。这时就需要慎重处理赋值操作符以免删除了自己后再用自己来赋值。
解决方法有:

  • 先进行对象是否相同的检查。
  • 先创建一个temp对象指向本对象,然后令本对象复制目标对象,然后删除temp对象(原本对象)。
  • 先创建一个temp对象复制目标对象,然后交换temp对象与本对象。

条款12:复制对象时勿忘其每一个成分

复制构造函数和赋值构造函数要确保复制了对象内的所有成员变量所有基类成分,这意味着你如果自定义以上构造函数,那么每增加成员变量,都要同步修改以上构造函数,且要调用基类的相应构造函数。
复制构造函数和赋值构造函数看似代码类似,但不要用一个调用另一个,好的做法是建立一个private的成员函数来做这件事,然后两个构造函数都调用该成员函数。

资源管理

条款13:以对象管理资源

为了确保一个对象在初始化后能够最终有效被delete,最好使用shared_ptr和auto_ptr,而前者更好,因为是基于引用计数机制,可以在复制时保持两个指针都指向同一对象,且只有两个指针都销毁时才delete,而auto_ptr只会保证一个指针有效,在复制时,原指针会指向null。
对于数组对象,两个指针不会使用对应的delete[],所以容易发生错误,最好使用string或vector来取代数组。

条款14:在资源管理类中小心copying行为

如果对想要自行管理delete(或其他类似行为如上锁/解锁)的类处理复制问题,有以下方案,先创建自己的资源管理类,然后可选择:

  • 禁止复制,使用条款6的方法
  • 对复制的资源做引用计数(声明为shared_ptr),shared_ptr支持初始化时自定义删除函数(auto_ptr不支持,总是执行delete)
  • 做真正的深复制
  • 转移资源的拥有权,类似auto_ptr,只保持新对象拥有。

条款15:在资源管理类中提供对原始资源的访问

封装了资源管理类后,API有时候往往会要求直接使用其原始资源(作为参数的类型只能接受原始资源,不接受管理类指针),这时候就需要提供一个获取其原始资源的方法。有显式转换方法(如指针的->和(*)操作,也比如自制一个getXXX()函数),还有隐式转换方法(比如覆写XXX()取值函数)。显式操作比较安全,隐式操作比较方便(但容易被误用)。

条款16:成对使用new和delete时要采取相同形式

new 对应 delete。
new a[4] 对应 delete [] a。
两者的使用必须对应。对于数组,不建议使用typedef行为,这会让使用者不记得去delete []。对于这种情况,建议使用string或者vector。

条款17:以独立语句将newed对象置入智能指针

如果有函数参数接收智能指针对象,那么该智能指针对象一定要在调用该函数前用独立语句去创建,否则在创建所指对象和用该对象绑定智能指针两个操作之间,可能插入一些操作(由于C++的独特性),这时候如果出异常,那么会造成创建的对象还没来得及用智能指针修饰,也就无法被自动回收了。

设计与声明

条款18:让接口容易被正确使用,不易被误用

好的接口要容易被正确使用,不容易被误用,符合客户的直觉。

  • 促进正确使用的办法包括保持接口的一致性,既包括自定义接口之间的一致性,也包括与内置类型行为的相似一致性。
  • 阻止误用的办法包括建立新类型来限制该类型上的操作、束缚对象的值以及消除客户管理资源的责任,以此来作为接口的参数与返回类型。
  • shared_ptr支持定制删除函数,所以可以很方便的实现上述问题,以及防范DLL问题。

条款19:设计class犹如设计type

在设计class时,要考虑一系列的问题,包括

  • 对象的创建和销毁(构造、析构)
  • 对象的初始化与赋值(构造、赋值操作符)
  • 复制操作(复制构造)
  • 合法值(约束条件)
  • 继承体系(注意虚函数)
  • 支持的类型转换(显示转换、类型转换操作符)
  • 成员函数和成员变量的可见范围(public/protected/private)
  • 是否用模板就能实现?

条款20:宁以传递const引用替换传递值

尽量用 常量引用类型 来作为函数的参数类型,这通常比较高效,也可以解决基类参数类型被赋值子类时引起的内容切割问题。
但对于内置类型和STL的迭代器与函数对象,通常编译器会对其专门优化,直接传值类型往往比较恰当。

条款21:必须返回对象时,别妄想返回其引用

虽然函数参数最好用引用值,但函数返回值却不要随便去用引用,这回造成很多问题,比如引用的对象在函数结束后即被销毁,或是需要付出很多成本和代码来保证其不被销毁且不重复,这大概率没有必要,就返回一个值/对象就好了。

条款22:将成员变量声明为private

切记将成员变量声明为private,这可以保证客户访问数据的一致性、可以细微划分访问控制、允许约束条件获得保证,并提供类作者充分的实现弹性来修改对其的处理,因为这保证了“封装性”,作者可以改变实现和对成员变量的操作,而不改变客户的调用方式。
protected并不比public更加具有封装性,因为protected修饰的成员变量一旦修改,也会造成子类的大量修改。

条款23:宁以非成员、非友元替换成员函数

宁可拿非成员非友元函数来替换成员函数。因为这种函数位于函数之外,不能访问类的private成员变量和函数,保证了封装性(没有增加可以看到内部数据的函数量),此外,这些函数只要位于同一个命名空间内,就可以被拆分为多个不同的头文件,客户可以按需引入头文件来获得这些函数,而类是无法拆分的(子类继承与此需求不同),因此这种做法有更好的扩充性。

条款24:若所有参数皆需类型转换,请为此采用非成员函数

如果你要为某个函数的所有参数(包括this所指对象本身)进行类型转换,那么该函数必须是个非成员函数。
举个例子,你想为一个有理数类实现乘法函数,支持与int类型的乘积,可以,因为传参int进去后会调用构造函数隐式转换为有理数类型,同时你想满足交换律,这时就会报错,因为int类型并没有一个函数用来支持你的有理数类做参数的乘法运算。解决方案是将该乘法运算函数作为一个非成员函数,传两个参数进去,这样不管你的int放在前面还是后面,都能作为参数被转换类型了。
但是,非成员函数不代表就一定成为友元函数,能够通过public函数调用完成功能的,就不该设为友元函数,避免权力过大造成麻烦。

条款25:考虑写出一个不抛异常的swap函数

由于swap函数如此重要,需要特别对他做出一些优化。
常规的swap是简单全复制三次对象进行交换(包括temp对象),如果效率足够就用常规版。
如果效率不够,那么给你的类提供一个成员函数swap,用来对那些复制效率低的成员变量(通常是指针)做交换。
然后,提供一个非成员函数的swap来调用这个成员函数,供别人调用置换。
对于类(非模板),为标准std::swap提供一个特定版本(swap是模板函数,可以特化)。
在使用swap时,记得 using std::swap,让编译器可以获取到标准swap或特化版本。编译器会自行从所有可能性中选择最优版本。

实现

条款26:尽可能延后变量定义式的出现时间

尽可能延后变量定义式的出现,既包括延后构造它,保证只有真正使用才构造;也包括只有到赋值时才构造它,避免默认构造函数无畏调用。
对于循环操作,在循环前还是中进行构造,取决于赋值操作与构造+析构操作的成本对比。

  • 循环前:1个构造函数+1个析构函数+n个赋值操作
  • 循环后:n个构造函数+n个析构函数

条款27:尽量少做转型操作

尽量避免使用转型cast(包括C的类型转换和C++的四个新式转换函数),特别是注重效率的代码中避免用dynamic_casts。如果一定要用,试着考虑无需转型的替代设计,例如为基类添加一个什么也不做的衍生类使用的函数,避免在使用时需要将基类指针转型为子类指针。
如果一定要转型,试着将其隐藏于某个函数后,客户调用该函数而无需自己用转型。
宁可使用C++新式转型,也不用用C的旧式,因为新式的更容易被注意到,而且各自用途专一。

条款28:避免返回handles指向对象内部成分

避免让外部可见的成员函数返回handles(包括引用、指针、迭代器)指向对象内部(更隐私的成员变量或函数),即使返回const修饰也有风险。这一方面降低了封装性,另一方面可能导致其指向的对象内部元素被修改或销毁。

条款29:为异常安全而努力是值得的

异常安全函数是指即使发生异常也不会泄露资源或者导致数据结构破坏,分三种保证程度:基本保证、强烈保证和不抛异常型。
只有基本类型才确保了不抛异常型。对于我们自己设计的函数,往往想要提供强烈保证,即一旦发生异常,程序的整个状态会回到执行函数前的状态,实现方法一般用复制一个副本然后执行操作,全部成功后再替换原对象的方式来实现。但这一操作有时对时间和空间的消耗较大,适用性不强。这种情况下可以提供基本保证。
函数提供的保证程度通常最高只等于其所调用的各个函数中的保证的最弱者——木桶理论。

条款30:透彻了解inline的里里外外

只将inline用在小型、被频繁调用的函数身上。inline会带来体积增大的问题,此外,不要对构造函数、析构函数等使用inline,即使你自己在其中写的代码可能很少,编译器却会为他添加很多代码。
不要只因为模板函数出现在头文件,就将它们声明为inline,模板函数和inline并不是必须结对出现的。

条款31:将文件间的编译依存关系降至最低

为了增加编译速度,应该减少类文件之间的相互依存性(include),但是类内又常常使用到其他类,不得不相互依存,解决方案是:将类的声明和定义分开(不同的头文件),声明相互依存,而定义不相依存,这样当定义需要变更时,编译时不需要再因为依赖而全部编译。
基于此构想的两个手段是Handle classes和Interface classes。Handle classes是一个声明类,一个imp实现类,声明类中不涉及具体的定义,只有接口声明,在定义类中include声明类,而不是继承。而Interface classes是在接口类中提供纯虚函数,作为一个抽象基类,定义类作为其子类来实现具体的定义。

继承与面向对象设计

条款32:确定你的public继承是is-a关系

public继承意味着 is-a 关系,也就是要求,适用于基类身上的每一件事情,是每一件,也一定适用于衍生类身上。有时候,直觉上满足这一条件的继承关系,可能并不一定,比如,企鹅是鸟,但并不会飞。

条款33:避免遮掩继承而来的名称

就如函数作用域内的变量会掩盖函数作用域外的同名变量一样。
衍生类中如果声明了与基类中同名的函数(无论是虚、非虚,还是其他形式),都会掩盖掉基类中的所有同名函数,注意,是所有,包括参数不同的重载函数,都会不再可见。此时再通过子类使用其基类中的重载函数(子类没有声明接收该参数的重载函数时),都会报错。
解决方案一是使用using声明式来在子类中声明父类的同名函数(重载函数不需要声明多个),此时父类的各重载函数就是子类可见的了。二是使用转交函数,即在子类函数的声明时进行定义,调用父类的某个具体的重载函数(此时由于在声明时定义,成为inline函数),此举可以只让需要的部分父类重载函数于子类可见。

条款34:区分接口继承和实现继承

声明一个纯虚函数的目的是为了让衍生类只继承其函数接口,而自己进行函数定义实现。
声明一个非纯虚函数的目的是为了让衍生类继承该函数的接口和缺省实现(一般实现),如果有特别的操作需求,可以在衍生类中进行实现来覆盖。如果担心因此忘记做特异化实现,可以利用纯虚函数,在父类给纯虚函数一个实现,然后在子类的该函数的实现中调用它,这样就会记得在需要特异化的子类中进行其他特异化实现。
声明一个非虚函数的目的是为了让衍生类完全继承该函数的接口和实现,也就是声明该函数的实现方式不得更改,所有子类都表现一致。

条款35:考虑虚函数以外的其他选择

虚函数(本质是希望子类的实现不同)的替代方案:

  • 用public的非虚函数来调用private的虚函数具体实现,非虚函数必须为子类继承且不得更改,所以它决定了何时调用以及调用前后的处理;虚函数实现可以在子类中覆写,从而实现多态。
  • 将虚函数替换为函数指针成员变量,这样可以对同一种子类对象赋予不同的函数实现,或者在运行时更改某对象对应的函数实现(添加一个set函数)。
  • 用tr1::function成员变量替换虚函数,从而允许包括函数指针在内的任何可调用物搭配一个兼容于需求的签名式。
  • 将虚函数也做成另一个继承体系类,然后在调用其的类中添加一个指针来指向其对象。

本条款的启示为:为避免陷入面向对象设计路上因常规而形成的凹洞中,偶尔我们需要对着车轮猛推一把。这个世界还有其他许多道路,值得我们花时间加以研究。

条款36:绝不重新定义继承而来的非虚函数

不要重新定义继承而来的非虚函数,理论上,非虚函数的意义就在于父类和子类在该函数上保持一致的实现。

条款37:绝不重新定义继承而来的缺省参数值

不要重新定义一个继承而来的函数(虚函数)的缺省参数的值(参数默认值),因为函数是动态绑定(调用指针指向的对象的函数实现),但参数默认值却是静态绑定(指针声明时的类型所设定的默认参数,比如基类设定的)。这会导致两者不对应,比如:

Base *p = new SubClass();

条款38:通过复合表示 has-a 或者“根据某物实现出”的关系

注意 has-a 和 is-a 的区分。如果是 is-a 的关系,可以用继承,但如果是 has-a 的关系,应该将一个类作为另一个类的成员变量来使用,以利用该类的能力,而不是去以继承它的方式使用。

条款39:明智而审慎地使用private继承

Private继承意味着“根据某物实现出”,而不是 is-a 的关系。与上面的复合(has-a)很像,但比复合的级别低。当衍生类需要访问 protected 基类的成员,或需要重新定义继承而来的虚函数时,可以这么设计。
此外,private继承可以让空基类的空间最优化。

条款40:明智而审慎地使用多重继承

多重继承确实有正当使用场景,比如public继承某个接口类的接口(其接口依然是public的),private继承某个类的实现来协助实现(继承来的实现为private,只供自己用)。
虚继承会增加大小、速度、初始化(及赋值)复杂度等成本,如果虚基类不带任何数据,将是最具使用价值的情况。

模板与泛型编程

条款41:了解隐式接口和编译期多态

类和模板都支持接口和多态。
类的接口是显式定义的——函数签名。多态是通过虚函数在运行期体现的。
模板的接口是隐式的(由模板函数的实现代码所决定其模板对象需要支持哪些接口),多态通过模板具现化和函数重载解析在编译期体现,也就是编译期就可以赋予不同的对象于模板函数。

条款42:了解typename的双重意义

声明模板的参数时,前缀关键字 class 和 typename 可互换,功能相同。
对于嵌套从属类型名称(即依赖于模板参数类型的一个子类型,例如迭代器),必须用typename来修饰,但不能在模板类的基类列和初始化列表中修饰基类。

条款43:学习处理模板化基类内的名称

如果基类是模板类,那么衍生类直接调用基类的成员函数无法通过编译器,因为可能会有特化版的模板类针对某个类不声明该接口函数。
解决方法有:

  • 在调用动作前加上“this->”
  • 使用using声明式来在子类中声明基类的该接口
  • 明确指出被调用的函数位于基类:Base