1. C++ 设计准则
1. Philosophy
1. 保持代码直观清晰
1 | class Date |
在上述示例中,前者是更好的写法,它的返回值是类型明确的,并且本身具有 const 限定。
1 | int index = -1; |
使用 STL 能使代码更加清晰简洁,不易出错。有一句现代 C++ 谚语:如果你还在显式地使用循环,那么你就没有真正理解 STL 算法。
2. 遵循 ISO Standard
按照由国际标准化组织(ISO)制定并发布的《ISO/IEC 14882》C++ 标准来编写代码。
这意味着尽量避免依赖各编译器厂商(如 GCC、MSVC、Clang)提供的额外语法或库扩展,以保证代码在不同环境下都能编译和运行。
另外,需要注意 Undefined Behavior 和 Implementation‑Defined Behavior
-
Undefined Behavior
指 C++ 标准未明确规定行为规范的代码操作,允许编译器自由处理。程序可能崩溃、产生错误结果、或看似正常运行实则存在隐患。程序应避免任何 UB。
-
Implementation‑Defined Behavior
指 C++ 标准允许编译器或运行时环境自由选择具体实现方式,但要求该行为必须被明确文档化。虽然具有平台依赖性,但结果是可预测的,由特定编译器的实现文档保证。
3. 注释应写明意图
1 | for(const auto& v : vec) {...} // (1) |
上述示例中,(1) 与 (2) 的区别在于前者不会修改容器中的元素。
(3) 是使用并行的方式对容器中的每个元素执行一个操作。但是需要注意的小规模数据可能因线程调度开销导致性能下降,这种写法更适用于大规模数据。
总之,注释应阐明意图,指出应该做什么。
4. 保证程序的静态类型安全
C++ 是静态类型语言。即所有的变量或表达式的类型必须在编译时确定。静态类型安全要求编译器不仅知道其类型,还要能检测出类型相关的错误。C++ 并非完全类型安全,因此需要在编写代码时人工规避问题。
以下是常见的类型安全的问题及规避方案:
-
联合体(Union)
联合体允许不同类型共享同一内存空间,同一时间只能存储一个成员的值,可以达到节省内存的效果,
union的大小等于其最大成员的大小;union不记录当前活跃的成员,访问错误的成员会导致未定义行为。1
2
3
4
5
6
7
8
9union Data
{
int i;
float f;
};
Data data;
data.i = 10;
std::cout << data.f << std::endl; // 错误:此时通过 f 访问是未定义行为在 C++11 之前,
union仅支持平凡类型。C++11 之后可以包含非平凡类型,但有严格限制。必须提供自定义构造函数,因为union的默认构造函数是delete状态。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
union MyUnion
{
int i;
std::string s; // 非平凡类型
// 编译器不会生成默认构造函数
// MyUnion() = delete;
};
int main()
{
// MyUnion u; // 错误,默认构造函数被删除
return 0;
}必须显式管理生命周期和追踪活跃成员,比如析构时需要识别出活跃成员,否则析构出错会有未定义行为。
C++17 引入了
std::variant,它是一个类型安全的联合体。可以方便地存储非平凡类型,并且自动管理存储资源的生命周期,是union的上位替代。1
2
3
4
5
6
7
8
9
10std::variant<int, std::string> v = "hello";
// 需要知道类型进行访问
std::cout << std::get<std::string>(v) << std::endl;
// 类型安全的访问方式
if (auto* str = std::get_if<std::string>(&v))
{
std::cout << *str << std::endl;
} -
类型转换
显式类型转换(如
static_cast、dynamic_cast、reinterpret_cast、const_cast)可能引发错误或未定义行为dynamic_cast相对安全,有运行时类型检查。对于指针类型,转换失败会返回空指针;对于引用类型,转换失败会抛出std::bad_cast异常。而基于模板的泛型代码减少了对类型转换的需求,从某种程度上可以减少此类错误。
-
数组退化
C 风格数组在传递给函数时,会退化为指向其首元素的指针,丢失数组大小信息。
1
2
3
4
5
6void processArray(int arr[])
{
// 这里 arr 实际上是指针,不是数组
// 输出指针大小(8/4字节),无法获取原始数组大小
std::cout << "Size in function: " << sizeof(arr) << std::endl;
}C++20 提供的
std::span是一个轻量级的视图,包含指针和大小信息,提供安全的数组访问,可以解决这个问题。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
// 使用 std::span 接收数组,自动保持大小信息
void processWithSpan(std::span<int> arr)
{
std::cout << "Span size: " << arr.size() << std::endl;
std::cout << "Span data: ";
for (int value : arr)
{
std::cout << value << " ";
}
std::cout << std::endl;
// 安全的索引访问
for (size_t i = 0; i < arr.size(); i++)
{
std::cout << arr[i] << " ";
}
std::cout << std::endl;
// 子视图,同样安全
if (arr.size() >= 3)
{
auto subspan = arr.subspan(1, 2); // 从索引1开始,取2个元素
std::cout << "Subspan: ";
for (int val : subspan)
{
std::cout << val << " ";
}
std::cout << std::endl;
}
}
int main()
{
// 1. C 风格数组,自动推导大小
int cArray[] = { 1, 2, 3, 4, 5 };
processWithSpan(cArray);
// 2. std::array
std::array<int, 4> stdArray = { 6, 7, 8, 9 };
processWithSpan(stdArray);
// 3. std::vector
std::vector<int> vec = { 10, 11, 12, 13, 14 };
processWithSpan(vec);
// 4. 动态数组,需要显式提供大小
int* dynamicArray = new int[3] {15, 16, 17};
processWithSpan(std::span<int>(dynamicArray, 3));
delete[] dynamicArray;
return 0;
}需要注意的是,动态数组的大小在运行时确定,编译期无法推导,所以需要显式提供大小。而
std::vector因为它本身是包含 size 信息的完整类型,所以即使其 size 是在运行期确定的,也无需向std::span显式提供大小。 -
窄化转换
隐式转换可能导致数据丢失(如
double→int截断小数)使用
{}初始化(列表初始化)时,编译器会强制检查并拒绝窄化转换,帮助在编译期捕获此类错误。
5. 优先编译期检查而非运行期检查
所有能够在编译期进行的检查,都应当置于编译期完成。C++11 引入编译期断言 static_assert ,它接受一个常量表达式,在编译期验证条件,对条件不满足或者运行期才能确定的表达式产生编译错误。
此外,类型特征库(type-traits library)允许开发者构建强大的条件检查:例如static_assert(std::is_integral<T>::value)。
6. 无法在编译期检查的内容,应当能够在运行期进行检查
借助 dynamic_cast,我们能够安全地在继承层次结构中向上、向下及横向转换类的指针和引用。如果转换失败,对于指针会返回 nullptr,对于引用则会抛出 std::bad_cast 异常
dynamic_cast 依赖于运行时类型信息(Run-Time Type Information, RTTI),而 RTTI 只对多态类型(即包含虚函数的类)可用。
编译器在
vtable中嵌入type_info对象,非多态类类型没有vtable,因此没有存储类型信息的地方,dynamic_cast通过查询对象的type_info来验证转换的合法性
7. 尽早捕获运行期错误
可采用多种对策消除运行时错误。比如检查指针、数组范围、类型转换等,避免错误的传播和扩散。
8. 不要泄露任何资源
资源不仅指内存,还包括系统资源,如文件句柄、网络套接字、数据库连接、图形界面句柄、互斥锁等
任何资源如果只申请不释放,都会随着程序的运行而不断累积。对于需要长时间运行的服务端程序或后台进程,即使是微小的泄露,最终也可能耗尽系统资源,导致程序崩溃或系统变得不稳定。
处理资源的惯用方法是 RAII (Resource Acquisition Is Initialization):
- 将资源的生命周期与一个对象的生命周期绑定
- 在对象的构造函数中获取资源
- 在对象的析构函数中释放资源
- C++保证,当对象离开其作用域时(无论是正常离开,还是因为异常而离开),其析构函数一定会被自动调用,无需手动干预
成员对象的析构函数在持有它的类析构时会自动调用,但它持有的资源是否需要手动释放,取决于该成员类型本身的实现
RAII 应用示例:
- 锁:如
std::lock_guard或std::unique_lock。在构造时锁定互斥量,在析构时自动解锁。这防止了因忘记解锁而导致的死锁 - 智能指针:如
std::unique_ptr和std::shared_ptr。它们包装了原始指针,在析构时自动释放所指向的内存。这是现代C++取代new/delete的首选方式 - STL容器:如
std::vector,std::string等。它们在析构时会自动清理其内部动态分配的所有元素和内存缓冲区,无需手动管理
9. 不要浪费时间和空间
需要有意识地避免不必要的时间和空间开销。比如以下例子:
1 | void lower(std::string s) |
该函数想将字符串 s 转换为小写字符,但有以下问题:
- 时间开销:
std::strlen(s.data())在每次循环迭代时都会被调用。strlen是一个 O(n) 复杂度的函数,它需要遍历整个字符串直到找到空终止符\0。这导致一个原本是 O(n) 的循环变成了 O(n²),对于长字符串会造成巨大的性能浪费 - 传参错误:应传引用,传值并没有改动
s,并造成了额外的拷贝
使用 std::transform 可以解决上述问题:
1 | std::transform(s.begin(), s.end(), s.begin(), |
下一个例子是抑制移动语义:
1 | struct S |
C++11 引入了移动语义,通过移动构造函数和移动赋值运算符实现。它将资源从一个临时对象(通常是右值)移动过来,而不是复制,成本低廉。
但是在这个例子中,用户手动定义了拷贝构造函数和拷贝赋值运算符。根据C++标准规则,一旦用户显式定义了这些拷贝操作,编译器就不会自动生成默认的移动操作(移动构造函数和移动赋值运算符)。
但可以通过
= default显式要求编译器生成移动操作
因此,在代码 S s2 = std::move(s1); 中,std::move(s1) 本意是将 s1 转换为一个右值,期望触发移动操作。但由于 S 没有移动构造函数,编译器只能退而求其次,调用拷贝构造函数。这里的 std::move 实际上没有起到任何加速作用,反而产生了昂贵的拷贝开销。
现代C++的最佳实践是“零规则”(Rule of Zero):尽量避免手动定义拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符和析构函数。让编译器为你生成所有这些默认操作。在无需直接管理资源的大多数情况下,编译器生成的都是正确且最优的。
如果类需要直接管理资源,确实需要自定义这些操作,那么应遵循 “五规则”(Rule of Five):如果需要自定义其中任何一个,那么很可能需要同时自定义所有五个(拷贝构造、移动构造、拷贝赋值、移动赋值、析构),并正确地实现它们。
10. 优先使用不可变数据而非可变数据
不可变数据(常量)有以下优势:
- 固化状态流转路径,降低代码复杂度
- 利于编译期优化:包括常量传播、公共子表达式消除、死代码删除等编译优化技术
- 并发安全
11. 封装杂乱的构造,而非将其散布在代码中
如果有可能,尽量不要自己实现复杂易错的底层代码,优先考虑类似 STL 这样的成熟代码库。
如果找不到现成的高级库来替代必要的复杂逻辑,尽量将混乱、易变的细节隐藏在简洁的接口之中。
12. 根据需要使用辅助工具
使用如静态分析工具、并发分析工具、测试工具等辅助工具,可有效提高代码的正确性,可移植性和健壮性。
不同的编译器(如 GCC, Clang, MSVC)对 C++ 标准的实现和理解略有不同,使用多种 C++ 编译器来编译代码,也是很好的验证代码的方式。
13. 根据需要使用支持库
根据需要使用高质量外部库可避免重复造轮子,提升开发效率和代码质量。
2. Interfaces
1. 避免使用非 const 的全局变量
全局变量向函数内部注入了一个隐藏的依赖项,并且这个依赖项并非接口的一部分,容易出错,并且存在并发安全问题。
2. 谨慎使用单例
从本质上讲,单例也是一种全局变量。虽然它在很多场景下是有必要的,但我们在使用时也需要考虑以下问题:
- 谁负责销毁单例?
- 应该允许单例派生吗?
- 如何以线程安全的方式初始化单例?
- 当单例相互依赖且位于不同的编译单元时,它们的初始化顺序是怎样的?(静态初始化顺序问题)
3. 书写好的接口
好的接口应遵循以下规则:
- 明确
- 强类型
- 尽可能少的参数
- 避免相邻的、不相关的同类型参数
结构体传参是一种好的写法,将复杂的参数列表封装为一个 struct,既很好地满足了上述规则,还使得接口的调整变得容易。如果接口需要新的参数,只需要在 struct 新增一个给出默认值的参数即可,所有的老代码依然正常调用,而无需到处修改。如:
1 | struct Light |
而所有的老代码依然可以正常调用,未指定的 brightNess 会具有默认值 .0
1 | void buildLightPass({.name = "Light1", .color = {1, 1, 1}, .type = 1, .loc = {.0, .0, .0}}); |
同理,如果有必要的话,返回值也可以定义为结构体。
对于返回值可能为空的的函数,或许空值语义是一种更好的写法。C++17 引入了 std::optional,形如 std::optional<T> 的类型有两种可能的状态:为空nullopt 和有值。这是一种语义更加明确的设计。
4. 不要以指针的方式传递数组
当我们将数组传递给一个入参是指针的函数时,数组会自动退化为其首元素的指针,所以往往还需要传递数组的大小。
这是一种容易出错的写法,如果有可能的话使用 std::vector 作为函数的入参更好,但如果场景中为了满足入参不得不拷贝数据,从数组创建 std::vector,std::span 将是更好的选择。
5. 为了稳定的 ABI,可考虑 Pimpl
Pimpl(Pointer to Implementation)是 C++ 中的一种编译时封装技术,通过将类的实现细节隐藏在一个指向实现类的指针后面来减少编译依赖和提高封装性。
ABI 是二进制程序组件之间的接口,它定义了:
内存布局:类/结构体的大小、成员偏移量
函数调用约定:参数传递方式、栈清理责任
名称修饰:C++ 函数名在二进制中的表示方式
异常处理机制:异常如何抛出和捕获
虚函数表布局:多态类的运行时结构
基本结构
1 | class MyClass |
Pimpl 主要优势有两个:
- 分离定义,加速编译(修改了具体实现
Impl,只需要重新编译MyClass.cpp) - 可以保持 ABI 的稳定性,方便发布库更新和插件热装载
其代价是每次访问实现都有一次指针间接寻址,有极轻微的性能损失,代码结构更加复杂。适用于大型类(有很多的私有成员和方法),库开发(需要保持二进制的兼容性),编译时间敏感等场景。
3. Functions
1. 函数定义
定义一个函数首先需要考虑如何命名,好的函数命名并无铁律,不过这里仍有三条实用的建议:
- 取有意义的名字
- 函数应该执行单个逻辑操作
- 保持函数简洁
如果一个函数在编译期求值,应将其声明为 constexpr:
-
当在一个常量表达式中调用
constexpr函数,或者将其结果赋值一个constexpr变量时,它会在编译期执行 -
constexpr函数并非只能在编译期执行,当传递给它的参数是运行时才能确定的,或者没有强制要求结果是常量,那么它将退化成在运行时执行 -
当函数在编译期执行完成后,计算结果作为一个普通的字面常量存储在 ROM(Read Only Memory) 中,通常对应可执行文件中的
.rodata段(只读数据段) -
constexpr函数是隐式内联的,这意味着可以(并且通常应该将constexpr函数的定义写在头文件中)如果是普通函数,那么编译器并不需要知道函数的具体实现,因为链接器 (Linker) 稍后会负责找到它。
但如果是
constexpr函数,函数的完整定义必对当前编译的文件可见。最简单直接的方式就是将完整定义写在头文件里(也有一些其他实现方法:如 C++ 20 Modules,Untiy Build[单编译单元构建]等)引申:如果是一个普通函数的定义写在了头文件里,是一种禁忌:
-
当两个
.cpp源文件都包含这个头文件时,将分别生成两个同名符号,链接器在链接时无法区分,于是报错Multiple definition of 'add'(重定义错误) -
这违背了 ODR(One Definition Rule,单一定义规则):一个非内联函数在整个程序中只能有一个定义
-
inline现在真正的作用是让多个翻译单元共享同一个定义,突破单一定义规则,可以解决这个问题
-
-
constexpr函数天然线程安全
如果函数不会抛出异常,应将其声明为 noexcept:
-
noexcept是一种异常规范,它告诉编译器此函数不会抛出异常 -
如果在一个被
noexcept声明的函数中出现了异常并试图传播出该函数(即没有在函数内部捕获并处理),程序将调用std::terminate终止程序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
void noExceptionFunc() noexcept
{
throw std::runtime_error("exception inside noexcept function");
}
void customTerminate()
{
std::cerr << "Custom terminate handler called. Program will abort.\n";
std::abort();
}
int main()
{
std::set_terminate(customTerminate);
try
{
noExceptionFunc();
}
catch(const std::exception& e)
{
std::cerr << "Caught exception: " << e.what() << '\n';
}
return 0;
}在上述示例中,我们编写了自定义的终止处理函数,可以印证这一点。
-
明确的
noexcept能够让编译器做出更激进的优化决策(更少的跳转,更容易内联,更紧凑高效的机器码) -
原则上析构函数、
swap、移动操作、默认构造函数不应抛出异常(这几类函数处在程序正确性和性能的关键路径上,其抛出异常的代价可能包括:std::terminate、破坏容器不变式、退回到代价更高的策略)从 C++11 起析构函数默认是
noexcept(true)
更推荐使用纯函数(Pure Functions):
- 纯函数是指在相同参数下总是返回相同结果的函数。其输出仅由输入参数决定,且在计算过程中不依赖或修改外部观测状态(如全局变量,IO,随机数发生器,系统时间等)
- 非纯函数例如
random()或time()等,每次调用可能返回不同的结果 - C++ 并不强制纯函数风格,语言层面允许并广泛使用状态、指针、全局变量等,因此纯函数编码依赖开发人员的自律
- 纯函数有以下好处:可在隔离环境下被测试或重构;可以缓存(记忆化)其结果;被自动重排序或在其他线程上并行执行
constexpr函数在编译期求值时是纯的,模板元编程是在命令式语言 C++ 中内嵌的一个纯函数式语言
2. 参数传递
2. C++ 基础
1. 平凡类型(Trivial Type)
C++11 引入平凡类型的概念,用于描述“没有副作用的构造/析构/赋值等操作”的类型,一个类型是平凡类型当且仅当它满足以下所有条件:
- 无用户定义的构造函数
- 无用户定义的析构函数
- 无用户定义的拷贝/移动构造函数
- 无用户定义的拷贝/移动赋值操作符
- 无虚函数,无虚基类
- 所有非静态成员和基类都是平凡的
可使用 std::is_trivial 进行判别。
POD 类型(Plain Old Data)相比于平凡类型是一个更严格、更古老的限制。在平凡类型的基础上还有标准布局(Standard Layout)的要求。
标准布局类型满足以下条件:
- 所有非静态成员具有相同的访问控制
- 无虚函数,无虚基类
- 没有引用类型的非静态数据成员
- 所有非静态数据成员都是标准布局类型
- 所有基类都是标准布局类型
- 满足以下继承条件之一:
- 没有基类
- 只有一个基类且没有非静态数据成员
- 基类和派生类中不能同时有非静态数据成员
可使用 std::is_standard_layout_v 判别是否是标准布局类型。
POD 类型也有 std::is_pod 这样的判断方法,但是该方法已经在 C++20 中被移除,不推荐使用。可通过 std::is_standard_layout_v 和 std::is_trivial 的组合来达到相同的目的。即:
POD = Trivial && Standard Layout
1 |
|
应用场景
-
平凡类型
其核心特性在于可以使用
memcpy等低级内存操作进行复制,而不需要逐个调用拷贝构造函数,在需要高效移动和复制时很有用。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// 高效内存复制
template<typename T>
void trivial_copy(const T* src, T* dest, size_t count)
{
static_assert(std::is_trivial_v<T>,
"Type must be trivial for memcpy operations");
std::memcpy(dest, src, count * sizeof(T));
}
// 序列化/反序列化
struct TrivialData
{
int id;
double value;
char name[32];
};
void serialize_trivial(const TrivialData& data, char* buffer)
{
// 安全地直接复制内存
std::memcpy(buffer, &data, sizeof(TrivialData));
}
// 对象池和内存管理
template<typename T>
class ObjectPool
{
static_assert(std::is_trivial_v<T>, "ObjectPool requires trivial types for efficient reuse");
std::vector<T> pool;
public:
T* allocate()
{
// 可以安全地重用内存而不调用析构函数
return &pool.emplace_back();
}
}; -
标准布局类型
其核心特性是确定性的内存布局,并与 C 语言兼容
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// 与C语言互操作
extern "C"
{
struct CStruct
{
int x;
double y;
};
void process_c_struct(CStruct* data);
}
struct CompatibleCppStruct
{
int x;
double y;
}; // 标准布局,可以与C结构体安全互操作
static_assert(std::is_standard_layout_v<CompatibleCppStruct>);
// 硬件寄存器映射
struct DeviceRegisterMap
{
volatile uint32_t control;
volatile uint32_t status;
volatile uint32_t data;
}; // 标准布局确保成员顺序与硬件寄存器一致
// 网络协议数据包
struct NetworkPacket
{
uint16_t header;
uint32_t sequence;
uint8_t payload[1024];
uint16_t checksum;
}; // 标准布局 + 打包确保精确的内存布局
// 通过偏移量访问成员
template<typename T, typename M>
size_t member_offset(M T::*member)
{
static_assert(std::is_standard_layout_v<T>);
return reinterpret_cast<size_t>(&(reinterpret_cast<T*>(0)->*member));
} // 只有标准布局类型才有确定的成员偏移量 -
POD 类型
同时拥有以上两种类型的特性,是二者的交集。但现代 C++ 编码中应尽量避免使用这个概念。
2. 奇异递归模板模式(CRTP)
奇异递归模板模式(Curiously Recurring Template Pattern)是 C++ 中的一种模板编程技术,用于实现编译期多态。
其基本范式是:
1 | template <typename Derived> |
父类是模板(类模板不是类),子类使用自身类型作为类型参数去继承具体实例化的父类类型 Base<Derived>。
一般的使用方法是父类暴露接口,子类实现接口:
1 | template <typename Derived> |
与虚函数相比, CRTP 无 vptr,是零成本多态,具有更好的性能,但代价是派生类需要在编译时知道模板基类,不能通过单一非模板基类指针在运行时处理不同的派生类型(比如上面的示例中如果要使用基类指针指向 Square 应为:Shape<Square>*)。
如果我们想为这个示例提供通用接口,其形式可以类似于:
1 | template<typename T> |
这里我们能够成功调用 generalPrint 而不必显式指明模板参数,是因为 Square 继承自 Shape<Square>,编译器知道前者可以隐式转换为后者,自动推导出 T = Square,这和下面的写法有所区别:
1 | template<typename T> |
两者虽然都能工作,但后者将处理任何有 print 方法的对象(鸭子类型),前者将会在编译期检查 shape 是否可转为 Shape<T>&(静态多态)。
3. C++ 并发编程
1. 并发基础
并发:指两个或两个以上的独立活动同时发生。计算机的并发通常指单个系统同时执行多个独立的任务。
并发的两种方式:
-
真正并行
依赖硬件的多核或多处理器架构,每个处理器或核心都独立执行任务
-
任务切换
在单个处理器上快速切换执行多个任务,使得它们看起来像是同时进行
操作系统通过分配给每个任务很短的执行时间片(time slice),并在这些时间片之间切换任务,以实现并发的效果。这也会造成时间和空间上的额外开销
进程与线程
-
进程
进程是操作系统拥有资源基本单位。每个进程都有自己的独立地址空间,包括代码段、数据段、堆和栈。一个进程可以包含多个线程
-
线程
线程是进程中的一个执行单元,是独立调度的基本单位。一个进程可以包含多个线程,这些线程共享进程的地址空间和资源(如全局变量、文件描述符等)
-
多进程并发
多进程并发是将应用程序分为多个独立的进程同时运行,通过进程间的通讯渠道传递讯息(信号、套接字、文件、管道等)
进程间通信复杂且缓慢。操作系统会对进程进行保护,以避免一个进程去修改另一个进程的数据
-
多线程并发
多线程并发是在单个进程中运行多个线程。由于线程间地址空间共享且缺乏数据保护,所以开销远远小于多进程
线程并不是越多越好,每个线程都需要一个独立的栈空间,线程之间的切换要保存很多中间状态,会耗费本该程序运行的时间
常见操作系统默认栈空间大小
操作系统 / 编译环境 默认栈空间大小 (子线程) 备注 / 查看与修改方式 Windows (MSVC/链接器) 1 MB 由链接器参数 /STACK决定Linux (Glibc/Pthreads) 8 MB 系统级限制,可用 ulimit -s查看(单位 KB)macOS (Pthreads) 512 KB 子线程默认较小;主线程默认通常为 8 MB iOS 512 KB 子线程默认大小;主线程为 1 MB Android (Bionic) 1 MB 或 2 MB 取决于具体的 Android 版本和系统位数 嵌入式系统 (如 FreeRTOS) 由开发者定义 通常在创建任务(Task)时手动分配,可能仅为几百字节
并发的意义
- 分离关注点
- 性能
并发相关基本概念
- 共享数据:可能被多个线程访问的数据。对于不是原子量的共享数据,需要确保在修改这些数据时,没有其他线程在同时修改或读该数据;否则会导致数据竞争,后果是未定义行为
- 锁:一种同步机制,持有锁意味着我们有权利执行某项操作,如对共享数据的读或写。对共享数据提供锁,要求持有锁才能访问共享数据,是一种非常简单的避免数据竞争的方式。然而,等待锁往往也是并发编程的性能瓶颈所在
- 通知:一种抽象的同步机制,用来通知其他线程发生了某个事件。根据具体的并发环境,通知可能携带额外的数据,也可能没有
- 数据同步:共享数据可能存在于多个不同的地方,因此需要某种机制来同步多份数据。数据同步可能带意料之外的延迟
2. 线程管理
1. 使用线程
简单来说,启动线程就是构造 std::tread 对象或者 std::jthread 对象(C++ 20)。
1 |
|
std::thread 代表操作系统级别线程的 C++ 封装,具有以下特性:
- 可移动,不可拷贝
- 构造即启动:通过传入可调用对象(函数,函数对象,lambda 等)构造
std::thread会启动线程并立即执行该可调用对象
其构造函数签名为:
1 | template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0> |
_Fn: 代表可调用对象的类型(Function)。class... _Args: 是一个变长模板参数,向线程传递任意数量的参数作为可调用对象的参数。
1 | enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0 |
这个 SFINAE 约束的作用是防止编译器在应该匹配移动构造函数时匹配到这个通用构造函数:如果第一个模板参数_Fn 经过处理(去掉 const、引用等修饰符后),发现它本身就是一个 std::thread 类型,那么这个构造函数就会通过 SFINAE 机制被禁用。
例如当编译器遇到
std::thread t2(std::move(t1))这行代码时,它会去查找std::thread的构造函数,此时有两个候选者在竞争:
- 移动构造函数:
thread(thread&& _Other) noexcept- 通用模板构造函数:
template <class _Fn...> thread(_Fn&& _Fx...)因为
std::move(t1)产生的是一个thread&&,这与模板构造函数里的_Fn&&完美匹配,所以就可能错误地匹配到通用模板构造函数,并尝试将t1当作一个可调用对象传给_Start去执行。而有了 SFINAE 约束后,如果
_Fn的类型是std::thread,通用模板构造函数就会主动认输。编译器只能匹配移动构造函数,正确完成线程所有权转移。
join、detach
使用 std::thread 时除了关注线程对象本身,还需考虑其调用线程。
假设存在线程对象 t, join() 的作用是阻塞 t 的调用线程,直到 t 执行完毕 。
detach() 的作用是将线程对象 t 与线程本身分离,线程对象 t 失去了线程资源的所有权,此线程本身将独立运行,执行完毕后资源将被系统自动给回收。
调用 join() 或 detach() 后 joinable() 将返回 false,并且不可再次 join() 或 detach();被移动后的线程对象调用 joinable() 同样会返回 false。
如果 std::thread 在析构时仍然 joinable()(即未 join() 或 detach()),则程序会调用 std::terminate() ,因此必须显式 join() 或 detach() 在对象销毁前处理线程。
detach() 后的线程仍在后台运行, 如果它访问了调用线程的局部变量(通过引用或指针),而调用线程提前退出,则会导致未定义行为(访问已销毁的对象)。因此,分离线程通常应避免访问调用线程栈上的变量,而是通过其他同步机制(如原子操作、互斥量)或使用动态内存来共享数据。
4. C++ Template
我们或许会经常听到“模板元编程”这样的说法,又或者会将它们同泛型编程混为一谈。但严谨地说,它们是相对独立的三个概念:
- C++ 模板(Template):是 C++ 提供的语法机制,允许在编译时生成代码(此过程即为实例化)
- 泛型编程(Generic Programming):是一种编程范式,目的是将代码与特定的数据类型解耦
- 元编程(Metaprogramming):是一种在编译阶段执行计算的技术。模板是实现这一技术的手段,不过引入
constexpr后,后者才是更加现代化的选择
我们学习的重点是 C++ 模板本身。
1. 函数模板
1. 定义与范式
在 C++ 标准中,函数模板是一个抽象的函数声明,它定义了一族函数的生成规则。
其基本范式为由关键字 template 引导,后续 <> 包围的模板参数列表:
1 | template<typename T1, typename T2, ...> |
typename 是模板参数的占位符,也可以使用 class。
二者在大部分场景是等价的,但
typename具有更明确的语义。此外,只有typename可以定义嵌套从属名称,而class不行,即:当模板中的某个名称依赖于模板参数,并且想要将其当作一个类型时,必须在它前面加上
typename。否则,编译器编译器会默认把它当作一个变量或静态成员,从而导致编译错误依赖名称是指名称的含义依赖于模板参数,如:
T::foo,T是模板参数。但是foo具体是什么呢(类型、变量,枚举值)?编译器在编译阶段无法确定,只有使用具体类型替换模板参数进行模板实例化时,才能知道依赖名称的真实含义。在默认情况下,依赖名称被当作非类型(变量/静态成员),如果想让它被解释为类型,就必须显式使用
typename关键字:
1 | template <typename Container> |
下面是一个函数模板示例,返回两个对象中较大那个:
1 | template<typename T> |
例如,当我们调用 max(1, 2) 时,编译器首先进行类型推导(此处推导 T 为 int),然后实例化出 int 版本的 max 函数。此处的返回值的类型也是 T,这要求 T 还需要是可复制或可移动的,才可以作为返回值类型返回。
C++17 之前,类型 T 必须是可复制或移动才能传递参数。C++17 以后,即使复制构造函数和移动构造函数都无效,因为 C++17 强制的复制消除,也可以传递临时纯右值。
所谓复制消除简单来说,在 C++17 之前,即使编译器非常聪明(使用了 RVO,即返回值优化),它在语义检查上仍然比较死板,例如:
- 当编译器遇到
T x = T()时,会认为发生了两步:创建一个临时的T对象;调用复制/移动构造函数将其搬运给x- 哪怕编译器想要优化这个搬运过程,C++ 标准也要求
T必须具备可用的复制或移动构造函数。如果这二者被delete了,代码就无法通过编译而 C++17 之后,对纯右值(prvalue) 进行了重新定义,prvalue 不再是一个临时对象,而是一个初始化操作(initializer)。
T x = T()不再被视为创建后搬运,二十直接在x的内存上执行T的构造函数。因为根本没有发生复制或移动的操作,所以编译器完全不需要检查复制或移动构造函数是否存在。
1 |
|
2. 实例化
模板并不是真正的函数或类,而是定义了一族函数或类的生成规则。编译器不会为模板本身生成机器码,只有指定了具体的类型,它才会生成真正的代码,这个过程称之为实例化。
Reference
