C++#
对比系列#
静态局部变量 v.s. 全局变量 v.s. 局部变量#
| 分类 | 局部变量 | 静态局部变量 | 全局变量 |
|---|---|---|---|
| 作用域 | 当前函数或者代码块内 | 当前函数内部(外部不能访问) | 整个程序内 |
| 生命周期 | 每次进入函数创建,用完就没 | 程序一运行就存在,一直到程序结束 | 程序启动时创建,程序结束才销毁 |
| 初始化行为 | 不赋值的话值不确定(是随机的) | 初始化一次(默认0),之后值会保留 | 默认初始化为0 |
| 存储位置 | 栈区(stack),速度快但不持久 | 数据段或 BSS 段(非栈),可长期保存 | 数据段或 BSS 段,生命周期长 |
| 适合场景 | 做临时运算,比如循环里的变量、临时数组等 | 想在函数里“记住之前的值”,如统计次数、递归深度控制等 | 整个程序都需要共享的变量,比如配置信息、缓冲区等 |
指针 v.s. 引用#
本质(是什么)
- 指针:是一个变量,其值是另一个变量的内存地址。它本身占用内存空间(通常是 4 或 8 字节,取决于系统)。
- 引用:是另一个已存在变量的别名。它不是独立的变量,不能为空,不占用额外的存储空间(在底层通常由指针实现,但语言层面隐藏了地址操作)。
从本质出发看到区别
初始化
可空性
重新赋值
操作符
- 指针:取地址、解引用、算术运算
- 引用: 使用方式与它所引用的原始变量完全相同
参数传递
多级间接(
int&& rr是右值引用)
底层
在大多数编译器的实现中,引用在底层通常就是通过指针来实现的。编译器会为引用生成类似指针操作的代码。
关键区别在于语言层面的抽象:
- 指针将内存地址直接暴露给程序员,赋予了更大的灵活性(但也带来了更多出错机会,如空指针、野指针、内存泄漏)。
- 引用隐藏了地址操作,强制要求绑定有效对象且不可重绑定,提供了更高的安全性和语法简洁性。编译器负责处理底层的地址操作。
主要用途
指针
- 动态内存管理
- 可选参数
- 低级操作
- 数据结构实现
引用
- 函数参数/返回值 (避免拷贝)
- 操作符重载
- 范围 for 循环
引申点
const 指针 v.s. const 引用
- const int* p/ int const* p: 指向常量整数的指针(指针可变,指向的值不可变)。
- int* const p: 指向整数的常量指针(指针不可变,指向的值可变)。
- const int* const p: 指向常量整数的常量指针(指针和值都不可变)。
- const int& r: 对常量整数的引用(通过引用不能修改原值)。这是传递不希望被修改的大对象(如 std::string)到函数的常见方式,避免拷贝开销。
右值引用
函数返回引用注意点
- 绝对不能返回局部变量的引用或指针
- 可以返回:静态局部变量、全局变量、动态分配内存的指针(但要注意所有权)、传入的引用/指针参数指向的对象、成员变量(通过 this 指针)等生命周期长于函数调用的对象的引用。
数据类型#
整型 short、int、long、long long
sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long)
无符号类型 unsigned
关键字系列#
const#
const 用于声明一个对象、引用、指针、成员函数或函数参数/返回值具有 “不可变性” 或 “只读” 属性。
其主要目的是增强代码的安全性、可读性和可维护性,并可能帮助编译器进行优化。
修饰变量(对象)
-
const type name = initializer;或type const name = initializer;
-
修饰指针
指向常量的指针 (Pointer to const)
-
const type* ptr;或type const* ptr;
-
常量指针 (Const pointer)
-
type* const ptr = &var;
-
指向常量的常量指针 (Const pointer to const):
-
const type* const ptr = &const_var;
-
修饰引用
常量引用 (Reference to const)
-
const type& ref = var;
-
修饰函数参数
修饰成员函数
-
return_type function_name(parameters) const;
-
修饰函数返回值
- 作用: 防止函数返回值被用作左值(即防止对返回值进行赋值)。
- 常见场景: 返回内部状态(如字符串、容器)的引用或指针时,如果不希望调用者修改内部状态,应返回
const引用或指向const的指针。 - 原理: 返回值被赋予
const属性,尝试修改它会导致编译错误。
对比提问:constexpr
constexpr#
constexpr (常量表达式)的核心思想是:将计算从运行时转移到编译时。
- 性能提升:在编译期完成计算,运行时直接使用结果,实现零开销抽象。
- 类型安全:在编译期进行运算和检查,比预处理器宏(如
#define)更安全、更强大。- 泛型编程支持:为模板元编程提供更直观、更易写的替代方案,使编译时计算更像“普通的 C++ 代码”。
- 扩大常量可用的语境:使得在数组大小、模板非类型参数、
case标签等要求编译时常量的地方,可以使用更复杂的表达式和函数调用。
- C++11 - 引入了 constexpr,但限制非常严格。
- C++14 - 大幅放宽限制
- C++17 - constexpr lambda 与 if constexpr
- C++20 - 几乎允许在 constexpr 语境中使用一切
volatile#
核心语义是告诉编译器: “这个变量的值可能会被程序之外的因素改变,不要对这个变量的访问做任何激进的优化假设。”
编译器优化:冗余加载消除、延迟写入、寄存器缓存
作用机制:禁止冗余加载/存储、禁止指令重排(相对其他 volatile操作)、强制内存访问
典型应用场景:
内存映射硬件寄存器: 这是最常见的用途。硬件寄存器的值会随着硬件状态(如外部输入、定时器溢出、通信完成)而随时改变,与程序逻辑无关。
被中断服务例程(ISR)修改的变量: 主循环中读取一个标志位,该标志位由中断服务程序设置。
注意:不等于原子性;不保证内存顺序;不能替代同步 = 》std::atomic通常是更好的选择
static#
核心思想是:控制存储期(Storage Duration)和链接性(Linkage)。 进而使其具有静态存储期(Static Storage Duration) ,并且通常限制其作用域(Scope) 。
函数内部(局部变量)
作用: 声明一个局部静态变量(Local Static Variable)
效果:
- 生命周期: 该变量的生命周期从程序启动时开始(在 main 函数执行之前进行初始化),直到程序结束时销毁。它不会在函数调用结束后被销毁。
- 作用域: 仍然局限于定义它的函数或代码块内部。函数外部无法直接访问它。
- 初始化: 只在第一次执行到其声明语句时进行初始化。后续的函数调用会跳过初始化,直接使用之前的值。
底层机制:
- 编译器会在程序的静态存储区(Static Storage Area) (通常是
.data 或.bss段)为这个变量分配内存,而不是在栈上(Stack)。 - 编译器会生成一个隐藏的标志位(guard variable) (在 C++11 之前,这通常不是线程安全的)。当函数第一次被调用时,检查标志位,如果未初始化则进行初始化并设置标志位。后续调用直接使用已初始化的值。C++11 标准规定了局部静态变量的初始化是线程安全的(编译器通常使用类似
std::call_once的机制或锁来实现)。
- 编译器会在程序的静态存储区(Static Storage Area) (通常是
典型用途: 需要在多次函数调用间保持状态,但又不想使用全局变量(避免全局命名空间污染)。例如计数器、缓存、单例模式(Meyers’ Singleton)。
在类定义内部(成员变量和成员函数)
静态成员变量(Static Data Member):
作用: 声明一个属于类本身的变量,而不是属于类的某个特定对象
效果:
- 生命周期: 与程序的生命周期相同(静态存储期)。
- 作用域: 在类的作用域内。可以通过类名加作用域解析运算符
:: 访问(ClassName::staticVar),也可以通过类的对象访问(obj.staticVar),但更推荐前者以明确其静态属性。 - 存储: 所有该类的对象共享同一个静态成员变量的实例。它在内存中只有一份拷贝。
- 初始化: 必须在类定义外部(通常在对应的
.cpp 文件中)进行唯一定义和初始化(使用数据类型 ClassName::变量名 = 初始值;)。C++17 引入了inline static,允许在类定义内部直接初始化(但本质上还是需要定义,编译器会处理)。
底层机制: 编译器将其视为一个全局变量,但其链接性(Linkage) 通常是内部链接(Internal Linkage) (在未命名的命名空间内)或者通过特定的修饰符(取决于编译器实现)使其作用域限定在类内。内存分配在静态存储区。
典型用途: 存储类的所有对象共享的信息,如对象计数器、常量配置、共享资源句柄。
静态成员函数(Static Member Function):
作用: 声明一个属于类本身的函数,而不是属于类的某个特定对象。
效果:
- 调用方式: 可以通过类名加作用域解析运算符
:: 直接调用(ClassName::staticFunc()),也可以通过类的对象调用(obj.staticFunc())。 -
this指针: 静态成员函数没有this 指针。因此,它不能直接访问类的非静态成员变量和非静态成员函数(因为它们需要通过this 指针来访问特定对象的数据)。它只能直接访问类的静态成员变量和其他静态成员函数。
- 调用方式: 可以通过类名加作用域解析运算符
底层机制: 编译器将其视为一个普通的全局函数,但其名称经过修饰(Name Mangling)以包含类名信息,并且其链接性(Linkage) 通常是内部链接(Internal Linkage) (在未命名的命名空间内)或者通过特定的修饰符使其作用域限定在类内。
典型用途: 执行与类相关但不依赖于特定对象状态的操作,如操作静态成员变量、工具函数、工厂方法。
在文件作用域(全局变量和函数):
作用: 声明具有内部链接(Internal Linkage) 的全局变量或函数。
效果:
- 链接性: 被
static 修饰的全局变量或函数,其链接性从默认的外部链接(External Linkage) (可以被其他源文件通过extern 声明访问)变为内部链接(Internal Linkage) 。 - 作用域: 仍然在定义它的文件内有效(文件作用域)。
- 生命周期: 与程序的生命周期相同(静态存储期)。
- 可见性: 只能在定义它的源文件(Translation Unit) 内被访问。其他源文件即使使用
extern声明也无法访问它。这有效地避免了不同源文件中同名全局标识符的冲突。
- 链接性: 被
底层机制: 编译器在生成目标文件时,会给这些
static 全局变量/函数打上“内部链接”的标记。链接器(Linker)在处理多个目标文件时,会忽略其他文件中对这些内部链接符号的引用请求(extern声明),只在定义它们的文件内部解析引用。典型用途: 限制只在当前源文件内使用的全局变量或函数,避免命名冲突和意外的外部访问,提高封装性。
| 用位置 | 关键特性 | 生命周期 | 作用域 | 链接性 | 初始化时机 | 可访问性 | 底层机制 | 典型用途 |
|---|---|---|---|---|---|---|---|---|
| 函数内部(局部静态变量) | static修饰局部变量 | 程序执行到声明语句后开始,直到程序结束 | 限于函数内部 | 无链接性(仅函数可见) | 首次执行到时初始化 | 仅函数内部可访问 | 存于静态存储区.data/.bss;C++11 起线程安全 | 保留状态、计数器、单例实现 |
| 类定义内部(静态成员变量) | static修饰成员变量 | 程序启动至结束 | 属于类而非对象 | ⚠️ 通常为外部链接(需类外定义),而非内部链接 | 程序开始时初始化(或首次使用时) | 类名或对象均可访问 | 静态存储区中共享一份拷贝 | 对象计数、共享配置、资源管理 |
| 类定义内部(静态成员函数) | static修饰成员函数 | 与程序同生命周期 | 属于类而非对象 | 内部链接(限定在类作用域) | 程序加载时 | 无this指针,仅能访问静态成员 | 视为带类名修饰的普通全局函数 | 工厂函数、工具函数、访问共享资源 |
| 文件作用域(全局变量 / 函数) | static修饰全局符号 | 程序生命周期 | 文件内有效 | 内部链接(Internal Linkage) | 程序启动时 | 文件内可见,其他文件不可见 | 编译器标记为内部符号,链接器不导出 | 封装模块内部变量/函数,防止命名冲突 |
引申点:
static v.s. extern
线程安全
- 局部静态变量初始化:语言底层机制保证安全
- 静态成员变量初始化:其初始化(在类外定义时)不是线程安全的
static and const
static 与 单例模式
class Singleton { public: static Singleton& getInstance() { static Singleton instance; // 线程安全的初始化 (C++11+) return instance; } // 删除拷贝构造和赋值 Singleton(const Singleton&) \= delete; Singleton& operator\=(const Singleton&) \= delete; private: Singleton() {} // 私有构造函数 };
inline static (C++17 引入)
# define#
核心概念:预处理器指令 (Preprocessor Directive)
处理时机:预处理,编译之前;操作本质:文本替换;结果:生成翻译单元
常见用途:
定义符号常量 (Symbolic Constants):
- 现代 C++ 建议: 优先使用 const 或 constexpr 定义常量
定义宏函数 (Macro Functions):
- 现代 C++ 替代方案: 优先使用内联函数 (inline) 或函数模板
条件编译 (Conditional Compilation):
特殊操作符:#和 ##
- 字符串化运算符 #: 将宏参数转换为字符串字面量。
- 连接运算符 ##: 将两个标记 (Token) 连接成一个新的标记。
typedef#
typedef是 C++(继承自 C)中的一个关键字,用于为现有的数据类型创建新的名称(别名) 。
typedef <existing_type> <new_name>;
主要场景
简化基础类型
简化结构体/类
简化指针类型
模板别名(C++98/03 方式)- using(C++11+)
底层原理
- 编译时行为: typedef 纯粹是一个编译时指令。它不分配内存,也不创建新类型。它只是在编译器进行类型检查时,告诉编译器“看到 <new_name> 的地方,就当作 <existing_type> 来处理”。
- 类型系统: typedef 创建的是类型别名。别名和原始类型在编译器看来是完全相同的类型。
- 作用域: typedef 遵循标准的作用域规则
引申
- typedef v.s. using
// typedef 函数指针 typedef bool (*OldComparator)(const std::string&, const std::string&); // using 函数指针 using NewComparator = bool (*)(const std::string&, const std::string&); // typedef 无法直接创建模板别名,只能为特定类型: typedef std::map<std::string, int> OldStringIntMap; // using 可以创建模板别名 template<typename T> using NewStringMap = std::map<std::string, T>;
using#
类型别名 (Type Aliases)
命名空间管理 (Namespace Management)
-
using声明 (using declaration) -
using指令 (using directive)
-
在类继承中引入基类成员 (Bringing Base Class Members into Scope)
inline#
本质(编译器提示): 向编译器建议将函数的代码体在每个调用点直接展开,而不是执行常规的函数调用(压栈、跳转、弹栈等)
- 减少函数调用开销: 对于非常小的、频繁调用的函数,调用本身的指令(参数传递、跳转、返回)可能比函数实际执行的操作开销更大。内联可以消除这部分开销。
- 避免链接时因重复定义导致的错误: 这是
inline在现代 C++ 中更关键的作用,尤其是在头文件中定义函数时。
单一定义规则 ODR - One Definition Rule
- 在函数定义前加上 inline 关键字,豁免了该函数定义对 ODR 的严格限制。它告诉链接器:所有翻译单元中看到的这个 inline 函数的定义都是相同的,链接器可以任意选择一个(通常是第一个遇到的)定义,并忽略其他重复的定义。这使得在头文件中安全地定义函数成为可能
- inline 变量(C++17 引入)
语法和位置
- 在函数声明或定义处都可以使用
inline关键字。为了清晰和确保在头文件中定义有效,通常直接在定义处使用。 - 类成员函数:在类定义内部直接定义的成员函数(包括友元函数)隐式地(implicitly) 是
inline的。你不需要显式添加inline关键字。 - 模板函数:函数模板的定义通常放在头文件中。编译器在实例化模板时,会为每个使用的类型参数组合生成具体的函数。这些实例化出来的函数不是隐式
inline的。但是,将函数模板定义放在头文件中通常不会导致链接错误,因为模板实例化规则本身处理了重复定义的问题(编译器/链接器会去重)。显式给模板函数加inline效果与非模板函数相同
- 在函数声明或定义处都可以使用
extern#
核心作用:声明外部链接(External Linkage), 声明一个变量或函数是在其他翻译单元(通常是其他 .cpp 文件)中定义的。
前置 ++ 与后置 ++#
类型转换#
https://yb.tencent.com/s/cUgYvUvqFXdU
隐式转换
定义: 由编译器在编译时自动执行的类型转换,无需程序员显式指定。编译器根据语言规则和上下文判断是否需要以及如何进行转换。
目的: 使表达式能够求值,函数调用能够匹配,赋值能够进行等。旨在提供一定的灵活性。
常见场景
- 算术提升
- 算术转换
- 整数到浮点数转换
- 派生类指针/引用到基类指针/引用(向上转型 - Upcasting)
- 0 或 nullptr 到指针类型
- T 到 void: 任何类型都可以转换为 void(丢弃值)
- 数组到指针的退化(Array-to-Pointer Decay)
- 用户定义的转换(User-Defined Conversions)
强制类型转换/显式转换
定义: 程序员在代码中明确指示编译器执行特定类型转换的操作。
目的: 覆盖编译器的隐式转换规则,执行一些编译器认为不安全或不允许的转换,或者明确表达转换意图。
- (new_type)expression (C 风格强制转换)
- static_cast
- dynamic_cast
- reintrpret_cast
- const_cast
- bit_cast (C++20 引入)
异常机制 | try/throw/catch#
虚函数表#
语言版本#
C -> C++
编程范式不同:C 纯过程语言;C++ 多范式语言(过程 + 面向对象 + 泛型)
- 面向对象 OOP 三大特性在 C++ 中的实现原理(封装-访问控制、继承-虚函数表、多态-(编译时:重载 + 模板)(运行时:虚函数)
特性 C C++ 底层原理剖析 内存管理 malloc/free new/delete + 智能指针 new 调用构造函数 + 内存分配,delete 触发析构 函数特性 无重载/默认参数 函数重载、默认参数、lambda 名称修饰(name mangling)实现重载 类型安全 弱类型(void*隐式转换) 强类型(static_cast/dynamic_cast) RTTI 机制支持安全向下转型 错误处理 返回码/errno 异常机制(try/catch) 栈展开(stack unwinding)代价 代码组织 头文件声明全局函数 命名空间避免污染 符号修饰添加 namespace 前缀 关键机制
访问控制
- struct/class 作用域差异,(C++ 中 struct 可包含方法)
虚函数表
- 每个含虚函数的类拥有隐藏指针指向虚函数表
动态绑定
- 运行时通过 vtable 查找实际函数地址(多态成本:1 次指针跳转 + 缓存不友好)
模板元编程 (TMP)
兼容性陷阱
- C++ 并不是 C 的超集. eg. C++ 不兼容的 C 写法
-
void* p = malloc(100); int* ip = p; // C允许void*隐式转换,C++需要显式转换 - C++ 与 C 不能一起编 extern
右值引用 (T&&)、移动语义和完美转发#
核心目标:解决不必要的拷贝开销,提升性能,特别是对于管理资源的对象(如 std::string, std::vector, 自定义包含指针的类)。
左值、右值#
左值: 可以放在等号左边,可以取地址并有名字
右值: 不可以放在等号左边,不能取地址,没有名字
e.g. 字符串字面值"abcd"也是左值,不是右值;++i、–i是左值,i++、i–是右值
将亡值#
将亡值可以理解为即将要销毁的值,通过“盗取”其它变量内存空间方式获取的值,在确保其它变量不再被使用或者即将被销毁时,可以避免内存空间的释放和分配,延长变量值的生命周期,常用来完成移动构造或者移动赋值的特殊任务
左值引用#
左值引用就是对左值进行引用的类型,是对象的一个别名
并不拥有所绑定对象的堆存,所以必须立即初始化。 对于左值引用,等号右边的值必须可以取地址,如果不能取地址,则会编译失败,或者可以使用const引用形式
右值引用#
表达式等号右边的值需要是右值,可以使用std::move函数强制把左值转换为右值
移动语义#
可以理解为转移所有权,对于移动语义,类似于转让或者资源窃取的意思,对于那块资源,转为自己所拥有,别人不再拥有也不会再使用。
通过移动构造函数使用移动语义,也就是std::move;移动语义仅针对于那些实现了移动构造函数的类的对象,对于那种基本类型int、float等没有任何优化作用,还是会拷贝,因为它们实现没有对应的移动构造函数
完美转发#
写一个接受任意实参的函数模板,并转发到其它函数,目标函数会收到与转发函数完全相同的实参,通过std::forward()实现
智能指针#
理念:RAII 自动化管理资源
- 资源获取即初始化
- 资源释放即析构
- 所有权管理
- std::unique_ptr(唯一所有权指针)
- std::shared_ptr(共享所有权指针)
- std::weak_ptr(弱引用指针)
- std::auto_ptr(已废弃 - C++11 移除, C++17 正式移除) 简述: C++98 引入的早期尝试,意图实现独占所有权。
lambda 表达式#
底层本质始终是编译器生成一个匿名的类类型(或类模板)。捕获列表决定了成员变量,参数列表和模板参数决定了
operator()的签名,mutable和constexpr修饰operator()的性质。
是什么:Lambda 表达式提供了一种定义匿名函数对象(闭包) 的简洁方式。它允许你在需要函数的地方(比如作为参数传递给算法)就地定义函数逻辑,而无需预先定义一个具名函数(函数指针或函数对象)。
基本语法结构
[capture-list] (parameters) mutable -> return-type { // 函数体 }-
[capture-list] (捕获列表): 这是 Lambda 区别于普通函数的关键。它指定了 Lambda 体中可以访问哪些外部作用域的变量以及如何访问(按值捕获、按引用捕获或混合)。捕获列表可以为空[]。 -
(parameters) (参数列表): 与普通函数的参数列表类似。可以为空()。 -
mutable (可选): 默认情况下,Lambda 生成的函数调用运算符 (operator()) 是一个const成员函数。这意味着,对于按值捕获的变量,你不能在 Lambda 体内修改它们的副本(即使原始变量是非 const 的)。使用mutable关键字会移除operator()的const限定符,允许修改按值捕获的变量的副本。它不影响按引用捕获的变量(你总是可以通过引用修改原变量)。 -
-> return-type (尾置返回类型 - 可选): 指定 Lambda 的返回类型。如果 Lambda 体只包含一个return语句,或者没有返回值(void),编译器通常可以推导出返回类型,这时可以省略。对于复杂的返回类型或多个返回路径,需要显式指定。 -
{ ... } (函数体): 包含 Lambda 要执行的代码。
-
捕获列表详解(核心难点)
- 按值捕获 [=]或 [var1, var2, …]:
- 按引用捕获 [&]或 [&var1, &var2, …]:
- 混合捕获: 可以同时使用按值和按引用捕获,例如 [x, &y]。
- 捕获 this:
- 初始化捕获(广义捕获)(C++14):
初始化捕获 (Generalized Lambda Capture / Init Capture) (C++14)
- C++11 的捕获列表只能捕获当前作用域中已存在的变量名。
- 无法直接捕获仅移动类型(如 std::unique_ptr),因为按值捕获需要拷贝构造(被删除),按引用捕获又不安全(可能导致悬空引用)。
- 无法用任意表达式初始化捕获的成员(例如,捕获一个计算结果的副本)。
- 无法实现移动语义捕获(高效转移资源所有权)。
constexpr Lambda (C++17)
- 允许 Lambda 表达式在编译时求值的上下文中使用(如 constexpr 变量初始化、模板元编程),扩展了 Lambda 在元编程和性能优化中的应用。
Lambda 模板参数 (Generic Lambdas with Explicit Template Parameters)(C++20)
类型推导#
auto 关键字
用于声明变量,让编译器根据初始化表达式推导变量的类型。
编译器分析初始化表达式(等号右边的部分)的类型。
- 应用与模板参数推导几乎相同的规则(见下文)来推导 auto 所代表的类型 T。
- 忽略顶层 const 和顶层 volatile 限定符(除非它们是初始化表达式类型的一部分)。
- 忽略引用! 这是关键点。如果初始化表达式是引用类型,auto 推导出的类型是引用所指向的对象的类型(去除引用)
主要用于简化变量声明
decltype 关键字
查询表达式或实体的声明类型。它返回表达式在编译时的确切类型,包括所有修饰符(const, volatile, 引用)。
对于变量名(如 decltype(x)):返回该变量的声明类型(包括 const, volatile, 引用)。
对于非变量名的表达式(如 decltype((x))或 decltype(x + y)):
- 如果表达式的结果是左值 (lvalue),则 decltype 产生 T&(其中 T 是表达式的类型)。
- 如果表达式的结果是亡值 (xvalue),则 decltype 产生 T&&。
- 如果表达式的结果是纯右值 (prvalue),则 decltype 产生 T。
主要用于需要精确类型信息的场景,如返回类型推导
decltype(auto)结合了auto的简洁和decltype的精确性。它使用decltype的规则推导auto变量类型,解决了auto会剥离引用和顶层const的问题,特别适用于完美转发和返回类型推导。
函数模板参数推导
- 作用: 在调用函数模板时,编译器根据传递给函数模板的实参类型来推导模板参数的类型。
- 编译器为每个函数实参和对应的模板参数建立一个推导上下文。
Lambda 表达式返回类型推导 (C++14)
- 作用: 允许省略 lambda 表达式的返回类型,让编译器根据
return语句推导返回类型。 - 编译器分析 lambda 函数体中的所有 return 语句。
- 作用: 允许省略 lambda 表达式的返回类型,让编译器根据
结构化绑定 (C++17)
- 作用: 从数组、元组 (
std::tuple)、结构体或类对象中一次性解包多个元素到不同的变量。
- 作用: 从数组、元组 (
类模板参数推导 (CTAD - C++17)
- 作用: 在构造类模板对象时,根据构造函数的实参推导类模板的参数类型,无需显式指定模板参数。
基于范围的迭代写法/for 循环#
列表初始化#
STL#
六大组件#
| 组件 | 功能 | 常见类型 |
|---|---|---|
| Containers(容器) | 存储数据 | vector,list,deque,set,map,unordered_map,stack,queue,priority_queue |
| Algorithms(算法) | 操作容器元素 | sort,find,for_each,accumulate,copy,remove_if,unique,lower_bound,upper_bound |
| Iterators(迭代器) | 连接算法与容器 | begin(),end(),rbegin(),advance(),next() |
| Allocators(分配器) | 管理内存分配 | 默认 allocator<T>,可自定义 |
| Functors(函数对象) | 行为参数化 | greater<int>,less<int>, 自定义比较器 |
| Adapters(适配器) | 改变接口行为 | 容器适配器(stack,queue,priority_queue)与函数适配器(bind,not1,mem_fn) |
常见容器#
顺序容器#
| 容器 | 底层结构 | 随机访问 | 插入删除 | 迭代器稳定性 | 特点 |
|---|---|---|---|---|---|
| vector | 连续内存数组 | ✅ O(1) | 尾部 O(1),中间 O(n) | 重新分配时失效 | 动态数组,扩容按倍增长 |
| deque | 分段连续数组 + 中控表 | ✅ O(1) | 头尾 O(1) | 插入时部分失效 | 支持双端插入删除 |
| list | 双向链表 | ❌ | 任意位置 O(1) | 稳定 | 不支持随机访问 |
| forward_list | 单向链表 | ❌ | O(1) | 稳定 | 节省内存,C++11 引入 |
| array | 固定大小数组 | ✅ O(1) | ❌ | 稳定 | 编译期确定长度 |
| string | 动态数组 + SSO (Small String Optimization) | ✅ O(1) | 尾部 O(1) | 分配后失效 | 支持字符串操作 |
关联容器#
| 容器 | 底层结构 | 有序性 | 插入删除 | 查找 | 比较方式 | 特点 |
|---|---|---|---|---|---|---|
| set / multiset | 红黑树 | ✅ 有序 | O(log n) | O(log n) | less<T> | 不重复 / 可重复键 |
| map / multimap | 红黑树 | ✅ 有序 | O(log n) | O(log n) | less<Key> | key-value 键值对 |
| unordered_set / unordered_map | 哈希表 | ❌ 无序 | 平均 O(1) | 平均 O(1) | hash<T>+== | 哈希冲突时性能退化 |
容器适配器#
| 容器 | 底层结构 | 接口限制 | 特点 |
|---|---|---|---|
| stack | deque(默认) /vector | 仅顶端操作 | 后进先出 (LIFO) |
| queue | deque(默认) /list | 仅首尾操作 | 先进先出 (FIFO) |
| priority_queue | vector+ 堆算法 | 仅访问最大元素 | 堆结构维护优先级 |
迭代器类别与特性#
| 类型 | 支持操作 | 示例容器 | 复杂度 |
|---|---|---|---|
| InputIterator | 单向读 | istream_iterator | O(1) |
| OutputIterator | 单向写 | ostream_iterator | O(1) |
| ForwardIterator | 单向读写 | forward_list | O(1) |
| BidirectionalIterator | 双向 | list,set,map | O(1) |
| RandomAccessIterator | 随机访问 | vector,deque | O(1) |
考察点
迭代器失效
常见算法与复杂度#
| 法 | 适用容器 | 时间复杂度 | 说明 |
|---|---|---|---|
| sort() | 随机访问迭代器(vector、deque) | O(n log n) | 快速排序改进版 |
| stable_sort() | 同上 | O(n log² n) | 稳定排序 |
| find() | 所有容器 | O(n) / O(log n) | 线性 / 二分查找 |
| lower_bound()/upper_bound() | 有序容器 | O(log n) | 二分边界查找 |
| unique() | 顺序容器 | O(n) | 去重相邻元素 |
| remove()/erase() | 顺序容器 | O(n) | 移除元素 |
| for_each() | 所有容器 | O(n) | 遍历应用函数 |
面试高频问答总结#
内存管理#
内存分区#

new/delete v.s. malloc/free#
// 代码:T* p = new T(value);
// 底层相当于:
void* memory = operator new(sizeof(T)); // 1. 分配内存
T* p = new(memory) T(value); // 2. 构造对象
// 代码:delete p;
// 底层相当于:
p->~T(); // 1. 调用析构函数
operator delete(p); // 2. 释放内存
| 特性 | new/delete | malloc/free |
|---|---|---|
| 语言 | C++ 运算符 | C 标准库函数 |
| 构造/析构 | ✅ 调用构造函数 / 析构函数 | ❌ 仅分配/释放原始内存 |
| 类型安全 | ✅ 返回类型指针 / 需类型匹配 | ❌ 返回void*/ 需强制转换 |
| 内存大小 | 自动计算 (sizeof由编译器处理) | 需手动计算 (sizeof由程序员指定) |
| 失败处理 | 抛出std::bad_alloc异常 (默认) | 返回NULL |
| 可重载性 | ✅ 可全局或按类重载 | ❌ 不可重载 |
| 内存区域 | 自由存储区 (free store) | 堆 (heap) |
| 初始化 | 调用构造函数初始化 | 内存未初始化 (需手动初始化) |
| 配对使用 | new必须配delete | malloc必须配free |
智能指针#
#include <memory>
// 独占所有权
std::unique_ptr<MyClass> up1 = std::make_unique<MyClass>();
// 共享所有权
std::shared_ptr<MyClass> sp1 = std::make_shared<MyClass>();
// 弱引用,解决循环引用
std::weak_ptr<MyClass> wp1 = sp1;
std::unique_ptr<T> (唯一所有权指针)核心特性:
- 独占所有权: 同一时间只有一个
unique_ptr拥有对对象的所有权。 - 禁止拷贝: 拷贝构造函数和拷贝赋值运算符被
= delete。 - 支持移动: 移动构造函数和移动赋值运算符允许所有权转移
std::move。转移后,原指针变为nullptr。
- 独占所有权: 同一时间只有一个
底层机制:
- 内部封装一个原始指针。
- 析构函数中调用
delete(或自定义删除器) 释放资源。 - 移动操作通过交换内部指针实现所有权转移。
使用场景:
- 明确资源只有一个所有者时(最常见)。
- 作为工厂函数的返回值。
- 管理需要明确释放顺序的资源(如依赖关系)。
- 在容器中存储动态分配的对象(优先于
auto_ptr,已废弃)。
面试发散:
-
make_unique为什么比直接new更安全?(避免构造函数抛出异常导致内存泄漏) - 如何实现一个简单的
unique_ptr?(展示对所有权、移动语义、析构的理解) -
unique_ptr如何管理数组?(std::unique_ptr<T[]>或使用std::array/std::vector)
-
std::shared_ptr<T> (共享所有权指针)核心特性:
- 共享所有权: 多个
shared_ptr可以指向同一个对象。 - 引用计数: 内部维护一个引用计数器(通常位于动态分配的控制块中)。当最后一个
shared_ptr被销毁或重置时,计数器归零,对象被销毁。 - 支持拷贝和移动: 拷贝增加引用计数;移动转移所有权,不影响原计数。
- 共享所有权: 多个
底层机制:
包含两个指针:一个指向被管理对象,一个指向控制块。
控制块 (Control Block): 通常包含:
- 强引用计数 (
use_count)。 - 弱引用计数 (
weak_count)。 - 指向被管理对象的指针。
- 自定义删除器 (如果提供)。
- 自定义分配器 (如果提供)。
- 强引用计数 (
拷贝构造/赋值:增加强引用计数。
析构:减少强引用计数;如果减到0,则调用删除器销毁对象并释放其内存;如果弱引用计数也为0,则释放控制块内存。
使用场景:
- 资源需要被多个对象共享访问,且无法确定哪个对象最后使用它时。
- 需要实现类似“观察者”模式(常配合
weak_ptr)。
关键问题:循环引用 (Cyclic Reference)
- 问题描述: 两个或多个
shared_ptr互相持有对方的引用,导致引用计数永远无法降到0,内存泄漏。 - 解决方案: 使用
std::weak_ptr打破强引用循环。
- 问题描述: 两个或多个
面试发散:
-
make_shared为什么通常比直接new更高效?(对象和控制块可能一次分配) - 引用计数是原子的吗?为什么?(是,保证多线程环境下计数正确)
- 循环引用是如何发生的?如何用
weak_ptr解决?(画图说明) - 控制块里具体存了什么?(深入理解实现细节)
-
shared_ptr的线程安全性?(引用计数本身是原子的,但管理的对象本身不是线程安全的)
-
std::weak_ptr<T> (弱引用指针)核心特性:
- 不拥有所有权: 不增加对象的引用计数。
- 不控制对象生命周期: 对象可能已被销毁。
- 用于观察: 观察由
shared_ptr管理的对象是否存在。 - 打破循环: 解决
shared_ptr循环引用问题的关键。
底层机制:
- 内部通常包含一个指向
shared_ptr控制块的指针。 - 不持有指向被管理对象的强引用。
- 控制块中的弱引用计数 (
weak_count) 记录有多少weak_ptr指向它。weak_count不影响对象的生命周期(当强引用计数为0时对象即被销毁),但影响控制块本身的生命周期(只有当强引用和弱引用计数都为0时,控制块才被释放)。
- 内部通常包含一个指向
使用方式:
- 必须从一个
shared_ptr或另一个weak_ptr构造/赋值。 - 要访问对象,需要调用
lock()方法:
- 必须从一个
使用场景:
- 打破
shared_ptr的循环引用(将循环链中的某个指针改为weak_ptr)。 - 缓存:存储对某个对象的弱引用,如果对象还在就使用,不在就重新加载。
- 观察者模式:观察者持有被观察者的
weak_ptr,避免影响被观察者生命周期。
- 打破
面试发散:
-
weak_ptr如何知道对象是否还存在?(通过检查控制块中的强引用计数) - 为什么
weak_ptr不直接提供operator*或operator->?(强制使用者检查有效性) -
weak_count的作用是什么?(管理控制块的生命周期)
-
面试手撕:
自己实现智能指针#
工厂模式#
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
};
class Circle : public Shape {
public:
void draw() const override { std::cout << "Drawing Circle\n"; }
};
class Square : public Shape {
public:
void draw() const override { std::cout << "Drawing Square\n"; }
};
// 工厂类
class ShapeFactory {
public:
enum Type { CIRCLE, SQUARE };
std::unique_ptr<Shape> createShape(Type type) {
switch(type) {
case CIRCLE: return std::make_unique<Circle>();
case SQUARE: return std::make_unique<Square>();
default: throw std::invalid_argument("Invalid shape type");
}
}
};
// 使用
ShapeFactory factory;
auto shape = factory.createShape(ShapeFactory::CIRCLE);
shape->draw(); // Drawing Circle
单例模式#
class Singleton {
static std::shared_ptr<Singleton> instance;
static std::mutex mtx;
Singleton() = default; // 私有构造函数
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static std::shared_ptr<Singleton> getInstance() {
std::lock_guard<std::mutex> lock(mtx);
if (!instance) {
instance = std::shared_ptr<Singleton>(new Singleton());
}
return instance;
}
void operation() { std::cout << "Singleton operation\n"; }
};
// 静态成员初始化
std::shared_ptr<Singleton> Singleton::instance = nullptr;
std::mutex Singleton::mtx;
// 使用
auto singleton = Singleton::getInstance();
singleton->operation(); // Singleton operation
观察者模式#
class Observer : public std::enable_shared_from_this<Observer> {
public:
virtual ~Observer() = default;
virtual void update() = 0;
};
class Subject {
std::vector<std::weak_ptr<Observer>> observers; // 弱引用避免循环
public:
void attach(const std::shared_ptr<Observer>& obs) {
observers.emplace_back(obs);
}
void notify() {
for (auto it = observers.begin(); it != observers.end(); ) {
if (auto sp = it->lock()) {
sp->update();
++it;
} else {
it = observers.erase(it); // 自动清理失效观察者
}
}
}
};
class ConcreteObserver : public Observer {
public:
void update() override {
std::cout << "Observer notified!\n";
}
};
// 使用
auto subject = std::make_shared<Subject>();
auto observer = std::make_shared<ConcreteObserver>();
subject->attach(observer);
subject->notify(); // Observer notified!
RAII(资源获取即初始化)原则#
class FileHandler {
FILE* file_;
public:
explicit FileHandler(const char* filename)
: file_(fopen(filename, "r")) {}
~FileHandler() {
if(file_) fclose(file_); // 自动释放资源
}
// 禁用拷贝(或实现深拷贝/移动语义)
FileHandler(const FileHandler&) = delete;
FileHandler& operator=(const FileHandler&) = delete;
};
- 获取即初始化
- 释放
- 所有权
常见内存问题#
内存泄漏
根本原因:动态分配的内存未被正确释放
- 意外泄漏:
new/delete或malloc/free不匹配 - 循环引用:智能指针的循环依赖导致引用计数无法归零
- 异常安全:在异常抛出前未释放已分配内存
- 意外泄漏:
非法访问
- 悬空指针:指针指向的内存已被释放,但指针值未置空,访问时可能读到垃圾数据或导致段错误。
- 野指针:比如使用未初始化的指针,可能修改关键内存区域,导致程序崩溃或安全漏洞。
内存管理错误
- 重复释放:破坏内存管理器的内部数据结构,可能被利用实现代码执行攻击。
- 内存越界:经典的安全漏洞源头,可导致栈溢出攻击。
| 特征 | 栈溢出 | 堆溢出 |
|---|---|---|
| 内存区域 | 调用栈 | 动态内存池 |
| 触发原因 | 栈帧累积超过上限 | 分配大小超过堆块边界 |
| 典型场景 | 递归/大局部变量 | 数组越界/缓冲区溢出 |
| 错误类型 | SIGSEGV(栈访问越界) | 未定义行为/堆破坏 |
| 调试难度 | 相对容易(有明确调用链) | 更难(可能延迟暴露) |
内存对齐#
内存对齐(Memory Alignment) 是编译器为提高内存访问效率而采取的关键优化策略。其核心原理是要求数据在内存中的起始地址必须是其自身大小或平台指定对齐值的整数倍。
底层原理与必要性#
硬件约束:
- CPU访问内存时通常以字长(Word Size) 为单位(如32位系统为4字节,64位系统为8字节)。
- 若数据未对齐(例如
int变量起始地址为0x0003),CPU需执行多次内存访问并拼接数据,显著降低性能(甚至在某些架构如ARM中触发硬件异常)。
缓存效率:
- 对齐数据能充分利用CPU缓存行(Cache Line,通常64字节)。未对齐数据可能跨越两个缓存行,导致缓存命中率下降。
对齐规则详解#
基本类型#
内置类型对齐值等于其大小(sizeof值)
结构体/类对齐:#
- 规则1:成员对齐按其自身对齐值或编译器指定对齐值(
#pragma pack)中的较小者。 - 规则2:结构体总大小必须是其最大成员对齐值的整数倍(需填充字节)。
- 规则3:成员顺序影响总大小(优化技巧见后文)。
手动控制对齐#
编译器指令:#pragma pack(n):强制按n字节对齐(慎用,可能影响性能)。
C++11标准对齐控制
- alignas关键字:指定自定义对齐值。
- alignof运算符:获取类型的对齐要求。
常见面试问题#
malloc v.s. mmap
本质与定位
malloc:- 用户态库函数:属于 C 标准库(如 glibc)提供的堆内存分配接口。
- 抽象层级:对开发者隐藏底层细节(可能使用
brk/sbrk或mmap实现)。 - 目标场景:通用的小块内存动态分配(如创建对象、数组等)。
mmap:- 系统调用:直接由操作系统内核提供,用于建立内存映射。
- 底层原语:可映射文件到内存、创建匿名内存区、设置共享/私有映射等。
- 目标场景:大块内存分配、文件 I/O 优化、进程间共享内存。
自定义内存池
- 预分配大块内存
- 维护空闲链表
- 减少系统调用次数
计算机中的乱序执行
常见笔试问题#
sizeof() 判断#
struct 大小判断#
内存拷贝函数实现,如strcpy()#
考虑dst和src内存重叠的情况
char * strcpy(char *dst,const char *src)
{
assert(dst != NULL && src != NULL);
char *ret = dst;
my_memcpy(dst, src, strlen(src)+1);
return ret;
}
/* my_memcpy的实现如下 */
char *my_memcpy(char *dst, const char* src, int cnt)
{
assert(dst != NULL && src != NULL);
char *ret = dst;
/*内存重叠,从高地址开始复制*/
if (dst >= src && dst <= src+cnt-1)
{
dst = dst+cnt-1;
src = src+cnt-1;
while (cnt--)
{
*dst-- = *src--;
}
}
else //正常情况,从低地址开始复制
{
while (cnt--)
{
*dst++ = *src++;
}
}
return ret;
}
手写String类#
// 构造函数
String::String(const char *str)
{
if(str==NULL)
{
m_data = new char[1]; //对空字符串自动申请存放结束标志'\0'
*m_data = '\0';
}
else
{
int length = strlen(str);
m_data = new char[length + 1];
strcpy(m_data, str);
}
}
// 析构函数
String::~String(void)
{
delete [] m_data; // 或delete m_data;
}
//拷贝构造函数
String::String(const String &other)
{
int length = strlen(other.m_data);
m_data = new char[length + 1];
strcpy(m_data, other.m_data);
}
//赋值函数
String &String::operate =(const String &other)
{
if(this == &other)
{
return *this; // 检查自赋值
}
delete []m_data; // 释放原有的内存资源
int length = strlen(other.m_data);
m_data = new char[length + 1]; //对m_data加NULL判断
strcpy(m_data, other.m_data);
return *this; //返回本对象的引用
}
网络编程#
并发相关#
基础概念#
进程、线程、协程#
进程(Process)#
定义:
- 操作系统进行资源分配和调度的基本单位。
- 拥有独立的虚拟地址空间、代码段、数据段、堆、栈、文件描述符、环境变量、安全上下文(用户ID、组ID)等。
- 进程间资源隔离性强,一个进程崩溃通常不会直接影响其他进程(操作系统保护)。
创建与通信:
创建 (C++) : 使用
fork()(POSIX) 或CreateProcess()(Windows)。C++标准库本身不直接提供创建进程的函数,需依赖平台API。-
fork(): 复制当前进程(写时复制优化),子进程从fork()返回处开始执行。 -
exec()族函数: 通常在fork()后调用,加载新程序替换当前进程映像。
-
通信 (IPC - Inter-Process Communication) :
- 管道 (
pipe /popen ) : 单向字节流,常用于父子进程。 - 命名管道 (
FIFO ) : 有名字的管道,可用于无亲缘关系进程。 - 共享内存 (
shm_open /mmap ) : 最高效的IPC方式,需配合信号量等同步机制。 - 消息队列 (
mq_open /mq_send /mq_receive ) : 结构化的消息传递。 - 信号 (
signal /sigaction ) : 异步通知机制(如SIGKILL,SIGTERM)。 - 套接字 (
socket ) : 最通用的IPC,支持网络通信。
- 管道 (
开销: 创建、销毁、上下文切换开销最大(涉及虚拟内存、文件描述符表、寄存器组等的切换)。
C++视角: C++标准库不直接管理进程。开发者需使用平台特定API或第三方库(如Boost.Interprocess)。
线程(Thread)#
定义:
- 进程内的执行流,是操作系统/运行时库调度的基本单位。
- 共享所属进程的地址空间、全局变量、堆、文件描述符、环境变量等资源。
- 拥有自己独立的栈空间和线程局部存储 (TLS) 。
- 线程崩溃可能导致整个进程崩溃(共享地址空间)。
创建与通信:
创建 (C++) :
C++11标准:
std::thread#include <thread> void task() { /* ... */ } std::thread t1(task); // 创建并启动线程 t1.join(); // 等待线程结束底层: 在POSIX系统通常封装
pthread_create,在Windows封装CreateThread。
通信与同步:
共享内存: 直接读写进程的全局变量或堆内存(最简单,但需同步!)。
同步原语 (C++11标准) :
-
std::mutex,std::lock_guard,std::unique_lock: 互斥锁。 -
std::condition_variable: 条件变量。 -
std::atomic: 原子操作(无锁编程基础)。 -
std::future/std::promise/std::async: 异步结果传递。
-
开销: 创建、销毁、上下文切换开销远小于进程(主要切换栈指针、寄存器、PC等,不涉及地址空间切换)。
C++视角: C++11起,线程成为语言标准的一部分,提供了跨平台支持。核心在于共享数据的同步。
- C++20 引入
std::jthread:自动联结(析构时自动join());支持协作中断。
- C++20 引入
协程(Coroutine)#
定义:
- 用户态的轻量级线程,由程序员或运行时库控制调度(非操作系统内核)。
- 协程在同一个线程内执行,通过协作式调度主动让出执行权 (
yield),而不是被操作系统抢占式调度。 - 拥有自己的栈帧(或栈片段)和寄存器上下文,但共享线程的堆和全局内存。
关键特性:
- 挂起 (Suspend) 与 恢复 (Resume) : 协程可以在执行中暂停 (
co_yield/co_await),并在之后从暂停点恢复执行,保留局部变量状态。 - 无抢占: 一个协程运行直到主动让出控制权,不会因时间片用完被强制切换。
- 极低开销: 上下文切换不涉及内核态切换,通常只是保存/恢复少量寄存器,开销远低于线程切换。
- 挂起 (Suspend) 与 恢复 (Resume) : 协程可以在执行中暂停 (
C++中的实现:
C++20 标准协程:
语言核心支持了协程框架 (
co_await,co_yield,co_return)。需要定义
promise_type、coroutine_handle等组件来管理协程生命周期和结果传递。示例(简化):
#include <coroutine> #include <iostream> struct ReturnObject { struct promise_type { ReturnObject get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {} }; }; ReturnObject my_coroutine() { std::cout << "Coroutine started\n"; co_await std::suspend_always{}; // 挂起点 std::cout << "Coroutine resumed\n"; } int main() { auto coro = my_coroutine(); // 创建协程(可能立即开始执行到挂起点) // ... 做其他事情 ... coro.coro_handle.resume(); // 手动恢复协程 return 0; }底层原理: 编译器将协程函数转换为一个状态机,用
coroutine_handle跟踪状态(挂起点、局部变量)。切换时保存/恢复寄存器上下文(通常是jmp_buf或类似机制)。
第三方库 (C++20之前) :
- Boost.Coroutine (Stackful) : 每个协程拥有独立栈(类似线程栈),开销相对较大,但功能强大。
- Boost.Context: 提供底层上下文切换原语,是构建协程库的基础。
- 无栈协程 (Stackless) : 通常基于状态机实现(如C++20协程),没有独立栈,局部变量需存储在堆或协程帧中,开销极小。
适用场景:
- 高并发I/O密集型: 网络服务器(处理大量连接)、游戏逻辑。
- 生成器 (Generators) : 惰性生成序列 (
co_yield)。 - 异步任务链: 简化异步回调(
co_await)。
与线程对比:
- 优点: 切换开销极小、数量可远多于线程(数万甚至百万级)、无锁编程更简单(单线程内)。
- 缺点: 需要显式调度(或依赖事件循环)、一个协程阻塞会阻塞整个线程、调试可能更复杂。
| 特性 | 进程 (Process) | 线程 (Thread) | 协程 (Coroutine) |
|---|---|---|---|
| 资源隔离 | 高 (独立地址空间) | 低 (共享进程资源) | 低 (共享线程资源) |
| 调度者 | 操作系统内核 (抢占式) | 操作系统内核 (抢占式) | 用户程序 (协作式) |
| 上下文切换开销 | 非常高 (内核态切换 + 资源切换) | 高 (内核态切换) | 极低 (用户态切换) |
| 创建数量 | 少 (数百) | 中等 (数千) | 极多 (数万至百万) |
| 通信方式 | IPC (管道、共享内存、Socket等) | 共享内存 + 同步原语 | 共享内存 (通常无锁) |
| 崩溃影响 | 不影响其他进程 | 可能导致整个进程崩溃 | 可能导致所属线程崩溃 |
| C++标准支持 | 无 (依赖平台API) | C++11 (std::thread) | C++20 (语言核心协程) |
| 典型应用 | 独立应用、安全隔离任务 | 并行计算、利用多核 | 高并发I/O、异步任务、生成器 |
线程 std::thread#
创建线程:将可调用对象(函数、Lambda表达式、函数对象)传递给std::thread构造函数。
底层原理浅析:
- 当
std::thread t1(callable)执行时,底层(如在Linux上)会通过类似pthread_create的系统调用,向操作系统申请创建一个新的线程(轻量级进程)。 - 这个新线程拥有自己独立的栈空间,但与创建它的进程共享堆内存、全局变量等资源。
-
join()操作会阻塞当前线程(这里是主线程),直到目标线程(t1)执行完毕。底层是通过类似pthread_join的同步机制实现的。 -
detach()操作将线程与std::thread对象分离,分离后的线程由系统“托管”,你不能再通过std::thread对象与之交互。
数据共享与同步机制#
互斥量 (Mutexes, std::mutex )#
用于保证同一时间只有一个线程可以进入临界区。
// 基本用法:lock()和unlock()。
#include <thread>
#include <mutex>
#include <vector>
std::mutex g_display_mutex;
int shared_data = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
g_display_mutex.lock(); // 进入临界区
shared_data++; // 临界区操作
g_display_mutex.unlock(); // 离开临界区
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final value: " << shared_data << std::endl; // 一定是200000
return 0;
}
// RAII惯用法:std::lock_guard和 std::unique_lock
// C++核心思想之一RAII完美适用于此。通过在栈上创建对象,在构造函数中加锁,在析构函数中自动解锁
void safe_increment() {
for (int i = 0; i < 100000; ++i) {
// lock_guard构造时自动lock,析构时自动unlock
std::lock_guard<std::mutex> lock(g_display_mutex);
shared_data++;
} // 离开作用域,lock析构,自动释放锁
}
std::recursive_mutex- 可重入锁(同一线程多次加锁);
- 递归函数调用
std::shared_mutex(C++17)- 读写锁(多读单写)
- 读多写少场景(如配置管理)
锁管理器(RAII 封装)#
std::lock_guard:轻量级,功能简单,只能在构造时加锁,析构时解锁。- 构造时立即加锁(无法延迟)。
析构时自动解锁(作用域结束时)。
不可复制/移动:禁止所有权转移。
无手动控制:不支持中途解锁或重新加锁。 std::mutex mtx; void safe_function() { std::lock_guard<std::mutex> lock(mtx); // 构造即加锁 // ... 临界区操作 ... } // 作用域结束,自动解锁template <typename Mutex> class lock_guard { public: explicit lock_guard(Mutex& m) : mutex(m) { mutex.lock(); // 构造时立即加锁 } ~lock_guard() { mutex.unlock(); // 析构时解锁 } // 删除拷贝和移动 lock_guard(const lock_guard&) = delete; lock_guard& operator=(const lock_guard&) = delete; private: Mutex& mutex; };
- 构造时立即加锁(无法延迟)。
std::unique_lock:更重量级,但功能灵活。可以延迟加锁、手动解锁、转移所有权。如果需要条件变量,必须使用unique_lock。- 延迟加锁:构造时不立即加锁(通过参数指定)。
手动解锁/加锁:支持在作用域内多次操作。
锁所有权管理:可移动(std::move),但不可复制。
与条件变量集成:必须配合 std::condition_variable使用。 std::mutex mtx; std::condition_variable cv; bool data_ready = false; void consumer() { std::unique_lock<std::mutex> lock(mtx); // 构造时加锁 while (!data_ready) { cv.wait(lock); // 1.解锁 2.阻塞 3.唤醒后重新加锁 } // ... 处理数据 ... } // 自动解锁 void producer() { { std::lock_guard<std::mutex> lock(mtx); data_ready = true; } cv.notify_one(); }template <typename Mutex> class unique_lock { public: // 延迟加锁选项 enum class defer_lock_t { defer_lock }; unique_lock(Mutex& m, defer_lock_t) : mutex(&m), owns(false) {} // 立即加锁(默认) unique_lock(Mutex& m) : mutex(&m), owns(true) { mutex->lock(); } // 手动解锁/加锁 void unlock() { if (owns) mutex->unlock(); owns = false; } void lock() { if (!owns) mutex->lock(); owns = true; } // 支持移动语义 unique_lock(unique_lock&& other) noexcept; ~unique_lock() { if (owns) mutex->unlock(); } private: Mutex* mutex; bool owns; // 标记锁的所有权状态 };
- 延迟加锁:构造时不立即加锁(通过参数指定)。
std::scoped_lock(C++17)- 多锁原子获取(防死锁)
- 同时获取多个互斥锁
条件变量 (std::condition_variable )#
用于线程间的通信,让一个线程等待某个条件成立,而另一个线程在条件成立时通知等待的线程。典型场景是生产者-消费者模型。
解决的问题:当线程需要等待某个共享状态满足特定条件时(如队列非空),若使用轮询检查会浪费 CPU 资源。
协作模式:线程主动阻塞,由其他线程在条件变化时通知唤醒。
底层原理:条件变量本身不管理条件,它总是和互斥量以及一个共享的“条件”变量一起使用。等待线程的流程是:
- 获取互斥锁(
unique_lock)。 - 检查条件是否满足,如果不满足,则调用
wait()。 -
wait()会原子地释放互斥锁并阻塞当前线程。 - 当被其他线程的
notify_one()或notify_all()唤醒时,线程会重新获取互斥锁,并再次检查条件(使用循环检查是为了防止虚假唤醒)。
- 获取互斥锁(
核心成员函数
- wait()系列:阻塞当前线程
- 唤醒函数:notify_one(); notify_all()
// 生产者-消费者模型
std::mutex mtx;
std::queue<int> data_queue;
std::condition_variable cv;
// 生产者
void producer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
data_queue.push(42);
lock.unlock();
cv.notify_one(); // 通知消费者
}
}
// 消费者
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// 避免虚假唤醒:队列空时继续等待
cv.wait(lock, []{ return !data_queue.empty(); });
int data = data_queue.front();
data_queue.pop();
lock.unlock();
// 处理 data...
}
}
// 线程安全的队列
template <typename T>
class ThreadSafeQueue {
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mtx);
queue.push(std::move(value));
cv.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this]{ return !queue.empty(); });
T value = std::move(queue.front());
queue.pop();
return value;
}
private:
std::queue<T> queue;
std::mutex mtx;
std::condition_variable cv;
};
异步操作 (std::async, std::future, std::promise)#
这是一种更高层次的抽象,它关注的是任务(Task)和结果(Future),而非线程本身。
-
std::async:异步地启动一个任务,返回一个std::future对象,该对象最终将持有任务的结果。 -
std::future:一个占位符,用于在未来获取异步操作的结果。通过get()函数获取结果,如果结果还未就绪,get()会阻塞当前线程。 -
std::promise:用于在线程间传递结果。生产者线程可以通过promise设置一个值,与之关联的future就可以获取到这个值。 -
std::shared_future:多线程共享结果;可拷贝,支持多个线程等待同一结果。 - 用
std::async简化简单任务。 - 用
promise/future手动控制复杂异步逻辑。 - 用
shared_future实现多消费者场景。
- 用
#include <future>
int expensive_computation() { return 42; }
int main() {
// 异步启动任务,策略可以是std::launch::async(新线程)或std::launch::deferred(延迟,同步执行)
std::future<int> result = std::async(std::launch::async, expensive_computation);
// ... 主线程可以同时做其他工作 ...
int value = result.get(); // 获取结果,如果未完成则阻塞
std::cout << "Result: " << value << std::endl;
return 0;
}
信号量(std::counting_semaphore)#
std::counting_semaphore是 C++20 引入的轻量级同步原语,用于控制对共享资源的并发访问数量。它基于经典的 Dijkstra 信号量模型,但针对现代硬件进行了优化,成为替代手写「条件变量+计数器」组合的标准化解决方案。
本质:带原子操作的计数器
计数器 (
count ) :表示可用资源的数量。- 获取 (acquire) :
count--(若count > 0,否则阻塞)。 - 释放 (release) :
count++(唤醒等待线程)。
- 获取 (acquire) :
示例:
// 初始化信号量, 初始计数为10
std::counting_semaphore<10> semaphore(10);
// std::binary_semaphore 二元信号量(counting_semaphore<1>别名)
// 资源获取
// 阻塞直到获取一个资源
sem.acquire();
// 非阻塞尝试获取
if (sem.try_acquire()) { /* 成功 */ }
// 带超时获取(C++20)
if (sem.try_acquire_for(100ms)) { /* ... */ }
// 资源释放
sem.release(); // 释放一个资源
sem.release(5); // 一次性释放 5 个资源(C++20)
内存模型与原子操作 (std::atomic)#
这是C++并发编程的基石,理解它才能写出正确的高性能代码。
问题:即使是一个简单的
counter++,在编译后也可能是多条机器指令(读-改-写)。在没有同步的情况下,两个线程可能同时读到旧值(如10),各自加1后写回,结果变成11而不是12。解决方案:
std::atomic
std::atomic模板为你提供的类型包装起原子操作。对一个原子变量的操作是不可分割的。#include <atomic> std::atomic<int> atomic_counter(0); void safe_increment_atomic() { for (int i = 0; i < 100000; ++i) { atomic_counter++; // 原子操作,无需锁! } }底层原理:原子操作的实现依赖于CPU的指令集支持(如x86的
LOCK指令前缀),这些指令能确保在执行期间总线被锁定,防止其他核心访问同一内存地址。它比互斥量更轻量,因为它通常不涉及操作系统的调度(如线程挂起/唤醒)。类型支持
- 整型:
atomic<int>,atomic<long>(支持++,+=,fetch_add等算术操作)。 - 指针:
atomic<T*>(支持fetch_add,fetch_sub指针运算)。 - 布尔:
atomic<bool>(标志位场景)。 - 自定义类型:需满足
TriviallyCopyable(通常用于简单结构体)。
- 整型:
内存顺序控制: 通过
std::memory_order指定内存访问顺序,平衡性能与一致性。内存序(Memory Order) :这是原子操作的高级话题。
std::memory_order允许你控制原子操作周围的非原子内存访问的可见性顺序。默认是memory_order_seq_cst(顺序一致性),保证最强的一致性,但性能开销最大。在极致的性能优化场景下,可以放宽内存序约束(如acquire,release),但需要非常小心,否则会引入难以调试的内存顺序问题。
内存序 (Memory Order)#
| 内存序 | 作用 | 性能 | 典型场景 |
|---|---|---|---|
memory_order_relaxed | 仅保证原子性,无顺序约束(编译器/CPU 可重排序) | 最高 | 计数器、进度统计 |
memory_order_acquire | 本操作前的读写不能重排到本操作后(读屏障) | 中 | 锁获取、数据发布后的读取 |
memory_order_release | 本操作后的读写不能重排到本操作前(写屏障) | 中 | 锁释放、数据发布前的写入 |
memory_order_acq_rel | acquire+release(RMW 操作默认) | 中 | CAS 操作 |
memory_order_seq_cst | 全局顺序一致(默认值),保证所有线程看到相同操作顺序 | 最低 | 需要强一致性的场景 |
死锁、预防避免死锁#
死锁:两个或多个线程互相等待对方持有的资源,导致所有线程都无法继续执行。
典型条件是:互斥、持有并等待、不可剥夺、循环等待。
避免方法:
- 固定顺序上锁:所有线程都按相同的顺序(如先锁A再锁B)获取锁。
- 使用
std::lock函数:它可以一次性锁住多个互斥量而不会死锁。std::lock(mutex1, mutex2); std::lock_guard lk1(mutex1, std::adopt_lock); ... - 层级锁(Hierarchical Locking) :为锁分配层级编号,高层级锁不能获取低层级锁。
- 无锁编程:使用原子操作或RCU避免锁的使用
活锁(Livelock)#
线程不断重试失败操作(如双方同时让路),虽未阻塞但无法推进。示例:
void worker(std::atomic<bool>& flag) {
while (!flag.compare_exchange_weak(/*...*/)) {
std::this_thread::yield(); // 持续重试导致CPU空转
}
}
活锁是"礼貌的死锁",需通过随机退避或队列调度解决。
读者-写者问题
编译流程#
预处理 (Preprocessing) 、编译 (Compilation) 、汇编 (Assembly) 和 链接 (Linking)
# 预处理:生成 .i 文件 (通常不单独做)
g++ -E main.cpp -o main.i
# 编译:生成 .s 汇编文件
g++ -S main.i -o main.s # 或者直接 g++ -S main.cpp
# 汇编:生成 .o 目标文件
g++ -c main.s -o main.o # 或者直接 g++ -c main.cpp
# 链接:生成可执行文件 (链接 main.o 和其他 .o / 库)
g++ main.o helper.o -o myprogram -lmylib
# 一步到位:预处理->编译->汇编->链接
g++ main.cpp helper.cpp -o myprogram
