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

前言

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. 提升资源管理安全性;
  3. 减少不必要的对象复制;
  4. 增强泛型编程能力;
  5. 提供跨平台并发标准;
  6. 在不牺牲性能的前提下提高表达能力。

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
2
3
4
5
6
7
8
9
10
11
12
int a{10};
int b = {20};
double price{19.9};

int array1[5]{1, 2, 3, 4, 5};
int array2[5]{}; // 全部初始化为 0

std::vector<int> numbers{1, 2, 3, 4, 5};
std::map<std::string, int> scores{
{"Alice", 90},
{"Bob", 85}
};

有无等号通常不改变这里的初始化目标,但会涉及直接列表初始化与拷贝列表初始化的规则差异。

2.2 值初始化

空花括号可以把基础类型初始化为零值:

1
2
3
4
int number{};          // 0
double ratio{}; // 0.0
int* pointer{}; // 空指针
bool finished{}; // false

相比未初始化的局部变量,{} 能显著减少不确定值问题。

2.3 防止窄化转换

列表初始化会拒绝许多可能丢失信息的窄化转换:

1
2
3
4
5
int a = 3.14;   // 可以编译,但会丢失小数部分
// int b{3.14}; // 编译错误:窄化转换

double value = 1000.0;
// float f{value}; // 普通变量转换可能丢失精度

这使花括号初始化比传统赋值式初始化更严格。

但“列表初始化能够拒绝所有危险转换”也不准确。仍然要理解目标类型的范围和构造函数行为。

2.4 自定义类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point
{
public:
Point(int x, int y)
: x_(x), y_(y)
{
}

private:
int x_;
int y_;
};

Point point{10, 20};

编译器会根据花括号中的实参寻找合适的构造函数。

2.5 initializer_list

std::initializer_list<T> 用于接收数量可变、类型相同的列表元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <initializer_list>
#include <vector>

class IntCollection
{
public:
IntCollection(std::initializer_list<int> values)
: data_(values)
{
}

private:
std::vector<int> data_;
};

IntCollection collection{1, 2, 3, 4};

initializer_list 提供:

  • begin();
  • end();
  • size()。

列表中的元素按 const 对象访问,不能直接修改原列表元素。

2.6 initializer_list 的优先匹配

如果一个类型同时拥有普通构造函数和 initializer_list 构造函数,花括号初始化会优先尝试 initializer_list:

1
2
std::vector<int> a(5, 10); // 5 个元素,每个值为 10
std::vector<int> b{5, 10}; // 2 个元素:5 和 10

二者结果完全不同。

因此,不能机械地把所有圆括号都替换成花括号。选择初始化形式时必须理解目标类型的构造函数集合。

2.7 避免最令人困惑的解析

1
2
Widget object(); // 声明函数,不是创建对象
Widget object2{}; // 创建对象

空花括号能够明确表达对象初始化,避免 most vexing parse。

三、nullptr

3.1 0 和 NULL 的问题

传统代码常用 0 或 NULL 表示空指针:

1
2
3
4
void process(int);
void process(void*);

process(0); // 调用 process(int)

许多实现中的 NULL 只是整数 0 的宏,也会产生重载歧义或调用错误重载。

3.2 nullptr 是真正的空指针字面量

1
process(nullptr); // 调用 process(void*)

nullptr 的类型是 std::nullptr_t,可以转换为任意指针类型,但不会被当作普通整数使用。

现代 C++ 中应优先使用 nullptr。

四、auto 类型推导

4.1 基本用法

auto 根据初始化表达式推导变量类型:

1
2
3
4
auto number = 10;        // int
auto price = 19.9; // double
auto text = "hello"; // const char*
auto iterator = map.begin();

它特别适合:

  • 迭代器类型;
  • 模板返回的复杂类型;
  • Lambda 闭包类型;
  • 类型已经能从右侧清楚看出的表达式。

4.2 auto 必须有初始化器

1
// auto value; // 错误:无法推导类型

auto 是静态类型推导,编译器在编译期确定具体类型,不是运行期动态类型。

4.3 auto 会丢弃顶层 const 和引用

1
2
3
4
5
const int value = 10;
const int& reference = value;

auto a = value; // int
auto b = reference; // int

如果要保留引用:

1
2
auto& c = value;       // const int&
const auto& d = value; // const int&

如果希望修改原对象:

1
2
3
int number = 10;
auto& alias = number;
alias = 20;

4.4 auto 与数组

1
2
3
4
int values[3]{1, 2, 3};

auto pointer = values; // int*,数组发生退化
auto& array_ref = values; // int (&)[3]

是否使用引用会直接影响推导结果。

4.5 auto 不是“越多越好”

不适合滥用的场景:

1
auto result = calculate(); // 如果函数名不能说明类型和语义,阅读成本可能更高

使用原则:

  • 右侧能够清楚表达类型时,auto 很合适;
  • 类型本身携带重要业务语义时,显式写出类型更清晰;
  • 不要用 auto 掩盖意外的整数、代理对象或窄类型。

五、decltype

5.1 基本作用

