上一章讲的是接口设计的原则,而这一章关注具体的实现细节。

Item26: Postpone variable definitions as long as possible

本节告诉我们,尽可能推迟变量的定义位置。换言之,在程序逻辑不变的情况下,越晚定义变量越好。

为什么?考虑到两个问题:

  1. 开销问题:变量定义太早,而程序由于某种原因没有继续执行(比如抛出异常)导致该变量未被使用,那么变量定义时调用构造函数的开销,相当于白费了。
  2. 代码风格:如果析构的位置不变,那么变量定义得越早,它的生命周期就越长,影响的代码范围就越广。而 coding 的一大原则就是“解耦”,尽可能减少每个部分对其他部分的影响。

所以说,只有在你马上需要用到这个变量的时候,才去定义它,而不是提早定义。

并且,最好在定义时就直接做完初始化的工作(开销更小),而不是先定义再赋值(开销更大)

  • 前者在构造时只需要调用一次对应的 constructor
  • 后者要调用 default constructor 和 copy assignment operator

作者的原话如下:

“Not only should you postpone a variable’s definition until right before you have to use the variable, you should also try to postpone the definition until you have initialization arguments for it.” (Dulimov, p. 115)

意思是一定要“忍到”你能够初始化(initialization)这个变量之后,你再去定义(definition)它。一定要将两者绑定,才能实现性能和资源利用最大化。

那么有人问了:如果在循环中需要使用某个变量,是在循环外部定义之后再在循环内部赋值好(方案 A),还是直接在循环内部构造好(方案 B)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Approach A
Widget w;
for (int i = 0; i < n; i++) {
	w = some value dependent on i;
}

// Approach B
for (int i = 0; i < n; i++) {
	Widget w(some value dependent on i);
}

可以看下两种方案各自的开销:

  • 方案 A:1 constructor + 1 destructor + n assignments
  • 方案 B:n constructors + n destructors

如果明确知道,n 次 assignments 的开销比 n constructors + n destructors 的开销更小,并且这段代码的核心问题是性能开销,那么应该选方案 A;否则,按照“尽可能推后变量定义”的原则,我们应该选择方案 B,因为这样定义的话,变量的影响范围更小,代码更易于维护。

Things to Remember:

  • Postpone variable definitions as long as possible. It increases program clarity and improves program efficiency.

Item27: Minimizing casting

C 风格的 cast 在 C++中仍然可以使用,但是不推荐。

C++提供了四类 cast 操作:

