Effective C++ 学习笔记 5:实现
- Effective C++ 学习笔记 1:让自己习惯 C++
- Effective C++ 学习笔记 2:构造、析构、赋值运算
- Effective C++ 学习笔记 3:资源管理
- Effective C++ 学习笔记 4:设计与声明
- Effective C++ 学习笔记 5:实现
- Effective C++ 学习笔记 6:继承与面向对象设计
- Effective C++ 学习笔记 7:模板与泛型编程
- Effective C++ 学习笔记 8:定制 new 和 delete
- Effective C++ 学习笔记 9:杂项讨论
条款 26:尽可能延后变量定义式的出现时间
Postpone variable definitions as long as possible.
尽可能的在需要使用变量(或指类型对象)时,再定义对象,尽可能避免无用的构造和析构操作。
如果构造对象之后,紧接着需要对对象进行赋值操作,更好的设计是直接调用有参数的构造函数直接完成。如:
1 | std::string enc; // 调用默认构造函数 |
如果一个对象是循环内变量,若满足以下两个条件:
- 能确定构造、析构操作比赋值操作代价低;
- 需要高度性能敏感的代码;
则将循环内变量外提,在循环之前定义;否则,建议在循环内定义对象。
原因是,前者的代价是:1 次构造 + 1 次析构 + n 次赋值;后者的代价是:n 次构造 + n 次赋值。(n 为循环次数)
建议使用后者的原因是,尽可能的控制对象的作用域。
总结
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款 27:尽量少做转型动作
Minimize casting.
传统的 C 转型方式有两种:
1 | (T)expression // 将 expression 转型成 T 类型 |
C++ 中提供了新型转型:
1 | const_cast<T> (expression) // const 属性去除转型,C 转型没有这个功能 |
建议使用新型转型,原因:
- 容易辨识,也容易自动化查找(如 grep);
- 功能效果细化,容易控制,不易出错;
谨慎使用转型动作,尽可能用其他设计来替代转型动作。
dynamic_cast 转型可能会非常慢,尽量避免使用,可使用多态的方案替代。
或者将转型动作藏起来。
总结
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast。如果需要转型,试着使用其他无需转型的替代方案。
- 如果必须要用转型,试着将其藏在函数内部,避免客户在他们自己的代码中去使用转型。
- 宁可使用 C++ 风格的新式转型,不要使用旧式转型。
条款 28:避免返回handles 指向对象内部成分
Avoid returnning “handles” to object internals.
本条款中所提的 handles 是指引用、指针等类型,可以间接访问到对象内部数据。
举例说,如果我们设计了一个类内公有成员函数,返回一个类私有成员的引用:
1 | // 假设类 Rec 中有指针私有数据 pData,指向 Point 类型的对象 point |
我们这样的设计,因为加了 const,本身是不希望 point 数据被修改,虽然 getPoint 本身无法修改 point 的值了,但其返回 point 数据的引用,用户可以通过引用来操作 point,所以,本质上并没有保证封装性。
成员变量的封装性最多只等于返回其 handles 的函数的访问级别。
我们可以通过修改为以下形式来避免问题:
1 | const Point& Rec::getPoint() const { return pData->point; } |
返回 handles 还会带来另一个潜在的问题,这便是如果返回的 handles 生存期长于 它所指向的对象时,导致 handles 被挂起(dangling)的问题。比如返回指针指向的对象仅仅是函数内部拷贝的临时对象的情况。
有时的设计必须这么做,那便不得不做,但尽可能避免。
总结
- 避免返回 handles (包括引用、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const,并将发生 dangling handles 的可能性将至最低。
条款 29:为“异常安全”而努力是值得的 (重要)
Strive for exception-safe code.
当异常抛出时,具有异常安全性的函数应该具有:
- 不泄露任何资源。
- 不允许产生错误的数据。
如下例子:
1 | class M { |
我们习惯会这么写代码,但是,这不是异常安全性的函数。如果 new Image(img) 时抛出异常,则互斥锁无法释放(违反:不泄露任何资源),而且imageChanges 实际已经改变,且bgImg 将为空指针(均违反:不允许产生错误数据)。
异常安全性的函数提供三种保证之一:
- 基本承诺:这是最基本的保证,保证发生异常后,程序内的状态依然有序,没有数据和对象被破坏。对于上例来说,便是能保证图像改变次数正常,图像指针不会为空。
- 强烈保证:更高级的保证,保证发生异常后,程序状态不变,也就是一旦发生了异常,程序会完全退回到发生异常之前的状态。对于上例来说,就像从来没调用过 changeB 函数一样。
- 不抛出异常保证,这是最佳保证,承诺不抛出异常,也就承诺了程序一定按正常的控制流执行,也就是一定能执行成功。
话题 1:不抛出异常保证
通常来说比较困难,只要使用了自定义的类型,比如容器类,就难免会遇到内存不足的异常(bad_alloc)。如果真的可以做到是最好。
话题 2:强烈保证
对于上例来说,强烈保证可以通过资源对象和智能指针来管理 bgImg 成员,在之前的条款中大概都提到过。
1 | class M { |
ml 对象来管理 mutex,将不再需要 unlock 操作,这个操作实际放在了 Lock 类的析构函数中,在退出 changeB 函数时会自动调用。而 bgImg 的使用,可以保证在 new Image(img) 完全正常的情况下,才修改 bgImg,如果 new Image(img) 出现异常,则不会进入 reset 函数内部,进而能够保证原始图像不变。
另一种非常巧妙的办法是 copy and swap 方法。也就是,先复制一个副本,对副本做好操作,再用 swap 将副本和原始对象做交换,我们还记得,swap 函数是需要保证不抛出异常的函数。
1 | struct PM { // 我们将所有的类资源拿出来单独做一个结构体 |
话题 3:连带影响
有些时候,我们很难做到强烈保证,这是因为连带影响。意思是,虽然我们自己的代码做到了高保证,但是我们的代码调用了一些其他代码,那些代码却没有做到高保证,那么实际上我们的代码也不是高保证。
所谓之,异常安全性级别,通常等价于那段代码中最差的安全性保证的级别。
当然,还有另一个原因是,强烈保证通常需要提供更多的资源来完成,比如为了 copy and swap,我们需要额外的一个资源副本,如果在一些场合下不适合提供这种条件,则我们只能做到基本保证。
将异常安全性做的越好的趋势,是一种期许,并没有说异常安全性差就是错,只是说那样做不够好。
总结
- 异常安全函数即使发生异常也不会泄露资源或允许任何数据结构被破坏,有三种可能的保证:基本保证、强烈保证和不抛出异常。
- 强烈保证通常能够使用 copy and swap 方式实现,但有些时候,这样做有点困难,所以并不强求。
- 函数提供的异常安全性保证级别等于它所调用的各个函数代码的异常安全性保证级别中的最差者。
条款 30:透彻了解 inlining 的里里外外
Understand the ins and outs of inlining.
只把精致短小且会被频繁调用的函数做成 inline 函数,最大化程序的运行效率的同时,避免代码体积膨胀。
inline 函数只是对编译器的一个申请,编译器会自己决定是否要把一个修饰为 inline 的函数 inline。
这个申请可以显式的通过指定 inline 关键字来发出,同时也可以隐式的发出。隐式发出比如可以将函数写在 class 内,比如成员函数或友元函数。
大多数编译器会拒绝将过于复杂的函数和 virtual 函数 inline。
大多数时候,即使一个函数 inline 了,编译器还是会生成一份函数本体,比如,程序中会通过函数指针调用到 inline 函数。
不要将构造函数和析构函数 inline,即使函数内容非常简单。原因是编译器会为构造和析构函数添加很多内容,所以实际上并不是值得 inline 的函数。
不要随意将 template function 声明为 inline,即使它们和 inline 函数一样是放到头文件中定义的。template function 可能会很复杂,并不适合 inline。
将函数 inline 还会遇到升级的问题,inline 函数的升级必将要求所有引用 inline 函数的程序重新编译。
调试器对 inline 函数会束手无策,因为无法调试一个不存在的函数。
谨记二八原则,去追查导致 80% 运行时间的那 20% 的代码,并将这块代码进行 inline 处理,才是最佳设计。
总结
- 将大多数 inlining 限制在小型、被频繁调用的函数身上。这可使得日后的调试过程和二进制升级更加容易,也可使代码膨胀问题最小化,使程序的速度有最大化的提升。
- 不要因为 function templates 出现在头文件中了,就一定将它们声明为 inline。
条款 31:将文件间的编译依存关系将至最低
Minimize compilation dependencies between files.
这个条款主要讲的是,如果我们设计了一个代码模块,在之后使用的过程中,如果代码模块中部分内容被修改,即使是类内的一个私有成员,那么在重新构建时,所有引用这个代码模块的其他代码都将会被重新编译。
为了解决这种问题,也就是尽量避免在修改一些代码后,编译会引入多余但非必要的编译动作,我们需要使用一些设计模式。
两种方法,一种是被称为 Handles classes,另一种是被称为 Interfaces classe。
我们假设一个例子,比如有一个 Person 类:
1 |
|
这个例子目前是常规写法,一旦我们修改了 Date 类里边的东西,那么 Person 类也一定会被重新编译。
话题 1:Handles classes
这种方法,是通过将 Person 类中的类依赖提出来,只做前置声明,而不做类实现。而是把本应该在类里边实现的东西单独拿到另一个类中完成。
1 |
|
PersonImpl 中实现了 theBirthDate,并且放在另一个文件中,和 Person 隔离开来,这样,当 Date 被修改后,因为 Person 类中并没有对 Date 对象的定义,所以不会重新编译。
更优雅的方式是,将所有前置声明放到一个头文件中,比如说叫 “datefwd.h”。
在实现 Person 中成员方法的 cpp 文件中,应该包含两个头文件,一个是 “Person.h”,存放类 Person 的定义,也就是上边的那段代码;另一个是 “PersonImpl.h”,存放类的实现类的定义(就是 PersonImpl 的定义)。
话题 2:Interface classes
比较好理解,就是把这个类声明为接口类,也叫虚拟类。虚拟类无法定义对象,他含有纯虚函数。
1 | class Person { |
通过继承这个接口类,在派生类中实现这些方法,进而实例化对象。
话题 3:缺点
对于 Handle classes,其缺点就是多一层封装,会多消耗内存,还需要手动的初始化 pImpl 指针,这需要在堆上分配 PersonImpl 的对象,可能会导致分配失败。另外,同时同步维护两个头文件,”Person.h” 和 “PersonImpl.h” 也需要花费精力。
对于 Interface classes,其缺点是虚函数指针会占用内存,多态函数会多一次跳转的成本。
虽然如此,还是要在可能做声明与定义分离时(在不担心缺点造成很严重问题的前提下),去做分离。
总结
- 支持 “编译依存性最小化” 的一般构想是:相依于声明,不要相依于定义。两个手段是 Handle classes 和 Interface classes。
- 程序库文件应该以 “完全且仅有声明” 的形式存在。这种做法无论对是否有 templates 都适用。
封面图片来自:Photo by Pawel Czerwinski on Unsplash
本文同步发布在 CSDN 账号下:Effective C++ 学习笔记 第五章:实现



