decltype(expression) 在编译期查询表达式的类型,并且通常不会执行表达式:

1
2
3
4
int a = 10;
double b = 2.5;

decltype(a + b) result = 0; // double

decltype 属于不求值语境:

1
2
3
int function();

decltype(function()) value = 0; // 推导返回类型,不调用 function

5.2 decltype 的核心规则

对于未加括号的变量名或成员访问,decltype 得到声明类型:

1
2
const int value = 10;
decltype(value) a = 20; // const int

对于其他表达式,根据值类别推导:

  • 左值表达式得到 T&;
  • 将亡值表达式得到 T&&;
  • 纯右值表达式得到 T。

经典区别:

1
2
3
4
int number = 10;

decltype(number) a = 20; // int
decltype((number)) b = number; // int&

多一层括号可能改变结果,因为 (number) 被按普通左值表达式处理。

5.3 尾置返回类型

C++11 中,函数返回类型依赖参数表达式时,可以使用尾置返回类型:

1
2
3
4
5
6
template <class Left, class Right>
auto add(const Left& left, const Right& right)
-> decltype(left + right)
{
return left + right;
}

注意:

  • C++11 的普通函数不能仅写 auto add(...) { ... } 自动推导返回类型;
  • 普通函数返回类型推导是 C++14 扩展;
  • decltype(auto) 也是 C++14 功能。

5.4 auto 与 decltype 对比

对比项 auto decltype
主要用途 定义变量并推导类型 查询表达式的精确类型
是否需要初始化 通常需要 不一定
是否执行表达式 初始化表达式会执行 通常不执行
顶层 const 按规则可能丢弃 可精确保留
引用属性 普通 auto 通常丢弃 由表达式规则决定

六、范围 for 循环

6.1 基本用法

1
2
3
4
5
6
std::vector<int> numbers{1, 2, 3, 4, 5};

for (int value : numbers)
{
std::cout << value << '\n';
}

范围 for 会遍历序列中的每个元素。

6.2 值、引用与 const 引用

按值遍历会复制元素:

1
2
3
4
for (auto value : numbers)
{
value *= 2; // 不修改容器
}

按引用遍历可以修改:

1
2
3
4
for (auto& value : numbers)
{
value *= 2;
}

只读遍历大型对象时使用:

1
2
3
4
for (const auto& item : items)
{
std::cout << item.name() << '\n';
}

这能避免复制并阻止修改。

6.3 底层思想

范围 for 大致会借助 begin 和 end:

1
2
3
4
5
6
for (auto iterator = numbers.begin();
iterator != numbers.end();
++iterator)
{
auto value = *iterator;
}

它支持:

  • 原生数组;
  • 标准容器;
  • 能够找到合适 begin 和 end 的自定义类型。

6.4 遍历中修改容器结构

如果在范围 for 中执行 push_back、erase 等操作,可能导致迭代器失效:

1
2
3
4
5
// 危险:push_back 可能扩容,使范围 for 内部迭代器失效
for (int value : numbers)
{
numbers.push_back(value);
}

修改元素值和修改容器结构是两件不同的事情。

七、override 与 final

7.1 override

override 明确表示派生类函数要重写基类虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base
{
public:
virtual void print() const
{
}

virtual ~Base() = default;
};

class Derived : public Base
{
public:
void print() const override
{
}
};

如果签名不匹配,编译器会报错:

1
2
3
4
5
class Wrong : public Base
{
public:
// void print() override; // 缺少 const,不能重写
};

override 不改变虚函数机制,但能让编译器验证程序员的意图。

7.2 final 修饰虚函数

1
2
3
4
5
6
7
class Derived : public Base
{
public:
void print() const final
{
}
};

后续派生类不能再次重写 print。

7.3 final 修饰类

1
2
3
4
5
class Utility final
{
};

// class Child : public Utility {}; // 错误

被 final 修饰的类不能继续被继承。

八、智能指针

8.1 为什么需要智能指针

裸指针管理动态资源时容易出现:

  • 忘记释放;
  • 异常路径泄漏;
  • 多次释放;
  • 所有权不清晰;
  • 提前返回时漏掉清理。

智能指针利用 RAII,让资源释放与对象生命周期绑定。

8.2 unique_ptr

std::unique_ptr 表达独占所有权:

1
2
3
#include <memory>

std::unique_ptr<int> pointer(new int(10));

不能复制:

1
// auto copy = pointer; // 错误

可以移动:

1
auto other = std::move(pointer);

移动后 other 拥有资源,原 pointer 为空。

数组形式:

1
2
std::unique_ptr<int[]> array(new int[100]{});
array[0] = 10;

std::make_unique 是 C++14 才加入的,不属于 C++11。

8.3 shared_ptr

std::shared_ptr 通过共享引用计数表达共同所有权:

1
2
3
4
auto first = std::make_shared<std::string>("hello");
auto second = first;

std::cout << first.use_count() << '\n'; // 通常为 2

最后一个拥有者销毁时,资源才会释放。

不要把 use_count() 当作同步协议或业务判断依据,多线程环境下计数在观察后可能立即变化。

