实现

条款26:尽可能延后变量定义的出现时间

只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。

你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。如果这样,不仅能够避免构造和析构非必要对象,还可以避免无意义的 default 构造行为。

循环怎么办?

小结:

  • 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。

条款27:尽量少做转型动作

C++ 提供四种新式转型:

  • const_cast 通常被用来将对象的常量性转除。它也是唯一有此能力的 C++ style 转型操作符。

  • dynamic_cast 主要用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。

  • reinterpret_cast 意图执行低级转型动作,实际动作及结果可能取决于编译器,这也就表示它不可移植。例如将一个 pointer to int 转型为一个 int

  • static_cast 用来强迫隐式转换,例如将 non-cost 对象转为 const 对象,或将 int 转为 double 等等。它也可以用来执行上述多种类型的反向转换,例如将 void*转为 typed 指针,将 pointer-to-base 转为 pointer-to-derived。但它无法将 const 转为 non-const ,这个只有 const_cast 才办得到。

新式转型较受欢迎:1. 容易在代码中辩识出 2. 各转型动作目标更明确,编译器更可能诊断出错误的运用。

任何一个类型转换往往真的令编译器编译出运行期间执行的代码。

1
2
3
4
class Base { ... };
class Derived { ... };
Derived d;
Base *pb = &d; // 隐喻的将 Derived* 转为 Base*

这种情况下,有时候上述的两个指针值并不相同。这个例子表明,单一对象(例如一个类型为 Derived 的对象)可能拥有一个以上的低质(例如“以Base指向它”时的地址和“以Derived指向它”时的地址)。

许多应用程序框架都要求 derived class 内的 virtual 函数代码的第一个动作就先调用 base class 的对应函数。

1
2
3
4
5
6
7
8
9
class SpecialWindow : public Window {
public:
virtual void onResize() {
static_cast<Window>(*this).onResize();
// 将 *this 转型为 Window,然后调用其 onResize,这不可行
... // 这里执行SpecialWindow专属行为
}
...
};

上述代码并非在当前对象身上调用 Window::onResize 之后又在该对象上执行 SpecialWindow 专属动作。它是在“当前对象之 base class 成分”的副本上调用 Window::onResize,然后在当前对象身上执行SpecialWindow 专属动作。

函数就是函数,成员函数只有一份,调用起哪个对象身上的函数有什么关系呢?关键在于成员函数都有个隐藏的 this 指针,会影响成员函数操作的数据。

解决方法是拿掉转型动作,代之以你真正想说的话,你只是想调用 base class 版本的 onResize 函数,令它作用于当前对象身上。

1
2
3
4
5
6
7
8
class SpecialWindow : public Window {
public:
virtual void onResize() {
Window::onResize();
... // 这里执行SpecialWindow专属行为
}
...
};

dynamic_cast 的许多实现版本执行速度相当慢,例如至少有一个相当普遍的实现版本基于“class 名称之字符串比较”,如果你在四层深的单继承体系内的某个对象身上执行 dynamic_cast 刚才说的那个实现版本提供的每一个 dynamic_cast 可能会耗用多达四次的 strcmp 调用,用以比较 class 名称。深度继承和多重继承的成本更高!

之所以需要 dynamic_cast ,通常是因为你想在一个你认定为derived class 对象身上执行 derived class 操作函数,但你的手上却只有一个“指向base”的 pointer 或 reference,你只能靠它们来处理对象。
有两个一般性做法可以避免这个问题:

  • 使用类型安全容器
  • 将 virtual 函数往继承体系上方移动,在 base class 内提供 virtual 函数做你想对各个派生类做的事。

小结:

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免使用 dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。
  • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
  • 宁可使用新式转型,不要使用旧式转型。前者很容易辩识出来,而且也比较有着分门别类的职掌。

条款28:避免返回 handles 指向对象内部成分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Point {
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData { // 表示矩形
Point ulhc; // 右上角
Point lrhc; // 左下角
};
class Rectangle {
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
private:
std::shared_ptr<RectData> pData;
};

这里是自我矛盾的,用户可通过 函数返回的 reference 更改内部数据。

1
2
3
4
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);
rec.upperLeft().setX(50); // 从(50, 0) 到 (100, 100)

Reference 、指针和迭代器统统都是所谓的 handles(号码牌,用来取得某个对象),而返回一个“代表对象内部数据”的 handle,随之而来的便是“降低对象封装性”的风险。同时,它也可能导致“虽然调用 const 成员函数却造成对象状态被更改”。
只要对它们的返回类型加上 const 就可以去除上面的问题,这样不再允许客户更改对象状态。
返回“代表对象内部”的 handles,有可能在其他场合带来问题,更明确的说,它可能导致空悬的handle:这种 handle 所指东西(的所属对象)不复存在。这种“不复存在的对象”最常见的来源就是函数返回值。

1
2
3
4
5
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);
//如果这么使用函数
GUIObject* pgo;
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());

这个语句结束之后,boundingBox 的返回值将被销毁,间接导致其内的 Points 析构,最终导致 pUpperLeft 指向一个不再存在的对象。
有个 handle 被传出去了,一旦如此你就是暴露在“handle比其所指对象更长寿”的风险下。

