本文发自 http://www.binss.me/blog/my-excerpt-of-more-effective-c++/,转载请注明出处。

将《Effective C++》看了第二遍后,我开始看《More Effective C++》。依然还是受益匪浅,然而也有许多未理解之处,期望日后第二遍时有更深入的理解。

以下是读书过程中的摘录和体会:

导读

C++中memory leak泄露的不止是内存,还有资源。如:构造函数中分配的文件描述符、信号量、窗口句柄、数据库锁等。

1 仔细区别pointers和references

引用必须总是代表某个对象,因此必须要有初值。指针则没有这个限制(空指针)。

指针可以被重新赋值从而指向别的对象。而引用总是指向最初的那个对象。

实现某些操作符时需要使用引用,比如operator[],其返回对象要求能够被赋值。若返回指针,则必须使用间址运算符,不直观。

使用指针时必须测试它是否为null。

2 最好使用C++转型操作符

Effecitve C++ 27条描述过。

C式转型几乎允许不加区分地进行任何类型之间的转换。并且由于使用常见的小括号,在代码中难以辨认出来。

新式转型虽然又臭又长,但是容易被解析,且编译器能过诊断转型错误。

static_cast和C式转型差不多。其他cast均缩窄了转型范围.

  • const_cast只用来改变常量性。

  • dynamic_cast只用来安全向下转型。

  • reinterpret_cast常用来做任意函数指针之间或和整数的转换。不具移植性,应该尽量避免。

3 绝对不要以多态方式处理数组

array[i]实际上为(array+i)的简写。array+i在运算时转换为array+isizeof(object)。因此偏移量取决于数组中的对象大小。

如果向接受父类对象数组的函数传入子类对象数组,编译器依然会按照父类对象的大小来在计算偏移量,而该偏移量往往小于子类对象的大小,造成不可预期的结果。

同理,通过父类指针删除子类对象数组也会导致不可预期的结果。

4 非必要不提供default constructor

默认构造函数是无需提供变量就能调用的构造函数,在以下场合有调用需求:

  • 产生数组

  • 模版容器类实例化

  • 继承虚基类的子类初始化

添加无意义的default constructor(提供了无意义的默认值)会影响效率,因为成员函数必须测试字段是否被初始化。

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

单自变量(能够以一个变量调用)的构造函数和隐式转换操作符允许编译器进行隐式转换,可能会导致非预期的调用。

为了避免这种情况,不要定义隐式转换操作符,且把构造函数声明为explicit。

string没有定义从stringchar *的隐式转换函数,而是提供了显式的 c_str 来执行转换,由此可见隐式转换函数并非想象中的那么好。

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

前置运算符:先改变后取出,返回引用

后置运算符:先取出再改变,返回const对象

为了解决语法问题,前置和后置式只能以参数来区分,后置式拥有一个不会用到的参数,因此在调用时会有构造和析构临时对象的额外开销。

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

&&|| 拥有“短路”逻辑,它们从左到右进行表达式评估,一旦能够确定表达式的真假,评估工作就会结束。比如A && B,一旦A为false,则表达式的结果已经确定为false,所以B不会被执行。

,会从左到右对表达式进行评估,最后返回右侧表达式的值。

我们无法完全模拟 &&||, 操作符的行为,因为对于函数调用,我们无法保障左侧表达式一定比右侧表达式先评估,所以最好不要去重载它们。

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

  • operator new:为对象分配内存,能够被重载,但规定第一个参数必须为size_t,用于指定分配的内存大小。声明如下:

    void * operator new(size_t size);

    调用:

    void * rawMemory = operator new(size(string));
  • new operator:C++自建的操作符,总是为对象分配内存(调用operator new)然后调用其构造函数。该操作符无法被重载。如:

    string *ps = new string("test");

    虽然我们可以自己手动分配内存,却无法手动调用构造函数(但编译器可以),所以要想新建堆对象,总是要使用new operator。

  • placement new:特殊的operator new,能够在分配好的内存上构造对象。调用如下:

    Widget * constructWidgetInBuffer(void *buffer, int widgetSize)
    {
        return new (buffer) Widget(widgetSize);
    }

    不能使用delete释放,应该直接调用对象的析构函数,然后在必要时释放那块提供给placement new的内存。

  • operator new[]:为数组分配内存。对于数组,new operator会先调用它,然后对数组中的每个对象调用构造函数。