8.4 weak_ptr

std::weak_ptr 观察 shared_ptr 管理的对象,但不增加强引用计数:

1
2
3
4
5
6
std::weak_ptr<std::string> observer = first;

if (auto locked = observer.lock())
{
std::cout << *locked << '\n';
}

lock() 成功时返回一个临时 shared_ptr,保证使用期间对象仍然存在。

8.5 循环引用

两个对象互相持有 shared_ptr 会导致引用计数无法归零:

1
2
Parent --shared_ptr--> Child
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
2
3
4
5
6
#include <array>

std::array<int, 4> values{{1, 2, 3, 4}};

std::cout << values.size() << '\n';
std::cout << values.at(2) << '\n';

特点:

  • 大小属于类型的一部分;
  • 支持迭代器和标准算法;
  • at 提供边界检查;
  • 不会像原生数组那样在大多数表达式中自动退化为指针;
  • 容量固定,不能 push_back。

9.2 forward_list

std::forward_list 是单链表:

1
2
3
4
#include <forward_list>

std::forward_list<int> list{1, 2, 3};
list.push_front(0);

它只支持向前迭代,主要使用 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 重点关注六类特殊成员函数:

  1. 默认构造函数;
  2. 析构函数;
  3. 拷贝构造函数;
  4. 拷贝赋值运算符;
  5. 移动构造函数;
  6. 移动赋值运算符。

讲义中把取地址运算符也列入编译器默认生成成员的说法不够严谨。讨论现代 C++ 的特殊成员函数时,通常指上面六类。

10.2 显式要求默认实现

1
2
3
4
5
6
7
8
9
10
11
12
13
class Number
{
public:
Number() = default;

explicit Number(int value)
: value_(value)
{
}

private:
int value_{};
};

即使定义了带参数构造函数,也可以通过 = default 明确要求默认构造函数。

类外定义:

1
2
3
4
5
6
7
class Data
{
public:
Data& operator=(const Data&);
};

Data& Data::operator=(const Data&) = default;

10.3 删除函数

1
2
3
4
5
6
7
class NonCopyable
{
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};

调用被删除函数会在编译期报错。

= delete 不只用于特殊成员函数,还能禁止不希望发生的隐式转换:

1
2
3
4
5
void print(int value);
void print(double) = delete;

print(10);
// print(3.14); // 编译错误

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
2
3
4
5
6
7
expression
├── glvalue
│ ├── lvalue
│ └── xvalue
└── rvalue
├── prvalue
└── xvalue
  • lvalue:左值,具有身份,通常可以取地址;
  • prvalue:纯右值,例如字面量和多数按值计算结果;
  • xvalue:将亡值,表示资源可以被复用;
  • glvalue:泛左值,包含左值和将亡值;
  • rvalue:右值,包含纯右值和将亡值。

11.3 常见示例

1
2
3
4
5
int value = 10;

value; // lvalue
value + 1; // prvalue
std::move(value); // xvalue

返回左值引用的函数调用是左值:

1
2
3
4
5
6
7
8
int global = 0;

int& get_global()
{
return global;
}

get_global() = 100;

十二、引用绑定规则

12.1 左值引用

1
2
3
4
int value = 10;
int& reference = value;

// int& wrong = 10; // 非 const 左值引用不能绑定纯右值

12.2 const 左值引用

1
2
const int& first = value;
const int& second = 10;

const T& 可以绑定左值和右值。绑定临时对象时,可以延长该临时对象的生命周期。

12.3 右值引用

1
2
3
4
int&& first = 10;
int&& second = value + 1;

// int&& wrong = value; // 不能直接绑定左值

右值引用能够区分“可以安全复用资源的对象”,这是移动语义的基础。

12.4 有名字的右值引用变量是左值表达式

1
2
3
4
5
6
void use(int&);
void use(int&&);

int&& reference = 10;
use(reference); // 调用 int&
use(std::move(reference)); // 调用 int&&

变量的声明类型是 int&&,但只要通过名字 reference 使用,它就是左值表达式。

这是理解移动构造和完美转发的关键。

十三、移动语义

13.1 为什么需要移动

一个对象拥有堆内存、文件句柄或其他资源时,深拷贝通常需要:

  1. 重新申请资源;
  2. 复制全部内容;
  3. 保留源对象资源;
  4. 最终分别释放。

如果源对象马上就要销毁,复制资源没有必要。移动语义允许目标对象接管源对象资源。

13.2 移动构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Buffer
{
public:
explicit Buffer(std::size_t size)
: data_(new int[size]{}),
size_(size)
{
}

~Buffer()
{
delete[] data_;
}

Buffer(const Buffer& other)
: data_(new int[other.size_]),
size_(other.size_)
{
std::copy(other.data_,
other.data_ + other.size_,
data_);
}

Buffer(Buffer&& other) noexcept
: data_(other.data_),
size_(other.size_)
{
other.data_ = nullptr;
other.size_ = 0;
}

private:
int* data_{};
std::size_t size_{};
};

移动构造把指针和大小交给新对象,再让源对象进入可安全析构的状态。

