Effective C++ 第三版笔记

书籍信息

  • 书名: Effective C++, Third Edition: 55 Specific Ways to Improve Your Programs and Designs
  • 作者: Scott Meyers
  • 页数: 321页
  • 出版: Addison-Wesley, 2005
  • ISBN: 0-321-33487-6

核心观点

本书是C++程序员必读的”第二本书”(在第一本入门教材之后)。全书以55个独立条目(Item)的形式,系统性地讲解了如何写出更有效、更安全、更高效C++代码的实践指南。每个条目聚焦一个具体主题,解释”怎么做”以及”为什么这样做”。

章节结构与关键要点

第1章:让自己习惯C++ (Accustoming Yourself to C++)

Item 1: 将C++视为一个语言联邦

C++不是单一语言,而是由四个子语言组成的联邦:

  • C: 过程式编程的基础
  • Object-Oriented C++: 基于类的面向对象编程(Classes, Constructors, Destructors, Encapsulation, Inheritance, Polymorphism, Virtual Functions)
  • Template C++: 泛型编程(Templates, STL)
  • STL: 标准模板库(Containers, Iterators, Algorithms, Function Objects)

不同子语言有不同的规则和行为模式,理解这种分层是有效使用C++的前提。

Item 2: 优先使用const、enum、inline替代#define

#define宏缺乏类型检查,容易造成意外行为。推荐替代方案:

  • 常量用 const 或 enum
  • 内联函数用 inline(而非宏函数)
  • 类内常量用 static const(C++11起可用= 0)

Item 3: 尽可能使用const

const不仅是编译期检查工具,更是设计契约:

  • 声明const成员函数表明不修改对象状态
  • const迭代器指向只读数据
  • const引用参数防止意外修改
  • const指针区分指向内容和指针本身是否可变

Item 4: 确保对象在使用前已被初始化

C++对”未初始化”的定义比想象中更复杂:

  • 默认构造函数保证内置类型不被初始化
  • 成员初始化列表(Member Initialization List)是正确初始化成员的唯一方式(尤其对于const和引用成员)
  • 不要在构造函数体内给成员赋值,应使用初始化列表

第2章:构造函数、析构函数和赋值运算符

Item 5: 了解C++默默编写并调用的函数

编译器自动生成四个特殊成员函数:

  • 默认构造函数(Default Constructor)
  • 复制构造函数(Copy Constructor)
  • 复制赋值运算符(Copy Assignment Operator)
  • 析构函数(Destructor)

理解这些函数的生成规则和调用时机,是控制对象行为的基础。

Item 6: 显式禁止编译器生成我们不想要的函数

通过将复制构造函数和赋值运算符声明为private(不实现),可以防止对象被复制:

class Uncopyable {
protected:
  Uncopyable() {}
  ~Uncopyable() {}
private:
  Uncopyable(const Uncopyable&);
  Uncopyable& operator=(const Uncopyable&);
};

Item 7: 在多态基类中声明虚析构函数

当类包含至少一个虚函数时,析构函数必须是虚的。否则通过基类指针删除派生类对象会导致未定义行为(派生类资源泄漏)。

Item 8: 不让异常从析构函数中抛出

析构函数抛出异常会导致程序终止(调用std::terminate)。解决方案:

  • 捕获析构函数内的异常并忽略
  • 设计一个普通成员函数来执行可能失败的操作
  • 使用”退出/操作”(cleanup/operation)模式分离

Item 9: 绝不在构造和析构过程中调用虚函数

在构造/析构期间,虚函数不会调用派生类版本,而是调用当前正在构造/析构的类的版本。因为此时派生类部分尚未构造/已析构。

Item 10: 让赋值运算符返回this的引用

支持链式赋值:a = b = c;

Widget& operator=(const Widget& rhs) {
    // ... 实现 ...
    return *this;
}

Item 11: 在operator=中处理”自我赋值”

使用copy-and-swap惯用法或先保存源对象再释放目标资源。

Item 12: 复制对象的所有部分

operator=应复制所有成员变量,包括基类部分。调用基类的operator=确保基类数据也被正确复制。

第3章:资源管理

Item 13: 以对象管理资源