9 利用destructors避免泄漏资源

引入异常的原因是因为异常无法被忽略(以往的手段,如状态变量和错误返回码,都可以被忽略)

为了避免抛出异常导致堆对象没有被释放,使用智能指针指向堆对象。

更具体来缩,我们需要把资源存放在对象内,这样资源就会随着对象的析构函数而被释放。

10 在constructors内阻止资源泄漏

若在构造函数中抛出异常,由于构造不完全,无法通过调用析构函数来释放,因为析构函数不知道哪些成员被构建完,哪些没有构建完。

所以应该在构造函数中try...catch异常,然后一旦失败立刻释放资源。为了捕捉初始化列表中的异常,应该把对象初始化操作放入某些私有成员函数内。

更好的方法是改用智能指针。

11 禁止异常流出destructors之外

析构函数会在以下情况下被调用:

  • 离开了作用域

  • 被明确删除(delete)

  • 被异常处理机制(栈展开)销毁

若析构函数抛出异常并传出析构函数之外,而该析构函数又是由于异常而调用的,程序会被terminate函数结束。

除此之外,析构函数抛出异常会导致析构不完全。

为了避免这种情况,应该使用try...catch,并且在catch中什么都不做,避免在catch中抛出新异常。

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

差异:

  1. 调用函数后控制权最终会回到调用端,而抛出异常后控制权不会。

  2. 一个对象被作为异常抛出时,无论是by value还是by reference传递,总会发生复制,否则该对象可能会因为离开作用域而被析构。

  3. 由于2,所以抛出异常速度慢(发生复制),且无法修改抛出对象(只能修改其副本),因此声明为const的意义不大。

  4. 抛出对象根据静态类型调用复制构造函数,失去的多态性

  5. 函数传参过程可以发生各种隐式转换(如int -> double,derived -> base等等)后进行调用,而抛出异常只能进行继承体系内的转换(如out_of_range -> logic_error -> exception)和有型指针到无型指针的转换(如double * -> void *)。

  6. 虚函数调用总是进行最佳匹配,而异常捕捉总是进行最先匹配(按catch子句的出现顺序进行尝试)。

由于异常的最先匹配,应该将捕捉子类异常的catch子句放到捕捉父类异常子句前。

13 以by reference方式捕捉exceptions

抛出指针(catch-by-pointer)可以避免对象复制,但如果对象因为离开作用域而被析构,则指针指向不存在的对象。如果该指针指向一个堆对象,则不能确定是否应该释放该对象。

抛出值(catch-by-value)存在两次复制以及切割问题(子类对象被切割成基类对象,从而调用基类的函数,丢失了虚特性)。

抛出引用(catch-by-reference)没有以上的问题,因此最佳。

14 明智运用exception specifications

exception specifications可以规范exception的运用。

一旦违反exception specification,程序会被终止,导致资源泄漏。

没有任何方法知道一个template类型参数可能抛出什么异常,因此不要为template提供任何exception specification。

如果被调用的函数无exception specifications,那么调用函数也不要设置exception specifications(回调函数要特别注意)。

将非预期的异常都以UnexpectedException取代之,并通过set_unexpected取代默认的unexpected函数。这样程序就不会被非预期的异常所终止。

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

处理异常会使代码膨胀、程序变大、执行变慢。成本来自于:异常处理机制(无论有无使用都要付出,除非禁用该机制)和try语句块。

16 谨记80-20法则

一个程序80%的资源用于20%的代码身上。