13.3 为什么移动参数通常不能是 const T&&

移动往往需要修改源对象:

1
other.data_ = nullptr;

如果参数是 const Buffer&&,就不能修改 other,通常只能退回复制。因此移动构造一般写成:

1
Buffer(Buffer&& other) noexcept;

13.4 移动赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Buffer& operator=(Buffer&& other) noexcept
{
if (this != &other)
{
delete[] data_;

data_ = other.data_;
size_ = other.size_;

other.data_ = nullptr;
other.size_ = 0;
}

return *this;
}

移动赋值不仅接管新资源,还要先正确处理目标对象原有资源。

13.5 noexcept 为什么重要

std::vector 扩容时需要迁移元素。为了在异常发生时保持旧容器有效,它通常更愿意使用不会抛异常的移动构造。

因此资源类的移动构造和移动赋值在确实不会抛出异常时应标记 noexcept:

1
2
Buffer(Buffer&&) noexcept;
Buffer& operator=(Buffer&&) noexcept;

否则容器可能选择拷贝而不是移动。

13.6 拷贝省略

1
2
3
4
5
6
7
Buffer make_buffer()
{
Buffer value(1000);
return value;
}

Buffer result = make_buffer();

即使写了移动构造,也不代表运行时一定能观察到移动。编译器可能执行返回值优化,直接在 result 的存储位置构造对象。

  • C++11 允许多种拷贝省略;
  • C++17 对部分场景规定了保证的拷贝省略;
  • 学习移动语义时不能把构造函数打印次数当成唯一标准。

GCC 可用 -fno-elide-constructors 辅助观察某些非强制省略,但这只是教学手段。

十四、std::move

14.1 std::move 不会主动移动

std::move 本质上进行类型转换,把表达式转换成适合绑定右值引用的将亡值:

1
2
std::string source = "hello";
std::string target = std::move(source);

真正是否移动,由随后被调用的构造函数或赋值运算符决定。

如果目标类型没有移动操作,仍然可能调用拷贝:

1
2
const std::string text = "hello";
std::string copy = std::move(text); // 通常拷贝

因为 std::move(text) 是 const std::string&&,普通移动构造 string(string&&) 不能修改它。

14.2 被移动对象不是“失效对象”

标准库对象被移动后通常处于:

有效但状态未指定。

可以对它:

  • 析构;
  • 重新赋值;
  • 调用没有额外前置条件的操作。

不能假定它一定为空:

1
2
3
4
std::string source = "hello";
std::string target = std::move(source);

source = "new value"; // 合法

14.3 不要移动后继续依赖旧值

1
2
3
send(std::move(message));

// 不要再假设 message 仍保存原文本

只有在不再需要原值时才使用 std::move。

14.4 返回局部变量时通常不要写 move

1
2
3
4
5
std::string make_text()
{
std::string result = "hello";
return result;
}

不要机械写成:

1
return std::move(result);

显式 std::move 可能阻碍命名返回值优化。直接返回局部对象,编译器可以执行 NRVO;未省略时,语言规则也可能选择移动。

十五、完美转发

15.1 普通转发会丢失值类别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void process(int&)
{
std::cout << "lvalue\n";
}

void process(int&&)
{
std::cout << "rvalue\n";
}

template <class T>
void wrapper(T&& value)
{
process(value);
}

无论传入什么,函数体中的具名变量 value 都是左值表达式,所以 process(value) 总选择左值重载。

15.2 转发引用

当 T&& 中的 T 由函数调用推导时,它是转发引用:

1
2
template <class T>
void wrapper(T&& value);

它既能接收左值,也能接收右值。

不是所有 && 都是转发引用:

1
2
3
4
5
6
7
8
void function(std::string&& value); // 普通右值引用

template <class T>
class Box
{
public:
void set(T&& value); // T 是类模板参数,不在此处推导
};

15.3 引用折叠

引用折叠规则:

1
2
3
4
T&  &  -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&

只要组合中出现左值引用,结果通常就是左值引用;只有两个右值引用组合才得到右值引用。

15.4 std::forward

1
2
3
4
5
template <class T>
void wrapper(T&& value)
{
process(std::forward<T>(value));
}

std::forward<T> 根据 T 的推导结果恢复原实参的值类别:

  • 原实参是左值,继续转发为左值;
  • 原实参是右值,继续转发为右值。

完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <utility>

void process(int&)
{
std::cout << "lvalue\n";
}

void process(const int&)
{
std::cout << "const lvalue\n";
}

void process(int&&)
{
std::cout << "rvalue\n";
}

template <class T>
void wrapper(T&& value)
{
process(std::forward<T>(value));
}

int main()
{
int a = 10;
const int b = 20;

wrapper(a);
wrapper(b);
wrapper(30);
}

15.5 move 与 forward 的区别

  • std::move(x):无条件把 x 转成将亡值;
  • std::forward<T>(x):有条件地恢复调用者传入时的值类别。

普通代码表达“我不再需要这个对象的旧值”时使用 move;泛型转发函数中使用 forward。

十六、可变参数模板

16.1 参数包

