C++11核心新特性详解:从类型推导、移动语义到并发编程

C++11核心新特性详解:从类型推导、移动语义到并发编程
Lichermione前言
C++11 是现代 C++ 的重要起点。它并不是简单增加几个语法糖,而是从语言表达能力、资源管理、泛型编程和并发支持等方面系统地改造了 C++。
与 C++98/03 相比,C++11 的重要变化包括:
- 统一的列表初始化;
auto与decltype类型推导;- 范围
for循环; nullptr、constexpr和static_assert;override、final、= default和= delete;- 右值引用、移动语义与完美转发;
- Lambda 表达式;
unique_ptr、shared_ptr和weak_ptr;array、forward_list和无序关联式容器;- 可变参数模板;
std::function与std::bind;- 标准线程、互斥锁、原子操作、条件变量和异步任务。
本文以讲义内容为主线,并补充实际开发和面试中最容易混淆的细节。
本文讨论的是 C++11。若某项能力来自 C++14、C++17 或更高标准,会单独标注,避免把不同标准混在一起。
一、C++11 简介
1.1 从 C++98/03 到 C++11
C++98 是第一份被广泛使用的 ISO C++ 标准。C++03 主要修正 C++98 中的问题,语言核心变化相对有限,因此二者常被合称为 C++98/03。
C++11 在制定期间被称为 C++0x。它带来了大量语言和标准库更新,奠定了后来“现代 C++”的基础。
C++11 的主要目标可以概括为:
- 让常见代码更简洁;
- 提升资源管理安全性;
- 减少不必要的对象复制;
- 增强泛型编程能力;
- 提供跨平台并发标准;
- 在不牺牲性能的前提下提高表达能力。
1.2 编译时启用 C++11
GCC 或 Clang:
1 | g++ -std=c++11 -Wall -Wextra -Wpedantic main.cpp -o main |
多线程程序还需要:
1 | g++ -std=c++11 -Wall -Wextra -Wpedantic main.cpp -pthread -o main |
较新的编译器默认标准可能已经高于 C++11,但学习某一标准时显式指定 -std=c++11,有助于发现误用了高版本功能的情况。
二、列表初始化
2.1 统一初始化语法
C++98 已经允许使用花括号初始化数组:
1 | int values[] = {1, 2, 3, 4, 5}; |
C++11 扩大了花括号初始化的适用范围:
1 | int a{10}; |
有无等号通常不改变这里的初始化目标,但会涉及直接列表初始化与拷贝列表初始化的规则差异。
2.2 值初始化
空花括号可以把基础类型初始化为零值:
1 | int number{}; // 0 |
相比未初始化的局部变量,{} 能显著减少不确定值问题。
2.3 防止窄化转换
列表初始化会拒绝许多可能丢失信息的窄化转换:
1 | int a = 3.14; // 可以编译,但会丢失小数部分 |
这使花括号初始化比传统赋值式初始化更严格。
但“列表初始化能够拒绝所有危险转换”也不准确。仍然要理解目标类型的范围和构造函数行为。
2.4 自定义类型
1 | class Point |
编译器会根据花括号中的实参寻找合适的构造函数。
2.5 initializer_list
std::initializer_list<T> 用于接收数量可变、类型相同的列表元素:
1 |
|
initializer_list 提供:
begin();end();size()。
列表中的元素按 const 对象访问,不能直接修改原列表元素。
2.6 initializer_list 的优先匹配
如果一个类型同时拥有普通构造函数和 initializer_list 构造函数,花括号初始化会优先尝试 initializer_list:
1 | std::vector<int> a(5, 10); // 5 个元素,每个值为 10 |
二者结果完全不同。
因此,不能机械地把所有圆括号都替换成花括号。选择初始化形式时必须理解目标类型的构造函数集合。
2.7 避免最令人困惑的解析
1 | Widget object(); // 声明函数,不是创建对象 |
空花括号能够明确表达对象初始化,避免 most vexing parse。
三、nullptr
3.1 0 和 NULL 的问题
传统代码常用 0 或 NULL 表示空指针:
1 | void process(int); |
许多实现中的 NULL 只是整数 0 的宏,也会产生重载歧义或调用错误重载。
3.2 nullptr 是真正的空指针字面量
1 | process(nullptr); // 调用 process(void*) |
nullptr 的类型是 std::nullptr_t,可以转换为任意指针类型,但不会被当作普通整数使用。
现代 C++ 中应优先使用 nullptr。
四、auto 类型推导
4.1 基本用法
auto 根据初始化表达式推导变量类型:
1 | auto number = 10; // int |
它特别适合:
- 迭代器类型;
- 模板返回的复杂类型;
- Lambda 闭包类型;
- 类型已经能从右侧清楚看出的表达式。
4.2 auto 必须有初始化器
1 | // auto value; // 错误:无法推导类型 |
auto 是静态类型推导,编译器在编译期确定具体类型,不是运行期动态类型。
4.3 auto 会丢弃顶层 const 和引用
1 | const int value = 10; |
如果要保留引用:
1 | auto& c = value; // const int& |
如果希望修改原对象:
1 | int number = 10; |
4.4 auto 与数组
1 | int values[3]{1, 2, 3}; |
是否使用引用会直接影响推导结果。
4.5 auto 不是“越多越好”
不适合滥用的场景:
1 | auto result = calculate(); // 如果函数名不能说明类型和语义,阅读成本可能更高 |
使用原则:
- 右侧能够清楚表达类型时,
auto很合适; - 类型本身携带重要业务语义时,显式写出类型更清晰;
- 不要用
auto掩盖意外的整数、代理对象或窄类型。
五、decltype
5.1 基本作用
decltype(expression) 在编译期查询表达式的类型,并且通常不会执行表达式:
1 | int a = 10; |
decltype 属于不求值语境:
1 | int function(); |
5.2 decltype 的核心规则
对于未加括号的变量名或成员访问,decltype 得到声明类型:
1 | const int value = 10; |
对于其他表达式,根据值类别推导:
- 左值表达式得到
T&; - 将亡值表达式得到
T&&; - 纯右值表达式得到
T。
经典区别:
1 | int number = 10; |
多一层括号可能改变结果,因为 (number) 被按普通左值表达式处理。
5.3 尾置返回类型
C++11 中,函数返回类型依赖参数表达式时,可以使用尾置返回类型:
1 | template <class Left, class Right> |
注意:
- C++11 的普通函数不能仅写
auto add(...) { ... }自动推导返回类型; - 普通函数返回类型推导是 C++14 扩展;
decltype(auto)也是 C++14 功能。
5.4 auto 与 decltype 对比
| 对比项 | auto |
decltype |
|---|---|---|
| 主要用途 | 定义变量并推导类型 | 查询表达式的精确类型 |
| 是否需要初始化 | 通常需要 | 不一定 |
| 是否执行表达式 | 初始化表达式会执行 | 通常不执行 |
顶层 const |
按规则可能丢弃 | 可精确保留 |
| 引用属性 | 普通 auto 通常丢弃 |
由表达式规则决定 |
六、范围 for 循环
6.1 基本用法
1 | std::vector<int> numbers{1, 2, 3, 4, 5}; |
范围 for 会遍历序列中的每个元素。
6.2 值、引用与 const 引用
按值遍历会复制元素:
1 | for (auto value : numbers) |
按引用遍历可以修改:
1 | for (auto& value : numbers) |
只读遍历大型对象时使用:
1 | for (const auto& item : items) |
这能避免复制并阻止修改。
6.3 底层思想
范围 for 大致会借助 begin 和 end:
1 | for (auto iterator = numbers.begin(); |
它支持:
- 原生数组;
- 标准容器;
- 能够找到合适
begin和end的自定义类型。
6.4 遍历中修改容器结构
如果在范围 for 中执行 push_back、erase 等操作,可能导致迭代器失效:
1 | // 危险:push_back 可能扩容,使范围 for 内部迭代器失效 |
修改元素值和修改容器结构是两件不同的事情。
七、override 与 final
7.1 override
override 明确表示派生类函数要重写基类虚函数:
1 | class Base |
如果签名不匹配,编译器会报错:
1 | class Wrong : public Base |
override 不改变虚函数机制,但能让编译器验证程序员的意图。
7.2 final 修饰虚函数
1 | class Derived : public Base |
后续派生类不能再次重写 print。
7.3 final 修饰类
1 | class Utility final |
被 final 修饰的类不能继续被继承。
八、智能指针
8.1 为什么需要智能指针
裸指针管理动态资源时容易出现:
- 忘记释放;
- 异常路径泄漏;
- 多次释放;
- 所有权不清晰;
- 提前返回时漏掉清理。
智能指针利用 RAII,让资源释放与对象生命周期绑定。
8.2 unique_ptr
std::unique_ptr 表达独占所有权:
1 |
|
不能复制:
1 | // auto copy = pointer; // 错误 |
可以移动:
1 | auto other = std::move(pointer); |
移动后 other 拥有资源,原 pointer 为空。
数组形式:
1 | std::unique_ptr<int[]> array(new int[100]{}); |
std::make_unique 是 C++14 才加入的,不属于 C++11。
8.3 shared_ptr
std::shared_ptr 通过共享引用计数表达共同所有权:
1 | auto first = std::make_shared<std::string>("hello"); |
最后一个拥有者销毁时,资源才会释放。
不要把 use_count() 当作同步协议或业务判断依据,多线程环境下计数在观察后可能立即变化。
8.4 weak_ptr
std::weak_ptr 观察 shared_ptr 管理的对象,但不增加强引用计数:
1 | std::weak_ptr<std::string> observer = first; |
lock() 成功时返回一个临时 shared_ptr,保证使用期间对象仍然存在。
8.5 循环引用
两个对象互相持有 shared_ptr 会导致引用计数无法归零:
1 | Parent --shared_ptr--> Child |
通常把反向、非拥有关系改成 weak_ptr。
8.6 make_shared
1 | auto object = std::make_shared<MyType>(arg1, arg2); |
常见优势:
- 写法简洁;
- 异常安全;
- 通常能把控制块和对象放在一次分配中。
但自定义删除器、私有构造、对象与控制块生命周期分离等场景可能需要其他创建方式。
8.7 智能指针不是线程安全万能药
不同 shared_ptr 实例对共享控制块的引用计数操作具备相应线程安全保证,但被指向对象本身不会因此自动线程安全。
多个线程同时修改 *pointer,仍然需要对象自身的同步机制。
九、C++11 新增容器
9.1 array
std::array<T, N> 是固定大小数组的标准库封装:
1 |
|
特点:
- 大小属于类型的一部分;
- 支持迭代器和标准算法;
at提供边界检查;- 不会像原生数组那样在大多数表达式中自动退化为指针;
- 容量固定,不能
push_back。
9.2 forward_list
std::forward_list 是单链表:
1 |
|
它只支持向前迭代,主要使用 insert_after、erase_after 等接口,不提供 size(),因为标准希望某些操作保持常数空间和效率特性。
9.3 unordered 系列
C++11 增加:
unordered_map;unordered_set;unordered_multimap;unordered_multiset。
它们通常基于哈希表,查找、插入和删除平均为 O(1),最坏可能退化为 O(n),遍历顺序没有排序保证。
十、= default 与 = delete
10.1 特殊成员函数
C++11 重点关注六类特殊成员函数:
- 默认构造函数;
- 析构函数;
- 拷贝构造函数;
- 拷贝赋值运算符;
- 移动构造函数;
- 移动赋值运算符。
讲义中把取地址运算符也列入编译器默认生成成员的说法不够严谨。讨论现代 C++ 的特殊成员函数时,通常指上面六类。
10.2 显式要求默认实现
1 | class Number |
即使定义了带参数构造函数,也可以通过 = default 明确要求默认构造函数。
类外定义:
1 | class Data |
10.3 删除函数
1 | class NonCopyable |
调用被删除函数会在编译期报错。
= delete 不只用于特殊成员函数,还能禁止不希望发生的隐式转换:
1 | void print(int value); |
10.4 隐式移动生成规则
编译器并不是永远自动生成移动操作。典型情况下,只有类没有用户声明以下成员时,才会隐式声明移动构造或移动赋值:
- 拷贝构造;
- 拷贝赋值;
- 移动构造;
- 移动赋值;
- 析构函数。
具体删除与可用性还取决于各个成员和基类是否可移动。
10.5 成员级移动,不是一概“浅拷贝”
编译器生成的移动操作会对基类和数据成员逐个执行移动。
std::string、std::vector、unique_ptr等成员会执行自己的移动;- 裸指针成员的移动只是复制指针值,不会自动把源指针置空;
- 因此裸资源所有权类不能依赖默认移动来避免重复释放。
“默认移动构造就是浅拷贝”过于笼统,正确说法是成员级移动。
10.6 Rule of Zero 与 Rule of Five
优先遵循 Rule of Zero:
让标准容器、字符串和智能指针管理资源,使业务类不必手写析构、拷贝和移动操作。
如果类必须直接管理资源,通常需要整体考虑五个函数:
- 析构;
- 拷贝构造;
- 拷贝赋值;
- 移动构造;
- 移动赋值。
这就是 Rule of Five。
十一、左值、右值与值类别
11.1 不能只靠等号位置判断
“能放在等号左边就是左值”只是初步直觉,并不严格。
1 | const int value = 10; |
value 不能被赋值,但表达式 value 仍然是左值。
更可靠的理解:
- 左值通常表示具有身份、可定位的对象;
- 右值通常表示临时结果或即将被复用资源的对象。
11.2 C++11 的值类别
简化关系:
1 | expression |
lvalue:左值,具有身份,通常可以取地址;prvalue:纯右值,例如字面量和多数按值计算结果;xvalue:将亡值,表示资源可以被复用;glvalue:泛左值,包含左值和将亡值;rvalue:右值,包含纯右值和将亡值。
11.3 常见示例
1 | int value = 10; |
返回左值引用的函数调用是左值:
1 | int global = 0; |
十二、引用绑定规则
12.1 左值引用
1 | int value = 10; |
12.2 const 左值引用
1 | const int& first = value; |
const T& 可以绑定左值和右值。绑定临时对象时,可以延长该临时对象的生命周期。
12.3 右值引用
1 | int&& first = 10; |
右值引用能够区分“可以安全复用资源的对象”,这是移动语义的基础。
12.4 有名字的右值引用变量是左值表达式
1 | void use(int&); |
变量的声明类型是 int&&,但只要通过名字 reference 使用,它就是左值表达式。
这是理解移动构造和完美转发的关键。
十三、移动语义
13.1 为什么需要移动
一个对象拥有堆内存、文件句柄或其他资源时,深拷贝通常需要:
- 重新申请资源;
- 复制全部内容;
- 保留源对象资源;
- 最终分别释放。
如果源对象马上就要销毁,复制资源没有必要。移动语义允许目标对象接管源对象资源。
13.2 移动构造函数
1 | class Buffer |
移动构造把指针和大小交给新对象,再让源对象进入可安全析构的状态。
13.3 为什么移动参数通常不能是 const T&&
移动往往需要修改源对象:
1 | other.data_ = nullptr; |
如果参数是 const Buffer&&,就不能修改 other,通常只能退回复制。因此移动构造一般写成:
1 | Buffer(Buffer&& other) noexcept; |
13.4 移动赋值
1 | Buffer& operator=(Buffer&& other) noexcept |
移动赋值不仅接管新资源,还要先正确处理目标对象原有资源。
13.5 noexcept 为什么重要
std::vector 扩容时需要迁移元素。为了在异常发生时保持旧容器有效,它通常更愿意使用不会抛异常的移动构造。
因此资源类的移动构造和移动赋值在确实不会抛出异常时应标记 noexcept:
1 | Buffer(Buffer&&) noexcept; |
否则容器可能选择拷贝而不是移动。
13.6 拷贝省略
1 | Buffer make_buffer() |
即使写了移动构造,也不代表运行时一定能观察到移动。编译器可能执行返回值优化,直接在 result 的存储位置构造对象。
- C++11 允许多种拷贝省略;
- C++17 对部分场景规定了保证的拷贝省略;
- 学习移动语义时不能把构造函数打印次数当成唯一标准。
GCC 可用 -fno-elide-constructors 辅助观察某些非强制省略,但这只是教学手段。
十四、std::move
14.1 std::move 不会主动移动
std::move 本质上进行类型转换,把表达式转换成适合绑定右值引用的将亡值:
1 | std::string source = "hello"; |
真正是否移动,由随后被调用的构造函数或赋值运算符决定。
如果目标类型没有移动操作,仍然可能调用拷贝:
1 | const std::string text = "hello"; |
因为 std::move(text) 是 const std::string&&,普通移动构造 string(string&&) 不能修改它。
14.2 被移动对象不是“失效对象”
标准库对象被移动后通常处于:
有效但状态未指定。
可以对它:
- 析构;
- 重新赋值;
- 调用没有额外前置条件的操作。
不能假定它一定为空:
1 | std::string source = "hello"; |
14.3 不要移动后继续依赖旧值
1 | send(std::move(message)); |
只有在不再需要原值时才使用 std::move。
14.4 返回局部变量时通常不要写 move
1 | std::string make_text() |
不要机械写成:
1 | return std::move(result); |
显式 std::move 可能阻碍命名返回值优化。直接返回局部对象,编译器可以执行 NRVO;未省略时,语言规则也可能选择移动。
十五、完美转发
15.1 普通转发会丢失值类别
1 | void process(int&) |
无论传入什么,函数体中的具名变量 value 都是左值表达式,所以 process(value) 总选择左值重载。
15.2 转发引用
当 T&& 中的 T 由函数调用推导时,它是转发引用:
1 | template <class T> |
它既能接收左值,也能接收右值。
不是所有 && 都是转发引用:
1 | void function(std::string&& value); // 普通右值引用 |
15.3 引用折叠
引用折叠规则:
1 | T& & -> T& |
只要组合中出现左值引用,结果通常就是左值引用;只有两个右值引用组合才得到右值引用。
15.4 std::forward
1 | template <class T> |
std::forward<T> 根据 T 的推导结果恢复原实参的值类别:
- 原实参是左值,继续转发为左值;
- 原实参是右值,继续转发为右值。
完整示例:
1 |
|
15.5 move 与 forward 的区别
std::move(x):无条件把x转成将亡值;std::forward<T>(x):有条件地恢复调用者传入时的值类别。
普通代码表达“我不再需要这个对象的旧值”时使用 move;泛型转发函数中使用 forward。
十六、可变参数模板
16.1 参数包
C++11 允许模板接收任意数量的模板参数:
1 | template <class... Args> |
Args... 是模板参数包,args... 是函数参数包。
16.2 C++11 递归展开
1 |
|
sizeof...(rest) 获取参数包中的元素数量。
折叠表达式是 C++17 功能,不属于 C++11。
16.3 与完美转发结合
1 | template <class Type, class... Args> |
这展示了标准库中 emplace、make_shared 等接口的核心思想:接收任意构造参数,并保持每个参数的值类别。
十七、Lambda 表达式
17.1 为什么需要 Lambda
传统自定义比较器:
1 | struct Goods |
如果比较逻辑只使用一次,单独定义仿函数类显得分散。Lambda 可以把行为写在使用位置。
17.2 基本语法
1 | [capture](parameters) mutable -> return_type |
示例:
1 | std::sort(goods.begin(), |
排序比较器必须形成严格弱序,不能写成 left.price <= right.price。当两个价格相等时,<= 会让 comp(a, a) 为真,违反算法要求,行为未定义。
17.3 捕获方式
| 捕获 | 含义 |
|---|---|
[] |
不捕获外部局部变量 |
[value] |
按值捕获 value |
[&value] |
按引用捕获 value |
[=] |
默认按值捕获使用到的局部变量 |
[&] |
默认按引用捕获使用到的局部变量 |
[=, &result] |
其他按值,result 按引用 |
[&, limit] |
其他按引用,limit 按值 |
[this] |
捕获当前对象的 this 指针 |
17.4 值捕获发生在闭包创建时
1 | int value = 10; |
Lambda 创建时保存了 value 的副本。
17.5 引用捕获
1 | int value = 10; |
引用捕获要求原对象在 Lambda 被调用时仍然存活。把引用捕获的 Lambda 返回到外部、交给异步线程或长期保存时尤其危险。
17.6 mutable
默认情况下,按值捕获的成员不能在 Lambda 调用运算符中修改:
1 | int value = 10; |
修改的是闭包内部副本,不是外部 value。
在 C++11 中使用 mutable 时不能省略参数列表的圆括号。
17.7 返回类型
简单情况下编译器可以推导:
1 | auto add = [](int left, int right) |
需要明确时使用尾置返回类型:
1 | auto divide = [](double left, double right) -> double |
17.8 闭包类型
每个 Lambda 表达式都会产生一个唯一的、编译器生成的闭包类型,内部效果类似重载了 operator() 的函数对象。
即使两个 Lambda 代码完全相同,它们的类型也不同:
1 | auto first = [] { return 1; }; |
无捕获 Lambda 可以转换为兼容的函数指针:
1 | int (*function_pointer)(int) = |
有捕获 Lambda 通常不能转换为普通函数指针。
17.9 this 捕获
C++11 中 [this] 捕获的是指针,不是把整个对象复制进闭包:
1 | class Counter |
如果闭包比对象活得更久,this 会悬空。异步任务中必须特别注意对象生命周期。
十八、std::function 与 std::bind
18.1 function
std::function 可以保存具有兼容调用签名的可调用对象:
1 |
|
它可以包装:
- 普通函数;
- 函数指针;
- 仿函数;
- Lambda;
- 绑定后的成员函数。
代价是可能存在类型擦除、间接调用和动态分配开销。性能敏感的泛型代码常直接使用模板参数接收可调用对象。
18.2 bind
std::bind 可以绑定参数或调整调用接口:
1 |
|
现代代码中,Lambda 往往比 bind 更直观:
1 | auto add_ten = [](int value) |
十九、线程库
19.1 创建线程
1 |
|
构造带可调用对象的 std::thread 后,新线程立即开始执行。
线程入口可以是:
- 普通函数;
- Lambda;
- 仿函数;
- 静态成员函数;
- 非静态成员函数。
19.2 成员函数作为线程入口
1 | class Worker |
非静态成员函数需要同时提供对象指针或对象引用。
19.3 thread 不可复制但可以移动
1 | std::thread first(task, 1); |
线程所有权转移给 second。此后:
first.joinable()为假;second.joinable()为真;- 线程本身并不会因为包装对象移动而重新启动。
19.4 joinable 的真正含义
joinable() 表示 std::thread 对象当前是否关联一个尚未被 join 或 detach 的执行线程。
它不表示线程函数“此刻还在运行”。即使线程函数已经返回,只要尚未 join,对象仍可能是 joinable。
以下对象不可连接:
- 默认构造的
thread; - 已经被移动走线程所有权的对象;
- 已调用
join的对象; - 已调用
detach的对象。
19.5 join
1 | std::thread worker(task, 1); |
join() 等待目标线程结束,并完成相关线程句柄的同步与回收。
同一个线程对象不能重复 join。对不可连接对象调用 join 会抛出 std::system_error。
19.6 析构 joinable 线程会 terminate
1 | void function() |
如果 std::thread 析构时仍然 joinable,程序会调用 std::terminate,而不只是发生普通资源泄漏。
因此每条控制路径都必须明确处理线程。
19.7 RAII 线程守卫
1 | class ThreadGuard |
线程对象必须比守卫活得久。
C++20 的 std::jthread 会在析构时自动请求停止并连接,但它不属于 C++11。
19.8 detach
1 | std::thread worker(task, 1); |
分离后不能再通过原 thread 对象等待或控制该线程。
detach 最大风险不是线程资源无法回收,而是生命周期:
- 引用的局部变量可能已经销毁;
this指向的对象可能已经析构;- 主程序可能先结束;
- 错误和完成状态难以传回。
除非后台任务的所有依赖生命周期都得到严格保证,否则优先使用 join 或更高层异步抽象。
二十、线程参数传递
20.1 默认进行 decay-copy
std::thread 会把函数和参数按衰减后的类型保存起来,数组和函数会发生退化,普通实参通常被复制或移动到线程内部。
如果线程函数按值接收:
1 | void increment_copy(int value) |
20.2 引用参数需要 std::ref
1 |
|
如果函数要求 int&,直接写 std::thread(increment, number) 通常无法通过编译,而不是“在线程中引用了一个副本”。
只读引用可以使用 std::cref:
1 | std::thread thread(read_data, std::cref(data)); |
20.3 引用对象必须存活
std::ref 不复制目标对象:
1 | std::thread create_dangerous_thread() |
函数返回后 local 已销毁,线程继续访问会产生悬空引用。参数传递方式正确不等于生命周期一定正确。
二十一、数据竞争与原子操作
21.1 数据竞争
两个线程并发访问同一个内存位置,至少一个是写操作,并且没有适当同步,就可能发生数据竞争。
数据竞争在 C++ 中属于未定义行为:
1 | long long counter = 0; |
++counter 并不因为是一行源代码就自动具有原子性。
21.2 atomic
1 |
|
对同一个原子对象的原子操作不会被其他线程观察到“执行了一半”的状态,并且还参与 C++ 内存模型中的同步与排序。
21.3 atomic 不等于内部加互斥锁
原子类型的实现可能使用硬件原子指令,也可能在某些类型和平台上使用其他机制。
可以查询:
1 | std::cout << std::boolalpha |
不能假设所有 atomic<T> 都无锁。
21.4 atomic 的复制限制
原子对象不能通过另一个原子对象进行普通拷贝构造或拷贝赋值:
1 | std::atomic<int> first{10}; |
但可以从普通值存储:
1 | second.store(first.load()); |
所以不能笼统地说原子类型“没有 operator=”;从普通 T 赋值是支持的。
21.5 内存序
1 | counter.fetch_add(1, std::memory_order_relaxed); |
memory_order_relaxed 只保证该原子对象操作本身的原子性,不建立其他普通内存访问的同步顺序。
默认的 memory_order_seq_cst 提供最强、最直观的顺序一致性。
初学阶段:
- 先使用默认内存序确保正确;
- 只有经过严格证明和性能测量后,才使用更弱内存序;
- 原子内存序错误通常非常隐蔽。
21.6 atomic 适合与不适合的场景
适合:
- 简单计数器;
- 标志位;
- 指针状态;
- 单个值上的无锁状态转换。
不适合直接替代锁:
- 多个变量需要保持联合不变量;
- 一段复杂代码必须整体互斥;
- 容器结构需要复合修改;
- 条件等待和资源协调。
二十二、mutex 与 RAII 锁
22.1 mutex
1 |
|
手工调用的风险:
- 中途
return; - 抛出异常;
- 忘记
unlock; - 多把锁顺序不一致。
22.2 lock_guard
1 | void update() |
构造 lock_guard 时加锁,离开作用域时析构并解锁。
它:
- 不可复制;
- 接口简单;
- 适合整个作用域都持锁的场景;
- 不支持手工提前解锁。
22.3 unique_lock
1 | void update_conditionally(bool should_update) |
unique_lock:
- 可以延迟加锁;
- 可以手工
lock和unlock; - 可以移动;
- 能查询
owns_lock(); - 可以与条件变量协作;
- 比
lock_guard状态更丰富,也可能略有额外开销。
22.4 常见锁标签
1 | std::lock_guard<std::mutex> first(mutex, |
adopt_lock:互斥量已经由当前线程持有,守卫接管;defer_lock:构造时暂不加锁;try_to_lock:构造时尝试加锁,不阻塞等待。
误用 adopt_lock 会造成未定义行为。
22.5 四种互斥量
| 类型 | 特点 |
|---|---|
std::mutex |
基本非递归互斥量 |
std::recursive_mutex |
同一线程可以重复加锁,需对应次数解锁 |
std::timed_mutex |
支持 try_lock_for、try_lock_until |
std::recursive_timed_mutex |
递归和超时能力组合 |
对已经由当前线程拥有的普通 std::mutex 再调用 lock 或 try_lock,标准不保证只是“稳定地死锁”,其行为可能是未定义的。
应优先调整设计,而不是轻易用 recursive_mutex 掩盖递归锁依赖。
22.6 多把锁与死锁
线程 A:
1 | 先锁 mutex1,再锁 mutex2 |
线程 B:
1 | 先锁 mutex2,再锁 mutex1 |
可能形成循环等待。
C++11 可使用 std::lock:
1 | std::lock(first_mutex, second_mutex); |
C++17 的 std::scoped_lock 更方便,但不属于 C++11。
22.7 缩小临界区
1 | Data local; |
锁只保护共享状态,不要在持锁期间执行不必要的 I/O、休眠或昂贵计算。
二十三、条件变量
23.1 为什么不能忙等
1 | while (queue.empty()) |
条件变量允许线程在条件不满足时睡眠,并在状态变化后被唤醒。
23.2 wait
1 |
|
wait 会:
- 原子地释放互斥锁并进入等待;
- 被通知后重新竞争互斥锁;
- 获得锁后检查谓词;
- 谓词为假则继续等待。
23.3 虚假唤醒
线程可能在没有目标事件的情况下被唤醒,因此等待必须检查条件。
推荐:
1 | condition.wait(lock, predicate); |
它等价于在循环中反复检查,而不是只用一次 if。
23.4 通知
1 | void add_task(int task) |
常用接口:
notify_one():唤醒一个等待线程;notify_all():唤醒所有等待线程。
通常先在锁内修改共享状态,再在合适位置通知。
二十四、async、future 与 promise
24.1 async
1 |
|
future.get() 等待结果并取出返回值;通常只能调用一次。
明确指定 std::launch::async 可以要求异步执行。使用默认策略时,实现也可能选择延迟到 get 或 wait 时执行。
24.2 promise
1 | std::promise<int> promise; |
promise 负责设置值或异常,future 负责接收。
二十五、其他重要 C++11 特性
25.1 constexpr
1 | constexpr int square(int value) |
C++11 对 constexpr 函数体限制较严格,C++14 才进一步放宽。
25.2 static_assert
1 | static_assert(sizeof(void*) >= 4, |
条件在编译期检查,失败时直接报错。
25.3 类型别名
1 | using StringMap = |
using 还能定义别名模板,这是 typedef 难以表达的:
1 | template <class T> |
25.4 委托构造
1 | class User |
一个构造函数可以委托同类的另一个构造函数完成公共初始化。
25.5 enum class
1 | enum class Color |
强类型枚举避免枚举项泄漏到外层作用域,也不会随意隐式转换成整数。
二十六、综合示例
下面的程序综合演示:
- 列表初始化;
auto;- 范围
for; override;- 智能指针;
- 移动语义;
- Lambda 排序;
std::thread;std::atomic。
1 |
|
编译:
1 | g++ -std=c++11 -Wall -Wextra -Wpedantic main.cpp -pthread -o main |
期望总和:
1 | 1 + 2 + ... + 100000 = 5000050000 |
二十七、常见错误
27.1 把花括号和圆括号构造当成完全相同
1 | std::vector<int> a(5, 10); |
前者有 5 个 10,后者只有元素 5 和 10。
27.2 认为 auto 一定保留 const 和引用
普通 auto 通常会丢弃顶层 const 和引用。需要原对象时使用 auto& 或 const auto&。
27.3 混淆 decltype(x) 与 decltype((x))
前者读取变量的声明类型,后者按左值表达式规则通常得到引用。
27.4 范围 for 中无意复制
1 | for (auto item : large_objects) |
会复制每个对象。只读时通常改成:
1 | for (const auto& item : large_objects) |
27.5 排序比较器使用 <=
std::sort 比较器必须满足严格弱序。应使用 < 或 >,不能用 <= 或 >=。
27.6 认为编译器默认移动一定安全
默认移动执行成员级移动。裸拥有指针仍然只是复制地址,可能造成重复释放。优先使用标准资源管理类型。
27.7 认为 std::move 会立即搬走资源
std::move 只是转换值类别,随后是否移动取决于重载解析和目标类型。
27.8 把移动后的对象称为“无效”
标准库对象通常仍然有效,但状态未指定。可以析构和重新赋值,不能依赖原值或假设一定为空。
27.9 移动构造写成 const T&&
移动通常需要修改源对象,const 会阻止资源转移,使调用退回拷贝或无法编译。
27.10 返回局部对象时手工 move
return std::move(local); 可能阻碍 NRVO。通常直接 return local;。
27.11 把具名右值引用当成右值表达式
有名字的变量都是左值表达式。需要继续移动时显式使用 std::move,泛型转发时使用 std::forward。
27.12 引用捕获局部变量后异步执行
局部变量可能已经销毁,Lambda 内部引用悬空。必须保证生命周期或改用安全的所有权对象。
27.13 认为 joinable 表示线程正在运行
joinable 表示线程对象仍关联一个未连接、未分离的线程。线程函数即使已经结束,也可能仍然 joinable。
27.14 thread 析构前漏掉 join 或 detach
析构一个仍 joinable 的 std::thread 会调用 std::terminate。
27.15 直接把变量传给引用线程函数
线程参数默认保存衰减后的值。真正按引用传递要使用 std::ref,并保证原对象生命周期足够长。
27.16 认为一行 ++ 就是线程安全
普通变量上的 ++ 不是并发原子操作。无同步并发写入会形成数据竞争和未定义行为。
27.17 手工 lock 后提前返回
容易漏掉 unlock。优先使用 lock_guard 或 unique_lock 管理锁生命周期。
27.18 用 atomic 保护多个变量的联合关系
多个原子变量分别安全,不代表跨变量不变量自动安全。复杂临界区仍需互斥锁或经过证明的无锁算法。
二十八、常见面试问题
28.1 C++11 为什么重要
它引入移动语义、智能指针、Lambda、标准线程和更强的模板能力,建立了现代 C++ 的资源管理和零开销抽象基础。
28.2 列表初始化有什么优势
语法统一、支持容器和自定义类型、能执行值初始化,并拒绝许多窄化转换。但 initializer_list 优先匹配可能让花括号与圆括号结果不同。
28.3 auto 和 decltype 有什么区别
auto 根据初始化器推导变量类型,常按模板推导规则丢弃顶层 const 和引用;decltype 查询表达式精确类型,通常不执行表达式。
28.4 override 有什么作用
让编译器确认派生类函数确实重写了基类虚函数,避免参数、const 或引用限定不一致造成的隐藏错误。
28.5 unique_ptr、shared_ptr 和 weak_ptr 的区别
unique_ptr:独占所有权,不可复制;shared_ptr:共享所有权,使用强引用计数;weak_ptr:非拥有观察,不增加强引用计数,可用于打破循环引用。
28.6 什么是左值和右值
左值通常表示具有身份的对象;右值包含纯右值和将亡值,通常表示临时结果或资源可被复用的对象。
28.7 std::move 做了什么
它把表达式转换成将亡值,从而使移动重载有机会被选择;它本身不搬资源。
28.8 移动后的对象还能用吗
通常有效但状态未指定。可以析构、赋新值及执行满足当前状态前置条件的操作,但不能依赖移动前的值。
28.9 为什么移动构造常标记 noexcept
容器扩容需要异常安全保证。移动不会抛异常时,容器更愿意使用移动;否则可能选择复制。
28.10 什么是转发引用
函数模板中由调用推导的 T&&。它能同时接收左值和右值,并配合 std::forward<T> 保留实参值类别。
28.11 std::move 与 std::forward 有什么区别
move 无条件转换为将亡值;forward 按模板推导结果有条件地恢复左值或右值属性。
28.12 Lambda 的本质是什么
编译器生成的匿名闭包类对象,闭包类实现 operator(),捕获变量成为闭包状态。
28.13 值捕获和引用捕获有什么区别
值捕获在闭包创建时保存副本;引用捕获保存对外部对象的引用,能修改原对象,但必须保证生命周期。
28.14 std::thread 为什么不能复制
线程对象代表独占的线程所有权,复制会造成谁负责 join 或 detach 的歧义,因此只能移动。
28.15 join 和 detach 有什么区别
join:等待线程结束并完成同步;detach:分离线程,之后不能通过原对象等待或控制,必须自行保证依赖对象生命周期。
28.16 什么是数据竞争
多个线程无同步地访问同一内存位置,至少一个访问是写操作。数据竞争导致未定义行为。
28.17 atomic 和 mutex 如何选择
单个简单状态更新适合原子;需要保护多个变量、容器或复合不变量时通常使用互斥锁。
28.18 lock_guard 和 unique_lock 有什么区别
lock_guard 简单、固定持锁范围;unique_lock 支持延迟加锁、提前解锁、移动和条件变量,功能更灵活。
28.19 条件变量为什么要使用谓词
因为存在虚假唤醒,也可能在获得锁前状态再次变化。谓词确保线程只在真实条件满足时继续。
28.20 并发与并行有什么区别
- 并发:多个任务在同一时间段内交替或重叠推进;
- 并行:多个任务在同一时刻真正同时执行。
单核可以通过调度实现并发,多核更容易实现并行。
二十九、总结
C++11 的核心不是记住一组新关键字,而是建立现代 C++ 的设计方式:
- 使用列表初始化统一对象创建,并利用窄化检查;
- 使用
nullptr表达空指针; - 使用
auto简化明确的复杂类型; - 使用
decltype获取表达式精确类型; - 使用范围
for,同时区分值、引用和const引用; - 使用
override和final明确继承意图; - 使用智能指针和标准容器管理资源;
- 优先遵循 Rule of Zero;
- 使用
= default和= delete控制函数生成; - 理解左值、纯右值和将亡值;
- 使用移动语义减少无意义的深拷贝;
- 理解
std::move只是转换; - 使用转发引用和
std::forward实现完美转发; - 使用 Lambda 在行为发生的位置表达小型逻辑;
- 使用
std::thread获得跨平台线程抽象; - 确保每个线程在析构前已经连接或分离;
- 数据竞争属于未定义行为;
- 使用原子操作保护简单共享状态;
- 使用 RAII 锁保护复合临界区;
- 使用条件变量代替忙等;
- 始终同时考虑资源所有权、对象生命周期和异常安全。
真正掌握 C++11 后,代码会从“手动管理一切”转向“用类型表达所有权、用语言规则保证安全、只为必要成本付费”。这也是继续学习 C++14、C++17、C++20 以及现代 C++ 工程实践的基础。