RAII(Resource Acquisition Is Initialization)是C++资源管理的核心范式:

  • 资源在构造函数中获取
  • 资源在析构函数中释放
  • 使用智能指针(std::auto_ptr、std::shared_ptr、std::unique_ptr)管理动态分配的资源

Item 14: 在资源管理类中考虑拷贝行为

需要明确资源的所有权语义:

  • 禁止拷贝(如Item 6)
  • 引用计数(shared_ptr)
  • 深度拷贝
  • 转移所有权(unique_ptr的移动语义)

Item 15: 在资源管理类中提供对原始资源的访问

为了与旧API兼容,资源管理类应提供获取原始资源的接口(如get()方法),同时保持所有权管理。

Item 16: 成对使用new和delete时要采用相同形式

int* p = new int;      // 单对象
delete p;
 
int* arr = new int[10]; // 数组
delete[] arr;

形式不匹配导致未定义行为。

Item 17: 以独立语句将newed对象置入智能指针

避免以下代码:

processWidget(std::shared_ptr<Widget>(new Widget), prioritize());

因为函数参数求值顺序未定义,可能先执行prioritize()再执行new,若prioritize()抛出异常则内存泄漏。正确做法:

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, prioritize());

第4章:设计与声明

Item 18: 让接口容易被正确使用,难以被错误使用

设计API时应考虑:

  • 类型安全(使用强类型)
  • 约束条件的显式化
  • 如果某些参数组合无效,通过类型系统阻止而非运行时检查

Item 19: 将类设计为类型设计

好的类设计应像好的数据类型设计:

  • 一致性
  • 最小化暴露
  • 清晰的语义

Item 20: 优先使用const引用传参而非值传递

  • 避免不必要的拷贝开销
  • const保证不修改参数
  • 对于内置类型和小型对象,值传递可能更高效

Item 21: 必须返回对象时不要返回引用

返回值优化(RVO/NRVO)可以消除临时对象的开销,无需担心性能。

Item 22: 声明成员变量为private

封装是面向对象的基本原则。保护数据不被外部随意修改。

Item 23: 优先使用非成员非友元函数而非成员函数

当函数不需要访问类的私有成员时,应声明为非成员函数,以保持类的封装性。这也有助于实现类型转换。

Item 24: 当所有参数都需要类型转换时,声明为非成员函数

成员函数的第一个参数(this)不参与隐式类型转换。若需要将类型转换应用于所有参数,应使用非成员函数。

Item 25: 考虑支持非抛出异常的swap

std::swap应保证不抛出异常。自定义类型可提供特化的swap成员函数,并通过ADL(Argument-Dependent Lookup)发现。

第5章:实现

Item 26: 尽可能推迟变量定义

扩大变量的作用域会降低代码可读性。在第一次使用前再定义变量。

Item 27: 尽可能避免强制类型转换

  • static_cast: 编译期检查,用于明确的类型转换
  • dynamic_cast: 运行时检查,用于多态类型的向下转换
  • reinterpret_cast: 危险的底层转换,尽量避免
  • const_cast: 仅用于添加/移除const

Item 28: 避免返回”句柄”指向对象内部

返回引用或指针指向对象内部数据会破坏封装,使对象状态可能被外部意外修改。

Item 29: 追求异常安全的代码

异常安全三个级别:

  • 基本保证:无资源泄漏,数据一致性
  • 强烈保证:操作成功或恢复到原始状态
  • 不抛出保证:永远不会抛出异常

Item 30: 理解inline的内在

  • inline仅是建议,编译器可忽略
  • 头文件中的函数通常应为inline
  • 虚函数通常不是inline候选
  • 类内定义的函数自动成为inline

Item 31: 最小化编译依赖性

  • 使用句柄类(Handle Class)或接口类减少头文件依赖
  • #include只包含必要的头文件
  • 前向声明(Forward Declaration)替代完整包含

第6章:继承与面向对象设计

Item 32: 确认公共继承建模”is-a”关系

公共继承意味着”is-a”(是一种)。派生类对象可以替换基类对象。如果不是”is-a”关系,考虑组合(Composition)或”has-a”关系。

Item 33: 避免隐藏继承的名称

派生类重新声明与基类同名的函数会隐藏基类版本,即使签名不同。使用using声明引入基类名称。