C++11 允许模板接收任意数量的模板参数:

1
2
3
4
template <class... Args>
void function(Args... args)
{
}

Args... 是模板参数包,args... 是函数参数包。

16.2 C++11 递归展开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void print()
{
std::cout << '\n';
}

template <class First, class... Rest>
void print(const First& first, const Rest&... rest)
{
std::cout << first;

if (sizeof...(rest) != 0)
{
std::cout << ' ';
}

print(rest...);
}

sizeof...(rest) 获取参数包中的元素数量。

折叠表达式是 C++17 功能,不属于 C++11。

16.3 与完美转发结合

1
2
3
4
5
6
template <class Type, class... Args>
std::unique_ptr<Type> make_unique_cpp11(Args&&... args)
{
return std::unique_ptr<Type>(
new Type(std::forward<Args>(args)...));
}

这展示了标准库中 emplace、make_shared 等接口的核心思想:接收任意构造参数,并保持每个参数的值类别。

十七、Lambda 表达式

17.1 为什么需要 Lambda

传统自定义比较器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Goods
{
std::string name;
double price;
};

struct CompareByPrice
{
bool operator()(const Goods& left,
const Goods& right) const
{
return left.price < right.price;
}
};

如果比较逻辑只使用一次,单独定义仿函数类显得分散。Lambda 可以把行为写在使用位置。

17.2 基本语法

1
2
3
4
[capture](parameters) mutable -> return_type
{
body
}

示例:

1
2
3
4
5
6
std::sort(goods.begin(),
goods.end(),
[](const Goods& left, const Goods& right)
{
return left.price < right.price;
});

排序比较器必须形成严格弱序,不能写成 left.price <= right.price。当两个价格相等时,<= 会让 comp(a, a) 为真,违反算法要求,行为未定义。

17.3 捕获方式

捕获 含义
[] 不捕获外部局部变量
[value] 按值捕获 value
[&value] 按引用捕获 value
[=] 默认按值捕获使用到的局部变量
[&] 默认按引用捕获使用到的局部变量
[=, &result] 其他按值,result 按引用
[&, limit] 其他按引用,limit 按值
[this] 捕获当前对象的 this 指针

17.4 值捕获发生在闭包创建时

1
2
3
4
5
6
7
8
9
int value = 10;

auto snapshot = [value]
{
return value;
};

value = 20;
std::cout << snapshot() << '\n'; // 10

Lambda 创建时保存了 value 的副本。

17.5 引用捕获

1
2
3
4
5
6
7
8
9
int value = 10;

auto modify = [&value]
{
value += 5;
};

modify();
std::cout << value << '\n'; // 15

引用捕获要求原对象在 Lambda 被调用时仍然存活。把引用捕获的 Lambda 返回到外部、交给异步线程或长期保存时尤其危险。

17.6 mutable

默认情况下,按值捕获的成员不能在 Lambda 调用运算符中修改:

1
2
3
4
5
6
7
int value = 10;

auto function = [value]() mutable
{
value += 5;
return value;
};

修改的是闭包内部副本,不是外部 value。

在 C++11 中使用 mutable 时不能省略参数列表的圆括号。

17.7 返回类型

简单情况下编译器可以推导:

1
2
3
4
auto add = [](int left, int right)
{
return left + right;
};

需要明确时使用尾置返回类型:

1
2
3
4
auto divide = [](double left, double right) -> double
{
return left / right;
};

17.8 闭包类型

每个 Lambda 表达式都会产生一个唯一的、编译器生成的闭包类型,内部效果类似重载了 operator() 的函数对象。

即使两个 Lambda 代码完全相同,它们的类型也不同:

1
2
3
4
auto first = [] { return 1; };
auto second = [] { return 1; };

// first = second; // 类型不同

无捕获 Lambda 可以转换为兼容的函数指针:

1
2
int (*function_pointer)(int) =
[](int value) { return value * 2; };

有捕获 Lambda 通常不能转换为普通函数指针。

17.9 this 捕获

C++11 中 [this] 捕获的是指针,不是把整个对象复制进闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Counter
{
public:
std::function<int()> make_reader()
{
return [this]
{
return value_;
};
}

private:
int value_{42};
};

如果闭包比对象活得更久,this 会悬空。异步任务中必须特别注意对象生命周期。

十八、std::function 与 std::bind

18.1 function

std::function 可以保存具有兼容调用签名的可调用对象:

1
2
3
4
5
6
7
8
9
10
#include <functional>

std::function<int(int, int)> operation;

operation = [](int a, int b)
{
return a + b;
};

std::cout << operation(2, 3) << '\n';

它可以包装:

  • 普通函数;
  • 函数指针;
  • 仿函数;
  • Lambda;
  • 绑定后的成员函数。

代价是可能存在类型擦除、间接调用和动态分配开销。性能敏感的泛型代码常直接使用模板参数接收可调用对象。

18.2 bind

std::bind 可以绑定参数或调整调用接口:

1
2
3
4
5
6
7
8
9
10
11
#include <functional>

int add(int a, int b)
{
return a + b;
}