如何找出这20%的瓶颈?可以借助程序分析器,使用有代表性、典型的(representative)数据去测试,观察性能指标。

17 考虑使用lazy evaluation(缓式评估)

从效率来看,最好的运算是从未被执行的运算。通过缓式评估,能够将运算延缓到必须用到运算结果的前一刻:如果运算结果不被需要,运算就一直不执行。

应用:

  • 引用计数

    通过引用计数,使得多个变量共享一个对象,避免产生副本的开销。

  • 区分读和写

    直到需要对对象作出修改(写)时,才产生对象的副本。

  • 缓式取出

    在产生一个大对象时,只产生该对象的外壳,不读取(从网络、磁盘)任何数据。当对象内某个字段被需要时才读取。最简单的方法就是产生指针,在需要时指向读取的数据。

  • 表达式缓评估

    将计算量大的表达式运算(如矩阵运算)记录成一个操作(两个指针指向操作数和一个枚举值表示操作即可),等到真正需要结果时才进行计算。若只需要计算其中的一部分结果,则只计算这部分。若结果最终没有用到,则避免了计算。

如果计算必要,缓式评估不仅不会节省任何东西,还会导致额外的开销。

18 分期摊还预期的计算成本

极速评估(over-eager evaluation):超前进度地做要求以外的工作。这样一旦需要时能立刻返回结果。典型应用:

  • caching。将查询的结果缓存起来,今后首先在缓存中查找,找不到再进行常规查找,然后将结果加入缓存中

  • 为了避免频繁的系统调用,为动态数组预先分配较大的空间

如果计算会常常被执行,极速评估可以降低每次计算的成本。是典型的空间换时间。

访问局部性(locality of reference):如果某处的数据被需要,通常其临近的数据也会被需要。

19 了解临时对象的来源

临时对象一般在隐式类型转换(传参时)或函数返回对象时(return时)出现。

临时对象可能很耗成本,应该尽可能消除它们。

为了避免临时对象被修改从而影响到原对象,不允许non-const reference产生临时对象。

20 协助完成“返回值优化(RVO)”

如果函数一定要以值的方式返回对象,我们绝对无法消除之。

返回值优化:在返回时才构造返回对象,编译器能够直接将对象构造于调用者变量的存储内。如:

return Rational(lhs.numerator() * rhs.numerator, lhs.denominator() * rhs.denominator());

当然也可不必如此,考虑 NRVO 。

为了消除函数调用的开销,还可以声明为inline。

21 利用重载技术(overload)避免隐式类型转换(implicit type conversions)

可以通过函数重载来避免隐式转换,因为一旦参数完全匹配,就不会在传参前进行隐式了。

重载操作符必须获得至少一个用户定制类型的变量。

22 考虑以操作符复合形式(op=)取代其独身形式(op)

考察:

x = x + y;

x += y;

在设计类时,必须两者都提供,独身形式可以通过调用复合形式来实现。

独身版本必须返回一个新对象,因此带来额外的构造和析构成本,效率不如复合形式。应该尽量使用复合形式。

23 考虑使用其他程序库

stdio一般比iostream快。

如果有不同的程序库提供相同的功能,应该根据程序的特点来选择效率更高的库。

24 了解virtual functions、multiple inheritance、virtual base classes、runtime type identification的成本

编译器一般通过virtual table pointer(vptr)和virtual table(vtbl)来实现虚函数调用。

vtbl一般被存放在类中第一个非inline且非纯虚的虚函数目标文件中(一般为析构函数),如果没有,则会为每一个使用了vtbl的目标文件都产生一个副本。每一个声明或继承虚函数的类都有一个自己的vtbl。vtbl由指针组成,指针指向各个虚函数的实现(会覆盖父类同名函数指针)。如果子类没有重新定义继承而来的虚函数,则会指向父类的实现。

凡是拥有虚函数的类,其对象都有一个隐藏的成员——vptr。vptr指向该对象对应的vtbl。

