精读《Effective C++》第五章:实现
- 🎯 前言
- 一、条款25:考虑写出一个不抛异常的 swap 函数
-
- 1. 默认 swap 为什么不够好
- 2. 三层 swap 结构
- 3. 客户调用 swap 的正确姿势
- 4. 成员 swap 绝不能抛异常
- 深度思考:为什么 C++ 不允许偏特化 function template
- 二、条款26:尽可能延后变量定义式的出现时间
-
- 1. 过早定义的成本
- 2. 循环里的变量:定义在内还是外?
- 深度思考:延后定义与 RAII 的关系
- 三、条款27:尽量少做转型动作
-
- 1. 四种新式转型
- 2. 转型不是"告诉编译器换个类型看"——它真的会生成代码
- 3. 一个隐蔽的 bug:在派生类函数里转型 *this
- 4. dynamic_cast 很慢,要避免连串使用
- 深度思考:为什么 C++ 的对象可以有多个地址
- 四、条款28:避免返回 handles 指向对象内部成分
-
- 1. const 成员函数却能修改对象
- 2. 加 const 可以解决封装问题
- 3. 悬空 handle(dangling handles)
- 深度思考:const 引用返回值与临时对象生命周期
- 五、条款29:为"异常安全"而努力是值得的
-
- 1. 异常安全的两个条件
- 2. 用对象管理资源解决泄漏
- 3. 三个异常安全保证
- 4. copy-and-swap 实现强烈保证
- 5. 异常安全是全有或全无的
- 深度思考:copy-and-swap 与 C++11 移动语义的关系
- 六、条款30:透彻了解 inlining 的里里外外
-
- 1. inline 是申请不是命令
- 2. inline 的成本:代码膨胀
- 3. 即使编译器愿意 inline,也可能生成 outlined 副本
- 4. inline 函数无法随库升级而升级
- 5. 80-20 法则
- 深度思考:inline 与 template 的关系
- 七、条款31:将文件间的编译依存关系降至最低
-
- 1. 问题:改一个 private 成员,全世界重编译
- 2. pimpl 手法:声明依存性替换定义依存性
- 3. 两种解耦手段
-
- Handle class(句柄类)
- Interface class(接口类)
- 4. 实践建议
- 深度思考:C++20 modules 是否终结了 pimpl
- 八、知识结构图
- 📝 总结
- 📚 参考资料
- 🏷️ 推荐标签
本文是我精读《Effective C++(第三版)》第五章"实现"(条款25-31)的学习笔记。如果说第四章谈的是"接口长什么样",这一章就钻进了代码内部——变量该什么时候定义、转型有什么隐藏代价、异常抛出后谁来收拾烂摊子、inline到底是银弹还是陷阱、改一个private成员为什么要重编整个世界。全是写代码时天天碰到但未必想透的问题,有不对的地方欢迎评论区指正~
🎯 前言
第四章讲设计与声明,讨论的是"接口应该长什么样"。这一章往下走一层,谈实现(Implementations)——当类和函数的设计确定之后,写代码时那些"看起来无关紧要"的选择,怎么悄悄影响程序的效率、健壮性和可维护性。
Scott Meyers 在这一章覆盖的七个条款,我读完后感觉它们其实围绕两个主题:
下面按条款顺序记录我的学习过程。
一、条款25:考虑写出一个不抛异常的 swap 函数
Consider support for a non-throwing swap.
1. 默认 swap 为什么不够好
std::swap 的默认实现很朴素:
namespace std {
template<typename T>
void swap(T& a, T& b) {
T temp(a); // 拷贝 a
a = b; // 拷贝 b 到 a
b = temp; // 拷贝 temp 到 b
}
}
对普通类型没问题,但如果你的类用了 pimpl 手法(pointer to implementation,条款31详解),默认 swap 就变成了灾难——它会复制三个 Widget 对象,连带复制三个 WidgetImpl 对象,而实际上你只需要交换两个指针。
class WidgetImpl {
int a, b, c;
std::vector<double> v; // 数据很多,复制很慢
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) {
*pImpl = *rhs.pImpl; // 复制整个 WidgetImpl
return *this;
}
private:
WidgetImpl* pImpl;
};
2. 三层 swap 结构
Meyers 给出的解法是一套"三层结构":
第一层:public 成员 swap
class Widget {
public:
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl); // 只交换指针
}
};
第二层:同命名空间的 non-member swap
namespace WidgetStuff {
template<typename T>
class Widget { ... };
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b); // 调用成员 swap
}
}
第三层:针对 class(非 template)的 std::swap 特化
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
a.swap(b);
}
}
为什么需要三层?因为 C++ 的名称查找规则很微妙:
- non-member 版本让 ADL(实参依赖查找) 能在 Widget 所在命名空间找到专属 swap
- std::swap 特化让那些"迷途程序员"写 std::swap(obj1, obj2) 时也能命中高效版本
- 对 class template 不能偏特化 function template,也不能往 std 里加新重载,所以只能靠命名空间里的 non-member 版本
3. 客户调用 swap 的正确姿势
template<typename T>
void doSomething(T& obj1, T& obj2) {
using std::swap; // 让 std::swap 可见
swap(obj1, obj2); // 不带命名空间修饰,让编译器挑最佳版本
}
关键:先 using std::swap,然后裸调 swap。这样编译器会优先找 T 专属版本,找不到才 fallback 到 std::swap。千万别写 std::swap(obj1, obj2),那会强迫编译器只认 std 里的版本。
4. 成员 swap 绝不能抛异常
这是这条条款最核心的约束。swap 是异常安全编程(条款29)的脊柱——copy-and-swap 策略依赖"swap 一定成功"才能保证"要么全改、要么不改"。而高效 swap 通常只操作内置类型(指针、整数),内置类型操作本来就不会抛异常,所以这个约束是自然的。
深度思考:为什么 C++ 不允许偏特化 function template
Q:条款说"可以全特化 std::swap,但不能偏特化 function template,也不能往 std 加重载",这些规则背后的原因是什么?
这涉及 C++ 模板重载决议的设计哲学:
| 全特化 template<> void swap<Widget>(…) | ✅ 合法 | 为特定类型提供精确替换,不影响重载决议 |
| 偏特化 template<typename T> void swap<Widget<T>>(…) | ❌ 不合法 | 函数模板不支持偏特化(只有类模板支持),这是语言规则 |
| 在 std 内加新重载 template<typename T> void swap(Widget<T>&, …) | ❌ 未定义行为 | std 命名空间保留给标准库,用户膨胀它可能破坏标准库实现 |
为什么函数模板不支持偏特化?核心原因是重载和特化的交互会导致反直觉的决议结果。C++ 标准委员会认为,如果允许函数模板偏特化,程序员很难预测编译器到底选择了主模板、偏特化还是普通重载。与其引入复杂的优先级规则,不如直接禁止,引导大家用"命名空间 non-member 函数 + ADL"的方式解决——这也正是 Meyers 推荐的做法。
现代 C++(C++11 之后)中 std::swap 自身已经基于移动语义实现,对有移动构造函数的类型默认就很高效。但对于 pimpl 类,手写 swap 仍然有价值——移动语义同样要走指针交换,而你无法保证编译器生成的 move 一定最优。
📖 swap 的三层结构不是仪式,而是为了在 C++ 名称查找的迷宫中确保"最高效版本总能被调用"。成员 swap 的 no-throw 保证是异常安全编程的基石。
二、条款26:尽可能延后变量定义式的出现时间
Postpone variable definitions as long as possible.
1. 过早定义的成本
只要你定义了一个带构造/析构函数的变量,控制流到达定义式时就要付构造成本,离开作用域时要付析构成本——即使这个变量最终没被用到。
书里给了一个加密函数的例子。如果密码太短抛异常,过早定义的 encrypted 就白构造白析构了:
// ❌ 不佳:encrypted 在长度检查之前定义
std::string encryptPassword(const std::string& password) {
std::string encrypted; // 先 default 构造
if (password.length() < MinimumPasswordLength) {
throw std::logic_error("Password is too short");
}
encrypted = password; // 再赋值——条款4说过这比直接构造低效
encrypt(encrypted);
return encrypted;
}
// ✅ 最佳:延后到有初值时才定义
std::string encryptPassword(const std::string& password) {
if (password.length() < MinimumPasswordLength) {
throw std::logic_error("Password is too short");
}
std::string encrypted(password); // 直接 copy 构造,跳过 default 构造
encrypt(encrypted);
return encrypted;
}
“尽可能延后"不只是"用到前再定义”,而是**“能给初值的时候再定义”**——无意义的 default 构造+赋值是纯浪费。
2. 循环里的变量:定义在内还是外?
// 方法A:定义在循环外
Widget w;
for (int i = 0; i < n; ++i) {
w = 取决于i的值; // n 次赋值
...
}
// 成本:1 构造 + 1 析构 + n 赋值
// 方法B:定义在循环内
for (int i = 0; i < n; ++i) {
Widget w(取决于i的值); // n 次构造+析构
...
}
// 成本:n 构造 + n 析构
Meyers 的建议是:除非你确认赋值成本低于"构造+析构"且这段代码是性能热点,否则默认用 B。因为 B 的作用域更小,可读性和可维护性更好,也不会让变量名 w 泄漏到循环外。
深度思考:延后定义与 RAII 的关系
Q:延后变量定义会不会和 RAII(资源获取即初始化)矛盾?RAII 要求变量定义时就获取资源。
完全不矛盾,反而是一致的。RAII 的精神是"获取资源后立即交给对象管理",而条款26说的是"别在真正需要资源之前就获取"。两者结合的正确姿势是:
// RAII + 延后定义:需要锁的时候才创建 Lock 对象
void process() {
// 前面不需要锁的准备工作
prepareData();
{
Lock ml(&mutex); // 需要时才获取锁
modifySharedState();
} // 离开作用域自动释放
// 后面不需要锁的工作
writeLog();
}
延后定义本质上是缩小变量的作用域和生命周期,这与 RAII 的"精确控制资源生命周期"完全同频。真正矛盾的是"过早定义一个 RAII 对象但长时间不用"——那等于提前占着资源不用,反而是反 RAII 的。
📖 延后变量定义的本质是"只为真正用到的对象付费",与 RAII 的精确资源管理理念一致。最佳实践是"有意义的初值出现时才定义"。
三、条款27:尽量少做转型动作
Minimize casting.
1. 四种新式转型
const_cast<T>(expression) // 去除 const 性
dynamic_cast<T>(expression) // 安全向下转型,有运行时成本
reinterpret_cast<T>(expression) // 低级转型,不可移植
static_cast<T>(expression) // 强迫隐式转换
新式转型比 C 风格 (T)expression 好的原因:一是代码里容易 grep 定位,二是每种转型职责窄化,编译器能帮你诊断误用。
2. 转型不是"告诉编译器换个类型看"——它真的会生成代码
这是最让我意外的部分。我以前也以为转型只是编译期的类型标注,但书里举了一个例子:
class Base { ... };
class Derived : public Base { ... };
Derived d;
Base* pb = &d; // Derived* → Base*
在多重继承下,Derived* 和 Base* 的指针值可能不同!编译器需要在运行期给 Derived 指针加上一个偏移量才能得到正确的 Base 指针。同一个对象可能有多个地址——这在 C、Java、C# 里都不会发生,但 C++ 会。
这意味着任何基于"我知道对象内存布局"做的转型都是脆弱的、不可移植的。
3. 一个隐蔽的 bug:在派生类函数里转型 *this
class Window {
public:
virtual void onResize() { ... }
};
class SpecialWindow : public Window {
public:
virtual void onResize() {
// ❌ 错误!这会创建 *this 的 Base 成分副本,在副本上调用
static_cast<Window>(*this).onResize();
// SpecialWindow 专属行为…
}
};
static_cast<Window>(*this) 不是"把自己当 Window 看",而是拷贝构造了一个临时 Window 对象,然后在这个副本上调用 onResize。如果基类版本修改了对象状态,改的是副本,原对象的基类部分没被改到——对象进入"基类没改、派生类改了"的伤残状态。
正确写法简单直接:
virtual void onResize() {
Window::onResize(); // 在当前对象上调用基类版本
// SpecialWindow 专属行为…
}
4. dynamic_cast 很慢,要避免连串使用
dynamic_cast 的很多实现基于类名字符串比较,在四层深的继承体系里可能要做四次 strcmp。连串 dynamic_cast 更是灾难:
// ❌ 糟糕:连串 dynamic_cast
for (auto& pw : winPtrs) {
if (auto psw1 = dynamic_cast<SpecialWindow1*>(pw.get())) { ... }
else if (auto psw2 = dynamic_cast<SpecialWindow2*>(pw.get())) { ... }
else if (auto psw3 = dynamic_cast<SpecialWindow3*>(pw.get())) { ... }
}
两种替代方案:
深度思考:为什么 C++ 的对象可以有多个地址
Q:C、Java、C# 里一个对象只有一个地址,为什么 C++ 允许多个地址?
这与 C++ 的内存布局自由度直接相关。C++ 标准不规定对象的内存布局,编译器可以自由安排。在多重继承下,一个派生类对象包含多个基类子对象,每个子对象在内存中有不同的起始位置:
Derived 对象内存布局(多重继承):
┌─────────────────┐
│ Base1 子对象 │ ← Base1* 指向这里
├─────────────────┤
│ Base2 子对象 │ ← Base2* 指向这里(偏移量 = sizeof(Base1))
├─────────────────┤
│ Derived 独有成员 │ ← Derived* 通常指向开头
└─────────────────┘
当你把 Derived* 转成 Base2*,编译器必须加上偏移量。这个偏移量在单继承下通常是0(所以你感觉不到),在多继承下几乎一定非零。
Java 和 C# 之所以没有这个问题,是因为它们的运行时统一用"对象引用+类型标签"的模型,引用本身是间接句柄,不直接指向内存中的子对象位置。代价是每次访问都多一层间接跳转——C++ 选择把这个成本暴露给你,让你能控制;Java/C# 选择帮你藏起来,但你永远要付间接跳转的税。
📖 转型不只是类型标注,它可能生成运行时代码、创建临时对象、改变指针值。优秀的 C++ 代码转型很少;如果设计需要频繁转型,尤其是连串 dynamic_cast,通常说明继承体系本身需要重构。
四、条款28:避免返回 handles 指向对象内部成分
Avoid returning “handles” to object internals.
1. const 成员函数却能修改对象
书里给了一个矩形类的例子:
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:
// ❌ const 函数却返回非 const 引用!
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
private:
std::tr1::shared_ptr<RectData> pData;
};
这两个函数被声明为 const,但它们返回的引用允许调用者修改内部数据:
const Rectangle rec(coord1, coord2); // const 矩形
rec.upperLeft().setX(50); // rec 居然被改了!
两个教训:
2. 加 const 可以解决封装问题
class Rectangle {
public:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
};
现在客户只能读不能写。但还有更隐蔽的危险——
3. 悬空 handle(dangling handles)
class GUIObject { ... };
// 返回一个 by-value 的临时矩形
const Rectangle boundingBox(const GUIObject& obj);
GUIObject* pgo;
// ⚠️ pUpperLeft 指向临时对象的内部成分
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
这行语句结束后,boundingBox 返回的临时 Rectangle 被销毁,它内部的 Point 也跟着销毁,pUpperLeft 变成悬空指针。
这就是为什么"返回 handle 指向对象内部"总是危险的——handle 可能比它所指的对象更长寿。不论 handle 是指针、引用还是迭代器,不论它是不是 const,不论成员函数是不是 const,都有这个风险。
当然也有例外:operator[] 就是返回引用指向容器内部数据,这是有意的设计,但它是例外而非常态。
深度思考:const 引用返回值与临时对象生命周期
Q:C++ 不是有"const 引用绑定临时对象会延长临时对象生命周期"的规则吗?为什么这里还是悬空了?
这是一个非常好的问题,也是 C++ 中最容易踩的坑之一。生命周期延长规则有严格的适用条件:
// ✅ 生命周期延长:const 引用直接绑定到临时对象
const Rectangle& rect = boundingBox(*pgo); // 临时对象存活到 rect 离开作用域
// ❌ 没有延长:const 引用绑定到的是临时对象的内部成员
const Point& pt = boundingBox(*pgo).upperLeft();
// boundingBox 返回的临时 Rectangle 在语句结束时销毁
// pt 引用的是那个临时对象的成员,成员随宿主一起销毁
关键区别:生命周期延长只作用于直接绑定的临时对象本身,不会递归延长"临时对象内部成分"的生命周期。upperLeft() 返回的引用指向的是 Rectangle 的成员,不是临时对象本身,所以不触发延长。
C++ 标准 [class.temporary] 明确规定:临时对象的生命周期不会因为"指向其成员的引用"而延长。这是编译器实现可行性的权衡——要追踪所有"从临时对象派生出的引用"在实践中极其困难且成本高昂。
📖 返回内部 handle 同时威胁封装性和生命周期安全。const 引用返回值无法解决悬空问题,因为生命周期延长不递归到内部成员。如果必须返回,确保返回的是值或长生命周期对象的 handle。
五、条款29:为"异常安全"而努力是值得的
Strive for exception-safe code.
1. 异常安全的两个条件
当异常被抛出时,异常安全函数要保证:
看一个反面教材:
class PrettyMenu {
Mutex mutex;
Image* bgImage;
int imageChanges;
void changeBackground(std::istream& imgSrc) {
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc); // 如果这里抛异常…
unlock(&mutex); // …unlock 永远不会执行 → 锁泄漏
// bgImage 指向已删除对象 → 数据败坏
}
};
2. 用对象管理资源解决泄漏
条款13-14 的 RAII 直接解决第一个问题:
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock m1(&mutex); // 构造时加锁,析构时解锁
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
代码变短了,也不会漏 unlock。但数据败坏还没解决——如果 new Image 抛异常,bgImage 已被 delete,imageChanges 已被递增。
3. 三个异常安全保证
| 基本承诺 | 异常抛出后,程序处于有效状态,所有对象内部一致,但具体状态不可预知 |
| 强烈保证 | 异常抛出后,程序状态完全回滚到调用前——要么成功,要么原状 |
| 不抛异常(nothrow) | 承诺绝不抛出异常(内置类型操作天然 nothrow) |
4. copy-and-swap 实现强烈保证
struct PMImpl {
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu {
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
void changeBackground(std::istream& imgSrc) {
using std::swap;
Lock m1(&mutex);
// 先在副本上改
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNew->imageChanges;
swap(pImpl, pNew); // 一次性提交,no-throw swap
}
};
核心思路:在副本上做所有可能抛异常的修改,全部成功后再用 no-throw swap 一次性提交。如果中途任何一步抛异常,原对象保持不变。
但强烈保证不是万能的:
- 如果函数有连带影响(side effects),比如改了数据库,异常后没法回滚数据库
- copy-and-swap 的时间和空间成本可能不可接受
- 函数的异常安全保证最高只等于它调用的最弱函数的保证——木桶效应
5. 异常安全是全有或全无的
Meyers 用了一个精妙的比喻:异常安全就像怀孕——要么怀了要么没怀,没有"部分怀孕"。只要系统中有一个函数不具备异常安全性,整个系统就不是异常安全的,因为调用那个函数可能泄漏资源或败坏数据。
深度思考:copy-and-swap 与 C++11 移动语义的关系
Q:C++11 的移动语义是否让 copy-and-swap 过时了?
没有过时,但应用方式有所演变。copy-and-swap 在 C++11 之后分化为两种用法:
| operator= 实现 | copy-and-swap(拷贝构造+swap) | copy-and-swap 或 move-and-swap |
| 多步修改提交 | 深拷贝整个对象再 swap | 可以 move 临时对象进去 |
| 性能 | 深拷贝成本高 | move 只交换指针,成本接近 0 |
C++11 之后,operator= 的 copy-and-swap 写法变得更优雅:
Widget& operator=(Widget rhs) { // 注意:按值传参,move 或 copy 都在这一步完成
swap(*this, rhs);
return *this;
}
如果传入的是右值,rhs 通过 move 构造,几乎零成本;如果是左值,正常 copy。然后统一 swap。这就是所谓的"统一赋值运算符"——一个函数同时处理 copy 和 move 赋值,且天然异常安全。
但条款29 描述的"在副本上做多步修改再提交"的模式仍然需要真正的深拷贝,移动语义帮不了你——因为你需要保留原件直到修改全部成功。
📖 异常安全的三个保证等级是函数接口的一部分,应该像选择参数类型一样慎重决策。copy-and-swap 是实现强烈保证的典型策略,但不是所有函数都该追求强烈保证——基本承诺对很多场景已经足够且务实。
六、条款30:透彻了解 inlining 的里里外外
Understand the ins and outs of inlining.
1. inline 是申请不是命令
inline 只是对编译器的申请,编译器可以拒绝。两种申请方式:
- 隐喻:成员函数定义在 class 定义式内
- 明确:函数定义前加 inline 关键字
编译器通常拒绝 inline 太复杂的函数(带循环、递归),而对 virtual 函数的调用(除非编译器能在编译期确定实际类型)也不会 inline——因为 virtual 意味着"运行期才知道调哪个",inline 意味着"编译期替换函数体",两者矛盾。
2. inline 的成本:代码膨胀
inline 的逻辑是"每个调用点都替换成函数体"。如果函数体很小,替换后的代码可能比"调用函数+传参+返回"的代码还小——反而省空间。但如果函数体较大且被调用很多次,inline 会导致目标代码暴涨:
- 更大的可执行文件
- 更多的换页(paging)行为
- 更低的指令缓存命中率(instruction cache hit rate)
即使有虚内存,代码膨胀带来的缓存抖动也会实实在在地拖慢速度。
3. 即使编译器愿意 inline,也可能生成 outlined 副本
当你取一个 inline 函数的地址时,编译器必须生成一个 outlined 的函数本体——否则指针指向什么?通过函数指针的调用通常不会被 inline:
inline void f() { ... }
void (*pf)() = f; // 需要 outlined f 的地址
f(); // 正常调用,可能被 inlined
pf(); // 通过函数指针调用,通常不会 inlined
构造函数和析构函数看起来是 inline 的绝佳候选(空函数!),但实际上它们是糟糕的 inline 候选人。C++ 保证"创建对象时每个基类和成员都被构造"“析构时反向析构”,编译器会在空构造函数里插入大量代码——调用成员变量构造函数、基类构造函数、异常处理栈展开逻辑等。如果这些构造函数又恰好是 inline 的,层层展开后,一个"空构造函数"可能膨胀成一大坨代码。
4. inline 函数无法随库升级而升级
这是对库设计者特别重要的一点。如果 f 是 inline 函数,客户把函数体编进了自己的程序。一旦库设计者修改了 f,所有客户必须重新编译。如果 f 是非 inline 的,客户只需要重新连接(动态连接时甚至无感升级)。
调试器对 inline 函数也很不友好——你怎么在一个不存在的函数里设断点?
5. 80-20 法则
平均而言,程序把 80% 的执行时间花在 20% 的代码上。
inline 应该集中在那 20% 的、小型、频繁调用的函数上。先别 inline,用 profiler 找出真正的热点,再针对性优化。
深度思考:inline 与 template 的关系
Q:条款特别提醒"不要因为 template 在头文件里就把它声明为 inline",这两者为什么经常被混淆?
混淆的根源是:inline 函数和 function template 通常都放在头文件里,但原因完全不同:
| inline 函数 | 编译器需要在调用点看到函数体才能替换 | 函数体在编译期可用 |
| function template | 编译器需要在具现化时看到模板定义才能生成代码 | 模板定义在编译期可用 |
两者的共同点是"定义必须在编译期可见",但这不意味着 template 必须是 inline 的。Template 具现化出的函数可以是普通的 outlined 函数——每个翻译单元生成一份,链接器负责去重(COMDAT 段)。
如果你给 template 加上 inline,意味着你要求所有具现化版本都尝试 inline,这对复杂模板(如 std::sort、std::transform)会导致严重的代码膨胀。这也是为什么标准库算法虽然全在头文件里,但绝大多数不是 inline 的。
C++17 引入的 inline 变量和 C++20 的 modules 正在逐步改变头文件的组织方式,但"template ≠ inline"这个原则依然成立。
📖 inline 是一种性能优化手段,不是默认选项。它的代价是代码膨胀、编译耦合和调试困难。遵循 80-20 法则,只对小型、高频、确认为热点的函数 inline,其余保持 outlined。
七、条款31:将文件间的编译依存关系降至最低
Minimize compilation dependencies between files.
1. 问题:改一个 private 成员,全世界重编译
// person.h
#include <string>
#include "date.h"
#include "address.h"
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
如果你在 Person 的 private 区加一个 int 成员,所有 #include "person.h" 的文件都要重编译。更糟的是,date.h 或 address.h 的任何改动也会级联触发重编译。
问题的根源是:C++ 编译器在编译期必须知道对象的大小,而大小取决于 class 定义式中列出的所有成员。如果你只前置声明 class Date;,编译器不知道 Date 多大,就没法算 Person 多大。
2. pimpl 手法:声明依存性替换定义依存性
核心思路:把实现细节藏到一个指针背后,让头文件只依赖声明,不依赖定义。
// person.h
#include <memory>
class PersonImpl; // 前置声明实现类
class Date;
class Address;
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::tr1::shared_ptr<PersonImpl> pImpl; // 指针,大小固定
};
客户只需要 #include "person.h",person.h 不再 include date.h 和 address.h。Date、Address、PersonImpl 的实现改动不会触发客户重编译。
// person.cpp
#include "PersonImpl.h" // 实现文件才 include 真正的定义
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr)) {}
std::string Person::name() const {
return pImpl->name();
}
3. 两种解耦手段
Handle class(句柄类)
就是上面的 pimpl 手法。Person 是一个"手柄",所有调用转发给 PersonImpl。
Interface class(接口类)
类似 Java/C# 的 interface:抽象基类,只有纯虚函数,没有成员变量:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
// factory 函数
static std::tr1::shared_ptr<Person> create(
const std::string& name, const Date& birthday, const Address& addr);
};
客户通过 Person::create() 创建对象,拿到的是 shared_ptr<Person>,调用虚函数。真正的实现藏在派生类 RealPerson 里。
两种手段的对比:
| 机制 | 指针转发 | 虚函数多态 |
| 运行时成本 | 每次访问多一层间接跳转 | 每次调用一次虚函数跳转 |
| 内存成本 | 每个对象多一个指针 | 每个对象多一个 vptr |
| 适合场景 | 实现细节变化频繁 | 有多个派生实现 |
4. 实践建议
- 能用引用/指针就别用值对象(引用/指针只需要声明,值需要定义)
- 头文件尽量只含声明,依赖通过前置声明而非 include
- 为声明和定义提供不同头文件(如 datefwd.h 只声明 Date,date.h 才定义),标准库的 <iosfwd> 就是范本
- 用渐进方式:开发阶段用 Handle/Interface 解耦,只有当性能/大小差异成为瓶颈时才换回具象类
深度思考:C++20 modules 是否终结了 pimpl
Q:C++20 引入了 modules,声称能大幅改善编译时间和头文件依赖。pimpl 是否还有存在必要?
Modules 确实解决了一部分问题:import 不会像 #include 那样递归展开所有头文件,模块接口可以精确控制哪些名称导出。但 pimpl 的价值不完全在于编译速度:
| 降低编译依存性 | ✅ 部分解决——modules 减少了宏泄漏和传递性 include |
| 二进制兼容性 | ❌ 未解决——改变私有成员仍然改变类布局和大小 |
| 隐藏实现细节 | ⚠️ 部分解决——模块可以不导出实现,但接口类仍暴露私有成员声明 |
| 接口与实现物理分离 | ❌ modules 是逻辑分离,不是物理分离 |
特别重要的是二进制兼容性。如果你在开发一个动态库(.so/.dll),客户的程序链接你的库。pimpl 允许你在新版本中增删私有成员而不改变类的大小和布局——客户不需要重新编译。modules 做不到这一点,因为模块接口仍然描述了类的完整布局。
所以 pimpl 在以下场景仍然是首选:
- 商业动态库,需要跨版本二进制兼容
- 隐藏平台相关代码(#ifdef 埋在 cpp 里)
- 减少头文件对重型依赖(如 Boost、第三方库)的暴露
对于普通应用开发、不涉及二进制分发的内部项目,modules 配合前置声明可能已经足够,pimpl 的间接跳转成本不值得。
📖 编译依存性最小化的本质是"相依于声明式,不要相依于定义式"。Handle class 和 Interface class 是两种实现手段,各有运行时成本。是否值得取决于项目规模、发布方式和性能敏感度。
八、知识结构图
#mermaid-svg-NDKNkiLggZDPn6aZ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NDKNkiLggZDPn6aZ .error-icon{fill:#552222;}#mermaid-svg-NDKNkiLggZDPn6aZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NDKNkiLggZDPn6aZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NDKNkiLggZDPn6aZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NDKNkiLggZDPn6aZ .marker.cross{stroke:#333333;}#mermaid-svg-NDKNkiLggZDPn6aZ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NDKNkiLggZDPn6aZ p{margin:0;}#mermaid-svg-NDKNkiLggZDPn6aZ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ .cluster-label text{fill:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ .cluster-label span{color:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ .cluster-label span p{background-color:transparent;}#mermaid-svg-NDKNkiLggZDPn6aZ .label text,#mermaid-svg-NDKNkiLggZDPn6aZ span{fill:#333;color:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ .node rect,#mermaid-svg-NDKNkiLggZDPn6aZ .node circle,#mermaid-svg-NDKNkiLggZDPn6aZ .node ellipse,#mermaid-svg-NDKNkiLggZDPn6aZ .node polygon,#mermaid-svg-NDKNkiLggZDPn6aZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NDKNkiLggZDPn6aZ .rough-node .label text,#mermaid-svg-NDKNkiLggZDPn6aZ .node .label text,#mermaid-svg-NDKNkiLggZDPn6aZ .image-shape .label,#mermaid-svg-NDKNkiLggZDPn6aZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-NDKNkiLggZDPn6aZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NDKNkiLggZDPn6aZ .rough-node .label,#mermaid-svg-NDKNkiLggZDPn6aZ .node .label,#mermaid-svg-NDKNkiLggZDPn6aZ .image-shape .label,#mermaid-svg-NDKNkiLggZDPn6aZ .icon-shape .label{text-align:center;}#mermaid-svg-NDKNkiLggZDPn6aZ .node.clickable{cursor:pointer;}#mermaid-svg-NDKNkiLggZDPn6aZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NDKNkiLggZDPn6aZ .arrowheadPath{fill:#333333;}#mermaid-svg-NDKNkiLggZDPn6aZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NDKNkiLggZDPn6aZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NDKNkiLggZDPn6aZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NDKNkiLggZDPn6aZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NDKNkiLggZDPn6aZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NDKNkiLggZDPn6aZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NDKNkiLggZDPn6aZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NDKNkiLggZDPn6aZ .cluster text{fill:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ .cluster span{color:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NDKNkiLggZDPn6aZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NDKNkiLggZDPn6aZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-NDKNkiLggZDPn6aZ .icon-shape,#mermaid-svg-NDKNkiLggZDPn6aZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NDKNkiLggZDPn6aZ .icon-shape p,#mermaid-svg-NDKNkiLggZDPn6aZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NDKNkiLggZDPn6aZ .icon-shape .label rect,#mermaid-svg-NDKNkiLggZDPn6aZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NDKNkiLggZDPn6aZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NDKNkiLggZDPn6aZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NDKNkiLggZDPn6aZ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
出问题不留烂摊
不为不必要付费
核心主题
实现层面的权衡与约束
条款25高效swap
条款26延后变量定义
条款27少做转型
条款30谨慎inline
条款31降低编译依存
条款28不返回内部handle
条款29异常安全保证
成员swapno-throw
dynamic_cast很慢
悬空handle封装泄漏
copy-and-swap强烈保证
80-20法则代码膨胀
pimplHandle/Interface
📝 总结
这一章七个条款读完,我最大的感受是:C++ 的"实现"层面没有银弹,每一个选择都在交换东西。
- swap 用三层结构换高效和异常安全,但要写不少样板代码
- 延后变量定义 用可读性换性能,而且大多数时候两者是一致的
- 少转型 是在提醒你"类型系统不是敌人",频繁转型往往是设计的味道
- 不返回内部 handle 同时守护封装和生命周期安全
- 异常安全 是全有或全无的承诺,copy-and-swap 是利器但不是万能药
- inline 是你向编译器提的交易:空间换时间,但可能两边都亏
- 降低编译依存 用运行时间接跳转换编译速度和二进制兼容性
如果让我挑这一章最改变我思维方式的一点,应该是条款29的"异常安全是全有或全无"。以前我觉得"大部分代码安全就行",但木桶效应意味着只要有一个函数不安全,异常抛出时你就不知道系统会处于什么状态。这种系统性的思维方式,比记住具体的编码技巧重要得多。
点个赞收藏一下呗~有理解不对的地方欢迎评论区指正!
📚 参考资料
- Scott Meyers《Effective C++(第三版)》第五章:Implementations(条款25-31)
- Herb Sutter《Exceptional C++》条款8-19、《More Exceptional C++》条款17-23(异常安全)
- cppreference.com:Copy elision、Return value optimization、Exceptions
- C++ Core Guidelines:E.3-E.8(异常安全)、F.15-F.20(参数传递)、I.27(pimpl)
- C++20 Modules 标准提案 P1103R3
🏷️ 推荐标签
C++ Effective-C++ 异常安全 inline pimpl swap 类型转换 编译依存 读书笔记
网硕互联帮助中心




评论前必须登录!
注册