auto add_ten =
std::bind(add, 10, std::placeholders::_1);

std::cout << add_ten(5) << '\n'; // 15

现代代码中,Lambda 往往比 bind 更直观:

1
2
3
4
auto add_ten = [](int value)
{
return add(10, value);
};

十九、线程库

19.1 创建线程

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <thread>

void task(int id)
{
std::cout << "worker " << id << '\n';
}

int main()
{
std::thread worker(task, 1);
worker.join();
}

构造带可调用对象的 std::thread 后,新线程立即开始执行。

线程入口可以是:

  • 普通函数;
  • Lambda;
  • 仿函数;
  • 静态成员函数;
  • 非静态成员函数。

19.2 成员函数作为线程入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Worker
{
public:
void run(int count)
{
for (int i = 0; i < count; ++i)
{
}
}
};

Worker object;
std::thread thread(&Worker::run, &object, 10);
thread.join();

非静态成员函数需要同时提供对象指针或对象引用。

19.3 thread 不可复制但可以移动

1
2
std::thread first(task, 1);
std::thread second = std::move(first);

线程所有权转移给 second。此后:

  • first.joinable() 为假;
  • second.joinable() 为真;
  • 线程本身并不会因为包装对象移动而重新启动。

19.4 joinable 的真正含义

joinable() 表示 std::thread 对象当前是否关联一个尚未被 join 或 detach 的执行线程。

它不表示线程函数“此刻还在运行”。即使线程函数已经返回,只要尚未 join,对象仍可能是 joinable。

以下对象不可连接:

  • 默认构造的 thread;
  • 已经被移动走线程所有权的对象;
  • 已调用 join 的对象;
  • 已调用 detach 的对象。

19.5 join

1
2
3
4
5
6
std::thread worker(task, 1);

if (worker.joinable())
{
worker.join();
}

join() 等待目标线程结束,并完成相关线程句柄的同步与回收。

同一个线程对象不能重复 join。对不可连接对象调用 join 会抛出 std::system_error。

19.6 析构 joinable 线程会 terminate

1
2
3
4
5
void function()
{
std::thread worker(task, 1);
// 离开作用域前既没有 join,也没有 detach
}

如果 std::thread 析构时仍然 joinable,程序会调用 std::terminate,而不只是发生普通资源泄漏。

因此每条控制路径都必须明确处理线程。

19.7 RAII 线程守卫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ThreadGuard
{
public:
explicit ThreadGuard(std::thread& thread)
: thread_(thread)
{
}

~ThreadGuard()
{
if (thread_.joinable())
{
thread_.join();
}
}

ThreadGuard(const ThreadGuard&) = delete;
ThreadGuard& operator=(const ThreadGuard&) = delete;

private:
std::thread& thread_;
};

线程对象必须比守卫活得久。

C++20 的 std::jthread 会在析构时自动请求停止并连接,但它不属于 C++11。

19.8 detach

1
2
std::thread worker(task, 1);
worker.detach();

分离后不能再通过原 thread 对象等待或控制该线程。

detach 最大风险不是线程资源无法回收,而是生命周期:

  • 引用的局部变量可能已经销毁;
  • this 指向的对象可能已经析构;
  • 主程序可能先结束;
  • 错误和完成状态难以传回。

除非后台任务的所有依赖生命周期都得到严格保证,否则优先使用 join 或更高层异步抽象。

二十、线程参数传递

20.1 默认进行 decay-copy

std::thread 会把函数和参数按衰减后的类型保存起来,数组和函数会发生退化,普通实参通常被复制或移动到线程内部。

如果线程函数按值接收:

1
2
3
4
5
6
7
8
9
10
void increment_copy(int value)
{
++value;
}

int number = 10;
std::thread thread(increment_copy, number);
thread.join();

// number 仍然是 10

20.2 引用参数需要 std::ref

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <functional>
#include <thread>

void increment(int& value)
{
++value;
}

int number = 10;
std::thread thread(increment, std::ref(number));
thread.join();

// number 变为 11

如果函数要求 int&,直接写 std::thread(increment, number) 通常无法通过编译,而不是“在线程中引用了一个副本”。

只读引用可以使用 std::cref:

1
std::thread thread(read_data, std::cref(data));

20.3 引用对象必须存活

std::ref 不复制目标对象:

1
2
3
4
5
std::thread create_dangerous_thread()
{
int local = 10;
return std::thread(increment, std::ref(local));
}

函数返回后 local 已销毁,线程继续访问会产生悬空引用。参数传递方式正确不等于生命周期一定正确。

二十一、数据竞争与原子操作

21.1 数据竞争

两个线程并发访问同一个内存位置,至少一个是写操作,并且没有适当同步,就可能发生数据竞争。

数据竞争在 C++ 中属于未定义行为:

1
2
3
4
5
6
7
8
9
long long counter = 0;

void increment()
{
for (int i = 0; i < 100000; ++i)
{
++counter; // 多线程同时执行时存在数据竞争
}
}

++counter 并不因为是一行源代码就自动具有原子性。

21.2 atomic

1
2
3
4
5
6
7
8
9
10
11
#include <atomic>