cast作用风险
static_cast编译时类型转换,编译器认为合法的转换(intdouble、基类↔派生类指针/引用、void* ↔ 具体类型)编译时检查,相对安全
dynamic_cast运行时类型安全的向下/跨继承转换(基类→派生类),转换失败返回 nullptr /抛异常有运行时开销(RTTI)
const_cast去掉 const / volatile 限定如果在原本 const 的对象上修改,是 UB
reinterpret_cast任意指针/整数类型的底层位重新解释(比如 int*char*完全由程序员保证正确性

快速判断用哪个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int a = 3;
double b = static_cast<double>(a);       // 1. 编译器知道合法的转换

Base* pb = new Derived;
Derived* pd = dynamic_cast<Derived*>(pb); // 2. 运行时检查,安全向下转型

const Widget* cw = new Widget;
Widget* w = const_cast<Widget*>(cw);     // 3. 只用来去掉 const

int* p = &a;
uintptr_t addr = reinterpret_cast<uintptr_t>(p); // 4. 底层位重解释

核心原则

  • 永远不要用 C 风格的 (type)value,它本质上是 reinterpret_cast + const_cast 的混合体,编译器完全不检查
  • 能用 static_cast 就不要用 reinterpret_cast ——前者保留类型信息,后者丢失
  • dynamic_cast 有运行时开销,性能敏感代码的基类析构函数(如果涉及多态行为)要用 virtual,这样才能支持 dynamic_cast
  • const_cast 几乎总是代码设计问题的信号——如果需要去掉 const,说明函数签名可能设计错了

出现 dynamic_cast 通常说明设计有问题。两个方向可以避免:

  1. 容器精确化——让编译器知道实际类型
  2. 接口泛化——用虚函数覆盖所有派生类的特殊行为

千万不要使用连续的 dynamic_cast,这对于维护和性能来说都是灾难。

Things to Remember:

  • Avoid casts whenever practical, especially dynamic_cast in performance-sensitive code. If a design requires casting, try to develop a cast-free alternative.
  • When casting is necessary, try to hide it inside a function. Clients can then call the function instead of putting casts in their own code.
  • Prefer C++-style casts to old-style casts. They are easier to see, and they are more specific about what they do.

Item28: Avoid returning “handles” to object internals

这一节是在说,不要泄露一些对象内部数据 (object internal) 的"handle"。

那么,什么是 handle,可以理解为“能够操作对象数据的工具”,包括引用、指针和迭代器:

“References, pointers, and iterators are all handles (ways to get at other objects),” (Dulimov, p. 125)

从常量保证性的角度考量,如果某个 const 类型的 member function 返回了没有 const 保护的引用、指针或者迭代器,那么实际上用户可以根据这个返回的值,直接去修改 object 内部的 private data member——这绝对是我们不希望看到的!

从封装性的角度考量,如果一个 public 的函数返回了 private 或者 protected 的 member handle,那么相当于它泄露了更底层的接口,而这些本不应被用户使用。所以,实际上返回 handle 其实减弱了代码的封装性。

核心原则:不应该返回比自己访问权限更小的 member 的 handle。

从数据安全性的角度考量,返回 handle 最大的问题是可能导致 dangling handles,即:某个 handle 对应的 object 已经消失了,但是 handle 还在。最经典的例子:对一个临时对象取地址,改临时对象已经析构,但是对应的指针还在,就会导致 dangling pointer,访问到未定义数据。

注意:“internals"这个词,不光可以指 private/protected data member,也同时包含那些 private 和 protected 的 member function。

Things to Remember:

  • Avoid returning handles (references, pointers, or iterators) to object internals. Not returning handles increases encapsulation, helps const member functions act const, and minimizes the creation of dangling handles.

Item 29: Strive for exception-safe code

异常安全的两个要求:

  1. 不泄露资源。比如说,异常抛出导致锁未释放,就是很严重的问题。
  2. 不会让数据结构崩溃。比如说,数据结构内部没有保持一致性,计数增加但是实际资源没有分配等等。

解决资源泄露问题,只需要用局部变量来管理锁即可,这样作用域结束时,这个局部变量就会自动析构。

所以,我们重点关注第二个问题:如何不让数据结构崩溃?

异常安全的函数,在下面三种保证类型中选择其一:

  • Basic guarantee:保证在抛出异常时,程序处在 valid state 中。但 valid state 不代表 correct state,实际程序的状态无法预测,只能保证不会错得很离谱,直接让程序崩溃。
  • Strong guarantee:保证在抛出异常时,程序状态没有改变,即直接回到调用函数前的状态。实际上,这样的函数保证了“原子性”(atomic),要么全部成功,要么全部回滚。
  • Nothrow guarantee:保证自身不会抛出异常,属于用逻辑上的正确性保证程序运行的正确性。

注意:声明一个函数为 noexcept 类型,并不保证这个函数不会抛出异常;而只是一种提醒:如果这个函数抛出异常,那么需要引起重视,调用一些特别的函数去处理。它只是警告,而不是保证。

“Offer the nothrow guarantee when you can, but for most functions, the choice is between the basic and strong guarantees.” (Dulimov, p. 130)

最好的情况当然是确保函数不会抛出异常,但这很难做到。所以,大部分情况下,我们都是在 basic guarantee 和 strong guarantee 之间做抉择。

通常,使用 smart pointer 就能在大部分情况下实现 strong guarantee。

“As a general rule, it’s a good policy not to change the status of an object to indicate that something has happened until something actually has.” (Dulimov, p. 130)

先实际更新资源,然后再更新状态,避免资源没到位,但是状态已经变化了。

使用 copy-and-swap 和 pointer-to-implementation (pImpl) 结合的方法,来实现 strongly exception-safe。

具体来说,就是先在副本上修改(copy),确定修改成功之后,再回到原本的内容上(swap)。

那么为什么要结合 pImpl 的原则呢?简单来说,std 里面的 swap 函数通常保证了 nothrow,但如果你有自定义的类型以及对应自己实现的 swap 函数,这个函数可能不是 nothrow 的。而使用 pImpl 方法,swap 函数就不用自己实现,而只需用 std::swap 交换两个指针值,不会抛出异常。

如果修改只涉及局部变量,那么实现 strongly exception-safe 是容易的;但如果修改存在 side effects,比如直接修改了数据库内容,那么 strongly exception-safe 很难保证。

最后作者提到了编程原则的变迁。读到下面这段话,还是挺感慨的:

Forty years ago, goto-laden code was considered perfectly good practice. Now we strive to write structured control flows.

Twenty years ago, globally accessible data was considered perfectly good practice. Now we strive to encapsulate data.

Ten years ago, writing functions without thinking about the impact of exceptions was considered perfectly good practice. Now we strive to write exception-safe code.

Time goes on. We live. We learn.

时间确实一直在流逝,编程原则也一直在变化,但我还能跟得上时代吗?不管跟不跟得上,该学的还是得学啊。

总的来说,异常安全的级别当然是越高越好 (nothrow > strong > basic)。实际情况中,我们不可能做到都是 nothrow 或者 strong 的异常安全级别,但至少保证有 basic 的保证。

Things to Remember:

  • Exception-safe functions leak no resources and allow no data structures to become corrupted, even when exceptions are thrown. Such functions offer the basic, strong, or nothrow guarantees.
  • The strong guarantee can often be implemented via copy-and-swap, but the strong guarantees is not practical for all functions.
  • A function can usually offer a guarantee no stronger than the weakest guarantee of the functions it calls.

Item 30: Understand the ins and outs of inlining

先说明什么是“内联”(inline)。

内联通常指的是,在编译的时候,在调用函数的位置,不是嵌入函数的地址然后跳转调用,而是直接把对应函数的整个函数体插入调用位置。

内联分为显式内联和隐式内联:

  • 显式内联:在函数声明的开头使用 inline 关键字,建议编译器内联该函数。但是否内联,还是取决于编译器自己的判断。
  • 隐式内联:在头文件中直接实现的类的成员函数,就是隐式内联,编译器会默认将其内联。

如果要内联,那么函数体必须对编译器可见,所以需要定义在头文件当中。(我们 #include 的是头文件,所以 cpp 文件里面的函数体是对其他头文件/ cpp 文件不可见的,只有定义在头文件中的函数体才是可见的)

但如果函数体定义在头文件里且不加 inline,并且被多个 .cpp 文件包含,那么 linker 会报重复定义错误(ODR 违反)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// foo.h
void foo() { /* 类外定义,不加 inline */ }
// 多个 .cpp 包含 foo.h → 链接报错!

class Bar {
public:
    void baz() { /* ... */ }  // 类内定义 → 隐式 inline → 安全
    void qux();
};

inline void Bar::qux() { /* ... */ }  // 显式 inline → 安全

内联的好处:

  • 省去了地址跳转的开销。

内联的坏处:

  • 可能打乱 CPU Cache 对地址的预测。
  • 造成代码膨胀(code bloat)的问题,带来更大的开销。

内联和模板,有一个共同点:都需要定义在头文件中。但这并不意味着模板一定要内联。

模板和 inline 是两回事。模板定义在头文件是因为编译器实例化时需要看到完整定义,而不是为了内联。所以:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
template<typename T>
void foo(T t) {
    // 这是模板,不是 inline
    // 虽然定义在头文件,编译器不一定内联它
}

template<typename T>
inline void bar(T t) {  // 这才是显式请求内联的模板
    // ...
}

有时候,一些函数明明可以内联,但由于调用方式的问题,编译器还是不得不在它的定义处设置一个函数体。比如说,直接获取函数的指针,并通过指针调用的时候。

程序员不是唯一调用函数指针的方式,编译器自己有时候也会调用函数指针,比如在构造函数和析构函数中。

以及,构造函数和析构函数不是内联的好对象,因为它们通常不像表面上看起来那么简单。比如说,子类的构造函数就需要调用父类的构造函数,同时还要检查构造是否成功等等,比表面上复杂得多。而父类的构造函数,由于要被子类调用,如果设置成内联的话,也会使得代码体积急剧膨胀。

此外,内联函数也会给用户调用、更新以及调试带来麻烦。

  • 采用内联函数编译后的程序,一旦内联函数被修改,那么整个程序都需要重新编译,非常麻烦。
  • Debugger 无法调试内联函数,因为内联函数本就没有一个确切的地址,如何调试呢?

所以,最好的做法是,一开始不要使用任何内联函数;在找到一些短小简单同时被频繁调用的函数之后,尝试有限的内联。

Things to Remember:

  • Limit most inlining to small, frequently called functions. This facilitates debugging and binary upgradability, minimizes potential code bloat, and maximizes the chances of greater program speed.
  • Don’t declare function templates inline just because they appear in headers files.

Item31: Minimize compilation dependencies between files

注:这一节很长,逻辑也有点复杂,阅读和整理都比较费劲,所以我借助 AI 做了最后的梳理。

逻辑链

问题:C++ 的 #include 导致编译依赖太强
  ↓
原因:#include 会让当前文件知道被包含类型的大小和布局
  ↓
解决办法:只依赖声明,不依赖定义(让编译器不需要知道大小)
  ↓
怎么做到?→ 只让当前文件看到指针,不看到类型定义
  ↓
两个具体方案:
  方案 A:Handle class(pImpl)— 用指针指向实现类
  方案 B:Interface class — 用抽象基类 + 工厂函数
  • 编译 compile:将 cpp 源文件翻译成 o 目标文件,这个过程要解析类型大小、成员布局、函数签名等等。
  • 链接 link:将多个 o 目标文件拼成可执行文件,只关心符号在哪,不关心符号的布局。

一旦有 #include,那么头文件和当前文件之间的编译依赖就出现了。如果任意一个被包含的头文件被修改了,那么当前文件就必须 recompile。

为什么 #include 头文件就需要 recompile,而前向声明就只需要 relink?

  • #include 头文件 → 当前文件知道了类型的完整定义(大小、布局)。如果被包含的类型改了布局,当前类的成员偏移量可能变 → 必须重编译
  • 前向声明 → 只知道"有这个类型”,不知道大小和布局。但当前类只持有指针(大小固定),被声明的类不管怎么改,当前类的内存布局不受影响 → 无需重编译。但由于当前类的实现 .cpp 中调用了被声明的类的成员,实现变了 → 只需重链接,把新的 Date.o 和当前目标文件拼起来即可。

为什么前向声明必须结合 pImpl

如果只通过前向声明告诉你"有这个类",那么编译器无法为当前的类分配空间,因为它不知道对应的成员大小。所以,简单的前向声明是不行的,必须结合 pImpl 方法来实现——让类只持有指向实现的指针,而不持有实现本身。

关于 implementation details

这里的 implementation details,指的就是 class 的私有成员变量及其实现细节。比如某个类有多大、有几个成员、成员是什么类型,这些都属于 implementation details。

隐藏实现:pImpl 设计思想

Hide the object implementation behind a pointer.

利用 pImpl 设计思想,将管理资源的类(实际只管理智能指针,就是一个接口)和具体实现的类分开。这样,就可以在修改底层实现细节的同时,防止接口相关的代码也跟着 recompile 一遍,从而减少了开销。

同时,由于 client 只能接触到指针,而无法接触到底层的具体实现,所以 pImpl idiom 真正实现了接口和实现的分离。

注意: Standard library components shouldn’t be forward-declared. 标准库组件不应该被前向声明,因为标准库的实现细节由各个编译器厂商决定,前向声明标准库组件可能导致未定义行为。

减少编译依赖的关键

“The key to this separation is replacement of dependencies on definitions with dependencies on declarations.” (Dulimov, p. 143)

关键是用 declaration 代替 definition。

“make your header files self-sufficient whenever it’s practical, and when it’s not, depend on declarations in other files, not definitions.” (Dulimov, p. 143)

减少编译依赖的三个简单方法

  1. 能用 reference 或者 pointer 的时候,不要使用对象本身。 因为引用和指针的大小固定,不依赖于被引用类型的完整定义。
  2. 尽可能依赖 class declaration,而不是 class definition。 实际上,如果能调用相关的函数,说明 caller 已经看到了对应 class 的完整定义,不需要再在被调用的文件里面写一遍完整的 #include
  3. 为 declarations 和 definitions 提供独立的头文件。

什么是"为声明和定义提供独立的头文件"

意思是将有完整实现的头文件拆成两个:

  • 一个只包含前向声明(比如 datefwd.h
  • 另一个包含完整定义(比如 date.h

这样,那些只需要前向声明的文件(比如只需要 Date*Date& 的文件),就不用导入完整的头文件,而只需要前向声明即可,减少了编译依赖。标准库中的 <iosfwd> 就是这样的例子——它只包含 iostream 类型的前向声明。

Declaration、Definition 和 Implementation 的关系

  • 声明(declaration):告诉编译器"有这么个东西"。比如 class Date;void foo(Date d);extern const int MAX;
  • 定义(definition):告诉编译器"这个东西的完整结构是什么"。比如完整的类定义(列出所有成员)、函数体、变量分配空间。
  • 实现(implementation):告诉编译器"这个东西具体怎么做"。指的是成员函数体、私有辅助函数、内部逻辑等实际运行的代码。

在 Item 31 语境下,三者的关键区别在于:definition 暴露的是类的结构(有多大、有哪些成员),implementation 暴露的是类的行为(怎么做)。

  • 头文件里的类定义(class definition)包含私有成员的声明——这会导致编译器知道这个类的大小和布局,造成编译依赖
  • .cpp 文件里的函数体(implementation)是真正干活的代码——其他文件不需要知道这些,只需要函数签名就够了。
  • pImpl 的核心就是把类的 definition(布局信息) 也藏到 .cpp 里,让头文件只保留 declaration + 指针。

看一个实际例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// date.h(用户看到的)

           这是 declaration:告诉用户 "有这两个函数"
class Date {
public:
    Date(int y, int m, int d);
    void print();

           这也是 declaration:只是告诉用户 "有个叫 Impl 的结构体"
private:
    struct Impl;

           这也是 declaration"Date 里有个 unique_ptr"
    std::unique_ptr<Impl> pImpl;
};

// 注意:用户不知道 Impl 的 definition,不知道 Date 里具体有什么数据
// 用户只知道 Date 的大小 = unique_ptr 的大小(一个指针的大小)


// date.cpp(用户看不到)

struct Date::Impl {           // ← 这是 Impl 的 definition + implementation
    int year, month, day;       // 定义:有哪些成员
    std::string toString() {    // 实现:具体怎么做
        return std::to_string(year) + "-" + ...;
    }
};
Date::Date(int y, int m, int d)  // ← implementation:具体的函数体
    : pImpl(std::make_unique<Impl>(y, m, d)) {}

总结三层关系:

层级解释在 Item 31 中
declaration“有这个函数/类/变量”放在头文件,用户能看到
definition“这个类有哪些成员、函数体是什么”编译器不关心成员函数体,只关心类的大小/布局 → 造成编译依赖
implementation“这个函数具体怎么做”藏在 .cpp 里,用户永远不需要知道

Handle class 和 Interface class

实现了 pImpl idiom 的类,一般被称为 Handle class。

Handle class:在语言层面消除编译依赖——类里只存指向实现的指针,具体实现在另一个类里。

Interface class:在继承层面消除编译依赖——类只定义纯虚接口,具体实现在派生类里。

两者的作用都是消除编译依赖,只是实现方式不同。

在 Interface class 中,由于用户代码只依赖于接口声明(头文件里面的纯虚函数签名),不涉及派生类具体实现(.cpp 里面的具体代码)。所以,当我们修改派生类内部实现时,用户调用的那部分代码完全不需要重新编译,因为它们之间没有依赖关系。修改实现在 .cpp,只需 make 重新编译那个 .cpp;其他文件只 relink,不需要 recompile

Interface class 通常配合 Factory functions(工厂函数,也称为 virtual constructors)使用,让用户通过基类的静态成员函数创建具体派生类对象,而不需要直接知道派生类的存在。

两种方案的代价

Handle class 的代价:

  • 增加跳转次数,运行速度变慢(需要通过 pImpl 指针跳转到实际实现)。
  • 必须增加 implementation pointer,内存占用变多。
  • 由于 pImpl 必须指向动态分配的实际 implementation 对象,所以增加了动态分配的开销。

Interface class 的代价:

  • Indirect jump,虚表指针 → 虚表 → 实际调用的函数,多一层间接跳转。
  • 虚表指针的额外内存开销。

Handle class 和 Interface class 都不能很好地利用 Inline Functions,因为 Inline function 的 implementation 必须写在头文件中,而 handle class 和 interface class 的核心思想都是 hide implementation details——内联要求暴露实现,和隐藏实现的目标直接冲突。

重新梳理

你要改一个类的内部实现
  ↓
所有 #include 了你的头文件的人都要重编译 → 很慢
  ↓
为什么?因为 #include 让编译器知道了类的大小和布局
  ↓
那不让编译器知道不行吗?
  ↓
可以,如果让调用方只持有指针(大小固定),就不要知道具体类型
  ↓
怎么实现?
  ├─ Handle class:我的类里只存指针,具体实现在另一个类里
  └─ Interface class:我的类只定义纯虚接口,具体实现在派生类里
  ↓
为了配合这两种方案,你的库应该同时提供:
  ├─ 声明-only 头文件(datefwd.h):给 Handle class / Interface class 用
  └─ 完整定义头文件(date.h):给真正需要的人

Things to Remember:

  • The general idea behind minimizing compilation dependencies is to depend on declarations instead of definitions. Two approaches based on this idea are Handle classes and Interface classes.
  • Library header files should exists in full and declaration-only forms. This applies regardless of whether templates are involved.