C++现代特性之CPP11
参考资料:
《C++ Primer Plus》
deepseek
Gemini
- 内存管理:移动语义、右值引用、智能指针
- 并发编程: 见cpp多线程
- 泛型编程: 模板
- 语法糖
左值右值
- 左值
- 指向内存中明确位置、具有持久状态(有名字、能取地址)的对象
- 可以出现在赋值号
=的左边或右边 - 示例:变量名、解引用表达式
*p、返回左值引用的函数调用。
- 右值
- 不具有内存实体(或即将被销毁的临时对象)、没有名字、无法取地址的值。
- 只能出现在赋值号
=的右边。 - 字面常量(如
42)、算术表达式(如a + b)、返回非引用的函数调用。
函数对象
函数对象就是一个表现得像函数一样的对象。在 C++ 中,它是通过在类(class/struct)中重载
operator()(圆括号运算符)来实现的。由于这种类实例化后的对象可以像函数一样被调用,所以也被称为 仿函数(Functor)。
1 |
|
优点
相对于普通函数:
A. 带有“状态”(State)
普通函数除非使用全局变量或静态变量,否则无法在多次调用之间保持状态。而函数对象是类,可以拥有成员变量。
- 例子: 在处理 MoE 专家调度时,你可以创建一个函数对象,在构造时传入当前的
GPU_ID或Stream_ID。每次调用这个对象执行任务时,它都知道自己在哪个设备上工作。
B. 更好的内联优化(Inlining)
当你把函数对象传递给模板算法(如 std::sort)时,编译器知道确切的类类型,因此可以直接将 operator() 的逻辑内联到调用处。 相比之下,传递“函数指针”往往会导致编译器无法内联,从而产生函数调用的额外开销。
谓词
返回布尔值(True 或 False)的函数或函数对象。
在 C++ STL中,谓词通常用于算法(如 std::sort, std::find_if, std::remove_if)中。
- 一元谓词(Unary Predicate): 接收一个参数,返回
bool。- 例子: 判断一个数是不是偶数?
isEven(x)
- 例子: 判断一个数是不是偶数?
- 二元谓词(Binary Predicate): 接收两个参数,返回
bool。- 例子: 判断第一个数是否大于第二个数?
compare(a, b)
- 例子: 判断第一个数是否大于第二个数?
A. 普通函数
1 | bool isPositive(int n) { |
B. 函数对象(仿函数)
函数对象作为谓词的强大之处在于它可以拥有状态。
1 | class GreaterThan { |
C. Lambda 表达式(最常用)
这是现代 C++ 中最优雅的写法。
1 | // 使用:std::find_if(vec.begin(), vec.end(), [](int n) { return n > 10; }); |
原始字面量
在 C++11 标准中,引入了原始字面量(Raw String Literal),用于简化字符串中反斜杠 \ 和引号 " 等特殊字符的处理。它让字符串内容可以“原样”呈现,无需转义,特别适合正则表达式、文件路径、多行文本等场景。
基本语法
原始字面量的格式为:
1 | R"delimiter(raw_characters)delimiter" |
R表示原始字面量前缀。delimiter是可选的定界符序列(长度不超过 16 个字符的自定义字符序列,不能包含反斜杠、空格和括号)用于唯一标识字符串的结束位置。当字符串内容本身包含)"时,避免编译器误认为字面量提前结束。( raw_characters )中是真正的字符串内容,可以包含任何字符(包括换行、引号、反斜杠等),无需转义。
如果省略定界符,简写为 R"(...)"。
关键特性
- 不处理转义序列:例如
\n、\t、\"等均作为普通字符对待。 - 允许换行:字符串可以直接跨越多行,换行符会被保留为字符串的一部分。
- 允许引号:无需转义即可包含
"字符。 - 允许反斜杠:
\就是普通反斜杠,不会引起转义。
示例
普通字符串 vs 原始字面量
1 |
|
多行字符串
1 | std::string multi = R"(第一行 |
包含括号的情况(使用定界符)
如果字符串本身包含 )" 序列,就需要自定义定界符来避免歧义。
1 | // 字符串内容为:Hello )" World |
正则表达式场景
正则表达式中经常出现大量反斜杠,原始字面量可以显著提高可读性。
1 |
|
文件路径
1 | std::string path = R"(C:\Users\Name\Documents\file.txt)"; |
注意事项
- 原始字面量中唯一需要留意的字符是
)"序列(如果未使用定界符)或)delimiter"(如果使用了定界符)。只要字符串内容不包含该精确序列,就可以安全使用。 - 定界符可以是任意可见字符(除了反斜杠、空格和括号),通常使用一个短单词或下划线,如
R"tag(...)tag"。 - 原始字面量仍然是标准
std::string(或字符数组),与普通字符串字面量完全兼容。 - 原始字面量也支持宽字符、UTF-8/16/32 前缀,例如
u8R"(...)"、LR"(...)"等。
nullptr
nullptr 代表一个空指针字面量(null pointer literal)。它的类型是 std::nullptr_t(定义在 <cstddef> 中),可以隐式转换为任意指针类型或成员指针类型,但不能转换为整数类型。
作用
在 C++11 之前,程序员通常用 NULL 或直接使用 0 表示空指针。例如:
1 | int* p = NULL; // NULL 通常被定义为 0 或 (void*)0 |
这种做法存在两个主要问题:
- 类型模糊:
NULL本质上是整数 0(或(void*)0),在函数重载时会导致意外行为。 - 与整数混用:由于 0 既可以表示整数也可以表示空指针,编译器在重载决议时可能选择错误的版本。
例如,考虑以下重载:
1 | void func(int); |
在不同的实现中,NULL 可能是 0 或 0L,因此调用 func(NULL) 会匹配到 func(int),而不是预期的 func(char*),造成逻辑错误。
nullptr 的类型与特性
nullptr的类型是std::nullptr_t(可视为一种特殊的“指针类型”)。std::nullptr_t的实例(如nullptr)可以隐式转换为任何指针类型(包括成员指针)和bool类型(转换为false)。nullptr不能隐式转换为整数类型(如int、long等)。- 所有类型为
std::nullptr_t的对象(实际上只有nullptr是标准定义的)彼此相等,且与任何空指针值相等。
使用示例
基本用法
1 | int* p = nullptr; // 指向 int 的空指针 |
解决重载歧义
1 | void func(int) { std::cout << "int version\n"; } |
与模板配合
1 | template<typename T> |
nullptr 与 NULL 的对比
| 特性 | nullptr |
NULL |
|---|---|---|
| 类型 | std::nullptr_t |
整数(通常为 int 或 long) |
| 可转换为指针 | 是(隐式) | 是(通过整数转换) |
| 可转换为整数 | 否 | 是 |
| 重载时优先匹配指针 | 是 | 否(优先整数) |
| 类型安全 | 高 | 低 |
注意事项
nullptr可以赋值给指针,但不能赋值给整型变量(需要显式转换如int n = reinterpret_cast<int>(nullptr);,但通常不应该这样做)。- 在条件判断中,
nullptr可转换为bool的false,因此if (!ptr)依然有效。 nullptr的引入并不强制废弃NULL,但现代 C++ 推荐使用nullptr以提升代码清晰度和类型安全。- 使用
nullptr时需要包含<cstddef>来获取std::nullptr_t的定义(不过许多编译器头文件已间接包含,且nullptr本身是关键字,不需要头文件即可使用)。
Lambda 表达式
Lambda表达式(也称为匿名函数或闭包)允许在代码中就地定义函数对象。在C98/03时代,若需要传递一个简单的可调用对象,通常需要单独编写一个函数或重载
operator()的仿函数(Functor),代码冗长且不直观。Lambda表达式的出现极大地简化了这一过程,尤其在STL算法、异步编程、事件回调等场景中,Lambda已成为现代C编程的标配。
Lambda 表达式其实就是函数对象的**“语法糖”**
基本语法
1 | [capture] (params) mutable constexpr noexcept -> retType { body } |
| 部分 | 说明 |
|---|---|
[capture] |
捕获列表,必填。指定Lambda内可以访问哪些外部变量以及访问方式(值或引用)。 |
(params) |
参数列表,可选。与普通函数的参数列表类似,C++14起可以auto(泛型Lambda)。 |
mutable |
可变修饰,可选。默认情况下,值捕获的变量在Lambda体内是只读的,加上mutable后可修改其副本。 |
constexpr |
常量表达式修饰,可选(C++17)。强制编译器在常量表达式中求值Lambda。 |
noexcept |
异常说明,可选。指明Lambda不会抛出异常。 |
-> retType |
返回类型,可选。若省略,编译器根据return语句自动推导(C++14起支持更灵活的自动推导)。 |
{ body } |
函数体,必填。实际执行的代码。 |
**捕获列表 **
这是 Lambda 与普通函数最大的区别。它决定了 Lambda 内部如何访问外部作用域的变量:
[]:不捕获任何外部变量。[=]:值捕获。按值复制一份外部所有变量到 Lambda 内部(只读,除非加mutable)。[&]:引用捕获。直接引用外部变量,内部修改会影响外部。[x, &y]:特定捕获。x按值捕获,y按引用捕获。T
Tips
- 引用捕获:必须确保被引用变量在Lambda被调用时仍然存活。例如,返回一个捕获了局部变量引用的Lambda,会导致悬垂引用。
- 值捕获:变量在Lambda定义时被拷贝(而非调用时)。对于只移动类型(如
std::unique_ptr),C++14允许通过初始化捕获来移动捕获。
示例
-
简单的Lambda表达式:
[]{}—— 空捕获、无参数、无返回类型、空函数体。1
2auto greet = []() { std::cout << "Hello Lambda!\n"; };
greet(); // 输出:Hello Lambda! -
带参数与返回类型
1
2
3
4
5
6//参数与返回类型
auto add = [](int a, int b) -> int { return a + b; };
std::cout << add(3, 4); // 输出:7
// 省略返回类型(自动推导)
auto multiply = [](int a, int b) { return a * b; }; -
捕获外部变量
1
2
3
4
5
6
7
8int x = 10, y = 20;
// 值捕获x,引用捕获y
auto func = [x, &y]() {
// x += 1; // 错误:x是只读的
y += 1; // 正确:y是引用
return x + y;
};
std::cout << func(); // 使用原始x副本和修改后的y -
使用
mutable修改值捕获的副本1
2
3
4
5
6
7int count = 0;
auto counter = [count]() mutable {
return ++count; // 修改的是捕获到的副本
};
std::cout << counter(); // 1
std::cout << counter(); // 2
std::cout << count; // 0(原变量未变)
泛型Lambda
C++14允许在参数列表中使用
auto,使得Lambda可以接受任意类型的参数,相当于定义了模板化的operator()。
1 | auto generic_lambda = [](auto a, auto b) { return a + b; }; |
实际上,编译器会为每种参数类型组合生成不同的重载。泛型Lambda大大提升了代码复用性,常用于算法中。
本质
Lambda表达式在编译时会被转换为一个仿函数类(匿名函数对象),其operator()默认是const的(除非使用了mutable)。捕获的变量会作为该类的成员变量。
例如,[x, &y](int z) { return x + y + z; }大致等价于:
1 | class AnonymousLambda { |
因此,值捕获产生拷贝,引用捕获产生引用成员。
override与 final
override
在派生类中显式声明某个虚函数意在重写基类中的一个同名虚函数。编译器会检查该函数是否真正重写了基类的某个虚函数(签名匹配),如果不匹配则产生编译错误。
在成员函数声明(或定义)的参数列表之后、函数体或 = default/delete 之前加上 override
1 | class Base { |
使用override可以防止出现,粗心导致子类重写虚函数出错,从而被认为是一个新的函数
1 | class Animal { |
- 只要在派生类中重写虚函数,就加上
override。这相当于一种自文档化,也防止未来因基类改动导致重写失效。 - 可以将
override与virtual同时使用,但override本身已经隐含了该函数是虚函数,因此virtual可省略(但保留也不影响)。
final
final 有两种使用场景:
-
作为虚函数的修饰符:禁止派生类进一步重写该虚函数。
1
2
3
4
5
6
7
8
9class Base {
public:
virtual void foo() final; // 此函数不可被任何派生类重写
};
class Derived : public Base {
public:
// void foo() override; // 错误!Base::foo 是 final 的
}; -
作为类的修饰符:禁止该类被继承。
1
2
3
4
5class FinalClass final {
// 该类不允许被任何类继承
};
// class BadAttempt : public FinalClass { }; // 编译错误! -
final可以用来修饰一个“新引入的虚函数”,而不仅仅是修饰一个“重写基类的虚函数”。1
2
3
4
5
6
7
8
9class Base {
public:
virtual void foo() final; // 这是一个全新的虚函数,没有重写任何基类函数
// 注意:Base 没有基类(或基类中没有 foo)
};
class Derived : public Base {
// void foo() override; // 错误!Base::foo 是 final 的,不能重写
}; -
final关键字只能用于虚函数,因此static函数不能用final。- 因为static函数属于整个类,不存在this指针,但虚函数是动态绑定的需要this指针
override + final
派生类可以在重写基类虚函数的同时,声明该重写版本为 final,从而阻止更深层的派生类再次重写。
1 | class GrandBase { |
统一初始化与列表初始化
constexpr
constexpr(常量表达式)是 C++11 引入的关键字,用于在编译期求值的表达式或函数。它允许程序员显式要求某个变量、函数或构造函数在编译阶段完成计算,从而提升性能并支持编译期元编程。
基本概念
- 常量表达式:指值在编译阶段就可以确定,且不会改变的表达式。
constexpr变量:必须用常量表达式初始化,且本身是const的(但constexpr隐含了const语义,对指针略有不同)。constexpr函数:其返回值或参数可以是常量表达式,当传入常量参数时在编译期求值,否则退化为普通函数。constexpr构造函数:允许创建编译期的用户自定义类型对象。
constexpr 变量
语法:constexpr 类型 变量名 = 常量表达式;
1 | constexpr int max_size = 100; // 编译期常量 |
与 const 的区别
const表示“运行期只读”,不保证编译期已知。constexpr表示“编译期常量”,一定在编译期求值,且隐含const属性。
1 | int a = 5; |
constexpr 函数
函数可以声明为 constexpr,此时被称为常量表达式函数,当所有参数都是常量表达式时,函数在编译期求值;否则像普通函数一样在运行期执行。
限制
- 函数体不能出现常量表达式以外的内容,但是
using、typedef、static_assert、return除外 。 - 返回值类型必须是常量表达式。
- 函数体不能有
try块或asm声明。
1 | constexpr int square(int x) { |
1 | constexpr int factorial(int n) { |
修饰构造函数
用户自定义类型可以通过 constexpr 构造函数创建编译期对象。该类必须满足:
- 函数体必须为空,采用初始化列表方式为各个成员变量赋值
- 至少有一个
constexpr构造函数。 - 析构函数不能是用户自定义的(C++11 中默认或隐式即可)。
1 | class Point { |
修饰模板函数
constexpr 经常与模板结合,实现编译期计算和元编程。
1 | template<int N> |
局限性(C++11)
- 函数体限制严格:只能有一条
return,不能用循环、局部变量(C++14 放宽)。 - 不能修改参数:所有参数都是
const的(按值传递可修改副本,但意义不大)。 - 构造函数不能有函数体(C++11 中
constexpr构造函数必须为空,成员通过初始化列表初始化)。 - 不能有虚函数。
自动类型推导
C++11 引入了 auto 和 decltype 两种自动类型推导机制。
auto
auto 让编译器根据变量的初始值来推导其类型。
基本用法
-
声明变量:
auto x = 5;(x为int) -
声明指针/引用:
auto会自动剥离顶层const和volatile,除非显示声明。1
2
3
4
5
6
7
8int a = 10;
int& ref = a;
const int ca = 20;
auto b = ref; // b 是 int(引用被忽略)
auto c = ca; // c 是 int(顶层 const 被忽略)
auto& d = ref; // d 是 int&(显式引用保留)
const auto e = ca; // e 是 const int(显式 const 保留)
推导规则
顶层:表示对象本身是常量,即该对象的值不可修改。
对于非指针/非引用的类型,
const总是顶层。对于指针,顶层
const修饰的是指针本身(即指针变量存储的地址不可变)const int ci = 42; // ci 是顶层 const:ci 本身不可修改 int* const p = &x; // p 是顶层 const:p 的指向不可变(但 *p 可修改) const int* const cp = &x; // cp 既是顶层 const(指针本身)又是底层 const(指向 const int)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
底层:**表示指针(或引用)所指的对象是常量**,即通过该指针/引用不能修改所指对象的值。
- 对于**指针**,底层 `const` 出现在 `*` 的左边,例如 `const int*`。
- 对于**引用**,所有 `const` 引用(`const T&`)都是底层 `const`,因为引用本身不是对象,无法被设为“引用本身不可改”,所以 `const` 修饰的是所指对象。
- ```cpp
const int* p = &x; //p是指向常量的指针,因此 p 是底层 const:不能通过 p 修改 x
int const* p2 = &x; // 同上,底层 const
const int& r = x; // r 是底层 const:不能通过 r 修改 x
-
auto会丢弃初始化表达式的引用和顶层const/volatile,但保留底层const/volatile- 如果希望保留引用或顶层 const,需要显式使用
auto&或const auto等
- 如果希望保留引用或顶层 const,需要显式使用
-
列表初始化:
auto可推导std::initializer_list。1
2auto lst = {1, 2, 3}; // lst 类型为 std::initializer_list<int>
// auto lst2{1, 2, 3}; // C++17 起:错误,直接列表初始化只能单元素 -
指针推导:当初始值是一个指针时,
auto和auto*都可以正确推断出指针类型,两者的效果通常是等价的。但auto*会强制要求初始值必须是一个指针。1
2
3
4
5int val = 42;
auto p1 = &val; // p1 推导为 int*
auto* p2 = &val; // p2 推导为 int*
// auto* p3 = val; // 编译错误:val 不是指针,无法匹配 auto* -
数组退化: 当用数组初始化
auto变量时,数组会“退化”为指向其首元素的指针。如果使用auto&,则不会退化,而是推导为数组的引用。1
2
3
4int arr[] = {1, 2, 3};
auto arr_ptr = arr; // arr_ptr 的类型是 int* (数组退化为指针)
auto& arr_ref = arr; // arr_ref 的类型是 int(&)[3] (对长度为3的数组的引用)
例子
1 | int temp = 110; |
限制
- 不能作为函数的参数直接使用。因为只有在函数调用的时候才会给函数参数传递实参,
auto要求必须要给修饰的变量赋值,因此矛盾 - 不能用于类的非静态成员变量的初始化
1 | class Test |
-
无法使用auto推导出模板参数
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
57template <typename T>
struct Test{}
int func()
{
Test<double> t;
Test<auto> t1 = t; // error, 无法推导出模板类型
return 0;
}
### 常用场景
1. **遍历stl容器**
2. **泛型编程,使用模板时,很多时候不知道变量应该定义为什么类型**
```cpp
//不用auto
using namespace std;
class T1
{
public:
static int get()
{
return 0;
}
};
class T2
{
public:
static string get()
{
return "hello, world";
}
};
template <class A> // 添加了模板参数 B
void func(void)
{
auto val = A::get();
cout << "val: " << val << endl;
}
int main()
{
func<T1>();
func<T2>();
return 0;
}
1 | //不用auto |
作者: 苏丙榅
链接: https://subingwen.cn/cpp/autotype/#1-3-auto%E7%9A%84%E5%BA%94%E7%94%A8
来源: 爱编程的大丙
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
## `decltype`
> decltype(全称:Declared Type),作用是向编译器发出查询:“这个表达式的类型是什么?”,与 auto 必须绑定变量初始化不同,decltype 只是**静态地在编译期分析表达式的类型**,绝不会真正计算(求值)该表达式。在**模板元编程和泛型编程**中,我们经常需要知道某个表达式的结果究竟是什么类型,但又不能或者不想真正去执行这个表达式,这时候 decltype 就成为了不可或缺的工具。
> ` auto `与 `decltype`
>
> 1. 精确推导:与 auto 会丢弃引用和顶层 const 不同,decltype **保留所有类型修饰符**(包括引用和 CV 限定符)。
> 2. 零运行时开销:`auto` 和 `decltype` 同样在编译阶段完成,不会生成任何对应的汇编指令。
### 推导规则
`decltype(expr)` 的推导结果极其依赖于表达式的形式以及它的值类别。C++ 标准将其推导规则严格分为以下几类:
1. **未加括号的标识符或成员访问:**
如果`e` 是一个没有被多余括号包围的**变量名、函数名**,或者是**类成员访问表达式**(如 obj.member 或 ptr->member),那么 `decltype(e)`推导出的就是该实体在代码中声明时的精确类型。
```cpp
const int ci = 0;
int x = 10;
int& ref_x = x;
struct Point { double x; double y; };
Point pt;
decltype(ci) a = 1; // a 的类型是 const int (完美保留 const)
decltype(ref_x) b = x; // b 的类型是 int& (完美保留引用)
decltype(pt.x) c = 0.0; // c 的类型是 double
-
其他表达式的值类别:
如果不符合1的条件(例如它是一个算术表达式、函数调用,或者被括号 () 包围的变量),编译器将根据表达式 e 的值类别来决定类型。假设表达式 e 的基础类型为 T:e是左值:decltype(e)为T&e是将亡值:decltype(e)为T&&e是纯右值:decltype(e)为T
1
2
3
4
5
6
7
8
9
10
11
12
13
14int i = 42;
int* p = &i;
// 函数调用
int f();
int& g();
decltype(f()) x1 = 1; // x1 是 int (纯右值)
decltype(g()) x2 = i; // x2 是 int& (左值)
// 算术表达式
decltype(i + 0) x3 = 5; // x3 是 int (i+0 产生纯右值)
// 解引用表达式
decltype(*p) x4 = i; // x4 是 int& (*p 返回左值,可以被赋值)
右值引用与移动语义
在 C11 之前,C 在处理临时对象(比如函数返回的大型容器、字符串拼接产生的中间结果)时,往往会触发昂贵的深拷贝 (Deep Copy)。为了解决这个性能瓶颈,C++11 引入了右值引用,它使得程序能够“窃取”临时对象的内存资源,从而实现零拷贝的移动语义 (Move Semantics)。
右值引用
传统 C++ 中的引用(现在称为左值引用)使用
&符号;C++11 引入的右值引用使用&&符号。
绑定规则
- 右值引用只能绑定到右值上,不能直接绑定到左值。它的核心目的是延长临时对象的生命周期,或者接管临时对象的资源。
1 | int a = 10; // a 是左值 |
- 右值引用变量本身是左值,上述代码中
rref_1的类型是右值引用,但它是左值
1 | void process(int& x) { /* 处理左值 */ } |
移动语义
移动语义是右值引用最大的价值所在。通过编写移动构造函数和移动赋值运算符,我们可以将资源(如动态分配的内存)从源对象直接转移到目标对象,而不是进行深拷贝。
std::move
如果想把一个左值当作右值来处理(即明确告诉编译器:“我不再需要这个左值了,你可以把它的资源拿走”),可以使用 std::move()。
注意:std::move 并在运行时不执行任何实际的移动操作,它仅仅是在编译期执行了类型转换(static_cast<T&&>),将左值强制转换为右值引用(将亡值)。
1 |
|
完美转发
当我们在编写模板函数时,常常需要将参数原封不动地传递给内部的另一个函数。所谓“原封不动”,指的是保持参数的左值或右值属性不变,以及保持
const属性不变。
万能引用
在模板中,如果参数类型写为 T&&(且 T 是需要推导的模板参数),那么它就不再是普通的右值引用,而是万能引用。
- 如果传入左值,
T&&会被推导为左值引用(发生引用折叠)。 - 如果传入右值,
T&&会保持为右值引用。
std::forward
右值引用作为形参进入函数体后,由于有了名字,就变成了左值。为了在继续向下传递时恢复它原本的“左右值”属性,必须使用 std::forward<T>()。
1 | void process(int& x) { std::cout << "Lvalue processed\n"; } |
C++11 将右值进一步细分为:
- 纯右值 (Prvalue):非引用返回的临时变量、运算表达式产生的临时变量、原始字面量。
- 将亡值 (Xvalue):与右值引用相关的表达式,比如
std::move(x)的返回值,或者返回右值引用的函数调用。它标志着某个对象的资源可以被安全地“移动”。
智能指针
在传统 C++(C++98/03)中,内存管理完全由程序员负责。这种“裸指针”模式面临三大痛点:
- 内存泄漏:申请了内存但忘记释放。
- 悬垂指针:指向的对象已释放,但指针仍在使用。
- 二次释放:对同一块内存调用两次
delete导致崩溃。C++11 引入了智能指针,其核心思想是 **RAII **:通过一个栈上的对象来管理堆上的资源。当栈对象生命周期结束析构时,自动释放其管理的堆资源。
RAII
RAII(Resource Acquisition Is Initialization,资源获取即初始化):将资源的生命周期与对象的生命周期绑定,在对象构造时获取资源(如动态内存、文件句柄、互斥锁、数据库连接等),在对象析构时自动释放资源。
原理
- 构造函数:负责获取资源(分配内存、打开文件、加锁等)。
- 析构函数:负责释放资源(释放内存、关闭文件、解锁等)。
- 当对象离开作用域时(包括正常结束、抛出异常等),C++ 保证析构函数会被自动调用,从而确保资源被正确释放。
1 |
|
优势
- 异常安全:即使发生异常,对象析构依然会被调用,资源不会泄漏。
- 防止忘记释放资源:不需要手动写
delete、fclose等代码。 - 代码简洁清晰:资源管理逻辑集中在构造/析构函数中。
std::unique_ptr
最常用、性能最高的智能指针,保证同一时间内只有一个指针拥有该对象。
- 禁止拷贝:不能通过赋值或构造进行拷贝。如果允许会出现两个指针指向同一个资源,发生重复释放
- 允许移动:可以通过
std::move转移所有权。因为禁止了拷贝所以只能通过移动来转移所有权 - 零开销:在运行时,其大小与裸指针完全一致,没有任何性能损失。
1 |
|
std::shared_ptr
std::shared_ptr允许多个指针同时指向同一个对象。对象内部维护一个**引用计数 **。对象内部通常包含两个指针:
- 指向管理对象的指针
- 指向控制块的指针(包含引用计数、弱引用计数、删除器等)
- 引用计数:每增加一个指向该对象的
shared_ptr,计数加 1;每有一个指针失效,计数减 1。 - 自动析构:当计数降为 0 时,自动销毁对象并释放内存。
- 线程安全:引用计数操作是线程安全的,但对象本身和它指向的资源需要程序员额外处理同步。”
1 | void test_shared() { |
std::weak_ptr
std::weak_ptr是一种不控制对象生命周期的智能指针,它是std::shared_ptr的观察者。
- 不增加计数:指向对象但不参与所有权。
- 安全性:在使用前必须调用
lock()升级为shared_ptr,如果对象已销毁,lock()返回空指针。 - 打破循环引用:这是它最重要的用途。
循环引用陷阱
当两个对象互相持有对方的 shared_ptr 时,引用计数永远不会归零,导致内存泄漏。
1 | struct B; |
在 C++ 中,当离开作用域(即遇到 })时,栈上的局部变量会按照后进先出的顺序被自动销毁。
- 销毁局部变量
b:栈上的b指针不复存在了。因此,对象 B 的引用计数减 1 (从 2 变成了 1)。 - 销毁局部变量
a:栈上的a指针也不复存在了。因此,对象 A 的引用计数减 1 (从 2 变成了 1)。
现在,函数已经彻底执行完毕,栈被清空了,但我们在堆上留下了两个对象:
- 对象 A 的计数是 1,因为虽然局部变量
a死了,但对象 B 内部的b->a_ptr仍然指着它。 - 对象 B 的计数是 1,因为虽然局部变量
b死了,但对象 A 内部的a->b_ptr仍然指着它。
因为引用计数都没有降到 0,所以它们的析构函数(~A() 和 ~B())永远不会被触发,这两块堆内存就永远挂在空中,造成了内存泄漏。
如果我们将其中一个结构体内部的指针换成 std::weak_ptr,它在上述步骤 3 或 4 中就不会增加引用计数(最高只能是 1),那么在函数结束销毁局部变量时,计数就会顺利降为 0,从而触发链式反应,完美释放内存。
安全性:lock()
weak_ptr 没有重载 -> 和 * 运算符。你在代码里根本无法直接写出 wp->value 这样的代码,编译器会直接报错。它强迫你必须使用 lock()。
既然不能直接用,那就必须把 weak_ptr 变成 shared_ptr 才能用。这个过程就叫做升级 (Upgrade)。
调用 wp.lock() 时,底层其实在做一个极其严谨且线程安全(原子性)的操作:
- 检查控制块:它去查看该对象当前的“强引用计数(shared count)”是否大于 0。
- 分支 A(对象还活着):如果大于 0,说明对象还在。
lock()会立刻将强引用计数加 1,并返回一个有效的shared_ptr。- 为什么叫安全? 因为一旦拿到了这个临时的
shared_ptr,引用计数就增加了。就算此时其他线程全都不管这个对象了,只要你手里的这个临时shared_ptr还没死,对象就绝对不会被销毁。你可以放心大胆地用。
- 为什么叫安全? 因为一旦拿到了这个临时的
- 分支 B(对象已死亡):如果强引用计数等于 0,说明对象已经被释放了。
lock()会返回一个空的(Null)shared_ptr。你的程序可以通过if判断安全地跳过操作,避免了崩溃。
使用手册
-
优先使用
unique_ptr:如果不需要共享所有权,默认使用它,因为它性能最优。 -
始终使用工厂函数:优先用
std::make_unique(C14) 和std::make_shared(C11)。它们能提高性能(减少内存分配次数)并防止由于异常导致的内存泄漏。 -
不要将裸指针直接初始化:尽量写成
auto p = std::make_shared<T>()。 -
接口传递:
-
只读访问:传递
const T&。 -
转移所有权:传递
std::unique_ptr<T>&&或值传递。 -
共享所有权:传递
std::shared_ptr<T>。
-