std::atomic<long long> counter{0};

void increment()
{
for (int i = 0; i < 100000; ++i)
{
counter.fetch_add(1);
}
}

对同一个原子对象的原子操作不会被其他线程观察到“执行了一半”的状态,并且还参与 C++ 内存模型中的同步与排序。

21.3 atomic 不等于内部加互斥锁

原子类型的实现可能使用硬件原子指令,也可能在某些类型和平台上使用其他机制。

可以查询:

1
2
3
std::cout << std::boolalpha
<< counter.is_lock_free()
<< '\n';

不能假设所有 atomic<T> 都无锁。

21.4 atomic 的复制限制

原子对象不能通过另一个原子对象进行普通拷贝构造或拷贝赋值:

1
2
3
4
5
std::atomic<int> first{10};
std::atomic<int> second{0};

// std::atomic<int> third(first); // 错误
// second = first; // 错误

但可以从普通值存储:

1
2
second.store(first.load());
second = 20;

所以不能笼统地说原子类型“没有 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
2
3
4
5
6
7
8
9
10
11
#include <mutex>

std::mutex mutex;
int number = 0;

void update()
{
mutex.lock();
++number;
mutex.unlock();
}

手工调用的风险:

  • 中途 return;
  • 抛出异常;
  • 忘记 unlock;
  • 多把锁顺序不一致。

22.2 lock_guard

1
2
3
4
5
void update()
{
std::lock_guard<std::mutex> lock(mutex);
++number;
}

构造 lock_guard 时加锁,离开作用域时析构并解锁。

它:

  • 不可复制;
  • 接口简单;
  • 适合整个作用域都持锁的场景;
  • 不支持手工提前解锁。

22.3 unique_lock

1
2
3
4
5
6
7
8
9
10
11
12
void update_conditionally(bool should_update)
{
std::unique_lock<std::mutex> lock(
mutex, std::defer_lock);

if (should_update)
{
lock.lock();
++number;
lock.unlock();
}
}

unique_lock:

  • 可以延迟加锁;
  • 可以手工 lock 和 unlock;
  • 可以移动;
  • 能查询 owns_lock();
  • 可以与条件变量协作;
  • 比 lock_guard 状态更丰富,也可能略有额外开销。

22.4 常见锁标签

1
2
3
4
5
6
7
8
std::lock_guard<std::mutex> first(mutex,
std::adopt_lock);

std::unique_lock<std::mutex> second(
mutex, std::defer_lock);

std::unique_lock<std::mutex> third(
mutex, std::try_to_lock);
  • 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
2
3
4
5
6
std::lock(first_mutex, second_mutex);

std::lock_guard<std::mutex> first(
first_mutex, std::adopt_lock);
std::lock_guard<std::mutex> second(
second_mutex, std::adopt_lock);

C++17 的 std::scoped_lock 更方便,但不属于 C++11。

22.7 缩小临界区

1
2
3
4
5
6
7
8
Data local;

{
std::lock_guard<std::mutex> lock(mutex);
local = shared_data;
}

process(local); // 耗时工作放在锁外

锁只保护共享状态,不要在持锁期间执行不必要的 I/O、休眠或昂贵计算。

二十三、条件变量

23.1 为什么不能忙等

1
2
3
4
while (queue.empty())
{
// 不断检查,浪费 CPU
}

条件变量允许线程在条件不满足时睡眠,并在状态变化后被唤醒。

23.2 wait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex mutex;
std::condition_variable condition;
std::queue<int> tasks;
bool stopped = false;

int take_task()
{
std::unique_lock<std::mutex> lock(mutex);

condition.wait(lock, []
{
return !tasks.empty() || stopped;
});

if (tasks.empty())
{
return -1;
}

int task = tasks.front();
tasks.pop();
return task;
}

wait 会:

  1. 原子地释放互斥锁并进入等待;
  2. 被通知后重新竞争互斥锁;
  3. 获得锁后检查谓词;
  4. 谓词为假则继续等待。

23.3 虚假唤醒

线程可能在没有目标事件的情况下被唤醒,因此等待必须检查条件。

推荐:

1
condition.wait(lock, predicate);

它等价于在循环中反复检查,而不是只用一次 if。

23.4 通知

1
2
3
4
5
6
7
8
9
void add_task(int task)
{
{
std::lock_guard<std::mutex> lock(mutex);
tasks.push(task);
}

condition.notify_one();
}

常用接口:

  • notify_one():唤醒一个等待线程;
  • notify_all():唤醒所有等待线程。

通常先在锁内修改共享状态,再在合适位置通知。

二十四、async、future 与 promise

24.1 async

1
2
3
4
5
6
7
8
#include <future>

auto future = std::async(std::launch::async, []
{
return 21 * 2;
});

std::cout << future.get() << '\n';

future.get() 等待结果并取出返回值;通常只能调用一次。

明确指定 std::launch::async 可以要求异步执行。使用默认策略时,实现也可能选择延迟到 get 或 wait 时执行。

24.2 promise

