5. 实现
26. 尽可能延后变量定义式的出现时间
- 当定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流(control flow)到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形。
- 这让我们联想起本条款所谓“尽可能延后”的真正意义。你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。如果这样,不仅能够避免构造(和析构)非必要对象,还可以避免无意义的default构造行为。更深一层说,以“具明显意义之初值”将变量初始化,还可以附带说明变量的目的。
- 循环
1 |
|
- 如果classes的一个赋值成本低于一组构造+析构成本,做法A大体而言比较高效。尤其当n值很大的时候。否则做法B或许较好。
- 此外做法A造成名称w的作用域(覆盖整个循环)比做法B更大,有时那对程序的可理解性和易维护性造成冲突。
- 因此除非(1)你知道赋值成本比“构造+析构”成本低,(2)你正在处理代码中效率高度敏感(performance-sensitive)的部分,否则你应该使用做法B。
- 尽可能延后变量定义式的出现时间,这样做可增加程序的清晰度并改善程序效率
27. 尽量少做转型动作
- 旧式转型
- expression
- T(expression)
- 新式转型
- const_cast
(expression) - dynamic_cast
(expression) - reinterpret_cast
(expression) - static_cast
(expression)
- const_cast
- const_cast通常被用来将对象的常量性转除,它也是唯一有此能力的C+-style转型操作符
- dynamic_cast主要用来执行“安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作
- reinterpret_cast意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植。例如将一个pointer to int转型为一个int。这一类转型在低级代码以外很少见。本书只使用一次,那是在讨论如何针对原始内存(raw memory)写出一个调试用的分配器(debugging allocator)时,见条款50。
- static_cast用来强迫隐式转换(implicit conversions),例如将non-const对象转为const对象(就像条款3所为),或将int转为double等等。它也可以用来执行上述多种转换的反向转换,例如将void*指针转为typed指针,将pointer-to-base转为pointer-to-derived。.但它无法将const转为non-const一这个只有const_cast才办得到。
- 使用旧式转型的时机是,当我要调用一个explicit构造函数将一个对象传递给一个函数时。
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内
- 宁可使用C++-style新式转型,不要使用就是转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
28. 避免返回handles指向对象内部成分
- 避免返回handles(包括references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降至最低。
29. 为”异常安全“而努力是值得的
- 异常安全函数即使发生异常也不会泄露资源或允许任何数据结构破坏。这样的函数区分为三种可能的保证:基本型,强烈型,不抛异常型
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态
- 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
- 不抛掷(nothrow)保证,承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如ints,指针等等)身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。
- 异常安全码(Exception-safe code)必须提供上述三种保证之一。如果它不这样做,它就不具备异常安全性。因此,我们的抉择是,该为我们所写的每一个函数提供哪一种保证?除非面对不具异常安全性的传统代码(我将在本条款末尾讨论那种情况),否则你应该只在一种情况下才不提供任何异常安全保证:你那“天才班”需求分析团队确认你的应用程序有“泄漏资源”并“在执行过程中带着败坏数据”的需要。
- 有个一般化的设计策略很典型地会导致强烈保证,很值得熟悉它。这个策略被称为copy and swap。原则很简单:为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。
- 实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object,.即副本)。
- copy-and-swap的关键在于“修改对象数据的副本,然后在一个不抛异常的函数中将修改后的数据和原件置换”,因此必须为每一个即将被改动的对象做出一个副本,那得耗用你可能无法(或无意愿)供应的时间和空间。
- 如果系统内有一个(惟有一个)函数不具备异常安全性,整个系统就不具备异常安全性,因为调用那个(不具备异常安全性的)函数有可能导致资源泄漏或数据结构败坏。
- 强烈保证往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备实现意义
- 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
30. 透彻了解inlining的里里外外
- 在一台内存有限的机器上,过度热衷inlining会造成程序体积太大(对可用空间而言)。即使拥有虚内存,inline造成的代码膨胀亦会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cache hit rate),以及伴随这些而来的效率损失。
- 换个角度说,如果inline函数的本体很小,编译器针对“函数本体”所产出的码可能比针对“函数调用”所产出的码更小。果真如此,将函数inlining确实可能导致较小的目标码(object code)和较高的指令高速缓存装置击中率!
- 声明inline函数的做法则是在其定义式前加上关键字inline
- inline函数通常一定被置于头文件内,因为大多数建置环境(build environments).在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。某些建置环境可以在连接期完成inlining,少量建置环境如基于,NET CLI(Common Language Infrastructure;公共语言基础设施)的托管环境(managed environments)竞可在运行期完成inlining。然而这样的环境毕竟是例外,不是通例。Inlining在大多数C+程序中是编译期行为。
- Templates通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
- Template的具现化与inlining无关。如果你正在写一个template而你认为所有根据此template具现出来的函数都应该inlined,请将此template声明为inline
- 大多数编译器拒绝太过复杂(例如带有循环或者递归)的函数inling,而所有对virtual函数的调用也都会使inlining落空
- virtual意味“等待,直到运行期才确定调用哪个函数”,而inline意味“执行前,先将调用动作替换为被调用函数的本体”。如果编译器不知道该调用哪个函数,你就很难责备它们拒绝将函数本体inlining。
- 程序库设计者必须评估“将函数声明为inline”的冲击:inline函数无法随着程序库的升级而升级。换句话说如果f是程序库内的一个inline函数,客户将“f函数本体”编进其程序中,一旦程序库设计者决定改变£,所有用到£的客户端程序都必须重新编译。这往往是大家不愿意见到的。然而如果f是non-inline函数,旦它有任何修改,客户端只需重新连接就好,远比重新编译的负担少很多。如果程序库采取动态连接,升级版函数甚至可以不知不觉地被应用程序吸纳。
- 大部分调试器面对inline函数都束手无策。
- 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为function templates出现在头文件,就将它们声明为inline
31. 将文件间的编译依存关系降至最低
- C++并没有把“将接口从实现中分离”这事做得很好
- class定义式包括class接口,实现细目(eg: std::string theName;)
- 将Person类分割为两个class,一个只提供接口,另一个负责实现该接口
1 |
|
- 在这里,main class(Person)只内含个指针成员(这里使用trl:shared ptr,见条款13),指向其实现类(PersonImpl)。这般设计常被称为pimpl idiom。这种classes内的指针名称往往就是pImpl
- 这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每一件事都源自于这个简单的设计策略:
- 如果使用object references 或者object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers,如果定义某类型的objects,就需要用到该类型的定义式
- 如果能够,尽量以class声明式替换class定义式。当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value方式传递该类型的参数(或返回值)亦然
1 |
|
- 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。因此程序库客户应该总是#include一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。
1 |
|
- "datefwd.h",命名方式取法c++标准程序库头文件的
。 深具启发意义的另一个原因是,它分外彰显“本条款适用于templates也话用于non-templates”。
1 |
|
- Person构造函数以new调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name。让Person变成一个Handle class并不会改变它做的事,只会改变它做事的方法
- 另一个制作Handle class方法是,令Person成为一种特殊的abstract base class(抽象基类),称为interface class。这种class的目的是详细一一描述derived classes的接口,因此它通常不带成员变量,也没有构造函数,只有一个virtual析构函数(见条款7)以及一组pure virtual函数,用来叙述整个接口。
- 在Handle classes身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation ppinter的大小。最后,implementation pointer必须初始化(在Handle class构造函数内),指向一个动态分配得来的implementation object,,所以你将蒙受因动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇bad al1oc异常(内存不足)的可能性。
- 至于Interface classes,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃(indirect jump)成本(见条款7)。此外Interface class派生的对象必须内含一个vptr(virtual table pointer,再次见条款7),这个指针可能会增加存放对象所需的内存数量一实际取决于这个对象除了Interface class之外是否还有其他virtual函数来源。
- 最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为。条款30解释过为什么函数本体为了被inlined必须(很典型地)置于头文件内,但Handle classes和Interface classes正是特别被设计用来隐藏实现细节如函数本体。
- 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes
- 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。
5. 实现
http://binbo-zappy.github.io/2024/11/27/effective-cpp/5-实现/