小结:

  • 避免返回 handles (包括 reference 、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const,并将发生“虚吊号码牌”的可能性降至最低。

条款29:为“异常安全”而努力是值得的

“异常安全”有两个条件,当异常被抛出时,带有异常安全性的函数会:

  • 不泄露任何资源。条款14,以“资源管理类”来管理资源。
  • 不允许数据被破坏。

“异常安全”函数必须提供以下三个保证之一:

  • 基本承诺:如果异常抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此被破坏,所有对象都处于一种内部前后一致的状态。然而程序的现实状态恐怕不可预料。
  • 强烈保证:如果异常被抛出,程序状态不改变。如果函数调用成功,就是完全成功;如果函数失败,程序会恢复到调用函数前的状态。
  • 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(如 int、指针)身上的所有操作都提供 nothrow 保证。

一般而言你应该会想提供可实施之最强烈保证即不抛出异常,但是很难。对大部分函数而言,抉择往往落在基本保证和强烈保证之间。

有个一般化的设计策略很典型地会导致强烈保证,很值得熟悉它。这个策略被称为 copy and swap 。原则很简单:为你打算修改的对象做出一件副本,然后在副本身上做一切必要修改。若有任何修改动作抛出异常,原对象扔保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。

实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object,即副本)。这种手法通常被称为 pimpl idiom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct PMImpl {
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu {
...
private:
Mutex mutex;
std::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
using std::swap;
Lock ml(&mutex);
std::shared_ptr<PMImage> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNew->imageChanges;

swap(pImpl, pNew); // 置换数据,释放mutex
}

copy and swap 策略是对对象状态做出“全有或全无”改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。问题出在“连带影响”。如果函数只操作局部性状态,便相对容易地提供强烈保证。但是当函数对“非局部性数据”有连带影响时,提供强烈保证就困难得多。

另一个主题是效率。copy and swap 的关键在于“修改对象数据的副本,然后在一个不抛异常的函数中将修改后的数据和原件置换”,因此必须为每一个即将被改动的对象做出一个副本,那得耗用你可能无法或无意愿供应的时间和空间。因此,强烈保证并非在任何时刻都显得实际。

当“强烈保证”不切实际时,你就必须提供“基本保证”。对许多函数而言,“异常安全性之基本保证”是一个绝对同情达理的选择。

如果你写的函数所调用的函数没有提供任何异常安全保证,那么自身也不可能提供任何保证。

如果系统内有一个函数不具备异常安全性,整个系统就不具备异常安全性,因为调用那个不具备异常安全性的函数有可能导致资源泄露或数据结构破坏。

小结:

  • 异常安全函数即使也不会导致资源泄露或允许任何数据结构破坏。这样的函数区分为三种可能的保证:基本型、强烈型和不抛异常型。
  • “强烈保证”往往能够以copy and swap 实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
  • 函数提供的“异常安全保证”通常最高只等于其所调用各函数的“异常安全保证”中的最弱者。

条款30:透彻了解 inlining 的里里外外

inline 函数可以免除函数调用的额外开销。

inline 函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。在一台内存有限的机器上,过度热衷 inlining 会造成程序体积太大(对可用空间而言)。也会导致额外的换页行为,降低缓存的命中率,以及伴随这些而来的效率损失。

inline 只是对编译器的一个申请,不是强制命令。隐喻方式是将函数定义于 class 定义式内,这样的函数通常是成员函数,或者 friend 函数。

明确声明 inline 函数的做法是在其定义式前加上关键字 inline。例如标准的 max template 这样实现:

1
2
3
4
template <typename T>
inline const T& std::max(const T& a, const T& b) {
return a < b ? b : a;
}

inlining 在大多数 C++ 程序中是编译期行为。inline 函数通常一定被置于头文件内,因为大多数 build environments 在编译过程中进行 inlining ,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。

template 通常也被置于头文件中,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。

大多数编译器拒绝将太多复杂的函数 inlining ,而所有对 virtual 函数的调用也都会使 inlining 落空。因为 virtual 意味“等待,直到运行期才确定调用哪个函数”,而 inline 意味“执行前,先将调用动作替换为被调用函数的本体”。

因此:一个表面看似 inline 的函数是否真是 inline,取决于你的构建环境,主要取决于编译器。

如果程序要取某个 inline 函数的地址,编译器通常必须为此函数生成一个 outlined 函数本体。与此并提的是,编译器通常不对“通过函数指针而进行的调用”实施 inlining ,这意味着对 inline 函数的调用有可能被 inlined ,也可能不被 inlined ,取决于调用的实施方式。

实际上构造函数和析构函数往往是 inlining 的糟糕候选人。

inline 函数无法随着程序库的升级而升级。一旦 inline 函数改变,所有用到此函数的代码都必须重新编译。如果是个 non-inline 函数,一旦它有任何修改,客户端只要重新链接就好,远比重新编译的负担少得多。

大部分调试器面对 inline 函数都束手无策,毕竟如何在一个不存在的函数内设立断点呢?许多构建环境仅仅只能“在调试版程序中禁止发生 inlining”。

小结:

  • 一开始先不要将任何函数声明为 inline,或至少将 inlining 施行范围局限在那些“一定成为 inline”或“十分平淡无奇”的函数身上。
  • 将大多数 inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
  • 不要只因为 funtion template 出现在头文件,就将它们声明为 inline。

条款31:将文件间的编译依存关系降至最低

Donate comment here