《More Effective C++》笔记
35 New Ways to Improve Your Programs and Designs
Scott Meyers 著
候捷 译
摘录整理。
条款1指本书条款1,条款E1指另一本书Effective C++的条款1。
1 基础议题
1 指针与引用的区别
任何情况下都不能使用指向空值的引用!如需指向空值,使用指针。
1 |
|
引用必须初始化,指针可以不初始化。
引用初始化后不能改变, 指针可以被重新赋值以指向另一个不同的对象。
2 尽量使用C++风格的类型转换
使用static_cast,const_cast,dynamic_cast,reinterpret_cast等四个类型转换操作符。
1 |
|
3 不要对数组使用多态
因为编译器无法正确判断数组中各元素内存地址与数组的起始地址的间隔。
从具体类(concrete classes)派生的具体类容易犯对数组使用多态的错误。(参见条款33)
4 避免无用的缺省构造函数
如果类没有缺省构造函数,且仅定义了有参构造函数,建立其对象数组有以下办法:
直接初始化。
1
A a[] = {A(1), A(2), A(3)};
只能用于非堆数组,不能用于堆数组。
创建指针数组。
1
2
3typedef A* PA;
PA pa[10];
PA *pb = new PA[10];浪费内存。
用 placement new 方法。(参见条款8)
1
2
3
4void *rawMemory = operator new[](10*sizeof(A));
A *a = static_cast<A*>(rawMemory);
for (int i = 0; i < 10; ++i)
new (&a[i])A(i);
通过仔细设计模板可以杜绝对缺省构造函数的需求,如vector。
2 运算符
5 谨慎定义类型转换函数
允许类型转换的两种函数:
- 单参构造函数(该函数可以是只定义了一个参数,也可以是虽定义了多个参数但第一个参数以后的所有参数都有缺省值)
- 隐式类型转换运算符
一般来说,越有经验的
C++程序员就越喜欢避开类型转换运算符。例如,库函数中的string类型没有包括隐式地从string转换成C风格的char*
的功能,而是定义了一个成员函数c_str用来完成这个转换。
使用explicit声明避免单参构造函数造成的无意义的类型转换。
编译器不会连续调用两个用户定义(user-defined)的类型转换进行隐式转换。因此可以考虑使用公有内部类的对象作为单参构造函数的入参。(这里的内部类在功能上被称为代理类,参见条款30)
6 自增(increment)、自减(decrement)操作符前缀形式与后缀形式的区别
C++规定后缀形式有一个int类型参数,当函数被调用时,编译器传递一个0做为int参数的值给该函数。
1 |
|
后缀increment和decrement应该根据它们的前缀形式来实现,便于维护。
7 不要重载&& || ,
对于&& ||
,重载会打破大家熟悉的短路求值原则。因为重载引发函数调用,而C++规范没有定义函数参数的计算顺序。
对于,
,一个包含逗号的表达式首先计算逗号左边的表达式,然后计算逗号右边的表达式;整个表达式的结果是逗号右边表达式的值。重载会打破上述规则。
以上是建议不要重载,不是不能重载。
不能重载的操作符:
1 |
|
能重载的:
1 |
|
8 理解各种不同含义的new和delete
new操作符为分配内存调用operator new函数。
1 |
|
特殊的operator new函数被称为placement new。(条款4有例子)
1 |
|
在堆上建立一个对象,分配内存且调用构造函数:使用new操作符。
仅分配内存:使用operator new函数。
定制在堆对象被建立时的内存分配过程:重载operator new函数,然后使用new操作符。(new操作符会调用你定制的operator new)
在一块已经获得指针的内存里建立一个对象:使用placement new。
如果用placement new在内存中建立对象,应该避免在该内存中用delete操作符。指针来源无法确定。
3 异常
C程序员能够仅通过setjmp和longjmp来完成与异常处理相似的功能。但是longjmp在C++中使用时,当它调整堆栈不能对局部对象调用析构函数。大多数C++程序员依赖于这些析构函数的调用,所以setjmp和longjmp不能够替换异常处理。
9 使用析构函数防止资源泄漏
即RAII,用智能指针代替普通指针。
10 在构造函数中防止资源泄漏
C++仅仅能删除被完全构造的对象(fully contructed objects)。
如果为没有完成构造操作的对象调用析构函数,析构函数如何去做呢?仅有的办法是在每个对象里加入一些字节来指示构造函数执行了多少步?然后让析构函数检测这些字节并判断该执行哪些操作。这样的记录会减慢析构函数的运行速度,并使得对象的尺寸变大。C++避免了这种开销,代价是不能自动地删除被部分构造的对象。
若构造函数中抛出异常,捕获所有的异常,然后执行一些清除代码,最后再重新抛出异常让它继续传递。
11 禁止异常信息(exceptions)传递到析构函数外
两种情况下会调用析构函数:
- 正常情况下删除一个对象,例如对象超出了作用域或被显式地delete。
- 异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。
如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++将调用terminate函数。这个函数立即终止程序的运行,连局部对象都没有被释放。
12 理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异
差异:
调用函数时,程序的控制权最终还会返回到函数的调用处;但是当抛出一个异常时,控制权永远不会回到抛出异常的地方。
C++规范要求被做为异常抛出的对象必须被拷贝(不论通过传值捕获异常还是通过引用捕获),然后再传入catch语句。拷贝使用对象的静态类型所对应的拷贝构造函数。
因此当抛出一个异常时,系统构造的(以后会析构掉)被抛出对象的拷贝数比以相同对象做为参数传递给函数时构造的拷贝数要多一个。例如通过传值方式捕获异常时,异常会被拷贝两次。
应该用throw来重新抛出当前的异常,因为这样不会改变被传递出去的异常类型(变为catch语句参数的静态类型),而且不用生成一个新拷贝。
函数调用中不允许转递一个临时对象到一个非const引用类型的参数里(参见条款19),但是在异常中却允许。
catch语句匹配异常类型时不会进行基本类型的隐式类型转换(int到double),但可以进行派生类到基类、类型化指针(typed pointer)到无类型指针(untyped pointer)的转换。
catch语句匹配顺序总是取决于它们在程序中出现的顺序。
虚拟函数采用最优适合法, 而异常处理采用的是最先适合法。
书中总结仅提了2、4、5。
13 通过引用(reference)捕获异常
通过指针捕获异常的缺点:
需要指针是全局与静态对象,否则抛出异常后指针指向的异常对象将被释放。
也可以建立一个堆对象抛出,但catch语句无法判断指针指向的是不是堆对象、是否要删除。
通过值捕获异常的缺点:
- 异常将被拷贝两次。
- 派生类的异常对象被做为基类异常对象捕获时,派生类行为会被切掉(sliced off)。
14 审慎使用异常规格(exception specifications)
如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数unexpected将被自动地调用。函数unexpected缺省的行为是调用函数terminate,而terminate缺省的行为是调用函数abort,所以一个违反异常规格的程序其缺省的行为就是halt(停止运行)。
动态异常规范(
throw(optional_type_list)
规范)在 C++11 中已弃用,并已在 C++17 中删除,但throw()
除外,它是noexcept(true)
的别名。
15 了解异常处理的系统开销
只要可能就尽量采用不支持异常的方法编译程序,把使用try块和异常规格限制在你确实需要它们的地方,并且只有在确为异常的情况下(exceptional)才抛出异常。
4 效率
从两个角度阐述效率问题:语言独立和语言本身。
16 牢记80-20准测
大约20%的代码使用了80%的程序资源;大约20%的代码耗用了大约80%的运行时间;大约20%的代码使用了80%的内存;大约20%的代码执行80%的磁盘访问;80%的维护投入于大约20%的代码上。
17 考虑使用lazy evaluation(懒惰计算法)
与之相对的是eager evaluation。
引用计数
只要可能就共享使用其它值而不是拷贝。如String。
区别对待读取和写入
推迟做出是读操作还是写操作的决定,直到能判断出正确的答案。 如opertaor[]操作符。
Lazy Fetching(懒惰提取)
对于大型对象,仅提取需要的字段。
mutalbe关键字声明的字段在const成员函数里也能被修改。如果编译器不支持mutalbe,建立一个non-const指针,其指向的对象与 this指针一样。
1 |
|
Lazy Expression Evaluation(懒惰表达式计算)
对于大型矩阵运算,仅计算需要的值。
18 分期摊还期望的计算
这个条款的核心就是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们。
集合每次更新时同步更新其最大最小值,用户可以随时使用最大最小值。
缓存那些已经被计算出来而以后还有可能需要的值。
iterator是一个对象,不是指针,所以不能保证”->”被正确应用到它上面。不过STL要求”.”和”*” 在iterator上是合法的,所以(*it).second在语法上虽然比较繁琐,但是保证能运行。
19 理解临时对象的来源
在任何时候只要见到常量引用(reference-to-const) 参数,就存在建立临时对象而绑定在参数上的可能性。在任何时候只要见到函数返回对象, 就会有一个临时对象被建立(以后被释放)。
禁止为非常量引用(reference-to-non-const)参数产生临时对象。
20 协助完成返回值优化
返回对象是必要的,应该优化开销而不是避免它。
摘录本章末尾一段的原文:
Programmers looking for a C++ compiler can ask vendors whether the return value optimization is implemented. If one vendor says yes and another says "The what?," the first vendor has a notable competitive advantage. Ah, capitalism. Sometimes you just gotta love it.
上文提到的return value optimization详细解释可见《深度探索C++对象模型》的2.3节,其被称为Named Return Value(NRV)优化。
21 通过重载避免隐式类型转换
大多数C++程序员希望进行没有临时对象开销的隐式类型转换。可以使用重载避免。
为避免程序混乱,每一个重载的operator必须带有一个用户定义类型 (user-defined type)的参数。
22 考虑用运算符的赋值形式(op=)取代其单独形式(op)
从零开始实现operator+=和-=,而operator+和 operator-则是通过调用前述的函数来提供自己的功能。使用这种设计方法,只用维护operator的赋值形式就行了。而且如果假设operator赋值形式在类的public接口里,这就不用让operator的单独形式成为类的友元。(参见条款E19)
总的来说operator的赋值形式比其单独形式效率更高,因为单独形式要返回一个新对象,从而在临时对象的构造和释放上有一 些开销。
23 考虑变更程序库
程序库的设计就是一个折衷的过程。理想的程序库应该是短小的、快速的、强大的、灵活的、可扩展的、直观的、普遍适用的、具有良好的支持、没有使用约束、没有错误的。这也是不存在的。为尺寸和速度而进行优化的程序库一般不能被移植。具有大量功能的的程序 库不会具有直观性。没有错误的程序库在使用范围上会有限制。
因为不同的程序库在效率、可扩展性、移植性、类型安全和其他一些领域上蕴含着不同的设计理念,通过变换使用给予性能更多考虑的程序库,你有时可以大幅度地提高软件的效率。
24 理解虚拟函数、多继承、虚基类和RTTI所需的代价
virtual table和virtual table pointers通常被分别地称为vtbl和vptr。
一个 vtbl通常是一个函数指针数组。vtbl 的大小与类中声明的虚函数的数量成正比(包括从基类继承的虚函数)。
vtbl应该放在哪里?
- 每一个可能需要vtbl的object文件生成一个vtbl拷贝。连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个 vtbl保留一个实例。
- 要在一个object文件中生成一个类的vtbl,要求该object文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义 (也就是类的实现体)。(这种启发式算法在类中的所有虚函数都内声明为内联函数时会失败)
在每个包含虚函数的类的对象里,你必须为额外的指针(vptr)付出代价。
RTTI被设计为在类的vtbl基础上实现。 (typeid得到的type_info会放在vtbl的slot里)
使用嵌套的switch语句或层叠的if-then-else语句模拟虚函数的调用,其产生的代码比虚函数的调用还要多,而且代码运行速度也更慢。你自己编写代码不可能做得比编译器生成 的代码更好
5 技巧
25 将构造函数和非成员函数虚拟化
虚拟构造函数是指能够根据输入给它的数据的不同而建立不同类型的对象。虚拟构造函数在很多场合下都有用处,从磁盘(或者通过 网络连接,或者从磁带机上)读取对象信息只是其中的一个应用。
被派生类重定义的虚拟函数不用必须与基类的虚拟函数具有一样的返回类型。如果函数的返回类型是一个指向基类的指针(或一个引用),那么派生类的函数可以返回一个指向基类的派生类的指针(或引用)。这不是C++的类型检查上的漏洞,它使得有可能声明像虚拟构造函数这样的函数。
具有虚拟行为的非成员函数很简单。编写一个虚拟函数来完成工作,然后再写一个非虚拟函数,它什么也不做只是调用这个虚拟函数。可以内联这个非虚拟函数来避免这个句法花招引起函数调用开销。
26 限制某个类所能产生的对象数量
在类中的静态对象实际上总是被构造(和释放),即使不使用该对象。与此相反,只有第一次执行函数时,才会建立函数中的静态对象,所以如果没有调用函数,就不会建立对象。
类中的静态成员初始化时间不确定。C++为一个translation unit(也就是生成一个 object 文件的源代码的集合)内的静态成员的初始化顺序提供某种保证,但是对于在不同translation unit中的静态成员的初始化顺序则没有这种保证(参见条款E47)。
不要建立包含局部静态数据的非成员函数,可能会使程序的静态对象的拷贝超过一个。
对象所处的三种不同的环境:
- 只有它们本身
- 作为其它派生类的基类
- 被嵌入在更大的对象里
这些不同环境极大地混淆了跟踪存在对象的数目的含义,因为人心目中的对象的存在的含义与编译器不一致。
1 |
|
若private继承Counted类,可以使用using Counted::objectCount;
恢复访问权至public。
必须定义Counted内的静态成员。对于numObjects来说,我们只需要在Counted 的实现文件里定义它:
1 |
|
对于maxObjects,留给此类的客户端类X初始化,例如:
1 |
|
若客户端类X不初始化,连接时会发生错误,因为maxObjects 没有被定义。
27 要求或禁止在堆中产生对象
要求类的对象仅分配在堆中:
将此类的析构函数声明为private,使析构函数不能被隐式调用,因此也就不能在栈中创建对象。客户端只能用伪析构函数释放他们建立的对象。(异常处理体系要求所有在栈中的对象的析构函数必须申明为公有)
如果要继承此类,应将析构函数声明为protected;若要包含此类的对象作为成员,应包含指向其的指针而不是本身。
没有一种可移植的方法来判断对象是否在堆上:
设置静态标志位的方法不行。即使重载operator new和operator
new[],在new对象数组时也只能设置一次标志位。在UPNumber *pn = new UPNumber(*new UPNumber);
情况下,C++不保证两次operator
new和两次UPNumber构造函数的调用顺序,标志位可能被提前清除。
利用栈在结构上趋向低地址、堆在在结构上趋向高地址的内存布局,堆中的变量或对象肯定比任何栈中的变量或对象地址小。首先,并不是所有系统都是这样;其次,这种方法无法辨别堆对象与静态对象。(在很多栈和堆相向扩展的系统里,静态对象位于堆的底端)
判断是否能够删除一个指针是可行的:
构建一个operator new返回的地址集合。通过继承抽象基类的方式实现,避免污染全局命名空间。
带有多继承或虚基类的对象会有几个地址(参见条款24和31),需要把this指针dynamic_cast成const void*
,变成一个指向当前对象起始地址的指针。
禁止堆对象是可行的:
把operator new、operator delete声明为private。但作为派生类的对象和被嵌入在更大的对象里时,无法禁止。
参见条款26中对象所处的三种不同的环境。
28 灵巧(smart)指针
auto_ptr已经废除了。C++11新标准增添了unique_ptr、shared_ptr以及weak_ptr这3 个智能指针。
使用成员模板重载类型转换运算符实现基类和派生类之间的智能指针的隐式转换。
const T*
对应shared_ptr<const T>
,T* const
对应const shared_ptr<T>
。
29 引用计数
写时拷贝:与其它对象共享一个值直到写操作时才拥有自己的拷贝。
除非使用proxy,否则无法区别当前是读操作还是写操作。
1 |
|
上述方法无法解决以下指针(或引用)造成的问题:
1 |
|
解决方法是增加一个标志位指出它是否为可共享的。在最初(对象可共享时)将标志打开,在非const的operator[]被调用时将它关闭。一旦标志位被设为false,就将永远保持在这个状态。
30 代理类
1 |
|
除了以上提到的,要使proxy对象的行为和它们所扮演的对象一致, 你必须重载可作用于实际对象的每一个函数。 例如取地址运算符。
编译器在调用函数而将参数转换为此函数所要的类型时,只调用一个用户自定义的转换函数(参见条款5),因此很可能在函数调用时,传实际对象是成功的而传proxy对象是失败的。
31 让函数根据一个以上的对象来决定怎么虚拟
虚函数体系只能作用在一个对象身上,C++没有提供作用在多个对象上的虚函数。
这个问题被称为二重调度 (double dispatch),名字来自于 object-oriented programming community, 在那里虚函数调用的术语是message dispatch,而基两个参数的虚调用是通过double dispatch实现的,推而广之,在多个参数上的虚函数叫multiple dispatch。
以游戏中的碰撞处理为例,当两个GameObject的派生类的对象碰撞时,碰撞处理函数如何处理?
方法1:虚函数+手动RTTI(typeid)
增加新类型时需要更新if-else,难以维护。
方法2:虚函数+虚函数
增加新类型时需要,每个类都需要增加一个新的虚函数,都需要重新编译。(方法1不需要重新编译)
方法3:虚函数+手动虚函数表(构造一个类似vtbl的映射表以实现二重调度的第二部分)
也需要重新编译。代码如下:
1 |
|
方法4:使用非成员的碰撞处理函数
不需要重新编译。代码如下:
1 |
|
我们可以将映射表HitMap放入一个类,并由它提供动态修改映射关系的成员函数。
6 杂项
32 在未来时态下开发程序
用C++语言自己来表达设计上的约束条件,而不是用注释或文档。 例如,如果一个类被设计得不会被继承,不要只是在其头文件中加个注释,用C++的方法来阻止继承。
提供完备的类,即使某些部分现在还没有被使用。如果有了新的需 求,不用回过头去改它们。
如果没有限制你不能通用化你的代码,那么通用化它。例如,如果在写树的遍历 算法,考虑将它通用得可以处理任何有向不循环图。
33 将非尾端类设计为抽象类
对于以下的OO体系:
1 |
|
应该允许通过指针进行同类型赋值,而禁止通过同样的指针进行混合类型赋值。
1 |
|
在赋值函数内加入dynamic_cast成本高,且可能抛出异常。
在Animal中将operator=置为private,会同时禁止时Animal对象间的赋值。
最容易的实现方法是抽象出AbstractAnimal抽象类作为基类。
实现纯虚函数一般不常见,但对纯虚析构函数,它不只是常见,它是必须。
34 如何在同一程序中混合使用C++和C
确保C++编译器和C编译器兼容,然后考虑4个问题:
名变换
名变换,即C++编译器给程序的每个函数换一个独一无二的名字。因为C++有函数重载,C没有。
因此需要使用 C++的
extern "C"
指示禁止名变换。不要将
extern "C"
看作是申明这个函数是用C语言写的,应该看作是申明在个函数应该被当作好象C写的一样而进行调用。可以对FORTRAN、Pascal、汇编等其它语言使用extern "C"
,也可以对C++本身使用extern "C"
(当要用C++写一个库给使用其它语言的客 户使用时,需要禁止名变换)。1
2
3
4
5
6
7
8
9
10#ifdef __cplusplus
extern "C"
{
#endif
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
#ifdef __cplusplus
}
#endif不同编译器名变换方式不同,如果混合链接来自于不同编译器的obj文件,极可能得到链接错误,因为变换后的名字不匹配。
静态初始化
静态初始化:静态的类对象和定义在全局的、命名空间中的或文件体中的类对象的构造函数通常在main被执行前就被调用。
静态析构:在main结束后析构。
编译器main的最开始插入静态初始化函数,在main结束时插入静态析构函数。
混编时应该用C++写main。将C写的main改名为realMain,然后用C++版本的main调用realMain。
如果不能用C++写main,处理起来就麻烦了。编译器生产商们几乎全都提供了一个额外的体系来启动静态初始化和静态析构的过程,请查阅编译器文档或联系生产商来知道如何实现。
动态内存分配
C++部分使用new和delete, C部分使用malloc和free。只要new分配的内存使用delete释放,malloc分配的内存用free释放,那么就没问题。
数据结构的兼容性
C++中的struct的规则兼容了C中的规则,可以安全传递。如果在 C++版本中增加了非虚函数,其内存结构也没有改变。增加虚函数的结构和有基类的结构无法安全传递,内存结构不同。
35 让自己习惯使用标准C++语言
此节描述了1996年C++的变化。
STL是标准运行库的一部分。
附录
Objects Counting in C++
"Do It For Me" pattern(curiously recurring template pattern)
1 |
|
private继承更紧密,不会扩张对象大小,比复合更好。
使用operator new及对应的operator delete,避免内存泄漏。