Effective C++ 中文版(第三版)
55 Specific Ways to Improve Your Programs and Designs
作者:[美] Scott Meyers | 译者:侯捷
书籍概览
Effective C++ 是 C++ 领域最负盛名的经典著作之一,与 Clean Code 齐名的”条款式”技术写作典范。Scott Meyers 以 55 条精炼的条款(Item),条分缕析地讲解了 C++ 编程中最容易踩坑、最值得掌握的最佳实践。
核心定位
- 不是 C++ 入门书:预设读者已有 C++ 基础,聚焦”如何高效地用 C++ 写出好软件”
- 条款式结构:每条条款独立成文,可按任意顺序阅读,也可作为案头参考书查阅
- 实战导向:每条条款都配有代码示例,说明”为什么要这样做”以及”不这样做的代价”
- 思维深度:不只是语法规则,更是设计原则、工程经验与语言哲学的结合
全书结构(9章 + 附录 + 索引)
第0章 导读 (Introduction)
├── 第1章 让自己习惯C++ — 4条(条款01-04)
├── 第2章 构造/析构/赋值运算 — 8条(条款05-12)
├── 第3章 资源管理 — 5条(条款13-17)
├── 第4章 设计与声明 — 7条(条款18-24)
├── 第5章 实现 — 6条(条款25-31)
├── 第6章 继承与面向对象设计 — 9条(条款32-40)
├── 第7章 模板与泛型编程 — 8条(条款41-48)
├── 第8章 定制new和delete — 4条(条款49-52)
└── 第9章 杂项讨论 — 3条(条款53-55)
附录A:本书之外
附录B:新旧版条款对应
索引
第1章:让自己习惯C++(Accustoming Yourself to C++)
建立正确的 C++ 思维框架,理解 C++ 不是一门语言,而是一个”语言联邦”。
条款01:视 C++ 为一个语言联邦
View C++ as a federation of languages.
C++ 由四个次语言组成,各自有不同的编程范式和规则:
| 次语言 | 核心特征 | 编程范式 |
|---|---|---|
| C | 内置数据类型、数组、指针、预处理 | 过程式 |
| Object-Oriented C++ | class、继承、多态、virtual 函数 | 面向对象 |
| Template C++ | 模板、泛型编程、模板元编程(TMP) | 泛型/编译期计算 |
| STL | 容器、迭代器、算法、函数对象 | 标准库编程 |
要点:C++ 的高效编程守则视情况而变——你使用 C++ 的哪一部分,决定了遵守哪些规则。
条款02:尽量以 const、enum、inline 替换 define
Prefer consts, enums, and inlines to defines.
本质:宁可以编译器替换预处理器。
#define 的问题:
- 宏名称在预处理阶段被替换,不会进入记号表(symbol table)
- 编译报错时只显示字面量,不显示宏名,难以追踪
- 调试器无法识别宏名称
- 浮点常量宏会导致目标码中出现多份副本
替换方案:
| 场景 | define 写法 | 推荐写法 |
|---|---|---|
| 常量数值 | #define ASPECT_RATIO 1.653 | const double AspectRatio = 1.653; |
| 类内常量整数 | #define NUM_TURNS 5 | enum { NumTurns = 5 };(enum hack) |
| 函数宏 | #define max(a,b) ((a)>(b)?(a):(b)) | template<typename T> inline T max(const T& a, const T& b) { ... } |
条款03:尽可能使用 const
Use const whenever possible.
const 是 C++ 中最强大的约束工具之一,它让编译器帮你保证”不该被修改的东西不被修改”。
- 修饰变量:局部常量、成员常量、全局常量
- 修饰指针:
const char* p(指向 const)vschar* const p(指针本身 const) - 修饰成员函数:const 成员函数承诺不修改对象的任何成员变量(mutable 除外)
- 修饰函数返回值:防止意外赋值(如
if (a*b = c)这种笔误) - 修饰函数参数:pass-by-reference-to-const 的基础
bitwise const vs logical const:bitwise const 是编译器的标准(不修改任何位),但逻辑上应该允许某些”概念上不改变对象状态”的修改——用 mutable 实现。
条款04:确定对象被使用前已先被初始化
Make sure that objects are initialized before they’re used.
- 读取未初始化的值会导致未定义行为
- C++ 不保证对象在定义时被初始化(内置类型尤其如此)
- 构造函数中应该用初始化列表(initialization list),而不是赋值——初始化列表直接调用构造函数,赋值则是先默认构造再赋值,效率更低
- 初始化列表的顺序应该与成员声明顺序一致(成员初始化顺序由声明顺序决定,与列表顺序无关)
- 跨编译单元的全局对象初始化顺序问题:无法保证——用”local static 对象替换 non-local static 对象”的手法解决(Singleton 模式的一种实现)
第2章:构造/析构/赋值运算(Constructors, Destructors, and Assignment Operators)
C++ 对象生命周期管理的核心:编译器默默做了什么?你应该如何控制?
条款05:了解 C++ 默默编写并调用哪些函数
Know what functions C++ silently writes and calls.
如果你自己不声明,编译器会为 class 默默生成:
- 默认构造函数(无参构造)
- 拷贝构造函数(copy constructor)
- 拷贝赋值运算符(copy assignment operator)
- 析构函数(destructor)
这些函数都是 public 且 inline 的。编译器生成的版本会逐个成员进行拷贝/赋值/销毁。
注意:只有当这些函数被调用时,编译器才会真正生成它们。
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
Explicitly disallow the use of compiler-generated functions you do not want.
场景:有些类不应该被拷贝(如代表唯一资源的类)。
C++98 做法:将拷贝构造和拷贝赋值声明为 private,且只声明不定义——链接期报错。
更优雅的做法:继承一个不可拷贝的基类(如 boost::noncopyable)。
C++11 做法:用 = delete 显式删除。
条款07:为多态基类声明 virtual 析构函数
Declare destructors virtual in polymorphic base classes.
- 如果一个类被设计为基类且具有多态用途(即通过基类指针删除派生类对象),析构函数必须是 virtual 的
- 否则,通过基类指针
delete派生类对象时,会导致未定义行为——通常只销毁基类部分,派生类部分不被销毁(内存泄漏) - 规则:任何 class 只要带有 virtual 函数,几乎确定应该有一个 virtual 析构函数
- 反向规则:如果 class 不被用作基类,或不是多态用途,不要声明 virtual 析构函数——会增加虚表指针(vptr)的空间开销
条款08:别让异常逃离析构函数
Prevent exceptions from leaving destructors.
- C++ 不禁止析构函数抛出异常,但强烈不鼓励
- 如果在栈展开(stack unwinding)过程中,析构函数又抛出异常,会导致程序直接终止(terminate)
- 最佳实践:
- 析构函数中吞下异常(catch 后记录日志,不传播)
- 将可能抛出异常的操作移到普通成员函数中,让用户有机会处理错误
- 析构函数只做”绝对安全”的清理工作
条款09:绝不在构造和析构过程中调用 virtual 函数
Never call virtual functions during construction or destruction.
- 构造基类部分时,对象的类型是基类类型,不是派生类类型——virtual 函数不会向下绑定
- 同理,析构基类部分时,派生类部分已经被销毁,virtual 函数也不会向下绑定
- 本质:在基类构造/析构期间,virtual 函数退化为非 virtual 函数
- 如果需要在构造期间执行”多态行为”,改用非 virtual 函数 + 工厂方法,或者在派生类构造函数中显式调用基类的初始化函数
条款10:令 operator= 返回一个 reference to *this
Have assignment operators return a reference to *this.
- 这是 C++ 的赋值链式调用传统:
a = b = c = 15 - 虽然不是强制要求,但遵循这个约定可以让你的类型与内置类型行为一致
- 所有赋值相关运算符都应该遵循:
operator+=、operator-=等
条款11:在 operator= 中处理”自我赋值”
Handle assignment to self in operator=.
自我赋值(a = a)看起来很傻,但可能以隐蔽形式出现:
- 别名(aliasing):
a[i] = a[j]当 i==j 时 - 指针/引用指向同一对象
危险:如果先释放旧资源再拷贝新资源,自我赋值时会释放掉正要拷贝的资源。
解决方案(从简到优):
- 证同测试(identity test):
if (this == &rhs) return *this;—— 简单但不是最异常安全 - 先 copy 再 delete:先创建副本,再释放旧资源,最后替换
- copy and swap:利用拷贝构造 + swap,天然兼具自我赋值安全和异常安全
条款12:复制对象时勿忘其每一个成分
Copy all parts of an object.
- 编译器生成的拷贝函数会拷贝所有成员,但你自己写的拷贝函数很容易漏掉新添加的成员
- 当类有继承关系时尤其危险:派生类的拷贝函数必须调用基类的拷贝函数
- 拷贝构造 vs 拷贝赋值:不要让一个调用另一个来复用代码——它们做的事情不同
- 真正想复用代码时,写一个私有的
init()辅助函数
- 真正想复用代码时,写一个私有的
第3章:资源管理(Resource Management)
RAII(Resource Acquisition Is Initialization)——C++ 资源管理的基石。
条款13:以对象管理资源
Use objects to manage resources.
RAII 原则:资源获取即初始化。资源在构造函数中获取,在析构函数中释放。
- 手动
new/delete容易出错:异常路径、提前返回、多个出口点 - 智能指针是 RAII 的典型代表:
auto_ptr(C++98):独占所有权,拷贝会转移所有权(已废弃,C++11 被unique_ptr取代)tr1::shared_ptr/std::shared_ptr:引用计数,共享所有权
- 关键:资源申请后立即放入管理对象中,不要让裸指针”裸奔”超过一行
条款14:在资源管理类中小心 copying 行为
Think carefully about copying behavior in resource-managing classes.
不是所有资源都适合用 shared_ptr 共享所有权。RAII 类的拷贝策略取决于资源本身的语义:
| 拷贝策略 | 适用场景 | 实现方式 |
|---|---|---|
| 禁止拷贝 | 唯一资源(互斥锁、文件句柄) | 条款06 的做法 |
| 转移所有权 | 资源只能被一个对象持有 | auto_ptr 语义 |
| 引用计数 | 资源可共享,最后一个使用者释放 | shared_ptr 语义 |
| 深拷贝 | 资源可以被复制 | 拷贝底层资源 |
条款15:在资源管理类中提供对原始资源的访问
Provide access to raw resources in resource-managing classes.
- 现实中很多 API 直接操作原始资源(如 C 风格的 FILE*、裸指针)
- RAII 类应该提供获取原始资源的途径
- 两种方式:
- 显式转换:
get()成员函数(更安全,调用者明确知道在做什么) - 隐式转换:
operator T*()(更方便,但容易意外触发)
- 显式转换:
- 没有绝对优劣,取决于使用场景——但显式转换通常是更安全的选择
条款16:成对使用 new 和 delete 时要采取相同形式
Use the same form in corresponding uses of new and delete.
new对应deletenew[]对应delete[]- 混用会导致未定义行为
- 原因:
new[]会多分配一些空间记录数组大小,delete[]才知道要调用多少次析构函数 - 尤其要小心:
typedef一个数组类型后,new 出来的东西也要用delete[],容易记混
条款17:以独立语句将 newed 对象置入智能指针
Store newed objects in smart pointers in standalone statements.
危险场景:
processWidget(std::shared_ptr<Widget>(new Widget), priority());C++ 编译器对函数参数的求值顺序没有规定。如果求值顺序是:
new Widgetpriority()(抛出异常)shared_ptr构造
那么 new Widget 申请的内存就泄漏了——因为还没进入智能指针的管理。
解决方法:用独立语句:
std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());第4章:设计与声明(Designs and Declarations)
好的接口设计:容易被正确使用,不易被误用。好的类设计:像设计内置类型一样用心。
条款18:让接口容易被正确使用,不易被误用
Make interfaces easy to use correctly and hard to use incorrectly.
接口设计的黄金法则:好的接口天然引导正确使用。
- 用类型系统防错:用不同的类型区分不同的概念(如用 struct 包裹日期的年月日,而不是三个 int)
- 限制用户能做的事:用 const 限制修改,用 explicit 禁止隐式转换
- 让行为符合直觉:与内置类型保持一致的行为(如 operator= 返回 *this)
- 用 shared_ptr 消除用户的资源管理责任:返回智能指针而非裸指针
- 定制析构行为:shared_ptr 支持自定义 deleter,可处理跨 DLL 分配释放等问题
条款19:设计 class 犹如设计 type
Treat class design as type design.
在 C++ 中,你定义的 class 就是一个新的类型。设计 class 应该像语言设计者设计内置类型一样认真。
设计 class 时要问的问题:
- 新类型的对象应该如何创建和销毁?
- 对象的初始化和赋值有什么区别?
- 对象按值传递(pass by value)意味着什么?
- 什么是新类型的”合法值”?
- 新类型在继承体系中吗?
- 新类型支持哪些转换?
- 哪些运算符和函数有意义?
- 哪些标准函数应该被驳回?
- 谁可以访问新类型的成员?
- 新类型的”未声明接口”是什么?
- 新类型有多通用?
- 你真的需要定义一个新类型吗?
条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
Prefer pass-by-reference-to-const to pass-by-value.
- 效率问题:按值传递会调用拷贝构造函数和析构函数,对大对象开销很大
- 切片问题(slicing problem):如果派生类对象按值传递给基类参数,派生类部分会被”切掉”,只剩基类部分——多态失效
- 引用本质上是指针:传引用几乎零成本
- 不是所有类型都适合传引用:内置类型、STL 迭代器、函数对象通常按值传递更高效(它们本来就小)
条款21:必须返回对象时,别妄想返回其 reference
Don’t try to return a reference when you must return an object.
- 不要返回局部对象的引用或指针——函数结束时局部对象已销毁
- 不要返回函数内 new 出来的对象的引用——谁来 delete?
- 不要返回 static 对象的引用(如果需要多个实例的话)——线程安全也是问题
- 结论:如果必须返回新对象,就老老实实地返回对象(值传递)。现代编译器有 RVO(返回值优化) 和 NRVO(具名返回值优化),效率并不差。
条款22:将成员变量声明为 private
Declare data members private.
- 封装性:成员变量隐藏在接口之后,实现可以灵活变更
- 语法一致性:访问成员只能通过函数,用户不需要记住哪些是数据、哪些是函数
- 精确控制访问权限:可以实现只读、只写、读写等精细控制
- protected 并不比 public 更具封装性——protected 成员一旦改变,所有派生类都受影响
- 封装 = 可变性:越多东西被封装,改变它们的弹性就越大
条款23:宁以 non-member、non-friend 替换 member 函数
Prefer non-member non-friend functions to member functions.
- 用非成员非友元函数可以增加封装性——函数越多地访问对象内部,封装性越差
- 非成员函数不会增加”能够访问 class 私有成分”的函数数量
- C++ 中的做法:将非成员函数放在同一个 namespace 中
- 这也是 STL 的设计哲学:算法是 non-member function,操作容器的迭代器
条款24:若所有参数皆需类型转换,请为此采用 non-member 函数
Declare non-member functions when type conversions should apply to all parameters.
- 如果你需要在一个表达式中对最左边的参数也进行隐式类型转换,这个运算符必须是 non-member
- 典型例子:
operator*——2 * someRational和someRational * 2应该一样有效 - 成员函数形式的
operator*左边参数必须是 Rational 类型本身,无法对2做隐式转换 - 成员函数 vs 非成员函数 的判断标准:是否需要对最左边参数做隐式转换
第5章:实现(Implementations)
好的实现不仅要正确,还要高效、安全、可维护。
条款25:考虑写出一个不抛异常的 swap 函数
Consider support for a non-throwing swap.
std::swap的默认实现是”拷贝三次”,对管理资源的类效率太低- 自定义 swap 的典型手法:交换成员指针(pimpl 手法特别适合)
- 自定义 swap 应该:
- 提供一个
swap成员函数,保证不抛异常(只交换指针/基本类型) - 提供一个非成员
swap函数,调用成员 swap - 如果是 class template,特化
std::swap(注意 std 命名空间只能做特化,不能添加重载)
- 提供一个
- 名称查找技巧:利用 ADL(Argument Dependent Lookup)先找自定义 swap,找不到再 fallback 到
std::swap
条款26:尽可能延后变量定义式的出现时间
Postpone variable definitions as long as possible.
- 变量定义得早,作用域长,构造/析构开销浪费
- 直到需要变量的前一刻才定义
- 甚至可以延后到有了初始化参数再定义(直接构造比默认构造+赋值更高效)
- 循环中的变量:
- 方案A:循环外定义,循环内赋值(1次构造 + 1次析构 + n次赋值)
- 方案B:循环内定义(n次构造 + n次析构)
- 选择哪个取决于赋值开销 vs 构造析构开销,以及作用域清晰度
条款27:尽量少做转型动作
Minimize casting.
C++ 四种新式转型(取代 C 风格转型):
| 转型 | 用途 | 特点 |
|---|---|---|
const_cast | 移除 const 属性 | 唯一能做这件事的转型 |
dynamic_cast | 安全向下转型(运行期检查) | 可能较慢,尽量用虚函数替代 |
reinterpret_cast | 低级别的重新解释 | 高度不可移植 |
static_cast | 强制隐式转换(如 void* → typed*) | 不做运行期检查 |
- 转型不是无害的:它们可能产生不同的地址偏移
- dynamic_cast 尤其值得警惕:很多情况下可以用虚函数或 visitor 模式替代
- 一条好规则:如果代码里到处都是转型,多半是设计有问题
条款28:避免返回 handles 指向对象内部成分
Avoid returning “handles” to object internals.
- 返回成员变量的引用、指针或迭代器,都会破坏封装
- 看起来是 const 的成员函数,返回内部成分的 non-const 引用,用户仍然可以修改对象
- 更隐蔽的问题:返回的 handle 可能比对象寿命更长,导致悬空引用/指针
- 如果你确实需要返回内部成分,返回 const 引用 是相对安全的折中
条款29:为”异常安全”而努力是值得的
Strive for exception-safe code.
异常安全函数提供的三个保证(等级从低到高):
- 基本承诺(basic guarantee):异常发生后,程序处于有效状态,没有资源泄漏,没有数据损坏
- 强烈保证(strong guarantee):异常发生后,程序状态不变——要么成功,要么回滚到初始状态
- 不抛掷保证(nothrow guarantee):承诺绝不抛出异常——通常只对简单操作适用
实现强烈保证的常用手法:
- copy and swap:先做一份副本,在副本上修改,成功后 swap
- “pimpl 手法” + copy and swap 尤其好用
- 注意:强烈保证不总是可实现或值得的——有时基本承诺就够了
条款30:透彻了解 inlining 的里里外外
Understand the ins and outs of inlining.
- inline 的本质:用函数本体替换函数调用——消除调用开销,但可能增大目标码体积
- inline 只是给编译器的建议,不是强制要求
- 隐式 inline:定义在 class 内部的函数默认是 inline 的
- 显式 inline:用
inline关键字标记 - inline 函数如果修改,所有调用方都要重新编译
- 编译器通常不会对含有循环、递归、虚函数、函数指针调用的函数做 inline
- 调试期建议:关掉 inline 方便调试
条款31:将文件间的编译依存关系降至最低
Minimize compilation dependencies between files.
- C++ 的编译依存问题:头文件变了,所有包含它的文件都要重编译
- 解决思路:用”声明”代替”定义”——尽可能让头文件只包含类的声明
- 两种主要手法:
- Handle 类 / pimpl 手法:将实现细节藏在一个指向实现的指针后面,头文件只暴露接口
- Interface 类:纯抽象基类,只定义接口,由派生类实现
- 代价:运行期多一层间接性,有性能开销和空间开销
- 设计权衡:编译速度 vs 运行时性能
第6章:继承与面向对象设计(Inheritance and Object-Oriented Design)
C++ 中最容易被误解的部分:继承的真正含义是什么?
条款32:确定你的 public 继承塑模出 Is-a 关系
Make sure public inheritance models “is-a.”
- public 继承 = is-a:每个派生类对象都是一个基类对象
- 这是面向对象设计的基石,但也是最容易被违反的
- 经典反例:“正方形是矩形吗?“——数学上是的,但在编程中,如果矩形有 setWidth/setHeight,正方形的行为就不一致了
- 设计继承之前,先问自己:基类的所有不变式(invariants),派生类都满足吗?
条款33:避免遮掩继承而来的名称
Avoid hiding inherited names.
- 派生类中的名字会**遮掩(hide)**基类中同名的名字——即使参数类型不同
- 这是 C++ 的名称查找规则决定的:先在派生类作用域找,找不到才去基类找
- 想让被遮掩的名字重新可见:
- 用
using声明:using Base::func; - 用转交函数(forwarding function):在派生类中写一个函数调用基类版本
- 用
条款34:区分接口继承和实现继承
Differentiate between inheritance of interface and inheritance of implementation.
纯虚函数 → 只继承接口 虚函数 → 继承接口 + 默认实现 非虚函数 → 继承接口 + 强制实现
设计建议:
- 纯虚函数最纯粹——只定义契约,不提供任何实现
- 虚函数提供默认实现,但允许覆盖——但要小心”忘记覆盖”的问题
- 非虚函数代表”不变式”——不应该被覆盖(见条款36)
- 一个常见的好模式:纯虚函数 + 默认实现(调用纯虚函数版本)
条款35:考虑 virtual 函数以外的其他选择
Consider alternatives to virtual functions.
虚函数不是实现多态的唯一方式。替代品各有优劣:
| 替代方案 | 原理 | 特点 |
|---|---|---|
| Template Method 模式(non-virtual interface, NVI) | 公有非虚函数调用私有虚函数 | 可以在虚函数前后加前置/后置动作(如日志、锁) |
| Strategy 模式(函数指针/ tr1::function) | 将行为从类中剥离,作为参数传入 | 灵活度高,运行时可替换 |
| 古典 Strategy 模式 | 继承一个策略基类 | 类型安全,比函数指针更结构化 |
| 模板 + Policy-based design | 编译期选择策略 | 零运行时开销,见 [[《Modern C++ Design》-现代C++设计-Andrei-Alexandrescu |
条款36:绝不重新定义继承而来的 non-virtual 函数
Never redefine an inherited non-virtual function.
- 非虚函数是静态绑定的——通过基类指针调用时,调用的是基类版本;通过派生类指针调用时,调用的是派生类版本
- 同一个对象、同一个函数调用,仅仅因为指针类型不同,行为就不同——这完全违反直觉
- 非虚函数代表不变式(invariant),是不应该被覆盖的
- 如果需要在派生类中改变行为,基类函数应该是 virtual 的
条款37:绝不重新定义继承而来的缺省参数值
Never redefine a function’s inherited default parameter value.
- 虚函数是动态绑定的,但缺省参数值是静态绑定的
- 这意味着:通过基类指针调用虚函数时,即使实际执行的是派生类版本,使用的缺省参数值也是基类定义的那个
- 结果:你可能在调用派生类的函数,但用着基类的默认参数——非常令人困惑
- 最好的做法:不要给虚函数定义缺省参数,或者用 NVI 手法(条款35)——非虚函数负责缺省参数,虚函数负责实现
条款38:通过复合塑模出 has-a 或”根据某物实现出”
Model “has-a” or “is-implemented-in-terms-of” through composition.
- 复合(composition):一个类的对象包含另一个类的对象
- 复合有两种含义:
- has-a(有一个):应用域中的关系,如”人有一个名字”
- is-implemented-in-terms-of(根据某物实现出):实现域中的关系,如”set 用红黑树实现”
- 优先用复合,而非 public 继承——继承是 C++ 中最强的耦合关系之一
- 很多人容易犯的错:为了复用代码就用继承,其实复合更合适
条款39:明智而审慎地使用 private 继承
Use private inheritance judiciously.
- private 继承 = is-implemented-in-terms-of(根据某物实现出)
- 它和复合的含义相同,但有一些差异:
- private 继承可以访问基类的 protected 成员
- private 继承可以重定义基类的 virtual 函数
- 复合没有”空基类优化(EBO)“的可能
- 通常应该优先选择复合——private 继承是最后手段
- private 继承的特殊用途:空基类优化(empty base optimization)——当基类没有数据成员时,private 继承不会增加对象大小
条款40:明智而审慎地使用多重继承
Use multiple inheritance judiciously.
- 多重继承(MI)是 C++ 中最具争议的特性之一
- MI 的问题:
- 菱形继承导致的二义性——需要 virtual 继承
- virtual 继承增加空间和时间开销
- 初始化规则更复杂(最派生类负责初始化虚基类)
- 什么时候 MI 是正确的选择:
- “public 继承某个接口类 + private 继承某个实现类”的组合
- 如:
class MyWidget : public Widget, private MyImplementation
- 经验法则:先尝试用单继承 + 复合,如果确实需要,再用多重继承
第7章:模板与泛型编程(Templates and Generic Programming)
C++ 模板不只是”写一次,适用于多种类型”——它是一个完整的编译期编程范式。
条款41:了解隐式接口和编译期多态
Understand implicit interfaces and compile-time polymorphism.
面向对象编程(OOP)世界:
- 显式接口:函数签名明确写在 class 中
- 运行期多态:virtual 函数在运行时根据对象类型决定调用哪个版本
泛型编程(GP)世界:
- 隐式接口:模板参数类型只要”支持某些操作”就行,不需要继承某个基类
- 编译期多态:模板在编译期实例化,不同类型生成不同代码——也是一种多态
两者是互补的,不是对立的。好的 C++ 程序员应该能在两个世界间自如切换。
条款42:了解 typename 的双重意义
Understand the two meanings of typename.
typename 在模板中有两种完全不同的用途:
-
声明模板类型参数:和
class完全等价template<class T> // 这两种写法 template<typename T> // 完全一样 -
标识嵌套从属类型名称:告诉编译器一个名字是类型,不是静态成员变量
template<typename T> void printSecond(const T& container) { typename T::const_iterator iter = container.begin(); // ^ 这里必须有 typename,否则编译器会以为 T::const_iterator 是个变量 }
规则:在模板中,如果一个名称依赖于模板参数且是个类型,前面必须加 typename——除了基类列表和成员初始化列表中的基类名。
条款43:学习处理模板化基类内的名称
Know how to access names in templatized base classes.
- 当类模板继承一个模板基类时,编译器默认不进入基类作用域查找名称
- 原因:基类模板可能有偏特化版本,不同实例化的基类可能有完全不同的成员
- 让编译器进入基类查找的三种方法:
this->前缀:this->func();using声明:using Base<T>::func;- 明确限定:
Base<T>::func();(但这样会关闭 virtual 绑定)
条款44:将与参数无关的代码抽离 templates
Factor parameter-independent code out of templates.
模板代码膨胀(code bloat):模板被不同类型实例化多次,产生重复代码。
- 类型参数无关的代码应该抽离到非模板基类或非模板函数中
- 常见的抽离手法:
- 将与类型无关的部分放入一个非模板基类(common base class)
- 模板派生类只写与类型相关的部分
- 不仅是代码体积问题,也包括指令缓存命中率等性能问题
- 非类型模板参数(size_t N) 也可能导致膨胀——把 N 变成函数参数而不是模板参数
条款45:运用成员函数模板接受所有兼容类型
Use member function templates to accept “all compatible types.”
- 普通构造函数不能处理”兼容类型”的转换——如从
shared_ptr<Derived>到shared_ptr<Base> - 用**成员函数模板(member template)**实现”泛化拷贝构造”和”泛化赋值”
- 注意:如果你写了泛化拷贝构造模板,编译器不会停止生成默认拷贝构造函数——如果你需要控制默认拷贝,还是要显式写出来
shared_ptr就是这样实现的:支持从任何兼容指针类型构造
条款46:需要类型转换时请为模板定义非成员函数
Define non-member functions inside templates when type conversions are desired.
- 条款24 的模板版本:需要对所有参数做隐式类型转换时,用非成员函数
- 但在模板中,隐式类型转换在模板参数推导(template argument deduction)阶段不被考虑
- 解决方案:将运算符函数定义在 class template 内部——作为友元函数
- 这样,当第一个参数确定了模板参数类型后,第二个参数可以正常进行隐式转换
- 这是模板中实现”对称隐式转换”的标准手法
条款47:请使用 traits classes 表现类型信息
Use traits classes for information about types.
- traits class 是一种 C++ 编程技术,用于在编译期获取类型的信息
- 它不是 C++ 的关键字或特性,而是一种设计模式
- 典型例子:STL 中的
iterator_traits——告诉算法某个迭代器是什么类型 - 实现原理:
- 主模板提供默认行为
- 特化版本针对特定类型提供特定信息
- 配合
typedef和模板元编程使用
- traits 让”类型信息”可以像数据一样在编译期传递和使用
条款48:认识 template 元编程
Be aware of template metaprogramming.
模板元编程(TMP, Template Metaprogramming):用模板在编译期执行计算。
- TMP 的工作全部发生在编译期——运行期零开销
- 它是”图灵完备”的——理论上可以计算任何东西
- TMP 的两大优势:
- 维度检查:编译期验证量纲(如质量×加速度=力)
- 提前分派:编译期根据类型选择最优算法
- TMP 的缺点:语法晦涩、编译慢、错误信息难懂
- 本书第三版将 TMP 从”边缘技术”提升为”值得了解”的地位——TR1 中很多组件都用了 TMP
第8章:定制 new 和 delete(Customizing new and delete)
为什么有人要自己写 new/delete?内存池、对齐需求、性能优化、调试追踪……
条款49:了解 new-handler 的行为
Understand the behavior of the new-handler.
- 当
operator new无法满足内存分配请求时,会调用 new-handler 函数(如果设置了的话) - new-handler 的典型行为:
- 释放一些内存再重试
- 安装一个不同的 new-handler
- 卸载 new-handler(返回 nullptr,让 new 抛出 bad_alloc)
- 直接抛出 bad_alloc(或其他异常)
- 直接 abort / exit
set_new_handler()用于设置当前的 new-handler- nothrow new:
new (std::nothrow) T——分配失败时返回 nullptr,不抛异常 - 注意:nothrow 只保证 new 本身不抛异常,不保证构造函数不抛异常
条款50:了解 new 和 delete 的合理替换时机
Understand when it makes sense to replace new and delete.
什么时候应该自己写 operator new/delete?
- 检测运用上的错误:内存泄漏、越界写入、重复释放等
- 收集动态内存使用统计:分配大小分布、寿命分布、分配顺序
- 提高分配/释放速度:默认分配器是通用的,特定场景可以更快
- 降低空间开销:默认分配器有簿记开销,小对象分配尤其浪费
- 弥补默认分配器的次优行为:对齐问题、多线程锁竞争
- 获得非传统的行为:共享内存分配、特定内存区域分配
但大多数时候,默认的 new/delete 已经足够好了。替换之前先用 profiler 确认瓶颈真的在内存分配。
条款51:编写 new 和 delete 时需固守常规
Adhere to convention when writing new and delete.
写自己的 operator new 时,必须遵守一些规则:
- 正确处理零字节申请:零字节的申请也要返回一个合法指针(等价于 1 字节申请)
- 分配失败调用 new-handler:循环尝试,不行再抛 bad_alloc
- 处理意外的申请:被派生类继承时可能发生(虽然 operator new 是 static 的)
- 注意多线程:线程安全是自定义分配器的常见难点
写 operator delete 时:
- C++ 保证 delete nullptr 是安全的,你的版本也要处理
- 如果有不同大小的内存池,要注意派生类大小不同的情况
- 写 class 专属的 delete 时,注意继承体系下的大小问题
条款52:写了 placement new 也要写 placement delete
Write placement delete if you write placement new.
- placement new:带额外参数的 operator new(最经典的是
new (addr) T——在指定地址构造对象) - 如果你写了 placement new,也应该写对应的 placement delete
- 原因:如果构造函数抛出异常,编译器需要调用对应形式的 delete 来释放内存——如果找不到 placement delete,就不会释放,导致内存泄漏
- 注意:placement 版本的 new/delete 会遮掩标准版本——确保普通的 new/delete 仍然可用
- 容易踩坑的点:
operator new的重载决议和名称查找规则
第9章:杂项讨论(Miscellany)
三条不成章但同样重要的忠告。
条款53:不要轻忽编译器的警告
Pay attention to compiler warnings.
- 很多程序员把警告当”噪音”,关掉或无视——这是危险的
- 编译器的警告通常意味着”你的代码可能有问题”
- 最佳实践:
- 打开最高级别的警告(
-Wall -Wextra或等效选项) - 追求零警告
- 不要为了消掉警告而强行转型(那可能掩盖真正的问题)
- 打开最高级别的警告(
- 注意:不同编译器对警告的理解不同——不要依赖某个特定编译器的警告来发现所有问题
条款54:让自己熟悉包括 TR1 在内的标准程序库
Familiarize yourself with the standard library, including TR1.
C++ 标准库在持续演进。TR1(Technical Report 1)是 C++03 和 C++11 之间的重要过渡,引入了大量新组件:
- 智能指针:
tr1::shared_ptr、tr1::weak_ptr - 函数对象:
tr1::function、tr1::bind - 正则表达式:
tr1::regex - 哈希表容器:
tr1::unordered_set、tr1::unordered_map - 随机数:
<tr1/random> - 元编程工具:
tr1::is_same、tr1::enable_if等 type traits - 元组:
tr1::tuple - 数组:
tr1::array
注:在 C++11 中,大部分 TR1 组件已被纳入标准,命名空间从
std::tr1变为std。
条款55:让自己熟悉 Boost
Familiarize yourself with Boost.
- Boost 是 C++ 领域最重要的开源库集合,质量极高、审查严格
- 许多 Boost 库最终进入了标准库(如 shared_ptr、regex、tuple 等)
- Boost 涵盖的领域:
- 字符串与文本处理(字符串算法、正则、tokenizer)
- 容器与数据结构(any、variant、multi_index、graph)
- 函数对象与高阶编程(bind、function、lambda、signals)
- 模板元编程(mpl、type_traits)
- 并发与网络(thread、asio)
- 数学与数值计算
- Boost 是学习现代 C++ 设计思想的宝库
附录A:本书之外
C++ 学习路径建议——本书只是起点,不是终点。后续可以深入的方向:
- 设计模式:GoF 的《设计模式》
- STL 深入:SGI STL 源码、《STL 源码剖析》
- 模板元编程:Modern C++ Design、Boost MPL
- 更多 Effective 系列:Effective STL、Effective Modern C++、More Effective C++
- C++ 标准:ISO C++ 标准文档
附录B:新旧版条款对应
第三版相比第二版的变化:
- 新增了模板与泛型编程章节(条款41-48)
- 新增了 TR1 和 Boost 相关内容
- 对原有条款进行了更新和重组
- 条款总数从 50 条增加到 55 条
- 附录B 提供了第二版到第三版的完整条款对照表
关键主题总结
1. 资源管理是 C++ 的核心难题
从构造/析构(第2章)到智能指针(第3章)再到定制 new/delete(第8章),C++ 编程的很大一部分精力都花在资源管理上。RAII 是贯穿全书的核心思想。
2. 接口设计决定易用性
第4章反复强调:好的接口应该”容易被正确使用,不易被误用”。这不仅是美学问题,更是工程质量问题。
3. 继承是强大但危险的工具
第6章用了整整 9 条条款讲继承——因为它是 C++ 中最容易被误用的特性。public 继承 = is-a 这句话说起来简单,真正做到很难。
4. 模板是 C++ 的第二范式
第7章标志着 C++ 从”面向对象语言”向”多范式语言”的转变。模板和泛型编程不是锦上添花,而是现代 C++ 的基石。
5. 细节决定成败
从初始化顺序(条款04)到异常安全(条款29),从名字查找(条款33)到 new-handler(条款49)——C++ 是一门”细节里藏着魔鬼”的语言。Scott Meyers 的价值就在于把这些细节系统化、条理化了。
相关书籍
- 《Effective STL》-50条有效使用STL的经验-Scott-Meyers — Effective 系列第三弹,聚焦 STL
- 《Effective Modern C++》-42条改善C++11与C++14的具体方法-Scott-Meyers — Effective 系列第四弹,聚焦 C++11/14
- 《Modern C++ Design》-现代C++设计-Andrei-Alexandrescu — Policy-Based Design 奠基之作,模板元编程经典
- 《A Tour of C++》-第二版-Bjarne-Stroustrup — C++ 之父的现代 C++ 全景导览
- 《The C++ Programming Language》-第四版-Bjarne-Stroustrup — C++ 之父的圣经巨著
- 《C++ Concurrency in Action》-第二版-Anthony-Williams — C++ 并发编程权威指南
- 《Functional Programming in C++》-Ivan-Cukic — C++ 函数式编程
- 《From Mathematics to Generic Programming》-从数学到泛型编程-Stepanov-Rose — STL 设计者的泛型编程思想源头
- 《代码整洁之道》-Clean Code-Robert-C-Martin — 通用代码质量经典,与 Effective 系列互为补充
- 《重构》-改善既有代码的设计 — 代码质量的另一座丰碑,Martin Fowler 经典