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.653const double AspectRatio = 1.653;
类内常量整数#define NUM_TURNS 5enum { 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)vs char* 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 默默生成:

  1. 默认构造函数(无参构造)
  2. 拷贝构造函数(copy constructor)
  3. 拷贝赋值运算符(copy assignment operator)
  4. 析构函数(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)
  • 最佳实践:
    1. 析构函数中吞下异常(catch 后记录日志,不传播)
    2. 将可能抛出异常的操作移到普通成员函数中,让用户有机会处理错误
    3. 析构函数只做”绝对安全”的清理工作

条款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 时
  • 指针/引用指向同一对象

危险:如果先释放旧资源再拷贝新资源,自我赋值时会释放掉正要拷贝的资源。

解决方案(从简到优):

  1. 证同测试(identity test):if (this == &rhs) return *this; —— 简单但不是最异常安全
  2. 先 copy 再 delete:先创建副本,再释放旧资源,最后替换
  3. 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 对应 delete
  • new[] 对应 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++ 编译器对函数参数的求值顺序没有规定。如果求值顺序是:

  1. new Widget
  2. priority()(抛出异常)
  3. 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 时要问的问题:

  1. 新类型的对象应该如何创建和销毁?
  2. 对象的初始化和赋值有什么区别?
  3. 对象按值传递(pass by value)意味着什么?
  4. 什么是新类型的”合法值”?
  5. 新类型在继承体系中吗?
  6. 新类型支持哪些转换?
  7. 哪些运算符和函数有意义?
  8. 哪些标准函数应该被驳回?
  9. 谁可以访问新类型的成员?
  10. 新类型的”未声明接口”是什么?
  11. 新类型有多通用?
  12. 你真的需要定义一个新类型吗?

条款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 应该:
    1. 提供一个 swap 成员函数,保证不抛异常(只交换指针/基本类型)
    2. 提供一个非成员 swap 函数,调用成员 swap
    3. 如果是 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.

异常安全函数提供的三个保证(等级从低到高):

  1. 基本承诺(basic guarantee):异常发生后,程序处于有效状态,没有资源泄漏,没有数据损坏
  2. 强烈保证(strong guarantee):异常发生后,程序状态不变——要么成功,要么回滚到初始状态
  3. 不抛掷保证(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++ 的编译依存问题:头文件变了,所有包含它的文件都要重编译
  • 解决思路:用”声明”代替”定义”——尽可能让头文件只包含类的声明
  • 两种主要手法:
    1. Handle 类 / pimpl 手法:将实现细节藏在一个指向实现的指针后面,头文件只暴露接口
    2. 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):一个类的对象包含另一个类的对象
  • 复合有两种含义:
    1. has-a(有一个):应用域中的关系,如”人有一个名字”
    2. 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 在模板中有两种完全不同的用途:

  1. 声明模板类型参数:和 class 完全等价

    template<class T>     // 这两种写法
    template<typename T>  // 完全一样
  2. 标识嵌套从属类型名称:告诉编译器一个名字是类型,不是静态成员变量

    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.

  • 当类模板继承一个模板基类时,编译器默认不进入基类作用域查找名称
  • 原因:基类模板可能有偏特化版本,不同实例化的基类可能有完全不同的成员
  • 让编译器进入基类查找的三种方法:
    1. this-> 前缀:this->func();
    2. using 声明:using Base<T>::func;
    3. 明确限定: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 的两大优势:
    1. 维度检查:编译期验证量纲(如质量×加速度=力)
    2. 提前分派:编译期根据类型选择最优算法
  • 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?

  1. 检测运用上的错误:内存泄漏、越界写入、重复释放等
  2. 收集动态内存使用统计:分配大小分布、寿命分布、分配顺序
  3. 提高分配/释放速度:默认分配器是通用的,特定场景可以更快
  4. 降低空间开销:默认分配器有簿记开销,小对象分配尤其浪费
  5. 弥补默认分配器的次优行为:对齐问题、多线程锁竞争
  6. 获得非传统的行为:共享内存分配、特定内存区域分配

但大多数时候,默认的 new/delete 已经足够好了。替换之前先用 profiler 确认瓶颈真的在内存分配。

条款51:编写 new 和 delete 时需固守常规

Adhere to convention when writing new and delete.

写自己的 operator new 时,必须遵守一些规则:

  1. 正确处理零字节申请:零字节的申请也要返回一个合法指针(等价于 1 字节申请)
  2. 分配失败调用 new-handler:循环尝试,不行再抛 bad_alloc
  3. 处理意外的申请:被派生类继承时可能发生(虽然 operator new 是 static 的)
  4. 注意多线程:线程安全是自定义分配器的常见难点

写 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 的价值就在于把这些细节系统化、条理化了。


相关书籍