《Effective C++》第三版笔记
55 Specific Ways to Improve Your Programs and Designs
Scott Meyers 著
候捷 译
摘录整理。
1 让自己习惯C++
1 视C++为一个语言联邦
C++是多范式(multiparadigm)编程语言,同时支持过程形式、面向对象形式、函数形式、泛型形式、元编程形式。
C++主要的4个次语言:C、Object-Oriented C++、Template C++、STL。
不同次语言的高效编程守则不相同。
2 尽量以const, enum, inline替换 #define
宁可以编译器替换预处理器。
使用#define定义的名称并未进入符号表,难以追踪,并且可能会导致目标码出现多份常量。
class专属常量
1 |
|
通常C++要求对使用的任何东西提供定义式,如果它是static整数类型class专属常量(integral type,例如int、char、bool),则需要特殊处理。只要不取地址,就可以声明并使用,无须提供定义式。
1 |
|
以template inline函数代替宏,可以避免很多潜在的问题。
3 尽可能使用const
const出现在星号左边,表示被指示物是常量;出现在星号右边,指示指针本身是常量。
声明STL迭代器为const就像声明T* const
一样,表示迭代器本身是常量,不得指向不同的东西,但所指的东西的值可以改动。const_iterator
则像const T*
,表示迭代器指向的东西的值不可改动。
const可以重载。
bitwise constness: 成员函数只有在不更改对象之任何成员变量(static除外)时才可以说是const。编译器保证这种常量性,但是仍有漏洞,如返回一个引用可能导致内部成员在外部被更改。
logical constness: 一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得以如此。使用mutable关键字可以释放掉non-static成员变量的bitwise constness约束。
如果要避免重复代码,可以用non-const版本调用const版本,只是要两次转型。不能用const版本调用non-const版本。
1 |
|
4 确实对象被使用前已先被初始化
别混淆赋值(assignment)与初始化(initialization)。
C++规定对象的成员变量的初始化发生在进入构造函数本体之前。member initialization list才是初始化。const和references必须初始化,不能赋值。
成员变量初始化顺序只与类中声明顺序有关,与member initialization list中出现次序无关。
编译单元(translation unit)是指产出单一目标文件的那些源码。基本上它是单一源码文件加上其所含入的头文件。
在函数内的static对象称为local static对象,其他static对象称为non-static对象。
C++对定义于不同编译单元内的non-local static对象的初始化顺序无明确定义。为了保证初始化顺序正确,应该以“函数调用”(返回一个reference指向local static对象)替换“直接访问non-local static对象”。
任何一种non-const static对象在多线程环境下“等待某事发生”都会有麻烦。处理这种麻烦的一种做法是:在程序的单线程启动阶段手工调用所有reference-returning,这可消除与初始化有关的“竞速形势(race conditions)”。
2 构造/析构/赋值运算
5 了解C++默默编写并调用哪些函数
1 |
|
6 若不想使用编译器自动生成的函数,就该明确拒绝
声明为private并不予实现,或继承一个uncopyable class。
现代C++做法是=delete
7 为多态基类声明virtual析构函数
并不是所有类的设计目的都是作为基类使用。为了具备多态性应该声明rtual析构函数;否则不应该。
现代C++可以用final禁止继承
8 别让异常逃离析构函数
如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,吞下它们或结束程序。
如果客户需要对某个操作函数运行期间抛出的异常做出反应,class应该提供一个普通函数(而非在析构函数中)执行该操作。
9 绝不在构造和析构过程中调用virtual函数
在构造/析构期间,virtual函数不是virtual函数。
10 令operator=返回一个reference to *this
这是一个被大家默认遵守的协议,便于连锁赋值。
1 |
|
11 在operator=中处理自我赋值
在赋值前进行证同测试,避免自我赋值;在赋值完成前确保传入对象不被删除;copy and swap。
12 复制对象时勿忘其每一个成分
拷贝应确保复制对象内的所有成员和继承来的所有成员。
不要试图令拷贝构造函数和拷贝赋值操作符相互调用。
3 资源管理
13 以对象管理资源
把资源放进对象内,依赖C++的析构函数自动调用机制确保资源被释放。
C++11之前常被使用的RAII classes是tr1::shared_ptr和auto_ptr。
14 在资源管理类中小心copying行为
复制RAII对象必须一并复制它所管理的资源,资源的copying行为决定RAII对象的copying行为。常见的行为有:拒绝copying、引用计数。
15 在资源管理类中提供对原始资源的访问
每一个RAII类都应该提供访问其管理的原始资源的方法。显式转换比较安全,隐式转换比较方便。
16 成对使用new和delete时要采用相同的形式
即同时带[]或同时不带[]。
17 以独立语句将newed对象置入智能指针
C/C++语言函数参数入栈顺序为从右至左。
参数计算顺序则与编译器有关,例如VS的计算顺序是从右至左,Clang的计算顺序是从左至右。
Java、C#总是以特定次序完成函数参数的计算。
由于C++函数参数计算顺序不确定,不要在函数参数中用new出来的普通指针构造智能指针,否则有可能new出来的普通指针在放入智能指针之前就因为其它参数计算异常而丢失,造成资源泄露。
用独立语句做这件事。
4 设计与声明
18 让接口容易被正确使用,不易被误用
利用类型系统限制函数参数,即为特定参数创建特定的类。
保持接口一致性。如C++ STL容器都有名为size的成员函数,Java则不然。
使用shared_ptr返回参数消除用户的资源管理责任。
shared_ptr支持定制删除器,可防范跨DLL问题。
跨DLL问题指如果两个DLL(或者EXE调用DLL)的CRT链接不同(如一个是MT一个是MD),跨DLL的new/delete成对运用会导致运行期错误。
这个问题的根本原因是同一个内存地址在不同的CRT里面指向的地方是不一样的。
如果都是用的MD就没有问题,那就是用的同一个CRT。
利用虚函数的动态绑定技术可以解决这个问题,因为虚表里面已经指向了创建这个对象的模块里面的CRT的new和delete,那么当我们在DLL里面调用虚函数来释放的时候,系统会为我们找到构造对象时候的释放函数。
19 设计class犹如设计type
新type的对象应该如何被创建和销毁?
对象的初始化和对象的赋值该有什么样的差别?
新type的对象如果被passed by value (以值传递),意味什么?
什么是新type的“合法值" ?
你的新type需要配合某个继承图系(inheritance graph)吗?
你的新type需要什么样的转换?
什么样的操作符和函数对此新type而言是合理的?
什么样的标准函数应该驳回?
谁该取用新type的成员?
什么是新type的“未声明接口”( undeclared interface) ?
你的新type有多么一般化?
你真的需要- - -个新type吗?
20 宁以pass-by-reference-to-const替换pass-by-value
此替换高效且可避免传参切割问题。也有例外:
如果窥视C++编译器的底层,你会发现,references往往以指针实现出来。因此pass by reference通常意味真正传递的是指针。
如果你有个对象属于内置类型(例如int),pass by value往往比pass by reference的效率高些。对内置类型而言,当你有机会选择采用pass by value 或pass by reference to const 时,选择pass by value 并非没有道理。这个忠告也适用于STL的迭代器和函数对象,因为习惯上它们都被设计为pass by value。
21 必须返回对象时,别妄想返回其reference
绝不要:
返回pointer或reference指向一一个 local stack 对象。返回前就已经销毁了。
返回reference指向一个heap-allocated对象。可能内存泄露。
返回pointer或reference指向一个local static对象而有可 能同时需要多个这样的对象。作比较时产生错误。
22 将成员变量声明为private
将成员变量声明为private可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
protected 并不比public更具封装性。
23 宁以non-member、non-friend 替换member函数
这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。
24 若所有参数皆需类型转换,请为此采用non-member函数
比如重载+。
另外,可以避免friend函数就应该避免。
25 考虑写出一个不抛异常的swap函数
C++只允许对class template偏特化,function template不行。
客户可以全特化std内的templates,但不可以添加新的templates(或classes或functions或其他任何东西)到std里头。
如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对 于classes (而非templates),也请特化std: : swap。
所有STL容器也都提供有public swap)成员函数和std::swap特化版本(用以调用前者)。
5 实现
26 尽可能延后变量定义式的出现时间
避免初始化后再赋值,最好等待实参出现,直接构造。
27 尽量少做转型动作
通常应该避免做出“对象在C++中如何布局”的假设。
尽量避免转型、注重效率避免 dynamic_cast、尽量设计成无需转型、可把转型封装成函数、宁可用新式转型,少用旧式转型。
28 避免返回handles指向对象内部成分
这样可以避免悬空指针。
29 为“ 异常安全”而努力是值得的
带有异常安全性的函数满足两个条件:
- 不泄露任何资源。(RAII解决)
- 不允许数据败坏。(copy and swap解决)
1 |
|
30 透彻了解inlining的里里外外
inlining可以免除函数调用成本,代价是更大的目标码和程序体积(导致额外的换页行为,降低高速缓存装置的命中率)。
inline只是一个申请,不是强制命令。定义于class内的函数隐式提出该申请。
编译器通常不对“通过函数指针而进行的调用”实施inlining(因为要取地址),这意味对inline函数的调用有可能被inlined也可能不被inlined,取决于该调用的实施方式。
inline函数的修改导致整个程序需要重新编译。
31 将文件间的编译依存关系降至最低
C++并没有把“将接口从实现中分离”这事做得很好。C++坚持将class 的实现细目置于class 定义式中:
1 |
|
这导致编译依存关系,若date.h改变,每一个含入Person class的文件就得重新编译。因为当定义一个Person对象时,编译器需要知道该分配多少空间。
Java不存在这个问题是因为Java定义对象时编译器只是分配一个指针指向该对象。
设计策略:
如果使用object references 或object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。
如果能够, 尽量以class声明式替换class定义式。注意,当你声明一个函数而它用到某个class 时,你并不需要该class 的定义;纵使函数以by value方式传递该类型的参数(或返回值)亦然。
为声明式和定义式提供不同的头文件。
基于此构想的两个手段是Handle classes和Interface classes。
程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及templates都适用。
6 继承与面向对象程序设计
32 确定你的public继承塑模出is-a关系
以C++进行面向对象编程,最重要的一个规则是: public inheritance (公开继承)意味"is-a" (是一种)的关系。
这个关系的含义:适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
33 避免遮掩继承而来的名称
derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
为了让被遮掩的名称再见天日,可使用using 声明式或转交函数( forwarding functions)。
34 区分接口继承和实现继承
可以为纯虚函数提供定义,调用它的唯一途径是调用时明确指出其class
名称(对象指针->类名::函数名
)。
35 考虑virtual函数以外的其他选择
NVI(non-virtual interface)手法:令客户通过public non-virtual成员函数间接调用private virtual函数。
将virtual函数替换为函数指针/function模板类成员变量。
将继承体系内的virtual函数替换为另一个继承体系内的virtual函数。
36 绝不重新定义继承而来的non-virtual函数
1 |
|
37 绝不重新定义继承而来的缺省参数值
virtual函数是动态绑定(dynamically bound),而缺省参数值是静态绑定(statically bound)。
1 |
|
38 通过复合塑模出has-a或"根据某物实现出"
复合(composition)关系指某种类型的对象内含它种类型的对象。
如果两个classes之间并非is-a的关系,复合比继承更好。
39 明智而审慎地使用private继承
以下情况采用private继承更好:
- 出于条款18,隐藏父类的接口。
- 相比复合,需要重定义virtual函数或访问类成员。
C++官方要求空对象大小不为零,但派生类的基类成分除外,可以为0,称为EBO(empty base optimization)。这例外也只对单继承有效,对多重继承无效。
40 明智而审慎地使用多重继承
看到是否有个函数可取用之前,C++首先确认这个函数对此调用之言是最佳匹配。找出最佳匹配函数后才检验其可取用性。
若多重继承中存在不同继承路径指向相同基类,缺省的做法是执行复制;当使用virtual继承时,基类成为virtual基类,只有一个。
virtual base的初始化责任是由继承体系中的最低层(most derived) class负责:
- classes若派生自virtual bases而需要初始化,必须认知其virtual bases——不论那些bases距离多远。
- 当一个新的derived class加入继承体系中,它必须承担其virtual bases (不论直接或间接)的初始化责任。
非必要不使用virtual继承,且不要放置数据(避免对其初始化和赋值带来的诡异的事)。(Java、.NET语言的Interfaces不允许含有任何数据)
多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class"的两相组合。
7 模板与泛型编程
41 了解隐式接口和编译期多态
classes 和templates都支持接口(interfaces)和多态(polymorphism)。
对classes而言接口是显式的(explicit),以函数签名为中心。多态则是通过virtual函数发生于运行期。
对template参数而言,接口是隐式的(implicit) ,奠基于有效表达式。多态则是通过template具现化和函数重载解析(function overloading resolution)发生于编译期。
42 了解typename的双重意义
意义一:声明template参数时,前缀关键字class和typename可互换。
意义二:C++有个规则可以解析(resolve) 下述歧义状态:如果解析器在template中遭遇一个嵌套从属名称,它便假设这名称不是个类型,除非你用前导typename告诉它是。所以缺省情况下嵌套从属名称不是类型。
1 |
|
上述规则有个例外。typename不可以出现在base classes list内的嵌套从属类型名称之前,也不可在member initialization list (成员初值列)中作为base class修饰符。
1 |
|
43 学习处理模板化基类内的名称
C++知道模板化基类(templatized base classes)有可能被特化,而那个特化版本可能不提供和一般性template相同的接口。因此它往往拒绝在模板化基类内寻找继承而来的名称(拒绝调用)。
就某种意义而言,当我们从Object Oriented C++跨进Template C++ (条款1)继承就不像以前那般畅行无阻了。
三种方法使继承函数有效:
- 函数调用动作前加上
this->
。 - 使用using声明式让编译器进入base class作用域查找该名称。(条款33中用其查找被派生类遮掩的名称)
- 使用
::
显式声明被调用的函数位于base class内。(此举会关闭virtual绑定行为)
44 将与参数无关的代码抽离templates
任何template代码都不该与某个造成膨胀的template参数产生相依关系。
因非类型模板参数(non-type template parameters )而造成的代码膨胀,解决办法是以函数参数或class成员变量替换template参数。
因类型参数(type parameters)而造成的代码膨胀,解决办法是让带有完全相同二进制表述(binary representations)的具现类型(instantiation types)共享实现码。例1,许多平台上int和long二进制表述相同,某些连接器会合并完全相同的函数实现码。例2,许多平台上所有指针类型二进制描述相同,使用void*更好。
45 运用成员函数模板接受所有兼容类型
模板和其派生模板的实例化类不是派生关系。
利用成员函数模板可以让函数接受所有兼容的类型,可以用于类的构造函数和赋值函数。
成员函数模板并不改变语言规则,在类内声明泛化拷贝构造函数并不会阻止编译器生成它们自己的拷贝构造函数。
1 |
|
46 需要类型转换时请为模板定义非成员函数
为了让类型转换可能发生于所有实参身上,我们需要一个non-member函数(条款24);对于模板,为了令这个函数被自动具现化,我们需要将它声明在class 内部;而在class内部声明non-member函数的唯一办法就是令它成为一个friend。(与friend 的传统用途“访问class 的non-public成分”毫不相干)
47 请使用traits classes表现类型信息
traits是一个C++程序员共同遵守的协议。这个技术的要求之一是,它对内置(built-in) 类型和用户自定义(user-defined )类型的表现必须一样好。traits必须能够施行于内置类型意味类型内的嵌套信息(nesting information)这种东西出局了,因为我们无法将信息嵌套于原始指针内。因此类型的traits信息必须位于类型自身之外。
(习惯上traits 总是被实现为structs,但它们却又往往被称为traits classes。)
标准技术是把它放进一个template及其一或多个特化版本中。这样的templates在标准程序库中有若干个。
以迭代器为例。 六种迭代器:
1 |
|
针对迭代器者的traits classes被命名为iterator_ traits:
1 |
|
对于用户自定义类型的做法:
1 |
|
对内置类型(这里是指针)的做法:
1 |
|
利用函数重载在编译期决议使用的函数:(若采用if...else语句和typeid运算符则是在运行期决议,且会导致编译问题,因为编译器必须确保所有源码都有效,纵使是不会执行起来的代码。)
1 |
|
48 认识template元编程
Template metaprogramming (TMP,模板元编程)是编写template-based C++程序并执行于编译期的过程。
TMP已被证明是个“图灵完全”(Turing-complete) 机器,意思是它的威力大到足以计算任何事物。使用TMP你可以声明变量、执行循环、编写及调用函数....
例如条款47展示的TMP if..else条件句是藉由templates 和其特化体表现出来。不过那毕竟是汇编语言层级的TMP。针对TMP而设计的程序库(例如Boost's MPL,见条款55)提供更高层级的语法。
1 |
|
8 定制new和delete
STL容器所使用的heap内存是由容器所拥有的分配器对象(allocator objects) 管理,不是被new和delete直接管理。本章并不讨论STL分配器。
49 了解new-handler的行为
当operator new抛出异常以反映一个未获满足的内存需求之前,它会先调用一个客户指定的错误处理函数,一个所谓的new-handler。
1 |
|
Nothrow new是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是可能抛出异常。
50 了解new和delete的台理替换时机
怎么会有人想要替换编译器提供的operator new或operator delete?
用来检测运用上的错误。
overruns(写入点在分配区块尾端之后)或underruns(写入点在分配区块起点之前)。
超额分配内存,以额外空间放置签名以供检查。
为了强化效能。
为了收集使用上的统计数据。
51 编写new和delete时需固守常规
C++裁定所有非附属(独立式)对象必须有非零大小(条款39)。
C++保证“删除null指针永远安全”,所以你必须兑现这项保证。
52 写了placement new也要写placement delete
当写一个placement operator new ,请确定也写出了对应的placement operator delete。如果没有这样做,程序可能会发生隐微而时断时续的内存泄漏。
当你声明placement new和placement delete,请确定不要无意识(非故意)地遮掩了它们的正常版本(条款33)。
9 杂项讨论
53 不要轻忽编译器的警告
字面意义。
54 让自己熟悉包括TR1在内的标准程序库
TR1代表"Technical Report 1",是C++11之前的过渡期产物。
55 让自己熟悉Boost
Boost是一个社群,也是一个网站。致力于免费、源码开放、同僚复审的C++程序库开发。Boost 在C++标准化过程中扮演深具影响力的角色。