1
2
3
4
5
6
7
8
9
10
11
12
std::promise<int> promise;
std::future<int> future = promise.get_future();

std::thread worker(
[](std::promise<int> result)
{
result.set_value(42);
},
std::move(promise));

std::cout << future.get() << '\n';
worker.join();

promise 负责设置值或异常,future 负责接收。

二十五、其他重要 C++11 特性

25.1 constexpr

1
2
3
4
5
6
constexpr int square(int value)
{
return value * value;
}

constexpr int result = square(5);

C++11 对 constexpr 函数体限制较严格,C++14 才进一步放宽。

25.2 static_assert

1
2
static_assert(sizeof(void*) >= 4,
"pointer is unexpectedly small");

条件在编译期检查,失败时直接报错。

25.3 类型别名

1
2
using StringMap =
std::map<std::string, std::string>;

using 还能定义别名模板,这是 typedef 难以表达的:

1
2
template <class T>
using Vector = std::vector<T>;

25.4 委托构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class User
{
public:
User()
: User("unknown", 0)
{
}

User(std::string name, int age)
: name_(std::move(name)),
age_(age)
{
}

private:
std::string name_;
int age_;
};

一个构造函数可以委托同类的另一个构造函数完成公共初始化。

25.5 enum class

1
2
3
4
5
6
7
8
enum class Color
{
red,
green,
blue
};

Color color = Color::red;

强类型枚举避免枚举项泄漏到外层作用域,也不会随意隐式转换成整数。

二十六、综合示例

下面的程序综合演示:

  • 列表初始化;
  • auto;
  • 范围 for;
  • override;
  • 智能指针;
  • 移动语义;
  • Lambda 排序;
  • std::thread;
  • std::atomic。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#include <algorithm>
#include <atomic>
#include <cstddef>
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <utility>
#include <vector>

class Task
{
public:
Task(std::string name, int priority)
: name_(std::move(name)),
priority_(priority)
{
}

virtual ~Task() = default;

virtual void print() const
{
std::cout << name_
<< ": "
<< priority_
<< '\n';
}

int priority() const
{
return priority_;
}

private:
std::string name_;
int priority_;
};

class ImportantTask final : public Task
{
public:
ImportantTask(std::string name, int priority)
: Task(std::move(name), priority)
{
}

void print() const override
{
std::cout << "[important] ";
Task::print();
}
};

static void accumulate_range(int first,
int last,
std::atomic<long long>& sum)
{
long long local = 0;

for (int value = first; value <= last; ++value)
{
local += value;
}

sum.fetch_add(local, std::memory_order_relaxed);
}

int main()
{
std::vector<std::shared_ptr<Task>> tasks{
std::make_shared<Task>("compile", 2),
std::make_shared<ImportantTask>("deploy", 5),
std::make_shared<Task>("test", 3)
};

std::sort(tasks.begin(),
tasks.end(),
[](const std::shared_ptr<Task>& left,
const std::shared_ptr<Task>& right)
{
return left->priority() >
right->priority();
});

for (const auto& task : tasks)
{
task->print();
}

std::atomic<long long> sum{0};

std::thread first(accumulate_range,
1,
50000,
std::ref(sum));
std::thread second(accumulate_range,
50001,
100000,
std::ref(sum));

first.join();
second.join();

std::cout << "sum = " << sum.load() << '\n';
return 0;
}

编译:

1
g++ -std=c++11 -Wall -Wextra -Wpedantic main.cpp -pthread -o main

期望总和:

1
1 + 2 + ... + 100000 = 5000050000

二十七、常见错误

27.1 把花括号和圆括号构造当成完全相同

1
2
std::vector<int> a(5, 10);
std::vector<int> b{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++ 的设计方式:

  1. 使用列表初始化统一对象创建,并利用窄化检查;
  2. 使用 nullptr 表达空指针;
  3. 使用 auto 简化明确的复杂类型;
  4. 使用 decltype 获取表达式精确类型;
  5. 使用范围 for,同时区分值、引用和 const 引用;
  6. 使用 override 和 final 明确继承意图;
  7. 使用智能指针和标准容器管理资源;
  8. 优先遵循 Rule of Zero;
  9. 使用 = default 和 = delete 控制函数生成;
  10. 理解左值、纯右值和将亡值;
  11. 使用移动语义减少无意义的深拷贝;
  12. 理解 std::move 只是转换;
  13. 使用转发引用和 std::forward 实现完美转发;
  14. 使用 Lambda 在行为发生的位置表达小型逻辑;
  15. 使用 std::thread 获得跨平台线程抽象;
  16. 确保每个线程在析构前已经连接或分离;
  17. 数据竞争属于未定义行为;
  18. 使用原子操作保护简单共享状态;
  19. 使用 RAII 锁保护复合临界区;
  20. 使用条件变量代替忙等;
  21. 始终同时考虑资源所有权、对象生命周期和异常安全。

真正掌握 C++11 后,代码会从“手动管理一切”转向“用类型表达所有权、用语言规则保证安全、只为必要成本付费”。这也是继续学习 C++14、C++17、C++20 以及现代 C++ 工程实践的基础。