
Effective Modern C++ 笔记(3)- 移步现代C++

Item 7: Distinguish between () and {} when creating objects
核心思想:C++11 引入的
{}
(花括号)初始化,也称为列表初始化(list initialization),旨在提供一种更统一、更安全的初始化方式。然而,它与传统的()
(圆括号)初始化在行为上存在关键差异,特别是在与std::initializer_list
交互时。理解这些差异并做出正确的选择,对于编写清晰、无误的 C++ 代码至关重要。
我们逐一分解你提到的几个要点。
1. 花括号 {}
初始化的优点
花括号初始化被设计为“更好的”初始化方式,主要有三大优点:
a. 最广泛使用的初始化语法(统一性)
在 C++11 之前,初始化一个对象的方式五花八门:
1 | int x = 10; |
而 {}
试图统一这一切:
1 | int x{10}; |
这种统一性让代码更易读、更一致。
b. 防止变窄转换(安全性)
这是 {}
的一个杀手级特性。它禁止任何可能导致信息丢失的“变窄”隐式转换。
1 | double d = 3.14; |
这种在编译阶段就发现潜在错误的特性,极大地提升了代码的健壮性。
c. 对 C++ 最令人头疼的解析有天生的免疫性
C++ 有一个解析规则,即“任何可以被解释为声明的东西,都必须被解释为声明”。这会导致一个经典问题:
1 | // 程序员的意图:调用 Widget 的默认构造函数,创建一个名为 w 的对象。 |
这个问题被称为 “C++’s most vexing parse”。使用 {}
初始化可以完美地避免这个问题:
1 | Widget w{}; // 毫无疑问:这是一个调用默认构造函数创建的 Widget 对象 w。 |
2. 花括号 {}
的最大陷阱:std::initializer_list
优先匹配
这是 {}
和 ()
行为上最关键、最危险的区别。
规则:在构造函数重载决议中,如果一个类有任何一个构造函数接受
std::initializer_list
作为参数,那么在使用{}
初始化时,编译器会极其强烈地优先匹配这个构造函数。
只要 {}
中的内容可以被构造成 std::initializer_list
的元素类型,编译器就会选择它,即使有其他参数类型完全匹配的构造函数存在。
示例:
1 | class Widget { |
这个“贪婪”的 std::initializer_list
匹配行为是 {}
初始化最需要警惕的地方。
3. std::vector
的经典案例
std::vector
完美地展示了 {}
和 ()
的巨大差异,因为它恰好有 std::initializer_list
构造函数和非 initializer_list
构造函数。
1 |
|
可以看到,对于 std::vector
,使用 {}
还是 ()
会产生截然不同的结果。这是一个非常常见的错误来源。
4. 模板编程中的挑战
在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。
当你在编写模板代码时,你不知道你将要创建的类型 T
是什么。你无法预知 T
是否有 std::initializer_list
构造函数,以及它对 {}
和 ()
的反应。
示例:
1 | template<typename T, typename... Args> |
假设我们调用 create_and_process<std::vector<int>>(10, 20);
- 如果使用**方案 A (
()
)**,obj
会是一个包含 10 个 20 的vector
。 - 如果使用**方案 B (
{}
)**,obj
会是一个包含 2 个元素(10 和 20)的vector
。
你作为模板的作者,无法替用户决定哪种行为是正确的。用户传递 (10, 20)
的意图是什么?这取决于 T
的具体类型。
怎么办?
没有完美的解决方案,但一般的建议是:
在普通代码中:
- 默认使用
{}
初始化,享受它的安全性和统一性。 - 但如果你正在处理一个带有
std::initializer_list
构造函数的类(如std::vector
),并且你想调用非initializer_list
的构造函数,那么必须使用()
。
- 默认使用
在模板代码中:
- 这是一个两难的境地。Scott Meyers 在书中建议,模板作者应该让调用者来决定。一种常见的方式是让调用者通过某种方式传递已经构造好的对象,或者提供不同的工厂函数,如
make_with_parens
和make_with_braces
。 - 在 C++20 中,可以使用
std::make_obj_using_allocator
配合std::uses_allocator_construction_args
来更精确地控制构造,但这已经是非常高级的用法了。 - 对于大多数模板代码,如果你只是想把参数完美转发给构造函数,使用
()
是更传统、更可预测的选择,因为它不会被std::initializer_list
“劫持”。
- 这是一个两难的境地。Scott Meyers 在书中建议,模板作者应该让调用者来决定。一种常见的方式是让调用者通过某种方式传递已经构造好的对象,或者提供不同的工厂函数,如
总结
特性 | 圆括号 () (Parentheses) |
花括号 {} (Braces/List Initialization) |
---|---|---|
统一性 | 差 (多种语法) | 好 (单一语法) |
安全性 | 弱 (允许变窄转换) | 强 (禁止变窄转换) |
Vexing Parse | 易受影响 (Widget w(); ) |
免疫 (Widget w{}; ) |
initializer_list |
不会特殊对待 | 强烈优先匹配 (核心陷阱!) |
适用场景 | 1. 当需要调用非 initializer_list 构造函数时 (如vector(10, 20) )2. 在不确定类型的模板代码中,为避免 initializer_list 劫持 |
1. 绝大多数情况下的默认选择 2. 需要初始化容器内容时 (如 vector{1,2,3} )3. 想要利用变窄转换检查时 |
这条规则的精髓在于:了解你的工具。{}
是一个强大的新工具,但它有一个非常独特的“个性”(initializer_list
优先),你必须尊重并适应它,才能安全地使用它。
Item 8: Prefer nullptr to 0 and NULL
好的,我们来详细解读一下《Effective Modern C++》中这条关于空指针表示的重要建议。这看起来是一个小细节,但它解决了 C++ 历史上的一个长期痛点,对于编写清晰、无误的现代 C++ 代码至关重要。
核心思想:在 C++11 之前,表示空指针的
0
和NULL
都不是真正的“指针类型”,它们本质上是整型。这导致了在函数重载和模板类型推导中会出现歧义和错误。C++11 引入的nullptr
是一个真正的“空指针字面量”,它有自己独特的类型std::nullptr_t
,从而完美地解决了这些问题。
让我们逐一分解这条建议的要点。
1. 0
和 NULL
的历史问题
在 C++98/03 的世界里,我们这样表示空指针:
1 | int* p1 = 0; // 使用整数 0 |
问题出在哪里?
0
是int
类型:0
是一个整数リテラル (integer literal),它的类型是int
。虽然 C++ 语言规定0
可以被隐式转换为空指针,但它的“根”是整数。**
NULL
是一个宏,通常也是0
**:NULL
宏的定义在不同实现中可能不同,但它通常被定义为0
或者0L
(长整型)。在 C++ 代码中,它几乎总是被处理为整型0
。它并不是一个特殊的指针关键字。
这种“用整数代表空指针”的妥协,在遇到函数重载时会引发严重问题。
2. 问题场景:函数重载的歧义
假设我们有两个重载函数:一个处理指针,一个处理整数。
1 | void func(int* ptr) { |
在 (B)
和 (C)
中,程序员的意图是传递一个空指针,希望调用 func(int* ptr)
。但由于 0
和 NULL
的类型都是 int
,编译器会选择精确匹配的 func(int i)
版本,而不是需要一次隐式转换的 func(int* ptr)
版本。
这完全违背了程序员的意图,并可能导致难以发现的 bug。程序不会崩溃,但会静默地执行错误的代码路径。
3. C++11 的解决方案:nullptr
C++11 引入 nullptr
来彻底解决这个问题。
nullptr
是一个关键字:它不再是宏或一个普通的整数。nullptr
有自己的类型:它的类型是std::nullptr_t
。这是一个独特的类型,专门用来表示空指针。nullptr
可以隐式转换为任何指针类型:std::nullptr_t
可以被隐式转换为int*
,char*
,MyClass*
,std::shared_ptr<int>
等任何指针或智能指针类型。nullptr
不能隐式转换为整型:你不能把nullptr
赋值给一个int
变量(除非用static_cast
强制转换,但你不应该这么做)。
现在,让我们用 nullptr
重新审视上面的重载问题:
1 | void func(int* ptr) { /* ... */ } |
在 (D)
中,nullptr
的类型是 std::nullptr_t
。
std::nullptr_t
->int*
:可以隐式转换。std::nullptr_t
->int
:不可以隐式转换。
因此,编译器只有一个合法的选择:调用 func(int* ptr)
。歧义被完美消除,程序员的意图得到了正确的实现。
4. 模板类型推导中的优势
nullptr
的优势在模板编程中同样明显。
1 | template<typename Func, typename Ptr> |
nullptr
保证了即使在泛型代码中,空指针的类型信息也不会丢失。
5. “避免重载指针和整型”
这条建议是上一条逻辑的自然延伸和一种防御性编程策略。
即使你已经全面使用 nullptr
,但你的代码库可能很庞大,或者你需要和一些还在使用 0
或 NULL
的旧代码交互。在这种情况下,重载指针和整型(特别是 int
)仍然是一个雷区。
为什么?
因为你无法控制你代码的调用者。一个不了解 nullptr
重要性的同事或用户,仍然可能用 0
或 NULL
去调用你的函数,从而触发我们上面讨论过的重载歧义问题。
1 | // 你的新代码,但接口设计有风险 |
如何避免?
不同的函数名:这是最简单、最清晰的方法。
1
2void process_pointer(int* ptr);
void process_value(int val);这样一来,调用者不可能弄错。
使用标签分发(Tag Dispatching):如果必须使用相同的函数名,可以通过引入一个额外的“标签”参数来区分。
1
2
3
4
5struct pointer_tag {};
struct value_tag {};
void process(int* ptr, pointer_tag);
void process(int val, value_tag);删除不想要的重载:如果你只想接受指针,可以明确地删除整数重载。
1
2
3
4
5
6
7void my_func(int* ptr);
void my_func(int) = delete; // 明确禁止 int 版本的调用
void my_func(bool) = delete; // bool 也可以隐式转为 0/1,有时也需禁止
my_func(0); // 编译错误,匹配到 delete 的函数
my_func(NULL); // 编译错误
my_func(nullptr); // OK
总结
**始终使用
nullptr
**:在 C++11 及以后的代码中,用nullptr
表示空指针。忘掉0
和NULL
。这让你的代码更类型安全、更清晰、更少歧义。nullptr
是类型安全的:它有自己的类型std::nullptr_t
,能正确地参与函数重载决议和模板类型推导,解决了0
和NULL
作为整型的历史遗留问题。谨慎重载指针和整数:即使你用了
nullptr
,这种重载模式本身也是脆弱的。为了编写更健壮的接口,最好避免创建仅靠指针和整数类型来区分的重载函数。如果必须这样做,请考虑使用更明确的函数名或delete
掉不想要的重载版本。
Item 9: Prefer alias declarations to typedef
我们来详细解释《Effective Modern C++》中关于用别名声明(Alias Declaration,即 using
)替代 typedef
的这条建议。这不仅仅是语法上的喜好问题,using
在功能和可读性上都对 typedef
构成了显著的优势,尤其是在模板编程中。
核心思想:C++11 引入的别名声明 (
using
) 是对传统typedef
的现代化升级。它提供了与typedef
等价的功能,但语法更清晰、更通用,并且原生支持模板化,从而解决了typedef
在现代 C++ 模板元编程中遇到的种种不便。
我们逐一分析这条建议的论据。
typedef
和别名声明 (using
) 的基本等价性
对于简单的类型别名,两者是完全等价的。
typedef
语法:
1 | typedef int MyInt; |
typedef
的语法有点像声明一个变量,只是在前面加了 typedef
关键字。MyInt
和 FuncPtr
分别成为 int
和一个函数指针类型的别名。
using
语法:
1 | using MyInt = int; |
using
的语法是 using NewName = OldType;
,这种 名称 = 类型
的形式更直观,更易于阅读,特别是当类型变得复杂时。
例如,对于函数指针,using
的语法清晰地将名称和类型分离开来,而 typedef
的语法中,新名称被嵌在类型声明的中间,可读性稍差。
1 | // typedef: 名称 'FP' 在中间 |
到目前为止,这还只是语法风格问题。真正的差异体现在模板上。
1. typedef
不支持模板化,但别名声明支持(别名模板)
这是 using
最核心的优势。假设你想创建一个别名,这个别名本身是模板化的。比如,你想创建一个 MyAllocVector
,它总是使用一个特定的分配器 MyAlloc
。
使用 typedef
的尝试(失败):
1 | // 这段代码无法编译!typedef 不能被模板化。 |
typedef
的语法设计根本不支持这种 template<...>
的前缀。为了在 C++11 之前实现类似的效果,程序员们不得不使用一种非常笨拙的变通方法:**在模板化的 struct
或 class
中嵌套一个 typedef
**。
1 | template<typename T> |
使用别名声明(using
)的解决方案(成功):
C++11 的别名声明原生支持模板化,我们称之为别名模板(Alias Template)。
1 | template<typename T> |
对比之下,using
的解决方案在语法上和使用上都完胜。
2. 别名模板避免 ::type
后缀,并简化模板内的类型使用
这条是第一点的自然延伸,特别是在模板元编程(TMP)中。
在 C++11 之前,很多类型萃取(type traits)都以“在结构体中嵌套 typedef
”的方式实现。例如,std::remove_const
:
1 | // C++11 之前的 type traits 实现风格 |
当你在一个模板函数内部使用这种类型时,事情会变得更糟,因为你还需要在前面加上 typename
关键字,来告诉编译器 ::type
是一个类型而不是一个静态成员变量。
typedef
在模板中的困境:
1 | template<typename T> |
typename
和 ::type
的组合让代码变得冗长且难以阅读。
using
的优雅解决方案:
C++11(以及后续版本)为几乎所有的类型萃取都提供了别名模板版本,通常以 _t
结尾。
1 | // C++14 提供了 _t 版本的别名模板 |
现在,上面的模板函数可以被极大地简化:
1 | template<typename T> |
代码瞬间变得干净、整洁,意图也更加明显。
3. C++14 提供了 C++11 所有 type traits 的别名版本
C++11 引入了别名模板的能力,并提供了一些 _t
版本。但 C++14 更进一步,为标准库中所有在 C++11 中返回 ::type
的类型萃取,都添加了对应的 _t
别名模板版本。
例如:
typename std::remove_reference<T>::type
=>std::remove_reference_t<T>
typename std::add_lvalue_reference<T>::type
=>std::add_lvalue_reference_t<T>
typename std::result_of<F(Args...)>::type
=>std::result_of_t<F(Args...)>
(C++17 中被invoke_result_t
替代)
这使得在 C++14 及以后,进行模板元编程时,你几乎可以完全告别 typename ... ::type
的写法,全面拥抱 _t
形式的别名模板。
总结
将这些点综合起来,我们得到一个清晰的结论:
特性 | typedef |
别名声明 using |
---|---|---|
基本别名 | typedef Old TypeName; (语法不够直观) |
using TypeName = Old; (语法清晰) |
函数指针别名 | typedef void (*FP)(...); (名称在中间) |
using FP = void (*)(...); (名称和类型分离) |
模板化 | 不支持 | 支持 (别名模板) |
模板元编程 | 需要 typename ... ::type 的繁琐写法 |
直接使用,如 std::remove_const_t<T> |
现代 C++ 实践 | 遗留特性 | 首选方式 |
因此,《Effective Modern C++》的建议 “Prefer alias declarations to typedef
“ 是一个非常明确且有力的指导。在任何可以使用 typedef
的地方,你都可以并且应该使用 using
。它不仅在语法上更一致、更清晰,而且其对模板的强大支持是现代 C++ 编程不可或缺的一部分。
Item 10: Prefer scoped enums to unscoped enums
我们来详细解读一下《Effective Modern C++》中这条关于枚举类型的重要建议。这是 C++11 引入的一项关键改进,旨在解决传统 C++ 枚举(enum
)存在的诸多问题。
核心思想:传统的 C++98
enum
(现在称为非限域枚举,unscoped enum)存在命名空间污染和意外的隐式类型转换两大问题,这使得它们在大型项目中不够安全。C++11 引入的限域枚举(scoped enum,也叫enum class
或enum struct
) 通过创建独立的命名作用域和禁止隐式转换,完美地解决了这些问题,从而成为现代 C++ 的首选。
让我们逐一分解这条建议的论据。
1. C++98 enum
的问题:命名空间污染
这是非限域枚举最臭名昭著的问题。它的枚举名(enumerators)会泄露到其所在的作用域中。
示例:
1 | // 在全局命名空间中 |
即使它们在不同的 enum
中,也无法共存。在大型项目中,这很容易导致命名冲突。为了避免这种情况,程序员们不得不使用一些变通手法,比如给枚举名加上前缀:
1 | enum Color { COLOR_RED, COLOR_GREEN, COLOR_BLUE }; |
2. 限域枚举 (enum class
) 的解决方案:强作用域
C++11 的 enum class
(或 enum struct
,两者完全等价) 解决了这个问题。它的枚举名被限制在枚举自身的作用域内,不会泄露出去。
示例:
1 | enum class Color { RED, GREEN, BLUE }; |
Color::RED
和 TrafficLight::RED
是两个完全不同、互不相干的实体。这极大地提高了代码的清晰度和安全性,完全消除了命名冲突的风险。
3. C++98 enum
的问题:危险的隐式转换
非限域枚举的另一个大问题是,它的枚举名可以被隐式地转换为整型,甚至可以参与算术运算。
示例:
1 | enum Color { RED, GREEN, BLUE }; // RED=0, GREEN=1, BLUE=2 |
这种隐式转换破坏了类型的抽象。Color
本应是一个独立的类型,代表颜色,而不应该随随便便就变成一个整数。这使得代码的意图变得模糊,并可能引入难以察觉的逻辑错误。
4. 限域枚举 (enum class
) 的解决方案:强类型,无隐式转换
限域枚举是强类型的。它的值不会隐式转换为任何其他类型,特别是整型。
示例:
1 | enum class Color { RED, GREEN, BLUE }; |
如果你确实需要将一个限域枚举的值转换为整数(例如,用于序列化或数组索引),你必须使用显式的类型转换(cast)。
1 | int i = static_cast<int>(Color::RED); // 必须显式转换,这很好! |
这种设计强制程序员思考每一次类型转换,从而避免了意外的、不安全的转换。
5. 底层类型(Underlying Type)
底层类型是指编译器用来存储枚举值的整数类型(如 int
, char
, unsigned long
等)。
限域枚举 (
enum class
)- 有默认底层类型:
int
。 - 可以显式指定:你可以使用
: type
语法来指定一个不同的整数类型。这对于控制内存布局或确保与外部接口(如 C API)的兼容性非常有用。
1
2
3
4
5// 默认是 int
enum class Status { OK, FAILED };
// 显式指定为 unsigned char,更节省空间
enum class HttpStatus : unsigned char { OK = 200, NotFound = 404 };- 有默认底层类型:
非限域枚举 (
enum
)- 没有默认底层类型:编译器会选择一个足够大的整数类型来容纳所有的枚举值。这个选择是实现定义的,可能会因编译器或编译选项而异。这导致了不确定性。
- C++11后也可以显式指定:为了与限域枚举保持一致,C++11 也允许为非限域枚举指定底层类型。
1
2// C++11 风格的非限域枚举
enum Color : std::uint8_t { RED, GREEN, BLUE };
6. 前置声明(Forward Declaration)
前置声明允许你在不知道一个类型的完整定义(比如它的大小)的情况下,先声明它的存在。这对于解耦头文件、减少编译依赖非常重要。
限域枚举 (
enum class
)- 总是可以前置声明。因为它们的底层类型要么是默认的
int
,要么是显式指定的,编译器在看到声明时就知道它的大小。
1
2
3
4
5
6
7
8
9// In some_header.h
enum class Color; // OK!可以前置声明
void use_color(Color c);
// In some_source.cpp
void use_color(Color c) {
if (c == Color::RED) { /* ... */ }
}- 总是可以前置声明。因为它们的底层类型要么是默认的
非限域枚举 (
enum
)- 默认情况下不能前置声明。因为编译器不知道它的底层类型是什么,也就无法确定它的大小。
- 只有在你为它显式指定了底层类型后,才能前置声明。
1
2
3// enum Color; // 错误!编译器不知道 Color 的大小。
enum Color : unsigned int; // OK!现在可以前置声明了。
这再次表明,限域枚举在设计上更加一致和健壮。
总结
特性 | 非限域枚举 (enum ) |
限域枚举 (enum class /enum struct ) |
---|---|---|
作用域 | 枚举名泄露到外部作用域 | 枚举名被限制在 enum 内部 |
类型安全 | 弱类型,可隐式转为整型 | 强类型,不可隐式转换 |
用法 | RED |
Color::RED |
底层类型 | 无默认,实现定义 | 默认 int |
前置声明 | 仅在指定底层类型时可以 | 总是可以 |
推荐 | 仅用于与旧代码/C API 交互 | 现代 C++ 的首选 |
因此,《Effective Modern C++》的建议 “Prefer scoped enums to unscoped enums” 是一个几乎没有例外的黄金法则。在编写新的 C++ 代码时,你应该始终默认使用 enum class
,只有在极少数需要与 C 语言库或依赖隐式转换的旧代码库交互时,才考虑使用传统的 enum
。
Item 11: Prefer deleted functions to private undefined ones.
我们来详细解读一下《Effective Modern C++》中这条关于如何禁止函数使用的建议。这涉及到从 C++98 的传统技巧到 C++11 现代化方法的演进。
核心思想:当你想禁止某个函数(尤其是编译器会自动生成的特殊成员函数,如拷贝构造函数)被使用时,C++11 提供的
= delete
语法是比 C++98 时代“声明为private
且不定义”的技巧更清晰、更强大、更通用的方法。
我们来深入分析这两个方法,并看看为什么 = delete
是更好的选择。
1. C++98 的传统技巧:声明为 private
且不定义
在 C++11 之前,如果我们想让一个类不可拷贝(例如 std::unique_ptr
的前身 std::auto_ptr
),标准的做法是:
- 将拷贝构造函数和拷贝赋值运算符声明为
private
。 - 故意不提供它们的定义。
1 | // C++98 风格的不可拷贝类 |
这种方法是如何工作的?
从类外部尝试拷贝:
1
2NonCopyable a;
NonCopyable b(a); // 编译错误!编译器会报错,因为它无法访问
private
的拷贝构造函数。这是我们想要的效果,错误在编译期就被捕获了。从类的成员函数或友元函数内部尝试拷贝:
1
2
3
4
5
6class MyFriend {
public:
void doSomething(NonCopyable& nc) {
NonCopyable copy(nc); // 尝试拷贝
}
};假设
MyFriend
是NonCopyable
的友元。在这种情况下,private
的限制被绕过了,编译器可以访问拷贝构造函数。于是,编译阶段会顺利通过。但是,当程序进入链接(linking)阶段时,链接器会尝试寻找NonCopyable::NonCopyable(const NonCopyable&)
的函数定义,但我们故意没有提供它。链接器找不到,于是会报告一个链接错误(”unresolved external symbol” 或类似的错误)。
这种技巧的缺点:
错误信息不清晰且滞后:链接错误比编译错误更难排查。错误信息通常很神秘,而且只在整个项目构建的最后阶段才出现,这会减慢开发周期。我们更希望在编译时就得到一个清晰的“这个函数被删除了”的错误。
作用范围有限:这种技巧只能用于类的成员函数。它无法禁止一个非成员函数(自由函数)被调用。
2. C++11 的解决方案:= delete
C++11 引入了一种更直接、更清晰的语法来表达“这个函数不可用”。
1 | // C++11 风格的不可拷贝类 |
= delete
是如何工作的?
当你将一个函数标记为 = delete
时,你是在告诉编译器:“这个函数存在,但它是被删除的,任何试图使用它的代码都是非法的。”
任何地方的任何调用都会导致编译错误:
1
2NonCopyable a;
NonCopyable b(a); // 编译错误!无论是从类外部、成员函数内部还是友元内部,只要代码尝试调用一个被删除的函数,都会在编译期立即失败。
清晰的错误信息:编译器会给出非常明确的错误信息,例如 “error: use of deleted function ‘NonCopyable::NonCopyable(const NonCopyable&)’”。这让开发者能立即明白问题所在。
3. = delete
的优势:更强大、更通用
= delete
不仅仅是 private
技巧的简单替代品,它的功能更加强大。
a. 任何函数都能被删除
private
技巧只能用于类的成员函数。而 = delete
可以用于任何函数,包括非成员函数。
这允许我们禁止某些特定的、不希望发生的函数重载。
示例:禁止特定的模板实例化
假设你有一个模板函数,可以处理任何类型,但你唯独不想让它处理 char*
,因为这可能意味着用户想传递一个 C 风格字符串,而你的函数可能处理不当。
1 | template<typename T> |
b. 禁止不期望的类型转换
= delete
还可以用来防止某些危险的隐式类型转换。
示例:只接受整数,不接受浮点数
1 | void print_integer(int i) { |
通过删除这些重载版本,我们强制调用者必须传递一个真正的整数,从而避免了数据丢失和逻辑错误。
总结
特性 | C++98 private 未定义技巧 |
C++11 = delete |
---|---|---|
错误报告时机 | 编译期(外部调用)或 链接期(内部/友元调用) | 总是编译期 |
错误信息 | 可能是神秘的链接错误 | 清晰明确,指出函数已被删除 |
适用范围 | 仅限类的成员函数 | 任何函数 (成员/非成员/模板实例) |
意图表达 | 间接、需要注释来解释 | 直接、自解释 (= delete ) |
功能 | 仅能禁止拷贝等成员函数 | 可禁止任意函数,可防止不期望的类型转换 |
因此,= delete
在各个方面都优于旧的 private
技巧。它更安全(错误发现得更早)、更清晰(错误信息更明确)、更强大(适用范围更广)。在现代 C++ 中,当你需要禁止一个函数时,= delete
是你唯一应该考虑的工具。
Item 12: Declare overriding functions override
我们来详细解读一下《Effective Modern C++》中这条关于 override
和引用限定符的建议。这两者都是 C++11 引入的,旨在让面向对象编程(特别是继承和多态)变得更安全、更精确。
核心思想:在复杂的继承体系中,程序员很容易在重写(override)虚函数时犯错(如函数签名写错)。C++11 的
override
关键字能让编译器帮你检查这种错误,从而将潜在的运行时 bug 转化为编译时错误。而引用限定符(&
和&&
)则进一步增强了函数重载的能力,允许我们根据对象本身是左值还是右值来提供不同的实现。
让我们逐一分解。
1. 虚函数重写的“静默”错误
在 C++11 之前,重写一个基类的虚函数完全依赖于程序员的细心。你必须保证派生类中的函数签名(函数名、参数列表、const
限定符)与基类中的虚函数完全一致。如果稍有差池,你得到的就不是重写,而是一个全新的、与基类无关的虚函数。
一个经典的错误示例:
1 | class Base { |
问题在哪里?
Derived::doSomething
并没有重写Base::doSomething
,因为它的const
限定符不匹配。- 编译器不会报错!它会认为你只是在
Derived
类中定义了一个新的、独立的虚函数。 - 后果:当通过基类指针或引用调用该函数时,多态行为会不符合预期。
1 | std::unique_ptr<Base> p = std::make_unique<Derived>(); |
上面那个 doSomething
的调用结果可能会让程序员大吃一惊。这是一个非常隐蔽且难以调试的运行时 bug。
2. override
:编译器的安全网
C++11 引入了 override
关键字来解决这个问题。override
是一个上下文关键字,你把它放在派生类函数的声明末尾。
它的作用很简单:向编译器做出一个明确的声明:“我确信这个函数正在重写一个基类的虚函数。请帮我检查一下!”
如果你的声明是正确的,编译器会通过。如果你的声明是错误的(比如函数签名不匹配),编译器会立即报错。
用 override
改进后的代码:
1 | class Base { |
当编译器看到 Derived::doSomething
上的 override
时,它会去基类 Base
中查找一个签名完全匹配的虚函数。它找不到 virtual void doSomething(int x)
,只找到了 virtual void doSomething(int x) const
。由于不匹配,编译器会立刻报错,并给出清晰的错误信息,例如:“’doSomething’ marked override
but does not override any base class method”。
这个原本需要运行时才能发现的 bug,现在在编译阶段就被轻松捕获了。
结论:在重写任何虚函数时,都应该无条件地加上 override
。这是一个零成本、高回报的最佳实践。
3. 成员函数引用限定(Ref-Qualifiers)
这是 C++11 带来的另一个强大的特性,它允许你根据调用成员函数的对象本身是左值(lvalue)还是右值(rvalue)来重载函数。
我们知道,函数的参数可以是左值引用(T&
)或右值引用(T&&
)。引用限定符做的其实是同样的事情,但它限定的是隐式的 *this
对象。
语法是在成员函数的参数列表之后,const
或 override
之前,加上 &
或 &&
。
&
:表示该函数只能被左值对象调用。&&
:表示该函数只能被右值对象调用。
为什么要这么做?
最常见的用途是进行资源所有权的转移优化。
示例:假设我们有一个 Widget
类,它内部有一个数据成员 data
(比如一个 std::vector
)。我们想提供一个 get_data()
方法。
1 | class Widget { |
分析:
1 | Widget w; // w 是一个左值 |
使用引用限定符进行优化:
我们可以提供两个版本的 get_data()
:
1 | class Widget { |
现在再看调用:
1 | Widget w; |
通过引用限定符,我们能够为不同的使用场景提供最合适的实现,榨取更高的性能。
总结
override
是安全带:它不改变任何运行时行为,但能让你在编译时就发现虚函数重写错误。**只要重写,就用override
**。引用限定符是性能优化器:它允许你区分对左值对象和右值对象的调用,从而实现更精细的资源管理。特别是当对象是临时量(右值)时,你可以安全地“窃取”其内部资源,避免不必要的拷贝。
这两个特性共同体现了现代 C++ 的一个核心设计哲学:将更多的错误检查和性能优化机会从运行时提前到编译时,编写出更安全、更高效的代码。
Item 13: Prefer const_iterators to iterators
我们来详细解读一下《Effective Modern C++》中这条关于迭代器的重要建议。这条规则旨在推动我们编写更安全、更通用、更符合现代 C++ 风格的代码。
核心思想:默认使用
const_iterator
可以让你只对容器进行只读访问,这是一种更安全、更符合“最小权限原则”的编程习惯。而使用非成员函数的std::begin
和std::end
等,则可以让你的代码不仅适用于标准容器,还能无缝支持 C 风格数组和其他可迭代对象,从而大大提升代码的通用性。
我们来逐一分解这两个部分。
Part 1: Prefer const_iterator
to iterator
在 C++98/03 时代,获取 const_iterator
是一件有点麻烦的事。你必须通过一个 const
的容器对象来调用 begin()
或 end()
。
1 | // C++98/03 风格 |
这种写法很笨拙,为了一个 const_iterator
还要引入一个 const
引用。
C++11 的改进:cbegin
和 cend
C++11 为所有标准容器引入了 cbegin()
和 cend()
成员函数。它们总是返回 const_iterator
,无论容器本身是不是 const
。
1 | // C++11 风格 |
这使得获取 const_iterator
变得非常简单直接。
为什么要优先使用 const_iterator
?
安全性(Const Correctness):这遵循了 C++ 的一个核心原则——常量正确性。如果你只是想遍历一个容器并读取其内容,你根本不需要修改它的能力。使用
const_iterator
可以让编译器帮你强制执行这个“只读”意图。万一你不小心写了试图修改元素的代码,编译器会立刻报错,从而在编译阶段就阻止了一个潜在的 bug。更清晰的意图:当其他人读你的代码时,
cbegin()
清晰地表明了你的循环是一个只读操作。这提高了代码的可读性和可维护性。更广泛的适用性:接收
const_iterator
的函数可以同时处理const
和非const
的容器,而接收iterator
的函数只能处理非const
的容器。编写接受const_iterator
的泛型算法,其适用范围更广。
实践法则:
- 当你写一个循环时,问自己:“我需要修改容器中的元素吗?”
- 如果答案是“否”,那么总是使用
cbegin()
和cend()
。 - 只有当答案是“是”时,才使用
begin()
和end()
。
Part 2: Prefer Non-Member begin
and end
C++11 不仅引入了 cbegin
/cend
,还提供了一套非成员函数版本的 std::begin
, std::end
, std::cbegin
, std::cend
等。
为什么要用非成员函数版本?——为了通用性
考虑一个你想让它尽可能通用的模板函数,它需要遍历一个“东西”。
1 | template<typename C> |
方案 A 的问题:它只对那些拥有 begin()
和 end()
成员函数的类型有效,比如 std::vector
, std::list
, std::string
等。但如果用户想传入一个 C 风格的数组呢?
1 | int arr[] = {4, 5, 6}; |
C 风格数组没有成员函数,所以 container.begin()
会导致编译失败。
方案 B:非成员函数 std::begin
和 std::end
(正确的做法)
1 |
|
方案 B 的优势:
对于标准容器:当你传入一个
std::vector
时,std::begin(vec)
内部会直接调用vec.begin()
。所以行为和方案 A 一样。对于 C 风格数组:C++ 标准库为 C 风格数组特化了
std::begin
和std::end
。std::begin(arr)
会返回一个指向数组第一个元素的指针 (&arr[0]
),std::end(arr)
会返回一个指向数组末尾之后位置的指针 (&arr[3]
)。这正好是迭代器所需要的行为!
现在,我们的 print_elements
函数变得更加通用了:
1 | std::vector<int> v = {1, 2, 3}; |
通过使用非成员函数,我们的代码无需任何修改就能同时支持标准容器和 C 风格数组。
std::cbegin
和 std::cend
呢?
同样的逻辑也适用于非成员版本的 cbegin
和 cend
。它们结合了前面两部分的优点:既能确保返回 const_iterator
(安全性),又能处理包括 C 风格数组在内的多种可迭代对象(通用性)。
1 | template<typename C> |
总结
将这两个建议结合起来,我们可以得出一条非常实用的现代 C++ 编码准则:
- **在需要只读迭代时,优先选择
const_iterator
**。这能让你获得编译期的安全保障,防止意外修改。 - **在获取迭代器时,优先使用非成员函数
std::begin
,std::end
,std::cbegin
,std::cend
**。这能让你的代码更具通用性,可以无缝地处理不同类型的可迭代对象(如标准容器和 C 风格数组)。
因此,在编写泛型代码时,std::cbegin(container)
和 std::cend(container)
几乎总是你的最佳选择,除非你明确需要修改容器内容。这体现了现代 C++ 对安全性、通用性和代码清晰度的追求。
Item 14: Declare functions noexcept if they won’t emit exceptions
核心思想:
noexcept
是一个承诺。当你将一个函数声明为noexcept
时,你是在向编译器和调用者保证:“这个函数绝对不会抛出任何异常,或者如果它内部发生了异常,它会自己处理掉,绝不会让异常传播到函数外部。”
如果这个承诺被打破(即一个 noexcept
函数确实企图向外抛出异常),程序不会像常规异常那样进行栈展开(stack unwinding)并寻找 catch
块。相反,程序会立即调用 std::terminate
,导致程序崩溃。这是一个非常严厉的惩罚,所以做出 noexcept
的承诺必须非常谨慎。
下面我们逐一分解你提到的几个要点:
1. noexcept
是函数接口的一部分,这意味着调用者可能会依赖它
这可能是 noexcept
最关键的一点。一个函数的接口(或称“契约”)不仅仅包括它的名字、参数和返回类型,还包括它能提供的异常安全保证。noexcept
是最强的保证:绝不抛出异常。
调用者为什么会依赖这个保证呢?因为这直接影响到调用者的性能和代码逻辑。
最经典的例子就是标准库容器(如 std::vector
)的移动操作。
假设我们有一个 std::vector<Widget>
,现在 vector
的容量不够了,需要重新分配一块更大的内存,并把旧内存中的所有 Widget
对象移动到新内存中。
情况A:
Widget
的移动构造函数是noexcept
的std::vector
知道移动Widget
对象是绝对安全的,不会抛出异常。因此,它可以放心地、高效地逐个调用移动构造函数,将对象从旧内存“搬”到新内存。操作完成后,释放旧内存。这个过程非常快。情况B:
Widget
的移动构造函数不是**noexcept
的**std::vector
必须考虑到最坏的情况:如果在移动第 N 个Widget
对象时,移动构造函数抛出了异常,会发生什么?此时,新内存中有 N-1 个已移动的对象,旧内存中还有剩下的对象,并且第 N 个对象的状态可能已经损坏。std::vector
为了维持其强异常安全保证(strong exception guarantee,即操作要么完全成功,要么不对容器产生任何影响),不能冒这个风险。
因此,它会放弃使用移动构造函数,转而使用拷贝构造函数来逐个“复制”对象到新内存。拷贝通常比移动昂贵得多(例如,涉及深拷贝),因为它需要分配新资源。
代码示例:
1 |
|
标准库中有很多类似 std::move_if_noexcept
的工具,它们在编译期检查一个操作是否是 noexcept
,并根据结果选择不同的、性能和安全性更优的代码路径。这就是“调用者依赖它”的直接体现。
2. noexcept
函数较之于 non-noexcept
函数更容易优化
这是从编译器的角度来看的。
当编译器编译一个可能抛出异常的函数(non-noexcept
)时,它必须生成额外的代码来处理潜在的异常。这个过程称为栈展开(stack unwinding)。
- 开销1:状态维护:编译器需要记录在
try
块中哪些对象已经成功构造。如果发生异常,它必须按照构造的逆序精确地调用这些对象的析构函数。 - 开销2:代码膨胀:编译器会生成额外的“着陆区”(landing pads),这是异常抛出后控制流跳转到的地方,用于执行清理代码。
- 开销3:抑制优化:异常处理的存在会限制编译器的很多优化手段。例如,编译器可能无法安全地重排指令、内联函数或将对象保存在寄存器中,因为它必须确保在任何时刻,如果异常发生,程序状态都能被正确地恢复和清理。
而对于一个 noexcept
函数,编译器知道它永远不会向外传播异常。因此:
- 不需要生成任何栈展开的代码。
- 不需要为函数调用设置异常处理的“着-陆区”。
- 编译器可以更自由地进行指令重排、函数内联等优化,因为不必担心这些优化会破坏异常安全。
最终结果是,noexcept
函数生成的机器码通常更小、更快。
3. noexcept
对于移动语义,swap
,内存释放函数和析构函数非常有用
我们来逐个分析为什么这些特定函数尤其需要 noexcept
:
移动语义(Move Semantics):如第1点所述,
noexcept
的移动操作是容器和算法实现高性能的关键。一个可能抛异常的移动操作,其价值大打折扣。所以,**移动构造函数和移动赋值运算符应该尽可能地被声明为noexcept
**。swap
函数:swap
是许多算法(如排序)的基础。一个健壮的swap
操作应该是原子性的,不会失败。如果swap
在交换两个对象的过程中抛出异常,对象的状态可能会变得混乱(一个半新半旧),导致整个程序进入不一致的状态。因此,一个好的swap
实现几乎总是noexcept
的。内存释放函数和析构函数(Destructors):这是绝对的规则。
- 析构函数绝对不能抛出异常! 理由是:如果一个异常正在被处理(即栈展开正在进行中),在这个过程中,某个局部对象的析构函数又抛出了一个新的异常。C++标准规定,此时无法同时处理两个异常,程序必须立即调用
std::terminate
终止。 - 为了防止这种情况,C++11及以后的版本中,析构函数默认就是
noexcept
的。你只有在非常特殊且明确知道后果的情况下,才会用noexcept(false)
来声明一个可能抛异常的析构函数(这几乎总是一个坏主意)。 - 同理,自定义的内存释放函数(如
operator delete
)也不应该抛出异常。
- 析构函数绝对不能抛出异常! 理由是:如果一个异常正在被处理(即栈展开正在进行中),在这个过程中,某个局部对象的析构函数又抛出了一个新的异常。C++标准规定,此时无法同时处理两个异常,程序必须立即调用
4. 大多数函数是异常中立的(Exception-Neutral)而不是 noexcept
这一点澄清了 noexcept
的适用范围。
noexcept
函数:承诺自己和它调用的所有函数都不会向外抛异常。这是一个非常强的承诺。- 异常中立(Exception-Neutral)函数:它自己本身不产生异常,但它调用的函数可能会抛出异常。它不处理(
catch
)这些异常,而是让它们自然地传播出去,由更上层的调用者来决定如何处理。
为什么大多数函数是异常中立的?
考虑一个函数 process_data()
:
1 | void process_data(const std::string& data) { |
process_data
函数本身没有 throw
语句,但它调用的 buffer.reserve()
可能因内存不足抛出 std::bad_alloc
,log_to_database()
可能因I/O问题抛异常。
我们不应该将 process_data
声明为 noexcept
。因为如果这么做:
- 一旦
reserve
真的抛了异常,整个程序就会崩溃。 - 这剥夺了调用者处理这个错误的机会。调用者可能希望
catch(std::bad_alloc)
,然后尝试释放一些内存,或者优雅地报告“内存不足”并退出,而不是直接崩溃。
process_data
的正确做法就是保持“异常中立”:它不捕获自己无法处理的异常,让它们透明地传递给上层调用者。
总结一下
**何时使用
noexcept
**:- 当一个函数确实不会抛出任何异常时(例如,它只操作基本类型、不分配内存、不调用任何可能抛异常的函数)。
- 对于移动构造函数、移动赋值运算符和
swap
函数,要尽最大努力让它们成为noexcept
。 - 析构函数和内存释放函数必须是
noexcept
(C++11后析构函数默认如此)。
**何时不使用
noexcept
**:- 当你不能 100% 保证函数不会抛出异常时。
- 当函数调用了其他可能抛出异常的函数,并且这些异常应该由调用者来处理时(即,大多数情况下的“异常中立”函数)。
记住 noexcept
的口号:“如果一个函数可能抛异常,就不要声明它为 noexcept
。只有在你确定它不会抛异常时,才这么做。” 这是一个关乎程序正确性和健壮性的重要设计决策。
Item 15: Use constexpr whenever possible
核心思想:
constexpr
的目标是将计算从运行时(Runtime)提前到编译期(Compile-time)。这不仅仅是为了性能优化,更是为了在编译阶段就验证逻辑、生成常量,从而编写出更安全、更强大的代码。
constexpr
可以修饰两种东西:对象(变量)和函数。
1. constexpr
对象是 const
,它被在编译期可知的值初始化
这一点揭示了 constexpr
对象的核心属性。
编译期可知的值:这意味着它的值在程序编译的时候就必须被确定下来。这个值可以是一个字面量(如
10
,'a'
,true
),或者是一个由其他constexpr
表达式计算出的结果。**
constexpr
对象是const
**:一旦一个constexpr
对象被初始化,它的值就不能再改变了。所以,每个constexpr
对象都是const
的。
但是,反过来不成立:**const
对象不一定是 constexpr
**。
我们来看一个对比:
1 | // func() 是一个普通的函数,它的返回值在运行时才能确定 |
小结:const
保证的是“运行时不可变”,而 constexpr
保证的是“编译期就确定值,且运行时不可变”。constexpr
是一个比 const
更强的约束。
2. 当传递编译期可知的值时,constexpr
函数可以产出编译期可知的结果
这是 constexpr
最强大的地方,它赋予了函数一种“双重身份”。
一个 constexpr
函数必须满足一定的条件(例如,函数体不能执行 I/O 操作、不能调用非 constexpr
函数等,这些限制在 C++14/17/20 中越来越宽松)。
它的“双重身份”体现在:
情况A:在编译期执行
如果传递给constexpr
函数的所有参数都是编译期常量,那么编译器会尝试在编译期间就执行这个函数,并用其返回的结果直接替换函数调用。1
2
3
4
5
6
7
8
9
10constexpr int power(int base, int exp) noexcept {
int result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}
// 编译期计算
// 编译器会直接计算 power(2, 10) 的结果是 1024
// 下面的代码等同于:constexpr int compile_time_val = 1024;
constexpr int compile_time_val = power(2, 10);情况B:在运行时执行
如果传递给它的任何一个参数是运行时才能确定的值,那么这个constexpr
函数就会像一个普通函数一样,在运行时被调用和执行。1
2
3
4
5
6
7
8
9// 运行时计算
int base_val = 2;
std::cout << "Enter exponent: ";
int exp_val;
std::cin >> exp_val;
// power() 像一个普通函数一样在运行时被调用
int run_time_val = power(base_val, exp_val);
std::cout << "Result: " << run_time_val << std::endl;
这个特性非常棒,因为你只需要写一份函数代码,它既能用于需要编译期常量的场景,也能用于普通的运行时场景。
3. constexpr
对象和函数可以使用的范围比 non-constexpr
对象和函数要大
这一点解释了“为什么我们要费心去搞编译期计算”。因为 C++ 的某些地方必须使用编译期常量。
这些场景包括:
- 数组的大小
std::array
的大小模板参数- 模板的非类型参数(Non-type template parameters)
enum
值的初始化case
语句的标签- 对齐说明符(alignas)
**如果没有 constexpr
**,我们通常只能使用字面量或者旧式的宏(#define
),但宏不具备类型安全,是 C++ 中极力避免的。
**有了 constexpr
**,我们可以用类型安全、具有作用域的函数和变量来生成这些常量。
示例:
1 |
|
在这些例子中,如果你试图使用一个非 constexpr
的函数或变量,代码将无法编译。constexpr
极大地扩展了我们能在这些“只接受编译期常量”的上下文中所能做的事情。
4. constexpr
是对象和函数接口的一部分
和 noexcept
一样,constexpr
是你对函数或对象使用者的一种承诺。
- 当你将一个函数声明为
constexpr
,你是在告诉调用者:“嘿,只要你给我编译期常量,你就可以在编译期使用我的返回值!” - 当你将一个对象声明为
constexpr
,你是在告诉使用者:“这个值在编译期就可用,你可以用它来定义数组大小、模板参数等。”
如果你的库中有一个 constexpr
函数,用户可能就会依赖这个特性来编写他们的代码,例如:
1 | // 你的库 v1.0 |
现在,假设在你的库 v2.0 中,你修改了 get_version
的实现,导致它不再是 constexpr
(比如,你改成从一个配置文件读取版本号)。
1 | // 你的库 v2.0 |
这时,所有依赖 get_version()
是 constexpr
的用户代码都会编译失败。你破坏了API的兼容性。
因此,将一个函数或对象声明为 constexpr
是一个重要的接口设计决策。反之,如果一个函数本来不是 constexpr
,后来你把它改成 constexpr
,这通常是安全的,而且是一种改进。
总结
constexpr
是现代 C++ 一个强大的特性,它模糊了编译期和运行期的界限。
- 对于变量:尽可能用
constexpr
替代const
来定义真正的常量。 - 对于函数:如果一个函数理论上可以在编译期求值(不依赖 I/O、不依赖非
constexpr
数据等),就应大胆地将其声明为constexpr
。
这样做的好处是:
- 性能提升:将计算转移到编译期,减少运行时开销。
- 增强表达力:可以用复杂的逻辑来生成编译期常量。
- 提升安全性:在编译阶段就能发现更多错误。
“Use constexpr
whenever possible” 是一个非常好的实践指导:把它作为你的默认选项,只有当确定一个值或函数逻辑上无法在编译期确定时,才不使用它。
Item 16: Make const member functions thread safe
核心思想:程序员有一个根深蒂固的直觉:对一个对象调用
const
成员函数是安全的,因为它只是“读取”数据,不会修改对象。在多线程环境中,这意味着多个线程可以同时在一个共享的const
对象上调用const
方法而不会出问题。这条规则要求我们,作为类的设计者,必须去实现和维护这个直觉,否则就会给类的使用者留下一个危险的陷阱。
让我们逐一分解这条建议的要点。
1. const
成员函数的“欺骗性”
首先,我们必须理解为什么 const
成员函数本身并不天然地等于线程安全。
一个成员函数被声明为 const
,编译器只会保证它不会修改类的任何非 mutable
成员变量。换句话说,它保证的是位层面上的常量性(bitwise constness)。
但是,为了实现某些优化(如缓存)或记录某些内部状态(如调用次数),我们经常需要修改一个逻辑上是 const
的函数内部的数据。这时我们就会使用 mutable
关键字。
一个经典的例子:缓存计算结果
假设我们有一个类,它执行一个昂贵的计算。为了避免重复计算,我们希望缓存结果。
1 | class Polynomial { |
问题在哪里?——数据竞争(Data Race)
想象一下两个线程同时在一个 const Polynomial
对象上调用 evaluate(1.0)
:
- 线程 A 执行
evaluate
,看到cacheValid
是false
。 - 在线程 A 即将执行计算之前,操作系统切换到线程 B。
- 线程 B 执行
evaluate
,也看到cacheValid
是false
。 - 线程 B 执行了昂贵的计算,然后把结果写入
cachedValue
,并把cacheValid
设置为true
。 - 操作系统切换回线程 A。线程 A 对刚才发生的一切毫不知情,它继续执行昂贵的计算(浪费了!),然后把结果写入
cachedValue
,并把cacheValid
设置为true
。
这不仅仅是效率低下的问题,如果 cachedValue
和 cacheValid
的写入不是原子操作(它们通常不是),就可能导致数据撕裂(torn reads/writes),最终使对象状态损坏。这就是一个典型的数据竞争,是未定义行为。
调用者看到的是一个 const
函数,他们理所当然地认为并发调用是安全的,但实际上却触发了 bug。
2. 解决方案:确保 const
成员函数线程安全
这条规则的核心就是要求我们修复上面这样的问题。
方案 A:使用互斥锁 (std::mutex
)
互斥锁是保护“临界区”(critical section)的标准工具。临界区就是那段访问共享资源、一次只允许一个线程进入的代码块。
我们需要在类中添加一个 mutable std::mutex
。它必须是 mutable
,因为加锁和解锁操作会修改互斥锁自身的状态,而这些操作发生在 const
成员函数内部。
1 |
|
现在,当多个线程调用 evaluate
时:
- 第一个进入的线程会获得锁
m
。 - 其他线程尝试获取锁,但会失败并被阻塞,直到第一个线程释放锁。
- 第一个线程安全地完成了检查、计算和更新缓存的全过程,然后释放锁。
- 下一个等待的线程获得锁,进入临界区,此时它会发现
cacheValid
已经是true
,于是直接返回缓存值,然后释放锁。
这样,既保证了线程安全,也实现了缓存的逻辑。
3. std::atomic
:更高性能的选择
互斥锁是一个强大的通用工具,但它可能带来性能开销,尤其是在高争用(high contention)的情况下,线程可能会被挂起和唤醒,这涉及昂贵的上下文切换。
对于单个变量的同步访问,std::atomic
提供了一种通常更轻量级、性能更高的方案。atomic
类型的操作是原子性的,意味着它们是不可分割的,不会被其他线程中断。
什么时候可以使用 std::atomic
?
当你需要同步的只是一个简单的标志、一个计数器或一个指针时。
让我们来看一个不同的例子,比如统计一个对象被访问了多少次:
1 |
|
在这里,使用 std::atomic<int>
比用一个 int
和一个 std::mutex
来保护它要高效得多。在许多平台上,accessCount++
会被编译成一条单一的、硬件支持的原子指令(如 LOCK INC
),这比操作系统层面的锁快得多。
std::atomic
的局限性
回到我们的 Polynomial
例子,能用 std::atomic
优化吗?
1 | // !!!这是一个错误的尝试!!! |
这个例子是错误的,因为它没有解决根本问题。cacheValid
和 cachedValue
的更新不是一个单一的原子操作。在 cacheValid
被设置为 true
之前,cachedValue
的写入可能对其他线程是不可见的,或者其他线程可能读到 cachedValue
的中间状态。
结论:std::atomic
非常适合同步单个内存位置。但如果你的逻辑涉及多个变量的协调(比如我们的 cacheValid
和 cachedValue
),或者需要保护一个代码块,那么 std::mutex
仍然是正确且唯一的选择。
总结
- 信守承诺:
const
成员函数是对调用者的一个承诺,即“调用我不会改变对象的外部可见状态,并且在并发环境下是安全的”。作为类的设计者,你有责任去兑现这个承诺。 - 识别风险:警惕
const
函数内部对mutable
成员的修改。这是数据竞争的温床。 - 选择工具:
- 使用
std::mutex
和std::lock_guard
来保护涉及多个变量或复杂逻辑的临界区。这是最通用、最安全的做法。 - 当且仅当你需要同步的是单个变量(如计数器、标志位)时,才使用
std::atomic
来获得更好的性能。
- 使用
- 最终目标:让你的类的使用者可以放心地在多线程中共享
const
对象,而无需担心内部实现细节,这符合“最小惊讶原则”(Principle of Least Astonishment)。
Item 17: Understand special member function generation
核心思想:C++ 编译器为了方便,会“好心”地为你的类自动生成一些关键的成员函数,我们称之为特殊成员函数。但是,这种“好心”的行为遵循一套复杂且在 C++11 后发生重大变化的规则。如果你不理解这些规则,编译器可能会生成你不想要的行为(比如低效的拷贝),或者拒绝生成你以为会有的函数(比如移动构造函数),导致代码性能下降或编译失败。
什么是特殊成员函数?
它们是处理对象创建、销毁、拷贝和移动的六个函数:
- 默认构造函数 (Default Constructor):
MyClass();
- 析构函数 (Destructor):
~MyClass();
- 拷贝构造函数 (Copy Constructor):
MyClass(const MyClass&);
- 拷贝赋值运算符 (Copy Assignment Operator):
MyClass& operator=(const MyClass&);
- 移动构造函数 (Move Constructor):
MyClass(MyClass&&);
- 移动赋值运算符 (Move Assignment Operator):
MyClass& operator=(MyClass&&);
自动生成的规则:“The Rule of Zero/Three/Five”
C++98 时代有一个著名的“三法则”(Rule of Three):如果你需要自己实现析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,那么你几乎肯定需要实现所有这三个。
C++11 引入了移动语义,这个法则扩展成了“五法则”(Rule of Five)。
但现代 C++ 更推崇“零法则”(Rule of Zero):尽可能不自己编写任何特殊成员函数,让编译器为你生成它们。这要求你使用智能指针(如 std::unique_ptr
, std::shared_ptr
)和标准库容器来管理资源,因为这些工具本身已经正确实现了五法则。
然而,当你确实需要自己管理资源时,就必须理解编译器何时生成、何时不生成这些函数。
让我们逐条分析你提到的规则,这些规则正是“五法则”背后的具体机制。
1. 移动操作的生成规则(最严格)
移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
这是最关键的一条。移动操作(移动构造和移动赋值)是“易碎”的,编译器在生成它们时非常保守。
核心逻辑:如果你自己写了以下任何一个函数:
- 析构函数 (
~MyClass()
) - 拷贝构造函数 (
MyClass(const MyClass&)
) - 拷贝赋值运算符 (
operator= (const MyClass&)
) - 移动构造函数 (
MyClass(MyClass&&)
) - 移动赋值运算符 (
operator= (MyClass&&)
)
编译器就会拒绝为你自动生成移动操作。
为什么?
因为用户声明这些函数通常意味着该类在进行某种特殊的资源管理(如裸指针、文件句柄、网络连接等)。
- 声明析构函数:暗示你需要做一些清理工作,比如
delete ptr;
。如果编译器此时还傻傻地生成一个移动构造函数,它可能只是简单地把ptr
的值从源对象拷贝到目标对象,然后源对象的析构函数会被调用,delete ptr;
,导致目标对象的ptr
变成悬空指针。这是灾难! - 声明拷贝操作:暗示简单的成员拷贝是不够的(需要深拷贝)。编译器不知道如何为这种复杂情况正确地“移动”资源,所以它选择放弃,不生成移动操作。
结论:只要你手动接管了资源管理的任何一个环节(拷贝、析构),编译器就会认为你比它更懂这个类,于是它会“罢工”,不再提供自动的移动语义。
示例:
1 | class MyWidget { |
2. 拷贝操作的生成规则(较宽松,但有陷阱)
拷贝构造函数仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是
delete
。
拷贝赋值运算符…(同理)
这条规则分为两部分:
Part A:基础规则(同 C++98)
- 如果你自己写了
MyClass(const MyClass&)
,编译器当然不会再生成一个。 - 如果你自己写了
MyClass& operator=(const MyClass&)
,编译器也不会再生成一个。
Part B:C++11 的新陷阱
- 如果你只声明了移动操作(移动构造或移动赋值),编译器会认为你的类是“只移类型”(move-only),比如
std::unique_ptr
。因此,它会主动将拷贝操作**标记为delete
**。
为什么?
声明移动操作表明你希望对资源进行所有权的转移,而不是共享或复制。如果允许拷贝,可能会意外地破坏这种所有权模型。所以编译器帮你禁止了拷贝。
示例:
1 | class UniqueResource { |
当用户声明了析构函数,拷贝操作的自动生成已被废弃(deprecated)。
这是 C++ 标准演进的一个细节。在 C++98 中,声明析构函数并不会阻止拷贝操作的生成。但在 C++11 及以后,这种行为被认为是有风险的(原因同第1点中解释的,自定义析构意味着特殊资源管理),因此标准不推荐(deprecated)这种做法,但为了向后兼容,大部分编译器仍然会生成拷贝操作(并给出一个警告)。未来的 C++ 版本可能会完全禁止。安全的做法是遵循五法则:如果你写了析构函数,最好把拷贝和移动操作也一并处理(或显式 = default
或 = delete
)。
3. 成员函数模板的特殊性
成员函数模板不抑制特殊成员函数的生成。
这是一个非常微妙但重要的点。假设你写了这样一个类:
1 | class Person { |
你可能会认为,Person(T&& name)
看起来很像移动构造函数 Person(Person&&)
或拷贝构造函数 Person(const Person&)
,所以编译器应该不会再生成它们了。
这是错误的!
编译器在查找特殊成员函数时,只看非模板的、签名完全匹配的函数。template<typename T> Person(T&&)
是一个函数模板,它本身不是一个函数。只有在被实例化时(例如 Person(some_person)
),它才会生成一个具体的函数。
因此,即使你写了这样的模板,编译器仍然会认为你没有显式声明拷贝构造/移动构造等函数,于是它会继续按上面的规则为你自动生成它们。
后果是什么?
- 当你写
Person p1; Person p2 = p1;
时,调用的是编译器自动生成的拷贝构造函数,而不是你的构造函数模板。 - 当你写
Person p3 = std::move(p1);
时,调用的是编译器自动生成的移动构造函数。
这可能导致意想不到的行为,特别是当你的模板函数和自动生成的函数行为不一致时。
总结与实践建议
理解这些规则后,我们可以得出一些实用的指导方针:
**遵循零法则 (Rule of Zero)**:尽可能使用标准库工具(
std::vector
,std::string
,std::unique_ptr
,std::shared_ptr
)来管理资源。这样你就不需要编写任何特殊成员函数,编译器会为你做对一切。1
2
3
4
5
6class MyGoodClass {
private:
std::string name;
std::vector<int> data;
std::unique_ptr<Widget> p_widget;
}; // 无需写任何特殊成员函数,一切都工作得很好!**遵循五法则 (Rule of Five)**:如果你必须手动管理资源(例如使用裸指针),那么请一次性把五个特殊成员函数(析构、拷贝构造/赋值、移动构造/赋值)都处理好。
**使用
= default
和= delete
**:如果你希望获得编译器默认生成的行为,但因为某些规则(比如你写了析构函数)导致它不被生成,你可以用
= default
强制编译器生成它。这是告诉编译器:“我知道规则,但我确定默认行为是正确的。”1
2
3
4
5
6
7
8
9class MyWidget {
public:
~MyWidget(); // 自定义析构
// 默认的拷贝和移动是安全的,所以我们显式要求编译器生成
MyWidget(const MyWidget&) = default;
MyWidget& operator=(const MyWidget&) = default;
MyWidget(MyWidget&&) = default;
MyWidget& operator=(MyWidget&&) = default;
};如果你想明确禁止某个操作(如拷贝),请使用
= delete
。1
2
3
4
5
6class MoveOnly {
public:
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
// 其他函数...
};
通过掌握这些规则和工具,你就能精确地控制你的类的行为,避免编译器“自作主张”带来的陷阱。
- Title: Effective Modern C++ 笔记(3)- 移步现代C++
- Author: Ethan Xu
- Created at : 2025-04-15 09:10:01
- Updated at : 2025-09-17 19:38:58
- Link: https://ethanx.netlify.app/2025/04/15/effective-modern-cpp-3-movetomodern/
- License: This work is licensed under CC BY-NC-SA 4.0.