因为虚特性意味着在运行期才决定哪个函数被调用,而inline意味着在编译器就确定该函数,所以虚函数不能为inline。

因此虚函数的成本:类的数据量增加、对象变大、不能inline

多继承和虚基类的成本:类的数据量增加(类需要保存vtbl)、对象变大(对象会含多个vptr和指向虚基类的指针)

runtime type identification(RTTI)成本:类的数据量增加(类需要保存type_info)

25 将constructor和non-member functions虚化

以下函数并非真正意义上的构造函数:

  • virtual constructor:能够根据获得的输入产生不同类型的对象,常用于从硬盘或网络读取对象信息。

  • virtual copy constructor:返回一个指向调用者的新副本的指针,通常直接调用类相应的复制构造函数。常命名为clone。当子类重定义父类的虚函数时,如果原返回的是父类的指针,那么子类可以返回子类的指针。引用同理。

  • virtual non-member function:

    为了让operator<<能够有虚特性,可以定义virtual ostream & print(ostream &s),然后重载全局的operator<<:

    inline ostream & operator<<(ostream &s, const X& c)
    {
        return c.print(s);
    }

26 限制某个class所能产生的对象数量

0:把构造函数和拷贝构造函数都声明为private。

1(单例):

方法一:把构造函数和拷贝构造函数都声明为private,然后:用friend函数封装一个static对象,并返回该对象的引用,最好都放入namespace内。或者直接使用static函数。

后者无论是否用到,static对象都会被构造,并且初始化的时机不确定。而前者只会在函数被调用时才构造static对象,更符合C++设计思想。

方法二:使用static来记录类当前实例化的对象个数。超过个数(1)时抛出异常。

然而这样有个致命的问题:当该类被继承或组合后,新类的实例化数也会被记到该static值上。为了避免这种情况,可以定义构造函数为private(带有私有构造、析构函数的类无法被继承也无法被内嵌)。

n:可采用1的方法二。

也可以实现一个计数模版类Counted,然后那些需要限制对象数量的类私有继承之,如 class Printer: private Counted<Printer>

27 要求(或禁止)对象产生于Heap之中

要求对象产生于堆中:私有化(private)析构函数,使得无法创建栈对象(因为无法隐式调用析构函数),只能通过new创建,然后析构时显式调用destory()。

为了继承,析构函数可以改为protected。为了组合,始终使用该类的指针而不是对象。

但这样并不能保证子类的对象是否产生于堆中,更准确的说是子类对象的父类部分。

判断对象是否处于堆内:

  • 无法通过设立一个类成员变量(static)标识,然后在构造函数中检验来判断。因为一次性建立多个对象时,总是先分配完内存再多次调用构造函数。

  • 无法通过新建一个栈对象,然后比较它和对象的地址值来判断。因为并不是所以系统的栈地址都高于堆地址,并且低于栈地址的不止有堆地址,还有静态成员地址。

  • 定义一个抽象mixin类,通过list来记录由new返回的指针。然后子类通过调用基类函数来判断是否是堆对象。

禁止对象产生于堆中:通过将operator new和delete私有化,只能解决自身,不能解决子类和被包含的情况。于是又回到判断对象是否处于堆内的问题。

28 smart pointers(智能指针)

智能指针提供以下功能:

  • 控制指针产生和销毁时执行的动作(如析构所指的资源对象)

  • 控制复制和赋值时发生的动作(如是否允许,深复制还是浅复制)

  • 控制间址访问(dereference)时发生的动作(如延时获取)

为了避免莫名其妙的类型转换,不要提供将smart pointer转换为dumb pointer的类型转换函数。

为了将子类的智能指针转换为父类的智能指针,应在智能指针中定义类型转换函数:

template<class newType>
operator SmartPtr<newType>()
{
    return SmartPtr<newType>(pointee);
}

T* pointee;

发生转换时newType被实例化为父类类型。

为了实现指向const对象的智能指针,应该定义两个智能指针类,然后用非const的去继承const的。为了避免包含两个指针,可以使用Union。

