EffectiveCpp0x5
Implementation
[toc]
26. Postpone variable definitions as long as possible.
只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流(control flow) 到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形。
考虑一个加密函数
当抛出一个异常时(如加密字符串长度过短),则该encrypted字符串将未被使用:
1 | // this function defines the variable "encrypted" too soon |
延后了声明时间,但是是缺省的构造函数相较于使用含参构造函数效率较低。条款4 曾解释为什么“通过default 构造
函数构造出一个对象然后对它赋值”比“直接在构造时指定初值”效率差。
1 | // this function postpones encrypted's definition until it's truly necessary |
让我们将构造和赋值连接起来
1 | // this function postpones encrypted's definition until |
这是推荐的方法:用 password 初始化 encrypted,
1 | // finally, the best way to define and initialize encrypted |
这让我们联想起本条款所谓“尽可能延后”的真正意义。你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止
。如果这样,不仅能够避免构造(和析构)非必要对象,还可以避免无意义的default 构造行为。更深一层说,以“具明显意义之初值”将变量初始化,还可以附带说明变量的目的。
循环
1 | // Approach A: define outside loop // Approach B: define inside loop |
方法 A:1 个构造函数 + 1 个析构函数 + n 个赋值。
方法 B:n 个构造函数 + n 个析构函数。
如果classes 的一个赋值成本低千一组构造+析构成本,做法A 大体而言比较高效。尤其当n 值很大的时候。否则做法B 或许较好。此外做法A 造成名称w 的作用域
(覆盖整个循环)比做法B 更大,有时那对程序的可理解性和易维护性造成冲突。因此除非
- 你知道赋值成本比“构造+析构”成本低。
- 你正在处理代码中效率高度敏感C performance-sensitive) 的部分,否则你应该使用做法B 。
请记住
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
27. Minimize casting
三种不同形式的转型
1 | (T)expression //C风格 |
许多程序员相信,转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型。这是错误的观念。
请看如下实现了多重继承:
1 |
|
pb2的地址比pb1和&d的地址多16,即一个int加上虚函数指针
4+8=12(内存对齐
使得其为16),但是普通函数并不占用此内存布局,多个虚函数只需要一个虚指针。
注意,对象的布局和地址的计算方式随编译器的不同而不同。
不要对this指针进行转型
1 | class Window { // base class |
强制转型创建了一个* this 的基类部分的新的临时的拷贝
,然后调用这个拷贝的 onResize!
它是在“当前对象之base class 成分”的副本上调用window: :onResize, 然后在当前对象身上执行Special window 专属动作。导致的境况是那些代码使当前对象进入一种病态,没有做基类的变更,却做了派生类的变更。
正确做法,你应该调用当前对象的基类版本。
1 | class SpecialWindow: public Window { |
这个例子也表明如果你发现自己要做强制转型,这就是你可能做错了某事的一个信号。在你想用 dynamic_cast 时尤其如此。
dynamic_cast
dynamic_cast
的实现依赖于 RTTI 机制。RTTI 会在每个对象中存储有关对象类型的信息,包括虚函数表(vtable)等。在运行时,dynamic_cast
使用这些信息来进行类型检查。- 我们为什么需要dynamic_cast:在我们只有基类指针的情况下,在我认定为的derived class对象上执行对应的操作。如:我们有一个指向基类人的指针,有两个派生类:敌人和人质。故我们需要判断,敌人就kiil掉,人质则保护他。我们可不能像
俄式救援
那样,不分敌我。
同时,有两种方式可以避免使用dynamic_cast:
- 使用存储着直接指向派生类对象的指针(通常是智能指针——参见 Item 13)的容器,从而消除通过基类接口操控这个对象的需要。如设计一个敌人容器和人质容器。
1 |
|
- 在base class内提供virtual 函数做你想对各个派生类做的事,通过缺省实现。
1 |
|
请记住
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynarnic_cast 。
- 如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 宁可使用C++-style (新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
28. Avoid returning “handles” to object internals
//TODO
1 | class Point { // class for representing points |
- upperLeft 的调用者能够使用被返回的reference (指向rec 内部的Point 成员变量)来更改成员。
- 第一,成员变量的封装性最多只等于“返回其reference” 的函数的访问级别。本例之中虽然ulhc 和lrhc 都被声明为private,它们实际上却是public。
- 第二,如果const 成员函数传出一个reference, 后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。这正是
bitwise constness
的一个附带结果,见条款3 。
我们通过将返回值修改为const来防止修改对象。
1 | class Rectangle { |
但是这种方法还是有问题的,若使用该引用时对象已经被销毁,会导致悬垂引用(dangling references)
,产生未定义行为。悬垂引用(dangling references)
指的是引用了已经被释放或无效的对象或变量的情况。还有悬垂指针
指指向已经被释放或无效的内存地址的指针。
请记住
- 避免
返回handles (包括references 、指针、迭代器)
指向对象内部。遵守这个条款可增加封装性,const 成员函数的行为像个const。
29.Strive for exception-safe code.
异常安全的三种承诺
- 基本异常安全性(Basic Exception Safety):
- 承诺:不会泄露资源,对象保持一致性。
- 简要描述:在发生异常时,对象的内部状态仍然保持一致,没有资源泄漏,但可能会存在部分修改未被回滚。
- 强异常安全性(Strong Exception Safety):
- 承诺:在发生异常时,程序状态不会改变,资源不会泄漏。
- 简要描述:无论异常发生与否,程序状态都会保持不变,对象的内部状态完全一致,没有资源泄漏。
- 无异常安全性(No-Throw or No-Except Safety):
- 承诺:永远不会抛出异常。
- 简要描述:在函数执行过程中不会抛出异常,即使是在异常发生的情况下也能够正常执行。这通常需要通过使用
noexcept
关键字来声明函数不会抛出异常。
1 | class PrettyMenu { |
当异常被抛出时,带有异常安全性的函数会:
- 不泄漏任何资源。上述代码没有做到这一点,因为一旦”new Image (imgSrc)” 导致异常,对unlock 的调用就绝不会执行,于是互斥器就永远被把持住了。
- 不允许数据败坏。如果”new Image (imgSrc)” 抛出异常,gimage 就是指向一个已被删除的对象, imageChanges 也已被累加,而其实并没有新的图像被成功安装起来。
由智能指针管理图像和锁,在reset成员函数旧图像被替换引用归零对象被删除,在作用域结束时,锁自动被释放。保证基本异常安全性。
1 | class PrettyMenu { |
copy and swap
通过pimpl idiom实现。实现了强异常安全性。
copy-and-swap
的关键在千“修改对象数据的副本,然后在一个不抛异常的函数
中将修改后的数据和原件置换”,
1 | struct PMImpl { //因为pretty的封装性使由pImpl是private得到了保证 |
考虑如下函数
1 | void someFunc() |
很明显,如果 f1 或 f2 低于强力异常安全,someFunc 就很难成为强力异常安全的。例如,假设 f1 仅提供基本保证。为了让 someFunc 提供强力保证,它必须写代码在调用 f1 之前测定整个程序的状态,并捕捉来自 f1 的所有异常,然后恢复到最初的状态。
即使 f1 和 f2 都是强力异常安全的,事情也好不到哪去。如果 f1 运行完成,程序的状态已经发生了毫无疑问的变化,所以如果随后 f2 抛出一个异常,即使 f2 没有改变任何东西,程序的状态也已经和调用 someFunc 时不同。
当“强烈保证”不切实际时,你就必须提供“基本保证”。现实中你或许会发现,你可以为某些函数提供强烈保证,但效率和复杂度带来的成本会使它对许多人而言摇摇欲坠。
问题出在“连带影响”(side effects) ~如果函数只操作局部性状态(local state,例如someFunc 只影响其“调用者对象”的状态),便相对容易地提供强烈保证。但是当函数对“非局部性数据” (non-local data) 有连带影响时,提供强烈保证就困难得多。
请记住
- 异常安全函数(Exception-safe functions) 即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:
基本型、强烈型、不抛异常型
。 - “强烈保证”往往能够以copy-and-swap 实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
- 函数提供的“异常安全保证“通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
30. Understand the ins and outs of inlining.
- 过度使用inline函数会膨胀你生成的二进制文件。
- inline只是对编译器的一个申请,不是强制命令。
1 | class Person { |
- 这样的函数通常是成员函数,但是 Item 46 解释了友元函数也能被定义在类的内部,如果它们在那里,它们也被隐式地声明为 inline。
例如,如果你的程序要持有一个 inline 函数的地址,编译器必须为它生成一个 outlined 函数本体。他们怎么能生成一个指向根本不存在的函数的指针呢?再加上,编译器一般不会对通过函数指针的调用进行 inline ,这就意味着,对一个 inline 函数的调用可能被也可能不被 inline 。
1 | inline void f() {...} // assume compilers are willing to inline calls to f |
- Inline 函数通常-定被置于头文件内,因为大多数建置环境(build environments)在编译过程中进行inlining, 而为了将一个“函数调用“替换为”被调用函数的本体”,编译器必须知道那个函数长什么样子。
- Templates 通常也被置千头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
- 大部分编译器拒绝将
太过复杂(例如带有循环或递归
)的函数inlining, 而所有对virtual 函数
的调用(除非是最平淡无奇的)也都会使inlining 落空。 - virtual 意味”等待,直到运行期才确定调用哪个函数”,而inline 意味“执行前,先将调用动作替换为被调用函数的本体”。
- 构造函数和析构函数不适合inline,因为inline后必须处理异常,膨胀生成的二进制文件。
- inline函数难以随程序库的升级而升级。如果你改变了内联函数的实现,必须重新编译引用了该内联函数的所有代码,以便更新这些代码中的内联函数定义。
请记住
- 将大多数inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary upgradability) 更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为function templates 出现在头文件,就将它们声明为inline 。
31. Minimize compilation dependencies between files.
请看一个例子
1 |
|
- 当我们在
#include时,
就在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)
,如果被包含文件发生任何改动,那么包含它的源文件也需要重新编译。在大型项目中,这是致命的。 - 我们可以通过
预编译头文件
来防止重复编译相同的头文件。 cpp编译器
在编译期间要知道它的对象的大小。
我们可以使用前向声明
,前向声明
是在编译时解析的,它告诉编译器某个实体的存在,但不提供其具体的实现细节。
1 | namespace std { |
又是pimple idiom
通过pimple idiom
可以实现,person
class只含有一个PersonImpl
指针成员,指向其实现类。
1 |
|
Person 的客户就完全与Dates, Addresses 以及Persons 的实现细目分离了。那些classes 的任何实现修改都不需要Person 客户端重新编译。此外由于客户无法看到Person 的实现细目,也就不可能写出什么“取决于那些细目”的代码。这真正是“接口与实现分离”!
关键在于以
“声明的依存性"
替换“定义的依存性”
,那正是编译依存性最小化
的本质:现实中让头文件尽可能自我满足,万一做不到,则计它与其他文件内的声明式(而非定义式)相依。
像 Person
这样使用 Pimpl Idiom 的类通常被称为Handle classes(句柄类)
。Handle 类封装了指向实现类(Implementation classes)的指针,并将所有函数调用转发给实现类来完成实际的工作。Handle 类的主要作用是提供公共接口,并隐藏了实现细节,使得用户只需与 Handle 类交互而无需了解其背后的具体实现。
1 |
|
在Handle classes 身上,成员函数必须通过implementation pointer 取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数景必须增加implementation pointer 的大小。最后, implementation pointer 必须初始化(在Handleclass 构造函数内),指向一个动态分配得来的implementation object, 所以你将蒙受因动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇bad—alloc 异常(内存不足)的可能性。
interface class
令person称为特殊的abstract base class(抽象基类)
工厂函数或虚拟构造函数的概念,用于创建派生类对象并返回指向基类接口的指针。这样的设计模式允许客户端代码通过调用工厂函数来创建新对象,而无需直接使用派生类的构造函数。这种做法有助于实现多态性和封装性
。
这种class 的目的是详细一一描述derived classes 的接口(见条款34) ,因此它通常不带成员变量,也没有构造函数,只有一个virtual 析构函数(见条款7) 以及一组pure virtual 函数,用来叙述整个接口。
1 | class Person { |
这个class 的客户必须以Person 的pointers 和references
来撰写应用程序,因为它不可能针对“内含pure virtual 函数”的Person classes 具现出实体。(然而却有可能对派生自Person 的classes 具现出实体)就像Handle classes 的客户一样,除非Interface class 的接口被修改否则其客户不需重新编译。
我们可以定义工厂函数
或virtual构造函数
返回智能指针
指向动态分配所得对象来为这种class创建新对象。
1 | class Person { |
1 | class RealPerson: public Person { |
1 | std::string name; |
对这个特定的 RealPerson,写 Person::create 确实没什么价值:
1 | std::tr1::shared_ptr<Person> Person::create(const std::string& name, |
Person::create 的一个更现实的实现会创建不同派生类型的对象,依赖于诸如,其他函数的参数值,从文件或数据库读出的数据,环境变量等等。
至于Interface classes, 由千每个函数都是virtual, 所以你必须为每次函数调用付出一个间接跳跃(indirect jump) 成本(见条款7) 。此外Interface class 派生的对象必须内含一个vptr (virtual table pointer, 再次见条款7) ,这个指针可能会增加存放对象所需的内存数量一实际取决于这个对象除了Interface class 之外是否还有其他virtual 函数来源。
others
如果使用object references 或object pointers 可以完成任务,就不要使用objects 。你可以只靠一个类型声明式就定义出指向该类型的references 和pointers: 但如果定义某类型的objects, 就需要用到该类型的定义式。
如果能够,尽量以class 声明式替换class 定义式。注意,当你声明一个函数而它用到某个class 时,你并不需要该class 的定义;纵使函数以by value 方式传递该类型的参数(或返回值)亦然:
1 | class Date; // 类声明。 |
- 为声明式和定义式提供不同的头文件
我们可以通过#include
头文件来代替手工前置声明
1 |
|
- C++提供export关键词,允许将template声明式和template 定义式分割
请记住
- 支持“
编译依存性最小化
”的一般构想是:相依千声明式,不要相依于定义式。基于此构想的两个手段是Handle classes 和Interface classes 。 - 程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms) 的形式存在。这种做法不论是否涉及templates 都适用。