type
date
slug
category
icon
password
参考资料
Effective-C-Plus-Plus
steveLauwh • Updated Sep 7, 2024
effective_cpp
liuxinyu123 • Updated Mar 16, 2017
EffectiveModernCppChinese
CnTransGroup • Updated Dec 11, 2025
Effective-Modern-Cpp
BartVandewoestyne • Updated Nov 17, 2025
C++ Core Guidelines
The C++ Core Guidelines are a set of tried-and-true guidelines, rules, and best practices about coding in C++
Learn
前言
《Effective C++ Third Edition》需要注意的事项
《Effective C++ Third Edition》(2006年)无疑是C++历史上最经典的著作之一,但它描述的是 C++98/03 标准。
历经 C++11、14、17、20 乃至 C++23 近20年的演进,虽然书中关于“软件设计哲学”的部分(如接口设计、资源管理思想)依然历久弥新,但具体的语法建议和实现手段已经发生了巨大变化。
以下为您梳理自2006年以来,Effective C++ 规则的淘汰、演变以及全新的发展趋势。
一、 已经“过时”或需彻底更新的规则 (Obsolete / Deprecated)
这些规则在现代 C++ 中通常被视为反模式,或者有了完全替代的更优解。
原书条款 (Effective C++) | 现代 C++ 的变化与现状 |
条款 13: 以对象管理资源 ( auto_ptr) | 彻底废弃 auto_ptr。C++11 引入了所有权语义明确的 std::unique_ptr 和 std::shared_ptr。auto_ptr 已在 C++17 中被正式移除。 |
条款 02: 尽量以 const, enum, inline 替换 #define | 趋势加强,但有了新工具。现在有了 constexpr (编译期常量) 和 consteval (C++20)。对于类型别名,使用 using (类型别名) 代替 typedef,因为它支持模板化。 |
条款 04: 确定对象被使用前已先被初始化 | 更简单的初始化。C++11 的统一初始化 (Uniform Initialization {}) 和类内成员初始化 (In-class member initializers) 极大地简化了这一规则,不再需要只依赖构造函数初始化列表。 |
条款 25: 考虑写出一个不抛异常的 swap 函数 | 不再那么重要。随着移动语义 (Move Semantics) 的引入,标准库的 std::swap 默认会使用移动操作,效率极高。除非你的对象拷贝及其昂贵且无法移动,否则很少需要特化 swap。 |
一般性建议: 使用 0 或 NULL | 废弃。全面使用 nullptr。它具有类型安全性,避免了 0 在指针和整数重载时的歧义。 |
二、 发生重大演变的老规则 (Evolved Rules)
这些规则的核心思想仍然正确,但实现方式因新特性(如移动语义、Lambda、智能指针)而变得不同。
1. “三法则” 变为 “五法则” (Rule of Three -> Rule of Five)
- 原规则:如果你需要显式定义析构函数、拷贝构造函数或拷贝赋值操作符中的一个,那你通常需要定义全部三个。
- 新现状:C++11 引入了移动语义。现在的规则是 “Rule of Five”(析构、拷贝构造、拷贝赋值、移动构造、移动赋值)。
- 最新趋势 (Rule of Zero):最好的做法是不手动编写任何一个。利用
std::string,std::vector,std::unique_ptr等自带资源管理的成员,让编译器自动生成所有默认操作。
2. 参数传递:不仅仅是 const reference
- 原规则:条款 20 建议“宁以 pass-by-reference-to-const 替换 pass-by-value”。
- 新现状:对于需要拷贝的“水槽”参数(Sink arguments,即函数内部需要持有一份副本的情况),按值传递 (Pass-by-value) + 移动 (std::move) 往往更高效且代码更简洁。
3. 替代虚函数的新思路
- 原规则:条款 35 提到了使用 Function Pointers 或 Strategy Pattern 替代虚函数。
- 新现状:
std::function和 Lambda 表达式 成为了更主流的回调机制。C++20 的 Concepts 使得静态多态(模板)比以往更容易编写,性能优于虚函数。
三、 发展出来的“新规则” (New Rules for Modern C++)
如果在今天重写《Effective C++》,以下内容极有可能会成为新的核心条款(参考自 Scott Meyers 的后续著作《Effective Modern C++》及社区共识):
1. 优先使用 auto (但在易读性受损时除外)
- 理由:避免类型不匹配(如
unsignedvsstd::size_t),避免“最令人头疼的解析” (most vexing parse),并且对重构更友好。
2. 优先使用智能指针 (std::make_unique / std::make_shared)
- 理由:杜绝
new和delete的显式调用。“No Naked New” 是现代 C++ 的核心戒律。
3. 只要可能,就使用 constexpr (甚至 consteval)
- 理由:将计算从“运行时”移到“编译时”。这不仅提高了运行时性能,更是一种更严格的正确性检查。
4. 了解并利用移动语义 (std::move 和 std::forward)
- 理由:这是现代 C++ 性能提升的关键。区分左值 (Lvalue) 和 右值 (Rvalue) 是中高级 C++ 开发者的必备技能。
5. 使用 enum class 限定作用域
- 理由:老式的
enum会泄漏名字到外层作用域,且允许隐式转换为整数。enum class更加类型安全。
6. 优先使用标准库算法和 Ranges,而非手写循环
- 理由:C++20 引入了 Ranges 库。写
std::ranges::sort(v)比std::sort(v.begin(), v.end())更安全、更具表达力,且易于组合(管道操作|)。
四、 C++ 规则的新趋势 (2024年及未来展望)
随着 C++20 和 C++23 的落地,以及 C++26 的制定,C++ 正在呈现以下明显趋势:
1. 安全性优先 (Safety First)
这是目前 C++ 社区最大的呼声。
- 避免越界:使用
std::span(C++20) 代替指针+长度;使用std::string_view(C++17) 代替const char*。
- C++ Core Guidelines:Bjarne Stroustrup 领导的“C++ 核心准则”项目,试图通过工具(静态分析)来强制执行类似 Rust 的内存安全规则(如 Lifetime profile)。
2. 编译期计算的极大扩展 (Compile-time Everything)
- 现在的趋势是让尽可能多的代码在编译期执行。
constexpr甚至开始支持动态内存分配(C++20 vector/string 可在 constexpr 中使用),通过if constexpr进行编译期分支判断代替复杂的 SFINAE 模板技巧。
3. 概念 (Concepts) 重塑泛型编程
- C++20 的 Concepts 彻底改变了模板的写法。你不再需要写复杂的
typename和enable_if,而是直接写void sort(Sortable auto& c)。这让模板编程变得像普通编程一样有明确的接口约束。
4. 消除代码重复的新机制
- C++23 "Deducing this":这是一个革命性的特性,允许显式指定
this参数。它解决了长期以来const成员函数和非const成员函数代码重复的问题,让 CRTP(奇异递归模板模式)变得极其简单。
总结建议
如果您想更新知识体系:
- 必读:Scott Meyers 的 《Effective Modern C++》 (2014),它不仅是续作,更是从 C++98 转到 C++11/14 的最佳指南。
- 参考:C++ Core Guidelines(在线文档),这是目前最权威的“现代 C++ 道德准则”。
- 实践:重点学习 智能指针、Move 语义 和 C++20 Ranges。
第1章 让自己习惯C++(Accustoming Yourself to C++)
条款1:视C++为一个语言联邦(View C++ as a federation of languages)
C++编程规范会根据你使用的语言特性而有所不同
含义解释:
这条规则强调C++实际上是多个"子语言"的联合体,包括:
- C:基础的过程式编程部分
- Object-Oriented C++:面向对象特性(类、封装、继承、多态等)
- Template C++:泛型编程部分
- STL:标准模板库
在不同的"子语言"中,编程的最佳实践会有所不同。例如:对于内置类型,pass-by-value通常比pass-by-reference效率高;但对于自定义类型,pass-by-reference-to-const往往更优。因此,高效的C++编程需要根据具体使用的语言特性来选择合适的策略。
条款2:尽量以const、enum、inline替换#define(Prefer consts, enums, and inlines to #defines)
核心原则:用编译器检查的机制替代预处理器宏,获得类型安全和作用域控制。
1. 常量定义:优先使用 const 或 enum
❌ 不推荐的做法:
✅ 推荐的做法:
为什么这样做?
- 类型安全:
#define只是文本替换,编译器看不到符号名,调试时显示的是1.653而非ASPECT_RATIO
- 作用域控制:
const变量可以限定在类或命名空间内,而宏是全局的
- 内存效率:
const对象通常只有一份实体,而宏可能产生多份副本
特殊情况 - 使用 enum hack:
当编译器不允许 "static const" 成员在类内初始化时,可使用 enum。它不会导致不必要的内存分配。
2. 函数宏:优先使用 inline 函数或模板
❌ 不推荐的做法:
✅ 推荐的做法:
为什么这样做?
- 避免重复求值:宏会将参数原样替换到每个使用位置,可能导致副作用执行多次
- 类型检查:模板函数有完整的类型检查,宏只是文本替换
- 调试友好:函数调用可以设置断点和单步调试,宏展开后难以追踪
- 作用域和访问控制:inline 函数可以是类的私有成员,宏无法做到
最佳实践总结
- 常量:用
const对象或enum替代#define常量
- 函数:用
inline函数(或 C++11 的constexpr)替代宏函数
- 条件编译:只有在需要条件编译时才使用
#ifdef,其他情况尽量避免宏
条款3:尽可能使用const(Use const whenever possible)
const 是 C++ 中最重要的关键字之一,它能帮助编译器在编译期捕获错误,提高代码的安全性和可维护性。下面详细说明 const 的各种用法、特点和注意事项。1. const 修饰变量
基本用法:
2. const 与指针
指针和 const 的组合有三种情况,需要特别注意:
记忆技巧:如果
const 出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量。3. const 与迭代器
STL 迭代器的 const 用法类似指针:
4. const 修饰函数返回值
将函数返回值声明为 const 可以防止意外的赋值操作:
5. const 修饰函数参数
使用 const 修饰参数可以防止函数内部意外修改参数:
6. const 成员函数
核心作用:const 成员函数承诺不会修改对象的任何成员变量(除了 mutable 成员)。
7. bitwise constness vs. logical constness
bitwise constness(物理常量性):
编译器执行的 const 检查。只要成员函数不修改对象的任何非 static 成员变量(即不改变对象的任何一个 bit),就是 const 的。
logical constness(逻辑常量性):
一个 const 成员函数可以修改对象的某些 bits,但只有在客户端侦测不出的情况下才得如此。使用
mutable 关键字实现。8. 在 const 和 non-const 成员函数中避免重复
问题场景:const 和 non-const 版本的函数实现几乎完全相同,导致代码重复。
✅ 解决方案:让 non-const 版本调用 const 版本(使用 casting)
⚠️ 注意:绝不要让 const 版本调用 non-const 版本!这会导致对象状态被修改,违反 const 承诺。
9. 最佳实践总结
- 尽可能使用 const:将不打算修改的变量、参数、返回值、成员函数都声明为 const
- const 引用传参:对于用户自定义类型,优先使用
const T&而非T
- const 成员函数:不修改对象状态的成员函数都应声明为 const,这样才能被 const 对象调用
- 使用 mutable:对于缓存等"逻辑上不影响对象状态"的成员,使用 mutable 修饰
- 避免代码重复:通过让 non-const 函数调用 const 函数来消除重复代码
- 编译器是你的朋友:const 能帮助编译器在编译期发现错误,减少运行时 bug
条款4:确定对象被使用前已先被初始化(Make sure that objects are initialized before they're used)
- C part of C++,不保证初始化,non-C parts of C++ 可以保证初始化
- 对于内置类型,手动初始化,对于内置类型以外,通过构造函数,将对象每个成员初始化。
- 区分赋值和初始化。初始化操作在构造函数本体之前,最好通过初始化列表是较佳写法。
- 赋值方式示例(不推荐):
- 成员初始化列表方式(推荐):
- 关键区别:
- 赋值方式:先调用默认构造函数,再在构造函数本体中赋值(两步操作)
- 初始化列表:直接用指定值初始化成员变量(一步到位,更高效)
- 对于const成员和引用成员,必须使用初始化列表
- 成员初始化顺序和声明一致,顺序调整也不会造成影响。
- 虽然初始化列表中 m_b 写在前面,但实际执行时会按照声明顺序初始化:先初始化 m_a,再初始化 m_b
- 当初始化 m_a 时,m_b 还未被初始化,此时
m_b * 2会使用未定义的值 - 这会导致 m_a 得到一个不确定的值,引发未定义行为
当成员初始化列表的顺序与类中声明的顺序不一致时,可能会导致潜在的问题。举例说明:
问题:
正确做法:
或者调整成员声明顺序与依赖关系一致:
总结:成员变量总是按照声明顺序初始化,而非初始化列表的顺序。为避免混淆和错误,应保持初始化列表顺序与声明顺序一致。
- 为免除“跨编译单元之初始化次序”问题,请以local static 对象替换non-local static 对象。
- 相关概念
- 编译单元:能生成单一目标文件的源码,通常由单个源文件加上其包含的头文件构成。
- static 对象:生命周期直到程序结束为止,包括 global 对象、定义于 namespace 作用域内的对象、在 class 内、函数内、file 作用域内被声明为 static 的对象。
- local static 对象:在函数内被声明为 static 的对象。
- non-local static 对象:除函数内 static 对象以外的所有 static 对象。
- 示例说明:
- C++ 保证函数内的 local static 对象会在该函数首次被调用时初始化
- 这样就避免了"在对象初始化前使用它"的问题
- 使用时通过
tfs()而非tfs访问对象
问题场景(使用 non-local static 对象):
问题:如果 tempDir 在 tfs 之前初始化,那么 tempDir 的构造函数会使用尚未初始化的 tfs,导致未定义行为。
解决方案(使用 local static 对象):
原理:
注意事项:这个技巧在单线程环境下非常有效。在多线程环境中,需要额外的同步机制来保证线程安全(C++11 之后编译器会自动处理 local static 的线程安全初始化)。
第2章 构造/析构/赋值运算(Constructors, Destructors, and Assignment Operators)
条款5:了解C++默默编写并调用哪些函数(Know what functions C++ silently writes and calls)
- 编译器处理后,会为一个空类添加一个default构造,一个copy 构造,一个copy assignment 操作,一个 default 析构函数。
- default构造函数:隐藏代码,用于
调用base classes 和 non-static 成员变量的构造函数和析构函数。 除非base class自身声明了virtual 析构函数,否则编译器产生的析构函数是non-virtual的。
- copy 构造和copy assignment 操作:隐藏代码,用于将来源对象的non-static 成员对象复制到目标对象。 这两个函数执行的是浅拷贝(shallow copy),即逐个成员变量进行复制。如果
类中包含指针成员变量,浅拷贝可能导致多个对象共享同一块内存,从而引发悬空指针和内存泄漏等问题。
- 假如操作不匹配(比如对象为字符串引用, const成员, 某个 bass classes 将 copy assignment 操作声明为private),编译器拒绝生成copy assignment 操作
这里有三个代码示例,分别说明编译器拒绝生成 copy assignment 操作的场景:
场景1:类中包含引用成员
场景2:类中包含 const 成员
场景3:基类的 copy assignment 操作声明为 private
原因总结:
- 引用成员:引用本质上是别名,无法重新赋值指向另一个对象
- const 成员:const 语义要求对象一旦初始化就不可修改
- 基类 private copy assignment:派生类无法访问基类的 private 成员函数
在这些情况下,如果需要 copy assignment 功能,必须手动定义。
条款6:若不想使用编译器自动生成的函数,就该明确拒绝(Explicitly disallow the use of compiler-generated functions you do not want)
- 通过声明 copy 函数和 copy assignment 操作为 private并不予实现,避免编译器自动生成函数。
- 防止member函数或者friend函数尝试拷贝,通过继承 Uncopyable 类,避免类被复制。并且将连接期错误移动到编译期。
以下是两种防止类被复制的示例代码:
方法1:将 copy 函数和 copy assignment 声明为 private 且不实现
问题:如果 member 函数或 friend 函数尝试复制,错误会在链接期才被发现(因为函数声明了但未实现)。
方法2:继承 Uncopyable 类(将错误提前到编译期)
方法2的优势:
- 即使 member 函数或 friend 函数尝试复制,也会在编译期就报错
- 错误信息更清晰,指向基类的 private 函数
- 代码意图更明确,一眼就能看出这个类是不可复制的
C++11 及以后的现代做法:
使用
= delete 是最现代和推荐的做法,它在编译期就会报错,且意图非常明确。条款7:为多态基类声明virtual析构函数(Declare destructors virtual in polymorphic base classes)
- 带多态性质的 base class 应该声明一个 virtual 析构函数。如果 class 带有任何 virtual 函数,它应该拥有一个 virtual 析构函数。
- derived class 对象经由一个 base class 指针被删除,而该 base class 带着一个 non-virtual 析构函数,其结果未有定义 。
- 实际执行时通常是对象的derived成分未被销毁 ,形成局部销毁,造成资源泄露,浪费调试时间
示例1:non-virtual 析构函数导致的问题
正确做法:声明 virtual 析构函数
- classes 的设计目的如果不是作为 base classes 使用,或不是为了具备多态性,则不需要 virtual 析构函数。
- 添加的话,会造成类体积增大,因为需要添加vptr(virtual table pointer)
- 错误继承无virual 析构函数的,比如STL容器如 vector,string,list,set,unordered_map等
示例2:不应该声明 virtual 析构函数的情况
错误继承 STL 容器的例子
- 为抽象class声明一个 pure virtual 析构函数,但仍然需要在 class 外定义它。
示例3:pure virtual 析构函数
为什么必须定义 pure virtual 析构函数?
总结:pure virtual 析构函数既能让类成为抽象类(不能实例化),又能确保派生类析构时正确调用基类析构函数。但必须提供函数体定义。
条款8:别让异常逃离析构函数(Prevent exceptions from leaving destructors)
- 析构函数绝对不要抛出异常。如果析构函数调用的函数可能抛出异常,析构函数应该捕获所有异常,然后吞下它们(suppress)或结束程序(terminate)。
- 原因:析构函数抛出异常会导致程序过早结束或出现未定义行为。
- 示例1:析构函数抛出异常的问题
- 方案1:结束程序
- 方案2:吞下异常
- 方案3:提供用户接口(最佳方案)
优势:用户可以主动调用
close() 并处理可能的异常;如果用户忘记调用,析构函数提供保底措施。- 如果客户需要对某个操作抛出的异常做出反应,class 应该提供一个普通函数(而非析构函数)来执行该操作。
- 示例2:将异常处理责任交给用户
条款9:绝不在构造和析构过程中调用virtual函数(Never call virtual functions during construction or destruction)
核心原则:在构造和析构期间不要调用 virtual 函数,因为这类调用不会下降到派生类(derived class),而是停留在基类(base class)层级。
- 原因:在 base class 构造期间,derived class 的成员变量尚未初始化,对象类型被视为 base class 而非 derived class。
- 问题示例:在构造函数中调用 virtual 函数
- 为什么会这样?
- 解决方案一:不要在构造/析构函数中调用 virtual 函数
- 解决方案二:将 virtual 函数改为 non-virtual,通过参数传递信息
优势:使用静态辅助函数
createLogString() 确保在 base class 构造前就准备好所需信息,避免访问未初始化的成员变量。条款10:令operator=返回一个reference to *this(Have assignment operators return a reference to *this)
核心原则:为了支持连续赋值(如
x = y = z = 15;),赋值操作符应该返回一个指向当前对象的引用(reference to *this)。标准做法:返回
*this 的引用适用于所有赋值相关运算符
支持连续赋值的示例
为什么返回引用而不是值?
原因:返回引用避免了不必要的拷贝,提高效率;返回值会创建临时对象,且无法正确支持连续赋值。
条款11:在operator=中处理“自我赋值”(Handle assignment to self in operator=)
条款12:复制对象时勿忘其每一个成分(Copy all parts of an object)
- Copying 函数必须复制对象的两个关键部分:
- 所有成员变量(member variables)
- 所有基类部分(base class components)
- 避免用一个 copying 函数调用另一个 copying 函数。应该将共同代码提取到独立的辅助函数中,让两个 copying 函数共同调用。
示例:正确实现 copying 函数
示例:提取共同代码到辅助函数
第3章 资源管理(Resource Management)
条款13:以对象管理资源(Use objects to manage resources)
核心原则:为防止资源泄漏,请使用 RAII(Resource Acquisition Is Initialization)对象,它们在构造函数中获得资源并在析构函数中释放资源。
为什么需要 RAII?
- 问题:单纯依靠配套的
delete函数无法保证资源的有效释放,因为程序可能抛出异常,改变控制流,跳过delete函数的调用。
- 解决方案:把资源放进对象里,依赖 C++ 的析构函数自动调用机制确保资源释放,这是一种更安全的做法。
常用的 RAII 智能指针
两个常被使用的 RAII classes 分别是
std::shared_ptr 和 std::unique_ptr(现代 C++ 中推荐使用,替代已废弃的 auto_ptr)。shared_ptr 通常是较佳选择,因为其拷贝行为比较直观,支持多个指针共享同一资源。示例:不使用 RAII 的问题
示例:使用 RAII 解决问题
示例:shared_ptr vs unique_ptr
选择建议:如果需要共享资源,使用
shared_ptr;如果需要独占资源且明确所有权,使用 unique_ptr(性能更好,开销更小)。条款14:在资源管理类中小心copying行为(Think carefully about copying behavior in resource-managing classes)
核心原则
当复制 RAII 对象时,必须同时复制它所管理的资源。资源的复制行为决定了 RAII 对象的复制行为。
常见的 RAII 复制行为
- 禁止复制(Prohibit copying):对于某些资源(如互斥锁、文件句柄),复制没有意义,应该禁止复制操作。
- 引用计数(Reference counting):允许多个对象共享同一资源,通过计数器追踪资源的使用者数量,当计数降为零时释放资源。
- 深拷贝资源(Deep copy):复制对象时同时复制其管理的资源,使每个对象拥有独立的资源副本。
- 转移资源所有权(Transfer ownership):将资源的所有权从一个对象转移到另一个对象(类似
std::unique_ptr的移动语义)。
示例1:禁止复制
示例2:引用计数法
示例3:深拷贝资源
示例4:转移所有权
选择建议:根据资源的特性选择合适的复制行为。互斥锁等不可复制资源应禁止复制;需要共享的资源使用引用计数;需要独立副本的资源使用深拷贝;需要明确所有权转移的资源使用移动语义。
条款15:在资源管理类中提供对原始资源的访问(Provide access to raw resources in resource-managing classes)
核心原则
RAII 类封装了资源管理,但实际使用中,许多 API 需要直接访问原始资源。因此,每个 RAII 类都应该提供访问其管理资源的方法。
两种访问方式
- 显式转换:通过成员函数(如
get())主动获取原始资源,更安全,不易误用。
- 隐式转换:通过类型转换运算符自动转换为原始资源,使用更方便,但可能导致意外错误。
权衡:显式转换更安全,隐式转换更便捷。一般推荐显式转换。
示例1:显式转换(推荐)
示例2:隐式转换(方便但有风险)
示例3:智能指针的实践
最佳实践:优先使用显式转换(
get() 函数),除非隐式转换能显著提升易用性且不会引入安全问题。标准库智能指针采用显式 get() + 运算符重载的混合方式,值得借鉴。条款16:成对使用 new 和 delete 时要采取相同形式(Use the same form in corresponding uses of new and delete)
核心原则
使用
new 创建单个对象时,必须用 delete 释放;使用 new[] 创建数组时,必须用 delete[] 释放。形式不匹配会导致未定义行为。为什么必须匹配?
new[] 分配的内存通常包含数组元素个数的信息,delete[] 会读取这个信息来正确调用每个对象的析构函数并释放内存。如果用 delete 释放 new[] 分配的数组,只会调用一个对象的析构函数,导致内存泄漏和资源泄漏。示例1:正确的使用
示例2:错误的使用
特殊注意:typedef 的陷阱
最佳实践:为避免混淆,尽量不要对数组类型使用 typedef。更好的做法是使用
std::vector 或 std::array 等容器,它们会自动管理内存,无需手动配对 new/delete。条款17:以独立语句将newed对象置入智能指针(Store newed objects in smart pointers in standalone statements)
第4章 设计与声明(Designs and Declarations)
条款18:让接口容易被正确使用,不易被误用(Make interfaces easy to use correctly and hard to use incorrectly)
- 设计优秀的接口应该让正确使用变得简单直观,让错误使用变得困难甚至不可能。这是接口设计的核心目标。
- 促进正确使用的方法:
- 保持接口一致性: 确保类似功能的接口具有相似的命名和行为模式。
- 与内置类型行为兼容: 让自定义类型的行为符合用户对内置类型的直觉。
- 阻止误用的方法:
- 建立新类型: 用类型系统防止逻辑错误。
- 限制类型上的操作: 通过
const等机制防止非法操作。 - 束缚对象值: 确保对象始终处于有效状态。
- 消除客户的资源管理责任: 使用智能指针等RAII技术自动管理资源。
std::shared_ptr支持自定义删除器(custom deleter),可用于:- 防范DLL问题: 确保对象在创建它的DLL中被销毁。
- 自动解锁互斥量: 利用RAII自动管理锁资源(见条款14)。
条款19:设计class犹如设计type(Treat class design as type design)
Class 的设计就是 type 的设计。在定义一个新type 之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
根据条款19"设计class犹如设计type",在设计一个class时应该考虑以下主题:
需要考虑的所有主题:
- 新type的对象应该如何被创建和销毁?(构造函数、析构函数、new/delete运算符的设计)
- 对象的初始化和对象的赋值该有什么样的差别?(构造函数与赋值运算符的行为差异)
- 新type的对象如果被passed by value,意味着什么?(拷贝构造函数的实现)
- 什么是新type的"合法值"?(成员变量的约束条件和不变式)
- 你的新type需要配合某个继承图系吗?(是否继承其他类,virtual函数的设计)
- 你的新type需要什么样的转换?(隐式/显式类型转换运算符)
- 什么样的操作符和函数对此新type是合理的?(应该声明哪些成员函数)
- 什么样的标准函数应该驳回?(哪些函数应该声明为private或delete)
- 谁该取用新type的成员?(public、protected、private的访问控制)
- 什么是新type的"未声明接口"?(性能、异常安全性、资源使用的保证)
- 你的新type有多么一般化?(是否应该定义class template而非class)
- 你真的需要一个新type吗?(是否可以用非成员函数或模板来达成目标)
完整的class范例:
主题对应关系说明:
- 主题1(创建/销毁):构造函数、析构函数确保对象正确初始化和清理
- 主题2(初始化vs赋值):拷贝构造函数与赋值运算符有不同的语义
- 主题3(pass by value):拷贝构造函数定义了传值行为
- 主题4(合法值):isValid()和构造函数中的检查确保日期有效性
- 主题5(继承):DateTime展示了继承关系的设计
- 主题6(类型转换):explicit operator string()提供显式转换
- 主题7(操作符/函数):提供了比较运算符、getter和addDays等合理操作
- 主题8(驳回函数):用delete禁止了++运算符
- 主题9(访问控制):成员变量为private,提供public接口
- 主题10(未声明接口):构造函数的异常安全性保证
- 主题11(一般化):如需要,可以改为template<typename T> class Date
- 主题12(是否需要):Date类确实需要封装日期逻辑和验证
条款20:宁以pass-by-reference-to-const替换pass-by-value(Prefer pass-by-reference-to-const to pass-by-value)
尽量以 pass-by-reference-to-const替换 pass-by-value。前者通常比较高效,并可避免切割问题(slicing problem)
以上规则并不适用于内置类型,以及 STL的迭代器和函数对象。对它们而言pass-by-value往往比较适当。
为什么 pass-by-reference-to-const 更高效?
Pass-by-value 会创建对象的完整副本,涉及:
- 调用拷贝构造函数
- 复制所有成员变量
- 函数结束时调用析构函数
而 pass-by-reference-to-const 只传递一个指针(通常4或8字节),无论对象多大,开销都是固定的。
示例:
什么是切割问题(Slicing Problem)?
当派生类对象以值传递给接受基类参数的函数时,派生类特有的部分会被"切掉":
为什么内置类型适合 pass-by-value?
- 性能相当或更优:内置类型(int、double、指针)本身很小(通常4-8字节),与引用大小相同,但值传递可能更快,因为:
- 编译器更容易优化
- 避免了间接访问(解引用)的开销
- 可以直接放在寄存器中
- STL 迭代器:设计为轻量级对象,内部通常只包含一个指针
- 函数对象:通常是
无状态或状态很小的对象,值传递更符合其设计意图
总结规则:
- 用户自定义类型 → pass-by-reference-to-const
- 内置类型、STL迭代器、函数对象 → pass-by-value
- 需要修改参数 → pass-by-reference(非const)
条款21:必须返回对象时,别妄想返回其reference(Don't try to return a reference when you must return an object)
核心原则:当函数必须返回一个对象时,应该直接返回对象(按值返回),而不是返回引用或指针。
三种错误做法及其问题:
- 返回指向 local stack 对象的 pointer 或 reference:对象在函数结束时被销毁,返回的是悬空指针/引用
- 返回指向 heap 分配对象的 reference:调用者不知道何时释放内存,导致内存泄漏
- 返回指向 local static 对象的 pointer 或 reference:当需要同时使用多个返回值时会出现问题(所有引用指向同一个对象)
有理数乘法示例:
为什么按值返回是正确的?
- 编译器会进行返回值优化(RVO)和移动语义(C++11),实际性能开销很小
- 返回的对象是独立的,不会有生命周期或所有权问题
- 语义清晰,符合数学运算的直觉(a * b 产生一个新值)
条款4中提到的"单线程环境中合理返回 reference 指向 local static 对象"是指像
Singleton 模式这样的特殊场景,其目的就是要返回同一个对象。但对于像乘法运算这样需要产生新值的场景,必须按值返回。条款22:将成员变量声明为private(Declare data members private)
核心原则:所有成员变量都应该声明为
private。这样做的好处包括:- 访问一致性:客户端统一通过成员函数访问数据,而不是有时直接访问变量、有时调用函数
- 细粒度访问控制:可以实现只读、只写或读写访问
- 约束条件保证:可以在 getter/setter 中验证数据有效性
- 实现弹性:可以改变内部实现而不影响客户端代码
示例:
为什么
protected 也不够好?将成员变量声明为
protected 看似比 public 好,但实际上封装性并未提升:- 派生类可以直接访问,导致大量代码依赖该变量
- 修改
protected变量会破坏所有派生类
- 从封装角度看,
protected和public一样糟糕
结论:将所有成员变量声明为
private,通过 public 或 protected 成员函数提供访问接口。条款23:宁以non-member、non-friend替换member函数(Prefer non-member non-friend functions to member functions)
宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性(packaging flexibility) 和机能扩充性。
1. 封装性(Encapsulation)
封装性的核心是:能够访问 private 数据的函数越少,封装性越好。
如果
clearEverything 是成员函数,理论上它可以直接访问 url、history、cookies;而非成员函数只能调用 public 接口,无法"偷看"内部数据,因此封装性更好。2. 包裹弹性(Packaging Flexibility)
非成员函数可以放在不同的命名空间或头文件中,客户端可以按需包含。
如果这些都是成员函数,客户端必须包含完整的类定义,增加编译依赖和编译时间。
3. 机能扩充性(Extensibility)
任何人都可以为类添加非成员便利函数,而无需修改类本身或获得源代码访问权限。
最佳实践:
- 如果函数不需要访问 private/protected 成员,就实现为非成员函数
- 将相关的非成员函数放在同一个命名空间中
- 按功能模块分散到不同头文件(如 STL 的
<algorithm>、<numeric>)
- 只有真正需要访问内部数据的函数才做成成员或友元
这种设计与 C++ 标准库一致:
std::vector 的许多操作(如 std::sort、std::find)都是非成员函数,而不是 vector 的成员函数。条款24:若所有参数皆需类型转换,请为此采用non-member函数(Declare non-member functions when type conversions should apply to all parameters)
当函数需要对所有参数(包括隐式的
this 参数)进行类型转换时,必须将该函数声明为非成员函数。典型例子:有理数乘法
为什么会出错?
当
operator* 是成员函数时:oneHalf * 2实际调用oneHalf.operator*(2),编译器可以将2隐式转换为Rational(2)
2 * oneHalf实际调用2.operator*(oneHalf),但int类型没有这个成员函数,编译器也不会尝试将2转换为Rational
解决方案:非成员函数
作为非成员函数时,编译器可以对两个参数都进行隐式类型转换,保证了运算的对称性。
是否需要声明为
friend?通常不需要。只要通过 public 接口(如
numerator()、denominator())就能实现功能,就应该保持为普通非成员函数,这样封装性更好。只有在以下情况才需要
friend:- 必须访问 private 成员才能高效实现
- public 接口不足以完成功能
对于大多数运算符重载(如
operator*、operator+),通过 public 接口实现即可,无需 friend。条款25:考虑写出一个不抛异常的swap函数(Consider support for a non-throwing swap)
核心原则
- 提供高效的成员函数版本:当标准库的
std::swap对你的类型效率不高时(比如使用了 pimpl 惯用法),应该提供一个不抛异常的swap成员函数
- 提供非成员函数封装:除了成员函数,还要提供一个同名的非成员
swap函数来调用成员版本
- 针对类进行特化:对于普通类(非模板),可以特化
std::swap;但对于类模板,应该在自己的命名空间中提供非成员swap
- 正确调用 swap:使用时先用
using std::swap;引入标准版本,然后直接调用swap(a, b)(不带命名空间限定),让编译器通过 ADL(参数依赖查找)选择最佳版本
- 不要污染 std 命名空间:可以全特化
std中的模板,但绝不要往std中添加全新的模板、类或函数
示例1:使用 pimpl 惯用法的类
说明:如果直接使用
std::swap,会进行三次昂贵的拷贝(临时对象 + 两次赋值),而成员 swap 只交换指针,高效得多。示例2:类模板的 swap
示例3:正确调用 swap
关键点:不要写
std::swap(obj1, obj2),这会强制使用标准版本,失去了自定义优化的机会。总结
通过提供成员
swap + 非成员封装 + 正确的调用方式,可以:- 让类型的交换操作达到最高效率
- 保持异常安全(
noexceptswap 是很多强异常保证的基础)
- 让泛型代码自动选择最优实现
第5章 实现(Implementations)
条款26:尽可能延后变量定义式的出现时间(Postpone variable definitions as long as possible)
核心思想是:只有在你真正需要这个变量的时候才定义它,最好是马上就能用它来做有意义的操作(最好能直接在定义的同时完成初始化)。
这样做的好处有三个:
- 避免不必要的构造/析构开销(尤其是对象有昂贵的构造函数/析构函数时)
- 避免未初始化变量带来的“使用前未定义”bug
- 代码意图更清晰(看到变量定义就知道它马上要被使用)
坏例子1:定义得太早
问题:
encrypted在函数一开始就被默认构造(空字符串)
- 然后再赋值一次(旧对象析构 + 新对象构造)
- 如果密码为空会抛异常,
encrypted的构造和析构完全是浪费
好例子1:延后到真正需要的地方
只构造了一次,异常情况下根本不构造,完美。
坏例子2:循环中定义在外面
代价:1 次默认构造 + n 次赋值(赋值 = 析构旧值 + 构造新值)
好例子2:定义在循环内部(最常见推荐写法)
代价:n 次构造(刚好需要这么多),没有多余的析构/赋值。
特例:循环里用引用时才放外面
如果
computeWidgetValue(i) 很昂贵,你又想避免重复调用,可以这样(仍然延后,但用引用避免拷贝):或者如果你必须拷贝,但又想少构造一次(极少见,不值得):
但通常“循环内定义”才是最清晰、最高效的。
总结一句话写法(推荐风格)
而不是:
把变量定义推迟到“第一次真正要用它的那一行”,并且尽量直接用有意义的实参初始化,这就是条款26的全部精髓。掌握了它,你的代码既更快,又更安全,还更好读。
条款27:尽量少做转型动作(Minimize casting)
如果可以,尽量避免转型,特别是在注重效率的代码中避免
dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数而不需将转型放进他们自己的代码内。
宁可使用 C++ 风格的新式转型(
static_cast、const_cast、dynamic_cast、reinterpret_cast),不要使用旧式转型。前者很容易辨识出来,而且有明确的分工职责。旧式转型的合理使用场景
虽然推荐使用新式转型,但在某些特定场景下,旧式转型(C 风格)仍然合理:
- 调用 explicit 构造函数:
doSomething(static_cast<Widget>(15))可以简写为doSomething(Widget(15)),后者更简洁自然。
- POD 类型间的转换:如
(int)3.14在简单数值转换时比static_cast<int>(3.14)更简短。
但在涉及指针、复杂类型、多态对象时,必须使用新式转型以避免隐藏的错误。
类型转换的常见易错点
1. 基类与派生类指针转换的陷阱
易错点:基类指针转换为派生类指针时,如果实际对象不是派生类类型,会导致未定义行为。
dynamic_cast 提供运行时类型检查,但有性能开销。2. *this 转型产生的副本问题
问题:
static_cast<Window>(*this) 创建了 *this 基类部分的副本,调用的是副本的 onResize(),而不是当前对象的基类方法。dynamic_cast 的性能陷阱与替代方案
dynamic_cast 需要在运行时检查类型信息(RTTI),在深继承层次中可能很慢。典型问题代码:替代方案1:使用类型安全的容器
替代方案2:通过虚函数实现多态
将转型封装到函数内部的优点
与其让客户代码到处写转型,不如提供一个接口函数:
优点:
- 客户代码更简洁、更安全
- 转型逻辑集中管理,易于维护和修改
- 可以在函数内添加断言或其他安全检查
- 接口更清晰,表达了"返回像素数据"的意图而不是"返回原始指针"
总结
- 优先使用新式转型,它们语义明确、易于搜索
- 避免向下转型(基类→派生类),考虑用虚函数或类型安全容器代替
- 转型
*this会产生副本,要调用基类方法应使用BaseClass::method()
dynamic_cast开销大,尽量通过良好的设计避免使用
- 将必要的转型封装在函数内部,不要暴露给客户代码
条款28:避免返回handles指向对象内部成分(Avoid returning “handles” to object internals)
避免返回 handles(包括引用、指针、迭代器)指向对象内部成员。遵守这个条款可以:
- 增强封装性,防止外部直接修改内部状态
- 保持 const 成员函数的语义一致性
- 避免悬空引用(dangling handles)问题
为什么要避免返回内部 handles?
问题1:破坏封装性
问题2:const 成员函数失效
问题3:悬空引用(最危险)
正确的做法
方案1:返回值而非引用
方案2:提供受控的访问接口
例外情况
某些情况下返回 handle 是合理的,比如
std::vector::operator[] 和 std::string::operator[] 返回引用以支持高效访问。但这些类的设计者必须非常小心地权衡利弊。条款29:为“异常安全”而努力是值得的(Strive for exception-safe code)
异常安全的三种保证级别
异常安全函数(Exception-safe functions)即使在发生异常时也能保证:
- 不泄漏资源:所有动态分配的内存、文件句柄等都能正确释放
- 不破坏数据结构:对象保持在有效状态,不出现部分修改的情况
异常安全函数提供三种保证级别:
1. 基本保证(Basic Guarantee)
如果异常被抛出,程序内的任何事物仍保持在有效状态。没有对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态。
问题:如果
new Image 抛出异常,bgImage 指向已删除的对象,imageChanges 已被递增但实际并未改变背景。2. 强烈保证(Strong Guarantee)
如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功;如果函数失败,程序会回到调用前的状态。
优点:要么成功,要么保持原状,类似数据库事务。
3. 不抛异常保证(No-throw Guarantee)
承诺绝不抛出异常。所有对内置类型的操作都提供这种保证。
copy-and-swap 策略
"强烈保证"常通过 copy-and-swap 实现:
- 先对对象的副本进行修改
- 如果修改过程抛出异常,原对象保持不变
- 修改成功后,用 swap(不抛异常)交换副本和原对象
实际限制
并非所有函数都能提供强烈保证:
- 如果函数调用了不提供强烈保证的其他函数
- 如果 copy-and-swap 的效率成本过高(复制大对象)
- 如果操作涉及副作用(如数据库操作、网络通信)
关键原则:函数的异常安全性取决于它所调用的所有函数中最弱的那个。如果某个被调用函数只提供基本保证,那么调用它的函数最多也只能提供基本保证。
建议:尽可能提供强烈保证,但至少要提供基本保证。明确记录函数提供的异常安全级别。
条款30:透彻了解inlining的里里外外(Understand the ins and outs of inlining)
显式和隐式 inline
显式 inline(Explicit inline)
使用
inline 关键字明确声明函数为内联:隐式 inline(Implicit inline)
以下情况编译器会自动将函数视为 inline:
- 类内定义的成员函数:在类定义内部直接实现的函数自动成为 inline
- 类内定义的友元函数:在类内部定义的友元函数也是隐式 inline
关键区别
- 显式 inline:需要在声明或定义时使用
inline关键字
- 隐式 inline:类内定义自动成为 inline,无需关键字
- 注意:无论显式还是隐式,
inline只是对编译器的建议,编译器可能选择不内联
核心原则
- 限制 inline 的使用范围:仅将小型、频繁调用的函数声明为 inline。这样可以:
- 简化调试过程(inline 函数没有独立的栈帧)
- 便于二进制升级(修改 inline 函数需要重新编译所有调用者)
- 避免代码膨胀(inline 会在每个调用点展开代码)
- 最大化性能提升机会(编译器更容易优化小函数)
- 不要盲目 inline 模板函数:函数模板通常放在头文件中,但这不意味着它们应该被声明为 inline。
示例1:适合 inline 的函数
示例2:不适合 inline 的函数
示例3:模板函数不一定要 inline
代码膨胀示例
易混淆的 inline 使用场景
1. 派生类的构造函数和析构函数
即使看起来很简单的空构造/析构函数,编译器也会插入大量代码(基类构造/析构、成员初始化、异常处理等),不适合 inline。
2. Template 函数定义
模板函数必须在头文件中定义(以便实例化),但这不意味着应该 inline。编译器会根据实例化后的复杂度自行决定。
3. 通过函数指针调用
通过函数指针调用的函数无法被内联,因为编译器需要生成函数的实体地址。
4. Virtual 函数
Virtual 函数的调用通常通过虚函数表进行,运行时才确定调用哪个版本,因此大多数情况下无法内联。只有在明确知道对象类型时才可能内联。
inline 对扩展性和可调试性的劣势
1. 扩展性问题
- 二进制兼容性差:修改 inline 函数的实现需要重新编译所有使用该函数的代码,无法通过替换库文件来升级
- 增加编译依赖:inline 函数的实现必须在头文件中可见,增加了编译时依赖关系
- 代码膨胀:如果 inline 函数在很多地方被调用,会导致目标代码体积显著增加,可能影响指令缓存命中率
2. 可调试性问题
- 无法设置断点:内联后的代码没有独立的函数调用,调试器很难在 inline 函数内部设置断点
- 调用栈信息缺失:inline 函数不会出现在调用栈中,难以追踪程序执行流程
- 单步调试困难:内联后的代码与原函数代码结构可能不同,单步执行行为难以预测
3. 可维护性问题
- 修改成本高:inline 函数的任何改动都需要重新编译所有使用者
- 版本管理困难:无法为不同客户提供不同版本的库
inline 使用的原则和策略
使用原则
- 80-20 法则:只对那些占用 20% 代码但贡献 80% 性能的热点函数考虑 inline
- 小函数优先:只有小型函数(通常 3-5 行以内)才应考虑 inline
- 稳定性要求:只有接口稳定、不太可能修改的函数才适合 inline
- 频繁调用:只有频繁被调用的函数,inline 带来的性能提升才能抵消代码膨胀的代价
使用策略
- 先测量后优化:使用性能分析工具(profiler)确定热点函数,不要凭直觉决定哪些函数需要 inline
- 让编译器决定:现代编译器很智能,即使不写 inline 关键字,编译器也会自动内联适合的函数;反之,即使写了 inline,编译器也可能拒绝内联复杂函数
- 使用编译器特定指令:如果确实需要强制内联,使用编译器特定的指令(如 GCC/Clang 的
__attribute__((always_inline))或 MSVC 的__forceinline)
- 分离接口与实现:对于库代码,保持非 inline 实现可以提供更好的二进制兼容性
总结
inline 是一把双刃剑:用得好可以提升性能,用得不好会带来代码膨胀、调试困难、维护成本增加等问题。应该遵循"先测量,后优化"的原则,只在确有必要时使用 inline,并优先考虑让编译器自动决定是否内联。对于库代码,更应谨慎使用 inline 以保持二进制兼容性和可维护性。
条款31:将文件间的编译依存关系降至最低(Minimize compilation dependencies between files)
核心思想:依赖声明而非定义
编译依存性最小化的核心原则是:让代码依赖于类型的声明(declaration),而不是定义(definition)。这样可以避免不必要的重新编译,提高编译速度和代码的可维护性。
实现这一原则的两种主要技术:
- Handle Classes(句柄类):使用指针成员(Pimpl idiom)将实现细节隐藏在实现文件中
- Interface Classes(接口类):通过抽象基类定义接口,将具体实现延迟到派生类
示例1:Handle Classes(Pimpl idiom)
示例2:Interface Classes(抽象接口)
程序库头文件的设计原则
程序库的头文件应该以"完全且仅有声明式"的形式存在,即:
- 只包含类型的前置声明和函数声明
- 不包含任何实现细节
- 避免
#include其他头文件,尽量使用前置声明
这一原则对模板和非模板代码都适用。
模板代码的编译依存性处理
总结
- Handle Classes 通过 Pimpl 将实现隐藏在指针后面,代价是增加一次间接访问和动态内存分配
- Interface Classes 通过抽象接口将实现与接口分离,代价是虚函数调用开销
- 两种技术都能显著减少编译依存性,提高大型项目的编译速度
- 即使对模板代码,也应尽量遵循"依赖声明而非定义"的原则
第6章 继承与面向对象设计(Inheritance and Object-Oriented Design)
条款32:确定你的public继承塑模出is-a关系(Make sure public inheritance models “is-a”)
Public 继承表示"is-a"关系
Public 继承意味着派生类"是一个"基类(is-a 关系)。这意味着适用于基类的所有属性和行为也必然适用于派生类,因为每个派生类对象本质上也是一个基类对象。
示例说明:
关键原则:
- 如果 B is-a A,那么任何需要 A 对象的地方都可以使用 B 对象
- 派生类必须能够替代基类而不破坏程序的正确性(里氏替换原则)
- 如果发现派生类需要"取消"基类的某些行为,说明 is-a 关系可能不成立
条款33:避免遮掩继承而来的名称(Avoid hiding inherited names)
派生类中的名称会遮掩(hide)基类中的同名名称。在 public 继承下,这通常不是我们期望的行为,因为它违反了 is-a 关系。
为了让被遮掩的名称重新可见,可以使用以下两种方法:
- using 声明式:在派生类中使用 using 声明引入基类的名称
- 转交函数(forwarding functions):在派生类中定义一个函数来调用基类的函数
示例说明:
关键要点:
- 名称遮掩与函数重载不同,它完全隐藏基类中的同名函数,即使参数列表不同
- using 声明式会引入基类中所有同名函数
- 转交函数提供更细粒度的控制,可以选择性地暴露特定重载版本
using 声明式与转交函数的关键差异:
1. 作用范围
- using 声明式:一次性引入基类中所有同名函数的重载版本
- 转交函数:可以选择性地只暴露特定的重载版本
2. 灵活性
- using 声明式:是"全有或全无"的方式,无法选择性地只引入部分重载
- 转交函数:提供更细粒度的控制,可以为每个需要暴露的重载版本单独编写转交函数
3. 使用场景
- using 声明式:当你希望派生类拥有基类的所有同名函数时使用,代码简洁
- 转交函数:当你只想暴露基类某些特定重载,或需要在调用基类函数前后添加额外逻辑时使用
4. 代码示例对比
5. 实际应用建议
- 如果需要基类的所有重载:优先使用
using声明式,代码更简洁
- 如果只需要部分重载或需要添加额外逻辑:使用转交函数
- 如果需要在 private 继承中选择性暴露接口:转交函数是唯一选择(因为 using 会改变访问级别)
条款34:区分接口继承和实现继承(Differentiate between inheritance of interface and inheritance of implementation)
在 C++ 中,继承涉及两个不同的概念:接口继承和实现继承。在 public 继承下,派生类总是继承基类的接口,但实现继承的方式取决于函数的类型。
三种函数类型及其继承特性:
- 纯虚函数(pure virtual):只继承接口,不继承实现派生类必须提供自己的实现,基类不提供默认行为。
- 虚函数(impure virtual):继承接口和默认实现派生类继承接口,可以使用基类的默认实现,也可以提供自己的实现。
- 非虚函数(non-virtual):继承接口和强制实现派生类继承接口和实现,且不应该被重写。
虚函数提供缺省行为的两种方式:
方式1:直接在虚函数中提供缺省实现
问题:这种方式的风险是,如果新增的派生类忘记覆盖 fly(),它会默默地使用基类的实现,这可能不是设计者想要的。
方式2:将接口和缺省实现分离(更安全的设计)
方式3:为纯虚函数提供定义(较少使用)
三种方式的对比:
- 方式1:简单直接,但可能导致派生类意外使用缺省实现
- 方式2:最安全,强制派生类明确选择实现方式,推荐使用
- 方式3:较少使用,但在某些特殊场景下有用
示例说明:
设计原则总结:
- 使用纯虚函数:当派生类必须提供自己的实现,且没有合理的默认行为时
- 使用虚函数:当你想提供默认行为,但允许派生类根据需要覆盖时
- 使用非虚函数:当你想确保所有派生类都使用相同的实现时(表示不变性)
- 当需要提供缺省实现时,考虑将接口和缺省实现分离(方式2),以避免派生类意外使用缺省行为
⛽补充说明运行时多态代价
运行时多态的代价:传统的虚函数机制提供了灵活的多态性,但引入了虚表查找、间接调用、阻碍内联等运行时开销。
示例说明:
性能对比:
- 虚函数调用:约 2-3 个额外的内存访问(vptr → vtable → 函数地址),无法内联
- 非虚函数调用:直接调用,可以内联优化,几乎零开销
- 实际影响:在性能关键代码(如游戏引擎的渲染循环)中,频繁的虚函数调用可能导致可测量的性能下降
条款35:考虑virtual函数以外的其他选择(Consider alternatives to virtual functions)
虽然这个条款标题是"考虑 virtual 函数以外的其他选择",但它并不是说要避免使用虚函数,而是在说即使在需要多态行为的场景下,也有多种实现方式可供选择,每种方式都有其优缺点。
为什么要考虑替代方案:
- 更好的封装性:NVI 手法允许基类在调用虚函数前后执行必要的操作(如资源锁定、日志记录、前置/后置条件检查),而派生类无法绕过这些操作
- 运行时灵活性:使用函数指针或 std::function 可以让同一类型的不同对象拥有不同的行为,甚至可以在运行期间动态改变行为,而传统虚函数的行为在对象创建时就固定了
- 避免继承层次的复杂性:Strategy 模式将算法独立成单独的类层次,可以在不修改使用者类的情况下添加新策略,符合开放封闭原则
- 权衡访问权限:虚函数必须是类的成员,可以访问私有成员;但如果将策略移到外部函数,就无法访问类的 non-public 成员,这有时反而能促进更好的接口设计
virtual 函数的四种替代方案:
1. NVI 手法(Non-Virtual Interface,非虚接口)
这是 Template Method 设计模式的一种特殊形式。核心思想是用 public non-virtual 成员函数包裹 private 或 protected 的 virtual 函数。
优点:基类可以在调用 virtual 函数前后执行一些必要的操作(如加锁、日志记录等)。
2. 函数指针策略(Function Pointer Strategy)
将 virtual 函数替换为函数指针成员变量,这是 Strategy 设计模式的一种实现方式。
优点:同一类型的不同对象可以有不同的健康值计算函数;可以在运行期间改变计算函数。
3. tr1::function 策略(现代 C++ 使用 std::function)
使用
std::function 成员变量替换 virtual 函数,允许使用任何可调用对象(函数、函数对象、lambda 等)。优点:提供最大的灵活性,可以使用任何兼容签名的可调用对象。
4. 传统的 Strategy 模式
将继承体系内的 virtual 函数替换为另一个继承体系内的 virtual 函数。
优点:易于添加新的策略;熟悉的面向对象设计模式。
权衡考虑:
- 将机能从成员函数移到 class 外部函数的缺点:非成员函数无法访问 class 的 non-public 成员
std::function对象的行为类似函数指针,但可以接受任何与目标签名式兼容的可调用对象(函数、函数对象、lambda 表达式等)
- 选择哪种方案取决于具体需求:如果需要在调用前后执行额外操作,使用 NVI;如果需要运行期灵活性,使用函数指针或 std::function;如果需要复杂的策略层次结构,使用传统 Strategy 模式
条款36:绝不重新定义继承而来的non-virtual函数(Never redefine an inherited non-virtual function)
核心原理:
Non-virtual 函数是静态绑定(statically bound)的,这意味着调用哪个函数版本是在编译期根据指针或引用的声明类型决定的,而不是根据对象的实际类型。
Virtual 函数是动态绑定(dynamically bound)的,调用哪个函数版本是在运行期根据对象的实际类型决定的。
为什么不应该重新定义 non-virtual 函数:
- 如果重新定义了继承而来的 non-virtual 函数,会导致同一个对象通过不同类型的指针或引用调用时,表现出不同的行为
- 这违反了 public 继承所表达的 is-a 关系:派生类对象应该能在任何需要基类对象的地方使用,且行为一致
- Non-virtual 函数表示的是一种不变性(invariant),基类通过 non-virtual 函数表达"所有派生类都应该使用这个实现"
问题示例:
违反 is-a 关系的实际影响:
正确的设计:
总结:
- Non-virtual 函数代表不变性,所有派生类都应该继承该函数的实现和接口
- Virtual 函数代表变化性,派生类可以(也应该)提供自己的实现
- 重新定义 non-virtual 函数会破坏 is-a 关系,导致通过基类接口使用派生类对象时出现不一致的行为
- 如果你认为派生类需要不同的实现,那么基类中的函数就应该声明为 virtual
条款37:绝不重新定义继承而来的缺省参数值(Never redefine a function's inherited default parameter value)
绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而 virtual 函数——你唯一应该覆写的东西—却是动态绑定。
1. 动态绑定 vs 静态绑定的差异
关键点:
- Virtual 函数是动态绑定的——调用哪个函数版本取决于对象的动态类型(实际类型)
- 缺省参数值是静态绑定的——使用哪个缺省值取决于指针或引用的静态类型(声明类型)
- 这导致你可能调用派生类的函数,却使用了基类的缺省参数值
2. 继承缺省参数值的缺点
问题:
- 同一个对象通过不同方式调用,使用了不同的缺省参数值
- 行为不一致,难以预测和维护
- 如果在多处代码中都重新定义了缺省参数,修改时容易遗漏某些地方
3. 使用 NVI (Non-Virtual Interface) 方法优化
NVI 手法将缺省参数值的责任从 virtual 函数移到 non-virtual 包装函数:
NVI 方法的优势:
- 缺省参数只在一处定义:在 non-virtual 公共接口函数中,避免了重复定义的问题
- 行为一致性:无论通过什么类型的指针或引用调用,都使用相同的缺省参数值
- 易于维护:修改缺省值只需在基类的一处修改
- 封装性更好:virtual 函数可以是 private 的,更好地控制继承接口
- 扩展性:可以在 non-virtual 包装函数中添加前后处理逻辑(如日志、验证等)
总结:
- 绝不重新定义继承而来的缺省参数值,因为缺省参数是静态绑定的,会导致行为不一致
- 如果需要提供缺省参数,使用 NVI 手法将缺省参数放在 non-virtual 包装函数中
- 让 virtual 函数专注于实现多态行为,不涉及缺省参数的问题
条款38:通过复合塑模出has-a或“根据某物实现出”(Model “has-a” or “is-implemented-in-terms-of” through composition)
复合(Composition)的两种含义
复合的意义和 public 继承完全不同,根据使用场景的不同,复合有两种含义:
- 应用域(Application Domain):复合意味着 has-a(有一个)关系
- 例如:Person 类包含一个 Address 对象,表示"人有一个地址"
- 实现域(Implementation Domain):复合意味着 is-implemented-in-terms-of(根据某物实现出)关系
- 例如:使用
std::list实现一个 Set 类,Set 并不"是一个"list,而是"用 list 实现的"
示例1:应用域中的 has-a 关系
示例2:实现域中的 is-implemented-in-terms-of 关系
为什么不能用 public 继承?
对比总结:
- Has-a(应用域):描述对象之间的组成关系,如"汽车有引擎"、"人有地址"
- Is-implemented-in-terms-of(实现域):描述实现细节,如"用链表实现集合"、"用数组实现栈"
- 当你不确定该用继承还是复合时,问自己:这是 is-a 关系还是 has-a/根据某物实现 关系?
- 如果是实现细节而非概念关系,应该使用复合而非 public 继承
条款39:明智而审慎地使用private继承(Use private inheritance judiciously)
Private 继承的含义与适用场景
Private 继承意味着 is-implemented-in-terms-of(根据某物实现出)关系。它通常比复合(composition)的级别低,但在以下两种情况下,private 继承是合理的选择:
- 当 derived class 需要访问 protected base class 的成员时
- 当需要重新定义继承而来的 virtual 函数时
示例1:访问 protected 成员
示例2:Empty Base Optimization (EBO)
Private 继承的另一个优势是可以实现空基类最优化。当基类没有数据成员时,编译器可以进行优化,使派生类不增加额外的内存开销:
总结:
- Private 继承主要用于实现细节,而非概念关系
- 优先使用复合,只在必须访问 protected 成员或重新定义 virtual 函数时使用 private 继承
- 空基类最优化对于需要最小化对象尺寸的程序库开发者很重要
条款40:明智而审慎地使用多重继承(Use multiple inheritance judiciously)
多重继承的复杂性
多重继承比单一继承复杂,主要体现在两个方面:
- 歧义性问题:可能从多个基类继承相同名称的成员,导致调用时产生歧义
- 菱形继承问题:需要使用 virtual 继承来避免重复继承同一个基类
Virtual 继承的代价
Virtual 继承会带来以下成本:
- 对象体积增加(需要额外的指针)
- 访问速度变慢(需要通过指针间接访问)
- 初始化和赋值更复杂(最底层的派生类负责初始化 virtual base)
最佳实践:如果 virtual base classes 不带任何数据成员,将是最具实用价值的情况。
多重继承的合理用途
尽管多重继承复杂,但在某些场景下是合理的。一个典型的应用场景是:
- Public 继承接口类 + Private 继承实现类
示例:多重继承的合理使用
示例说明:
CPersonpublic 继承IPerson,满足 is-a 关系(CPerson 是一个 IPerson)
CPersonprivate 继承PersonInfo,表示实现细节(用 PersonInfo 实现)
- 这种设计既满足了接口要求,又复用了实现代码
总结:
- 多重继承应谨慎使用,优先考虑单一继承或复合
- 当确实需要多重继承时,尽量让 virtual base classes 不包含数据
- "public 继承接口 + private 继承实现"是多重继承的一个合理应用场景
第7章 模板与泛型编程(Templates and Generic Programming)
条款41:了解隐式接口和编译期多态(Understand implicit interfaces and compile-time polymorphism)
类(classes)和模板(templates)都支持接口和多态,但它们的实现方式存在本质区别:
接口的区别:显式 vs 隐式
- 类的显式接口:通过明确的函数签名定义,在代码中清晰可见
- 模板的隐式接口:基于有效表达式推导,不需要预先声明具体类型
示例对比:
多态的区别:运行期 vs 编译期
- 类的运行期多态:通过 virtual 函数实现,在程序运行时动态绑定
- 模板的编译期多态:通过模板实例化和函数重载解析实现,在编译时确定
示例对比:
核心区别总结
特性 | 类(Classes) | 模板(Templates) |
接口类型 | 显式接口,明确的函数声明 | 隐式接口,基于有效表达式 |
多态时机 | 运行期(Runtime) | 编译期(Compile-time) |
实现机制 | 虚函数表(vtable) | 模板实例化 |
性能开销 | 有虚函数调用开销 | 无运行时开销,但增加代码体积 |
灵活性 | 运行时可替换不同派生类对象 | 编译时确定,类型安全更强 |
条款42:了解typename的双重意义(Understand the two meanings of typename)
在声明模板参数时,
class 和 typename 关键字可以互换使用。但是,当需要标识嵌套从属类型名称时,必须使用 typename 关键字。例外情况是:在基类列表(base class lists)和成员初始化列表(member initialization list)中,不能使用 typename 作为基类修饰符。为什么 typename 必要
在模板中,编译器无法区分嵌套名称是类型还是静态成员变量。使用
typename 可以明确告诉编译器这是一个类型名称。typename 的使用场景
任何时候在模板中引用嵌套从属类型名称时,都必须使用
typename:不能使用 typename 的两种情况
- 基类列表中:
- 成员初始化列表中:
typedef typename 的例子
使用
typedef 可以简化复杂的嵌套从属类型名称:条款43:学习处理模板化基类内的名称(Know how to access names in templatized base classes)
可在 derived class templates 内通过"this->"指涉 base class templates 内的成员名称,或藉由一个明白写出的“base class 资格修饰符”完成。
在派生类模板中访问基类模板的成员名称时,需要通过
this-> 前缀或显式的基类作用域限定符来引用,否则编译器可能无法识别这些名称。问题产生的原因:
当基类是模板时,编译器在解析派生类模板时不会在基类中查找非依赖名称。这是因为基类模板可能会被特化,而特化版本可能不包含该成员。
问题示例:
三种解决方法:
- 方法1:使用
this->前缀
- 方法2:使用
using声明
- 方法3:显式指定基类作用域
方法对比:
this->方式最常用,支持虚函数的动态绑定
using声明适合需要频繁调用的情况,代码更简洁
- 显式作用域限定符会禁止虚函数机制,应谨慎使用
条款44:将与参数无关的代码抽离templates(Factor parameter-independent code out of templates)
模板会为每个类型或参数组合生成独立的代码,导致代码膨胀。应将与模板参数无关的代码提取出来,避免不必要的重复。
核心原则:
- 模板代码不应与可能导致膨胀的参数产生依赖
- 非类型模板参数引起的膨胀可通过函数参数或成员变量消除
- 类型参数引起的膨胀可通过共享二进制表示相同的实现代码来降低
问题示例:非类型模板参数导致的代码膨胀
解决方案:提取与参数无关的代码
类型参数的代码膨胀示例
总结:通过将与模板参数无关的代码提取到基类或辅助函数中,可以显著减少编译后的代码体积,同时保持类型安全和编译期优化的优势。
条款45:运用成员函数模板接受所有兼容类型(Use member function templates to accept “all compatible types”)
使用成员函数模板(member function templates)可以让类接受所有兼容类型的对象,实现更灵活的类型转换。但需要注意,声明了泛化的拷贝构造或赋值操作后,仍需声明普通的拷贝构造函数和拷贝赋值运算符。
为什么需要成员函数模板:
在智能指针等场景中,我们希望支持派生类到基类的隐式转换,就像原始指针一样。但模板类不会自动生成这种转换关系。
解决方案:使用成员函数模板:
重要注意事项:
即使声明了泛化的拷贝构造和赋值操作,仍需要声明普通版本,因为编译器不会将成员模板视为特殊成员函数:
成员函数模板的优势:
- 实现了类似原始指针的隐式类型转换
- 编译期类型检查:不兼容的转换会编译失败
- 代码复用:一个模板处理所有兼容类型的转换
实际应用:标准库中的
shared_ptr 和 unique_ptr 都使用了这种技术来支持派生类到基类的智能指针转换。条款46:需要类型转换时请为模板定义非成员函数(Define non-member functions inside templates when type conversions are desired)
核心原则:当模板类中的函数需要支持所有参数的隐式类型转换时,应将这些函数定义为类模板内部的
friend 函数。问题背景:
回顾条款24中的
Rational 类,我们希望支持混合运算:但将
Rational 改为模板后,这种隐式转换就失效了:原因分析:
在模板参数推导过程中,编译器不会考虑隐式类型转换
这是一个关于C++类型系统的重要区别:
普通类(非模板)的隐式类型转换
对于普通类,编译器在函数调用时会尝试进行隐式类型转换,因为函数签名是明确的。例如:
模板的参数推导不考虑隐式转换
对于函数模板,编译器需要先进行模板参数推导才能确定函数签名。在推导阶段,编译器必须从实参的类型直接推导出模板参数,不会尝试任何类型转换:
为什么模板不能考虑隐式转换
- 推导的确定性:如果允许隐式转换,编译器将面临无穷多的可能性。比如
2可以转换为Rational、double、std::string等任何提供了转换构造函数的类型,编译器无法确定应该推导哪个类型
- 性能考虑:模板参数推导需要快速完成,如果考虑所有可能的类型转换路径,编译时间会显著增加
- 设计哲学:模板参数推导遵循"精确匹配"原则,这使得模板行为更可预测
解决方案
正如你页面中的例子所示,通过将操作符声明为类内的
friend 函数,可以绕过模板参数推导,使其成为普通函数,从而支持隐式类型转换。oneHalf 的类型是 Rational<int>,但 2 是 int,编译器无法推导出第二个参数的 T 应该是什么
解决方案:声明为
friend函数:工作原理:
- 当
Rational<int>被实例化时,friend函数也被声明为一个普通函数(非模板函数)
- 作为普通函数,
operator*可以对所有参数进行隐式类型转换
2可以隐式转换为Rational<int>(2)
如果函数体较复杂,可以将实现移到辅助函数:
总结:这种技术结合了模板的灵活性和普通函数支持隐式类型转换的能力,是处理模板类操作符重载的标准做法。
条款47:请使用traits classes表现类型信息(Use traits classes for information about types)
Traits Classes 详解:以迭代器为例
让我循序渐进地用迭代器的例子说明 traits classes 如何实现编译期类型信息查询。
1. 问题背景:不同迭代器的性能差异
考虑一个
advance 函数,需要将迭代器移动 n 步:不同类型的迭代器有不同的移动效率:
- 随机访问迭代器(如
vector::iterator):可以直接iter += d,O(1) 时间
- 双向迭代器(如
list::iterator):只能循环调用++或--,O(n) 时间
- 输入迭代器(如
istream_iterator):只能前进,不能后退
2. 错误方案:运行期类型判断
你可能想用
typeid 或虚函数在运行期判断类型:3. 正确方案:使用 Traits + Tag Dispatch
步骤 1:定义标签结构体(Tag Structs)
步骤 2:为每个迭代器定义 Traits
步骤 3:编写针对不同标签的重载函数
步骤 4:统一接口通过 Traits 查询类型
4. 关键点:编译期 vs 运行期
- 编译期确定:
iterator_traits<IterT>::iterator_category在编译时就能确定具体类型
- 函数重载解析:编译器根据标签类型选择正确的
doAdvance重载版本
- 零运行期开销:不需要
if判断或虚函数调用,编译后直接是最优实现
- 类型安全:错误用法(如对输入迭代器使用负数)在编译期就能发现
5. 使用示例
总结:Traits classes 通过模板特化在编译期提取类型信息,配合标签分派(tag dispatch)和函数重载,实现了类似
if...else 的条件选择,但完全没有运行期开销。条款48:认识模板元编程(Be aware of template metaprogramming)
模板元编程(TMP)的核心优势:将计算从运行期转移到编译期,从而实现:
- 更早的错误检测:类型错误和逻辑错误在编译时就能发现
- 更高的执行效率:运行时无需进行类型检查或条件判断
TMP 的主要应用场景:
- 生成定制化代码:根据策略组合(policy-based design)为客户生成特定实现
- 类型安全的代码生成:避免为不适合的类型生成无效代码
示例1:编译期计算阶乘
示例2:根据类型特性生成不同代码(条款47的延伸)
示例3:避免为不适合的类型生成代码
关键要点:
- TMP 是图灵完备的,可以执行任何计算
- 代价是编译时间增加和代码可读性降低
- C++11/14/17 引入的
constexpr、if constexpr等特性让 TMP 更易用
- 应在性能关键且逻辑可在编译期确定的场景使用
第8章 定制new和delete(Customizing new and delete)
⛽补充说明CRTP(Curiously Recurring Template Pattern)
CRTP(Curiously Recurring Template Pattern)概念
CRTP的产生背景
在C++的早期发展中,程序员面临一个核心矛盾:
- 运行时多态的代价:传统的虚函数机制提供了灵活的多态性,但引入了虚表查找、间接调用、阻碍内联等运行时开销。
- 性能敏感场景的需求:在科学计算、游戏引擎、机器人动力学等领域,这些开销不可接受——每毫秒的计算都至关重要。
CRTP应运而生,作为编译期多态的解决方案:它利用模板机制在编译期完成类型推导和函数绑定,实现零运行时开销的多态行为。这一模式在1980年代末期逐渐形成,并在《C++ Templates: The Complete Guide》等经典著作中得到系统总结。
什么是CRTP?
CRTP是一种C++模板编程技术,其中派生类将自身作为模板参数传递给基类:
这个"怪异"的继承关系构成了CRTP的核心:基类通过模板参数"知道"派生类的类型,从而可以在编译期调用派生类的方法。
CRTP的语法剖析
Step 1: 基类定义模板接口
关键技术点:
static_cast<const Derived*>(this):将基类指针转换为派生类指针,零开销(编译期完成)。
- 基类方法调用派生类的
_impl方法,形成"接口-实现"分离。
Step 2: 派生类实现具体逻辑
Step 3: 使用示例
执行流程深度剖析
当调用
c.area() 时:- 编译期类型推导:编译器知道
c是Circle类型,继承自Shape<Circle>。
- 调用基类方法:
area()定义在Shape<Circle>中。
- 静态向下转型:
static_cast<const Circle*>(this)将this转换为Circle*。
- 调用派生类实现:
Circle::area_impl()被直接调用。
- 内联优化:编译器通常会将整个调用链内联,最终生成的汇编代码等同于直接调用
c.area_impl()。
对比虚函数的流程:
为什么使用CRTP?
- 静态多态性:避免虚函数的动态开销,通过模板实现编译期多态
- 性能优化:所有函数调用在编译期确定,支持内联优化(性能提升可达20%-50%)
- 灵活的接口设计:基类可以调用派生类的特定实现,同时提供通用算法
- 类型安全:编译期检查,避免运行期错误(如调用未实现的方法)
- 零额外内存:无虚表指针,每个对象节省8字节(64位系统)
CRTP的典型用途
1. 接口实现分离(如Pinocchio)
2. 计数器模式(统计实例数量)
3. 策略模式(编译期策略选择)
CRTP的限制与注意事项
- 无法存储基类指针:
Shape<Circle>*和Shape<Rectangle>*是不同类型,无法放入同一容器。
- 代码膨胀:每个派生类都会实例化一份基类代码(模板特性)。
- 调试困难:编译错误信息冗长,涉及复杂的模板展开。
- 循环依赖:基类和派生类相互依赖,需要小心设计头文件结构。
何时选择CRTP vs 虚函数?
场景 | 推荐方案 |
性能关键路径(如内循环) | CRTP |
需要运行时类型切换 | 虚函数 |
接口简单且类型固定 | CRTP |
需要存储异构对象集合 | 虚函数 |
库的公共API(编译期确定) | CRTP |
条款49:了解new-handler的行为(Understand the behavior of the new-handler)
set new handler 允许客户指定一个函数,在内存分配无法获得满足时被调用。
Nothrow new是一个颇为局限的工具,因为它只适用于内存分配:后继的构造函数调用还是可能抛出异常。
使用 CRTP 为类添加 set_new_handler 支持
为什么这么做?
当内存分配失败时,C++ 允许通过
std::set_new_handler 指定一个全局回调函数。但这是全局的,如果我们想让不同的类有各自的内存分配失败处理策略,就需要为每个类实现自己的 new-handler 机制。CRTP 解决方案的核心思路
通过奇异递归模板模式(CRTP),创建一个基类模板
NewHandlerSupport<T>,让每个需要自定义 new-handler 的类继承它。这样每个类都会获得独立的静态成员变量来存储自己的 handler。实现步骤
步骤 1:创建 CRTP 基类模板
步骤 2:Widget 类继承这个模板
如何使用
关键机制解释
- 模板参数 T 的作用:虽然基类中没有直接使用 T,但每个不同的 T 会实例化出独立的类,从而拥有独立的静态成员
currentHandler
- 继承自己:
Widget : public NewHandlerSupport<Widget>看起来奇怪,但这正是 CRTP 的核心——让基类能通过模板参数区分不同的派生类
- 临时切换全局 handler:在
operator new中,先将全局 handler 设为当前类的 handler,分配完成后立即恢复,确保不影响其他类
实际效果
- 类型安全:每个类有独立的 handler,不会相互干扰
- 零运行时开销:继承是编译期确定的,没有虚函数调用
- 代码复用:所有需要此功能的类只需继承
NewHandlerSupport<自己>即可,无需重复实现
- 局限性:nothrow new(如
new (std::nothrow) Widget)不会触发 handler,且构造函数抛出的异常无法被 new-handler 处理
为什么不用虚函数?
如果用传统的虚函数继承,静态成员(handler)会被所有派生类共享,无法做到"每个类独立的 handler"。而 CRTP 通过模板实例化,让每个类都有独立的静态存储空间。
条款50:了解new和delete的合理替换时机(Understand when it makes sense to replace new and delete)
为什么需要自定义 new 和 delete?
自定义内存管理运算符主要有以下几个理由:
- 性能优化:针对特定大小的对象使用内存池,减少分配/释放开销
- 调试支持:检测内存泄漏、越界写入、重复释放等错误
- 统计分析:收集堆内存使用模式,优化内存布局
- 特殊需求:实现共享内存分配器、对齐特殊硬件要求等
示例:简单的调试版 operator new
自定义 new/delete 的主要难点
1. 对齐问题
C++ 要求 operator new 返回的指针必须适当对齐,以满足任何类型的对齐需求。例如,某些平台上 double 需要 8 字节对齐。
2. 无限循环风险
如果 new-handler 内部分配内存失败,可能导致无限递归调用。必须确保 handler 要么释放内存、要么终止程序。
3. 线程安全
自定义分配器必须是线程安全的,需要正确使用互斥锁,但加锁本身会降低性能。
编译器优化使自定义变得不必要
现代编译器和标准库已经做了大量优化:
- tcmalloc/jemalloc:专业的高性能分配器,通常比自定义实现更快
- 小对象优化:编译器自动为小对象使用栈或寄存器
- SIMD 对齐:自动处理复杂的对齐需求
- 内存池技术:标准库的
std::pmr(C++17)提供了内存池支持
开源内存分配器存在的问题
- 可移植性:针对特定平台优化的代码可能在其他平台性能下降
- 维护成本:需要持续跟进新的 C++ 标准和平台特性
- 调试困难:自定义分配器可能干扰内存检测工具(如 Valgrind)
- 碎片化:简单的自定义分配器容易产生内存碎片,降低长期性能
结论
除非有明确的性能瓶颈或特殊需求(如嵌入式系统、实时系统),否则应优先使用标准库和成熟的第三方分配器。自定义 new/delete 是"最后的优化手段",而非常规做法。
条款51:编写new和delete时需固守常规(Adhere to convention when writing new and delete)
operator new 的规范
- 无限循环处理:应包含循环,不断尝试分配内存;失败时调用 new-handler
- 处理零字节请求:
new(0)必须返回合法指针(通常分配 1 字节)
- 类专属版本:需正确处理派生类对象(可能比基类更大)的分配请求
operator delete 的规范
- 空指针安全:删除 null 指针必须安全(什么都不做)
- 类专属版本:需检查对象大小,将错误大小的请求转发给全局 delete
条款52:写了placement new也要写placement delete(Write placement delete if you write placement new)
核心原则
- 配对使用:每个 placement new 都必须有对应的 placement delete,否则构造函数抛异常时会内存泄漏
- 避免名称遮蔽:声明自定义版本时,要确保不会隐藏标准版本(使用 using 声明引入)
什么是 Placement New/Delete?
Placement new 是带额外参数的 operator new,最常见的是在指定内存地址构造对象:
为什么需要 Placement Delete?
当构造函数抛出异常时,编译器需要释放已分配的内存。它会查找参数匹配的 placement delete:
Placement new 的语法规则
Placement new 的完整语法是:
圆括号的两种用途:
- 第一个圆括号
(参数列表):传递给 operator new 的额外参数 - 例如:
new (std::cerr) Widget()- 传递std::cerr给 operator new - 例如:
new (buffer) Widget()- 传递 buffer 地址给 operator new
- 第二个圆括号
(构造函数参数):传递给对象构造函数的参数 - 例如:
new (buffer) Widget(10, "hello")- 10 和 "hello" 传给 Widget 构造函数
具体例子对比:
关键点:
第一个圆括号中的参数必须与自定义 operator new 的参数列表匹配(除了第一个 size_t 参数是自动传递的)。
名称遮蔽问题
类中声明的 operator new/delete 会隐藏全局版本和基类版本:
解决方案:使用 using 声明
标准形式汇总
实践建议
- 优先使用标准库的 placement new,除非有特殊需求
- 自定义时务必成对提供 new 和 delete
- 使用 using 声明避免遮蔽基类或全局版本
- 考虑提供一个包含所有标准形式的基类,供其他类继承
第9章 杂项讨论(Miscellany)
条款53:不要轻忽编译器的警告(Pay attention to compiler warnings)
严肃对待编译器发出的警告信息。努力在你的编译器的最高(最严苛)警告级别下争取“无任何警告”的荣誉。
不要过度倚赖编译器的报警能力,因为不同的编译器对待事情的态度并不相同。一旦移植到另一个编译器上,你原本倚赖的警告信息有可能消失。
条款54:让自己熟悉包括TR1在内的标准程序库(Familiarize yourself with the standard library, including TR1)
C++ 标准程序库的核心组成
- STL(标准模板库):提供容器(vector、list、map等)、算法和迭代器
- iostreams:输入输出流,如 cin、cout、文件流等
- locales:国际化支持,处理不同地区的格式差异
- C99 标准程序库:兼容 C 语言的标准库函数
TR1(Technical Report 1)扩展内容
TR1 为 C++ 标准库增加了许多实用组件:
- 智能指针:
tr1::shared_ptr(共享所有权)、tr1::weak_ptr(弱引用) - 示例:
std::tr1::shared_ptr<Widget> pw(new Widget());
- 函数对象包装器:
tr1::function可存储任意可调用对象 - 示例:
tr1::function<int(int, int)> f = std::plus<int>();
- 哈希容器:
tr1::unordered_map、tr1::unordered_set等 - 示例:
tr1::unordered_map<string, int> wordCount;
- 正则表达式:
tr1::regex用于模式匹配 - 示例:
tr1::regex pattern("[0-9]+");
- 以及其他 10 个组件:tuple、array、bind、ref 等
如何使用 TR1
TR1 本身只是技术规范文档,需要具体的实现才能使用。Boost 库是获取 TR1 功能的最佳来源,其中许多组件后来被纳入 C++11 标准(去掉 tr1 命名空间前缀)。
条款55:让自己熟悉Boost(Familiarize yourself with Boost)
Boost 是什么
Boost 是一个由 C++ 社群驱动的开源项目,致力于开发高质量、经过同行评审的免费 C++ 程序库。它在 C++ 标准化过程中发挥着重要作用,许多 Boost 库后来被纳入了 C++ 标准(如智能指针、正则表达式等)。
Boost 程序库的主要分类
- 字符串与文本处理:正则表达式、字符串算法、词法分析等
- 容器:array、unordered 容器、多维数组、循环缓冲区等
- 算法:范围算法、字符串算法、图算法等
- 函数对象与高阶编程:bind、function、lambda 表达式等
- 泛型编程:类型萃取、MPL(元编程库)、概念检查等
- 并发编程:线程库、协程、异步 I/O 等
- 数学与数值计算:数学特殊函数、随机数、区间算术等
- 输入/输出:序列化、格式化输出、文件系统操作等
- 内存管理:智能指针、池分配器等
- 其他工具:日期时间处理、测试框架、程序选项解析等
- Author:felixfixit
- URL:http://www.felixmicrospace.top/article/note_effective_cpp
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!