29 Reference counting(引用计数)

允许多个等值对象共享同一实值,避免储存多余的副本造成资源浪费,常用于垃圾回收机制。

适用于:

  • 相对多数的对象共享相对少量的实值。

  • 对象实值的产生或销毁的成本很高,或占用很多内存。

实现:

定义一个struct,存放对象的数据以及引用数,并放在类的私有字段。类同一个私有指针指向该struct的对象。

类在构造函数中初始化该struct;在拷贝构造函数中单纯复制指针和增加引用数;在析构函数中单纯减少引用数,如果引用数变为0,则析构struct对象。

在拷贝赋值运算符函数中先减少左操作数的引用,再增加右操作数的引用,并将左操作数的指针赋值为右操作数的指针。

写时复制(copy on write):和其他变量共享一份实值,直到必须要对实值进行修改时才复制。

对于string的operator [],由于不知道被用于读还是写,因此需要进行复制。先减少引用数,然后指针指向新创建的struct副本,最后返回相应位置的引用。

但是以下情况会很麻烦:保存了s1[]返回对象的指针或引用,之后对象被拷贝(s2 = s1),则利用先前保存对象的修改会影响到s2。

一种解决方法是为struct加上sharable flag,flag在[]操作时设为false,在构造时检查flag,然后复制对象。

可以通过继承引用计数基类来实现引用计数功能:继承RCObject基类,然后该类用智能指针RCPtr管理对象指针。

30 Proxy classes(替身类,代理类)

用来代理其他对象的对象称为代理,而它的类被称为代理类。

应用:

  • 区分左右值

    对于string的operator [],利用代理类我们能够区分对象是读还是写。

    首先在operator []时返回一个代理对象(CharProxy)。

    因为CharProxy操作了String类的私有成员,因此需要声明为String的友元。

    CharProxy保存了原string和operator []的参数(通过构造函数初始化)。拷贝赋值运算符函数(返回左值)在被调用时先复制String然后再赋值,但要考虑参数为CharProxy和char的情况;其余函数(返回右值)用于保持和char一致的行为:char类型转换函数返回相应位置的字符,operator []返回proxy对象。

    为了避免CharProxy和char不一致,还需重载取址运算符。

  • 实现动态多维数组

    C++中,静态数组的大小必须在编译期已知。动态数组不能多维。

    可以自己定义二维数组类,然后重载operator []。为了处理连续两个[]的情况(如array[1][2]),让二维数组的operator []返回一维数组的代理对象。

  • 压抑隐式转换

    为了避免单自变量(能够以一个变量调用)的构造函数造成隐式转换,将该变量定义为嵌套在类内的自定义类型。这样要隐式调用构造函数就因为必须进行两次隐式转换而无法发生。

Proxy类的缺点:

  • 对于模版版本,为了避免不一致,还要重载++、--、+=、-=、*=等等等等运算符。

  • 必须对代理类的所有函数进行重载

  • 无法传递代理对象给接受non-const引用对象类型参数的函数

  • 无法作为原来就需要隐式转换到的函数参数(编译器无法进行连续隐式转换)

  • 带来额外的构造和析构成本

31 让函数根据一个以上的对象类型来决定如何虚化

C++只能根据一个对象的动态类型来确定调用的虚函数,无法根据多个来调用。