Item 34: 区分继承接口与继承实现

  • 纯虚函数:继承接口
  • 虚函数(有默认实现):继承接口+可选实现
  • 非虚函数:继承接口+强制实现

Item 35: 考虑虚函数以外的替代方案

  • Non-virtual Interface (NVI) 惯用法:将虚函数设为private,公开非虚函数调用它
  • 策略模式(Strategy Pattern)
  • 函数对象(Function Objects)
  • 模板方法模式

第7章:模板与泛型编程

Item 36: 区分模板参数递推与显式指定

模板参数推导有时不符合预期,可使用显式指定或辅助函数解决。

Item 37: 使用参数推导处理”可接受值组合”

通过函数模板的多个重载处理不同类型组合。

Item 38: 通过”出参指针模型化reference-to-pointer”转换

C++中引用不能作为模板参数直接传递指针类型,需要特殊处理。

Item 39: 理解隐式接口和编译期多态

模板不要求显式接口(如虚函数),而是要求”满足特定操作的隐式接口”。编译期多态通过模板特化和重载解析实现。

Item 40: 明智使用CRTP获得类型无关的性能

Curiously Recurring Template Pattern(奇异的递归模板模式)允许在编译期获得类似虚函数的多态行为,避免运行时虚函数调用开销。

第8章:定制内存管理

Item 41: 理解自定义new-handler的行为

当operator new无法分配内存时,会调用global new-handler。可通过std::set_new_handler设置自定义处理函数。

Item 42: 理解malloc/free与new/delete的对应关系

  • new = operator new + 构造函数
  • delete = 析构函数 + operator delete
  • C风格的malloc/free不调用构造/析构函数

Item 43: 思考malloc-free与new-delete的区别

operator new可被重载以实现定制内存分配策略(池化、栈分配等)。

Item 44: 通过operator new/delete实现高性能小内存分配

对于频繁分配/释放的小对象,使用自定义分配器可以减少内存碎片和分配开销。

Item 45: 遵守”一致使用new和delete的形式”的规则

确保new/delete、new[]/delete[]、malloc/free成对使用。

第9章:谈票证

Item 46: 如果不想使用编译器生成的函数,应显式拒绝

再次强调Item 6的原则。

Item 47: 通过非成员函数处理类型转换

再次强调Item 24的原则。

Item 48: 通过traits classes实现类型相关行为

Traits类提供编译期类型信息,用于模板特化和SFINAE(Substitution Failure Is Not An Error)。

Item 49: 理解iostream的类型系统

iostream库使用显式类型,不同于STL的泛型设计。理解其设计决策有助于避免误用。

Item 50: 熟悉标准库的类型工具

  • std::tr1::result_of: 查询函数调用结果类型
  • std::mem_fun / std::bind1st / std::bind2nd: 绑定成员函数和参数
  • std::iterator_traits: 迭代器特性

Item 51: 通过non-member函数加入类型转换

再次强调Item 23-24的原则。

Item 52: 写好toString时考虑operator<<

自定义类型的字符串表示应通过operator<<实现,而非toString方法,以保持与iostream生态的一致性。

Item 53: 避免隐式转换的危险

  • 单一参数构造函数可能导致隐式转换
  • 使用explicit关键字阻止不需要的隐式转换
  • 仔细设计类型转换路径

Item 54: 理解operator new和operator delete的实现细节

  • operator new可能抛出std::bad_alloc
  • operator new可以被重载
  • nothrow版本的operator new不抛出异常,返回nullptr

Item 55: 编写placement new时小心不要遗漏delete

placement new在已分配内存上构造对象,不会自动调用全局operator delete。需要显式调用析构函数和正确的delete形式。

行动点

  1. 日常编码: 每写一个新类,对照第2章的12个Item检查构造函数、析构函数、赋值运算符的实现
  2. 代码审查: 使用第3章的RAII原则审查所有资源管理代码
  3. 接口设计: 参考第4章的原则设计API,特别关注Item 18的”易用难错”原则
  4. 性能优化: 理解第8章的内存管理技巧,在热点路径中使用合适的分配策略
  5. 模板编程: 掌握第7章的泛型编程技术,特别是traits和SFINAE的使用

与其他知识的关联