解决方案:

  • 虚函数+RTTI(运行时类型识别)

    根据一个对象的动态类型来确定调用的虚函数,然后在该虚函数通过RTTI来确定另一个对象的类型。

    缺点:破坏了封装性,因为每一个子类都必须知道其他的兄弟类。一旦定义新的兄弟类,需要改动所有兄弟类的代码。

  • 只使用虚函数

    父类重载虚函数的所有(参数类型)版本,并在子类中实现。并通过反向调用来两次利用虚特性:

    void A::call(Object & obj)
    {
        obj.call(*this);
    }

    缺点:还是破坏了封装性。

  • 自行仿真虚函数表格(Virtual Function Tables)

    利用map实现虚函数表(vtbl),表中存储相应函数的指针。map维护类名(typeid().name())到函数指针的映射。

    定义一个私有的static函数初始化map,然后用智能指针管理。

    不能采用函数重载,而是应该定义名称不同的函数,因为这些函数的参数类型必须都为基类引用类型(否则无法放到map中,reinterpret_cast不靠谱)。

    缺点:要修改兄弟类的定义,还是破坏了封装性。此外,map无法作用于继承子类的类型对象,尽管它们也是子类(is-a)对象,但是名称上不匹配。

  • 使用非类成员函数

    将函数表格定义到类外的一个匿名namespace中。map需要维护三元信息:(classA_name, classB_name, fun_ptr),先用pair将两个类名称捆起来作为key。

    优点:逻辑上更合理,并非是A作用于B,也不是B作用于A,而是A和B相互作用的过程,不应该调用某某类的某某函数来解决,而应该用中立的(非类成员函数)函数来解决。

    缺点:无法作用于继承子类的类型对象。

32 在未来时态下发展程序

采用“未来式”思维:对变化有良好的适应能力。在设计和实现时应注意帮助他人理解、修改、强化你的程序。

考虑:

  • 以C++本身语法(而非注释和说明文件)来表现规范:如果不符合,直接编译不通过。

  • 如果设计上合适(满足抽象性),声明函数为虚函数。

  • 为每一个类处理拷贝构造函数和拷贝赋值运算符函数的动作。如果不使用,声明为私有,避免默认版本被调用。

  • 努力让类的操作符函数拥有自然的语法和直观的语义(参考内建类型和STL的设计)。

  • 使类有预防、侦测,甚至更正的能力(不信任原则)。

  • 努力写出可移植代码。

  • 提高封装程度:尽量让实现成为private,尽量采用匿名namespace或文件内的static对象和函数,尽量避免设计出虚基类,尽量避免RTTI和+一堆if...else的用法。

  • 除非有不良的巨大后果,尽量使代码一般化(泛化)

33 将非尾端类(non-leaf classes)设计为抽象类(abstract classes)

子类对象会误调用成基类版本的函数(如#3中的数组问题)

方法1: 定义基类版本函数为private

问题:造成子类对于函数无法调用基类版本(定义为protected可以解决)。基类对象无法调用。

方法2:定义为虚函数

问题:带来语义上的问题:对于赋值运算符函数,会带来允许异型赋值的问题

方法3:用抽象基类取代具体的基类。

面向对象设计的目标是辨识出一些有用的抽象类,并强迫它们成为抽象类。当原有具体类被当作基类使用时,加入一个新的抽象类。

只有你有能力设计某个抽象类,使得未来的类可以直接继承它(而无需改变它)时,才能从抽象类中获得好处。如果不能确定,就应该定义为具体类,直到以后能够进行抽象时才补上抽象类。

34 如何在同一个程序中结合C++和C

Name Mangling(名称重整)

在C++中,由于函数重载,编译器需要为每一个函数编出独一无二的名称。如果需要混编,应该告诉编译器不要重整C函数的名称(不然连接器会找不到):

extern "C"
{
    void C_function();
}

Statics的初始化

在main()里的内容调用之前,需要初始化static成员。在main()里的内容调用之后,需要析构static成员。

一般来说,编译器会将static的构造和析构函数塞到main()的第一行和最后一行。所以尽量使用C++的main()函数,而不是C的。

动态内存分配

C++:用new分配,用delete释放

C:用malloc分配,用free释放

千万不要混用。

数据结构的兼容性

内建类型和struct兼容。但如果struct加上虚函数就无法兼容了。

35 让自己习惯于标准C++语言

语言特性:RTTI、namespace、mutable、explicit

模版弹性:允许非类型变量

异常处理:exception specifications、unexpected

新式转型:各种cast

IO:iostream

STL:容器、算法、迭代器