目录

开头:

ok了,大家好久不见,前几天忙于英语四级(其实也没咋复习),现在这不是又要期末考试了。但是但是,我们学习C语言的脚步不能停,正如标题所见,今天这一期我们要一起学习的是关于C++11的一些新语法,以及关于异常,和很重要的智能指针,是不是光听名字就感觉来者不善,不慌,我们一起来学习!

一. C++11

1.列表初始化

在 C++98 中,不同类型的初始化方式五花八门,没有统一规则:
• 普通数组:int arr[ ] = { 1,2,3 };
• 结构体:Point p = {1, 2};
• 内置类型:int a = 10;
• 自定义类:Date d(2025, 1, 1);
• 容器初始化:必须先创建空容器,再逐个插入

这种混乱带来了两个问题:

  1. 学习成本高:初学者要记住不同类型的不同初始化规则
  2. 容器使用繁琐:初始化一个带初始值的 vector,必须写循环或者多次 push_back

为了解决这种问题,C++11引入了列表初始化:
用一套花括号{}语法,统一所有类型的初始化方式,这就是「列表初始化」

(1)C++11中的{}

C++11 规定:所有对象都可以使用花括号{}进行初始化,并且支持省略赋值符 =
在这里插入图片描述

(2)列表初始化的重要特性:防止类型收窄

类型收窄转换:一类存在风险的隐式类型转换—— 转换后可能丢失数据精度、截断高位数值,或目标类型无法表示源类型的全部取值范围(越界),是 C++ 中隐蔽 Bug 的常见来源。

C++ 标准明确规定:列表初始化禁止隐式的类型收窄转换。如果代码中存在隐式收窄转换,编译器必须给出诊断(主流编译器默认直接报编译错误)。

在这里插入图片描述
要注意的是:显式转换不受限制

如果是开发者主动执行的显式类型转换,列表初始化不会拦截 —— 这是明确的有意操作,不属于隐式收窄范畴。

常见误区:

  1. 拓宽转换完全合法:从范围小的类型转到范围大的类型(如 int→long、float→double)不属于收窄,列表初始化正常支持
  2. 转 bool 不算收窄:整数、浮点转 bool 的逻辑转换不属于收窄范畴,bool b{42}; 是合法写法
  3. 编译器行为差异:标准要求收窄转换必须给出诊断,主流编译器(GCC/Clang/MSVC)默认通常报编译错误;开启严格标准模式(如 -pedantic-errors)会强制报错
  4. 重要例外:编译期常量的豁免,如果源值是编译期可确定的常量表达式(如 const 整型常量、constexpr 常量),且转换后的值在目标类型的表示范围内,则不算收窄转换,列表初始化合法。
    在这里插入图片描述

问题:为什么 const / constexpr 常量可以豁免??

列表初始化禁止「隐式收窄转换」,本质是编译期安全检查
• 编译器在编译代码的时候,就要判断:这个转换有没有丢失数据、越界的风险。
• 只要编译期没法 100% 保证 “转换后值一定安全”,就直接报错,宁可错杀也不放过。

第一个板块:
而被 const 修饰的常量,属于编译期就能确定值的常量—— 编译器在编译时就明确知道:num 的值就是 10,永远不会变。

  1. 这里 num 是用字面量 10 初始化的 const int,属于编译期就能确定值的常量—— 编译器在编译时就明确知道:num 的值就是 10,永远不会变
  2. 编译器会当场验算:char 类型的取值范围(通常是 -128 ~ 127)完全装得下 10,既不会截断也不会越界,没有任何收窄风险
  3. 既然 100% 安全,就不算收窄转换,直接放行

同理,第二个板块:
在这里插入图片描述
constexpr 是更严格的编译期常量,200 正好在 unsigned char(0 ~ 255)的范围内,验算通过,所以合法

constexpr 是 C++11 引入的关键字,核心作用是强制要求被修饰的内容,必须能在编译阶段就计算出确定的结果,是 C++官方认证的「编译期常量 / 编译期可计算」标签,用 constexpr 修饰的变量,天生就是编译期常量—— 它的值必须在编译时就能 100%确定,否则编译直接失败

关键反例:常量超范围,照样报错

不是只要加了 const 就一定能过。如果常量本身的值超出了目标类型范围,编译器验算不通过,依然会判定为收窄:
在这里插入图片描述

防止类型收窄是列表初始化最核心的安全优势之一:
• 将运行期才能发现的数据截断、数值异常问题,提前到编译期拦截,大幅降低调试成本
• 避免函数传参、构造初始化等场景下,不经意的隐式转换导致的隐蔽 Bug
• 这也是现代 C++ 推荐优先使用列表初始化的核心原因之一

(3)C++11中的std::initializer_list

std::initializer_list 是 C++11 引入的标准库模板类型,它是「花括号初始化列表」的底层载体,专门用来让构造函数、普通函数接收任意数量、同类型的初始化参数。我们平时写的 vector v = {1,2,3} 这类便捷语法,底层就是靠它实现的。

核心本质:它不是容器,是一个轻量级的「只读视图 / 代理对象」。内部仅保存两个信息:指向底层常量数组的首指针,以及元素个数
元素属性:内部元素均为 const T 类型,只能读取,不可修改。

核心作用:

a.作为构造函数参数(最常用:初始化列表构造函数)

在这里插入图片描述
给类添加接收 std::initializer_list 的构造函数后,这个类就能像 STL 容器一样,用花括号列表批量初始化元素。

b.作为普通函数参数

在这里插入图片描述
如上图,一个函数的参数是通过 initializer_list 来接收,那么函数传参的时候就可以使用花括号

(4)列表初始化和初始化列表

在这里插入图片描述

2.右值引用与移动语义

(1)左值和右值

左值:有明确的内存地址、可以持久存在的表达式。核心特征:可以对它取地址
• 普通变量、const 变量
• 解引用的指针*p
• 下标访问返回的引用s[0]
• 左值引用返回的函数调用

右值:没有持久存储状态、生命周期短暂的临时值。核心特征:不能对它取地址
• 字面量常量:10、3.14、“hello”
• 表达式求值结果:a + b、func(x,y)
• 传值返回的函数调用结果:str.substr(1,2)
• 匿名临时对象:string(“test”)

在这里插入图片描述

重要结论:判断左值右值只有一个金标准 —— 能不能取地址。能取就是左值,不能取就是右值

(2)左值引⽤和右值引⽤

C++98 里的引用叫左值引用(Type&),只能给左值取别名;C++11 新增右值引用(Type&&),专门给右值取别名。两者本质都是给对象起别名,底层都是用指针实现的

在这里插入图片描述

在这里插入图片描述

a.一个极易踩坑的特性:右值引用变量本身是左值

右值引用只是绑定了一个右值,但引用变量本身是有内存地址的,它是左值

在这里插入图片描述

这个设计不是 bug,而是故意为之的。后面讲移动构造和完美转发的时候,你就会明白这个设计的价值

b.生命周期延长:临时对象的 “续命” 机制

临时对象本应在所在表达式结束后立即销毁,但如果被引用绑定,生命周期会被延长到引用变量的作用域结束

在这里插入图片描述

如上图,表达式 s1+ ”world“ 是一个右值,只能用右值引用。临时对象的生命周期就在当前行,但是使用右值引用后,就可以延长它的生命周期

底层原理:编译器会把临时对象的存储位置和引用变量绑定,让临时对象的生命周期和引用变量对齐。本质是编译器帮你把临时对象 “存” 了起来,不会提前释放。

C.函数重载:左值 / 右值引用的精准匹配

当函数同时存在左值引用、const 左值引用、右值引用三个重载时,编译器会根据实参的类型进行最精准匹配:
• 左值 → 匹配T&版本
• const 左值 → 匹配const T&版本
• 右值 → 匹配T&&版本
• 如果没有右值引用重载,右值会退而求其次匹配const T&版本

在这里插入图片描述
注意: 我们说过右值引用本身的属性是左值,所以上面 f (x) 匹配的是左值版本的函数(x是左值),move 可以将左值强转为右值

(3)移动语义:移动构造和移动赋值

a. 左值引用的不足

左值引用主要使用场景是:在函数中左值引用传参和左值引用传返回值时减少拷贝,同时还可以修改实参和修改返回对象的值。左值引用已经解决大多数场景的拷贝效率问题,但是有些场景不能使用传左值引用返回,例如之前我们做过的杨辉三角的题目:

在这里插入图片描述

这里存在两个问题:

  1. 这个vector<vector<\int> > vv(numRows);是定义在函数内部的临时变量,离开函数后栈帧销毁,必然不能用引用返回
  2. 这个vector vv(numRows);二维数组,拷贝消耗非常巨大,自然不能拷贝返回

这种情况下,C++98 中的解决方案是使用输出型参数解决,但是输出型参数可读性和代码维护性都比较差

在这里插入图片描述
为了更好的解决这种问题,C++11带来了右值引用与移动语义

b. 移动构造和移动赋值

讲移动语义之前,我们重新思考一个问题:为什么需要移动语义?

对于 string、vector 这样的深拷贝类,拷贝构造 / 拷贝赋值需要重新申请内存、复制数据,代价非常高。但很多时候,我们拷贝的是一个马上就要销毁的临时对象 —— 既然对方马上就要死了,为什么不直接把它的资源 “拿过来”,还要费劲拷贝一份?

这就是移动语义的核心思想:对于即将销毁的右值对象,不做深拷贝,而是直接 “窃取” 它的资源,转移所有权,代价只是交换几个指针,O (1) 时间复杂度。

移动构造函数
构造函数的重载,第一个参数必须是本类类型的右值引用,其余参数要有缺省值。函数内部不做深拷贝,只和右值对象交换资源

移动赋值运算符
赋值运算符的重载,参数是本类类型的右值引用,释放自身资源后,窃取右值对象的资源

移动构造代码示例;

在这里插入图片描述

移动赋值代码示例;

在这里插入图片描述

在这里插入图片描述

c.右值引用和移动语义解决传值返回问题

在这里插入图片描述

如上图,左边是不优化的版本,str 作为函数中的临时对象,作为函数的返回值最终要传给 ret ,不优化的情况下,编译器会在 main 函数的栈帧中创建一个临时对象,先将 str 拷贝赋值给这个临时对象,然后临时对象再拷贝赋值给 ret,会进行两次拷贝构造,效率很低,所以一般情况下,编译器会进行优化,如右边,不创建临时对象,直接将 str 拷贝构造给 ret

如果 string 中有移动构造,就会是下面:

在这里插入图片描述

左图,因为 str 存在于 addstring 的函数栈帧中,出了作用域就会被销毁,所以编译器优先调用移动构造,创建临时对象,又因为这是个临时对象,所以在拷贝给 ret 时,调用的也是移动构造,右图和上面的同理

右值引用 + 移动语义 对比传统拷贝构造的核心优势

  1. 性能质变:以资源指针转移替代完整深拷贝,无内存分配与数据复制,大对象返回 / 传参开销大幅降低
  2. 资源复用:接管临时对象的废弃资源,避免无意义的内存申请 - 释放循环
d.右值引⽤和移动语义在传参中的提效

函数传参场景下,移动语义的核心提效逻辑是:按值传参时,右值实参会优先触发移动构造替代深拷贝,把大对象的复制开销降到近乎为零

在这里插入图片描述
如上图,C++98中,不管对 func 传左值还是右值,都会调用拷贝构造来创建临时对象 s,会有资源的申请效率低,但是现在我们有了移动构造,那么在匿名对象传参的时候就可以不用开空间,直接交换资源,提高了效率

(4)类型分类

在这里插入图片描述

C++11中,将表达式分成了泛左值和右值两大类,然后又进行了细分
其中将亡值本质是「即将过期、可以被掏空的有名字对象」,说白了就是通过 move 强制将一个有地址的左值转变成了右值

(5)引用折叠

一共 4 种叠加组合,记住一个结论就行:只有叠加后一共有4个 & 才是右值引用

在这里插入图片描述

在这里插入图片描述

a.万能引用

基于引用折叠,模板函数中的T&&形参被称为万能引用:它既能接收左值,也能接收右值。
• 传入左值:T 被推导为int&,T&& → int& && → 折叠为int&(左值引用)
• 传入右值:T 被推导为int,T&& → int&&(右值引用)

在这里插入图片描述
注意:只有发生模板推导时的T&&才是万能引用。如果 T 已经确定了(比如类的成员函数,T 在类实例化时就定了),那就是普通的右值引用。

b.完美转发 forward

万能引用有一个致命的问题:右值引用形参变量本身是左值。当你把参数继续传给下一层函数时,它的右值属性会丢失,永远匹配左值引用重载

在这里插入图片描述
如上图,如果使用了万能引用,我们传入的10这个值是右值,编译器进行类型推导出 t 为右值,但是右值引用本身是左值,调用 Fun 时就会匹配到左值版本

std::forward<\T>() 就是用来解决这个问题的,它能在参数传递时,保持参数原本的值属性:左值传过去还是左值,右值传过去还是右值

在这里插入图片描述
如上图,使用 forward<\T> 完美转发,就可以保留我们传参时参数的类型

I.完美转发底层核心:简单实现

完美转发的全部能力都来自 std::forward 函数模板,完全靠编译期引用折叠 + static_cast 强制转换实现,零运行时开销。
在这里插入图片描述
外层传入左值时:万能引用推导 T = int&
• 代入返回值:int& && → 引用折叠为 int&(左值引用)
• static_cast<int&>(arg) 强制转换为左值,返回左值引用,完整保留左值属性

外层传入右值时:万能引用推导 T = int
• 代入返回值:int&& → 原生右值引用
• static_cast<int&&>(arg) 返回右值引用,还原右值属性

其实 forward<\T>() 完美转发就相当于 static_cast<T&&> ()

II.move vs forward

在这里插入图片描述
• move 是「强转右值」,不管原来是什么类型
• forward 是「原样返还」,传入时是什么,转发后就是什么

3.可变参数模板

可变参数模板是 C++11 引入的核心泛型特性,允许函数模板 / 类模板接收任意数量、任意类型的参数,是 STL 中 emplace_back、std::make_unique 等工具的底层基础,通常完美转发搭配使用。

(1)基本语法和底层原理

• template<class …Args>:模板参数包,Args 是零个或多个类型的集合
• void Func(Args… args):函数参数包,args 是零个或多个参数的集合
• sizeof…(Args) / sizeof…(args):编译期计算参数包的元素个数

在这里插入图片描述

底层原理
可变参数模板本质还是模板实例化。编译器会根据你传入的参数个数和类型,自动实例化出对应版本的函数。比如上面的代码,编译器会生成 4 个 Print 函数:

V

(2)包扩展

参数包不能直接用 for 循环遍历,必须通过 “包扩展” 的方式逐个解析。常用两种展开方式:递归展开、逗号表达式 + 初始化列表展开

a.方法1:递归函数展开(最经典、最常用)

思路:写一个递归终止函数(参数包为空时调用),再写一个递归模板函数,每次取出第一个参数处理,剩下的参数继续递归

在这里插入图片描述

看到上面这张图,大家一定有这些疑问:… 这三个点到底放哪里??是接在class后面,还是在Argc的前面??在函数参数时怎么又放到 Argc后面,作为参数时,怎么又放到 argc 后面???

下面我们来重点讲讲这三个点

b.符号 …(三个点)

… 的位置完全由它的作用决定:是**「声明一个参数包」,还是「展开一个已声明的参数包」**。总共就 3 种固定写法,对应你图里的所有位置,下面逐一对号入座。

I.声明「模板参数包」:… 夹在 class/typename 和包名中间

• 作用:告诉编译器,这不是一个类型,而是一组任意数量的类型
• 语法格式:class… 包名 / typename… 包名

在这里插入图片描述
上面的写法都可以

II.声明「函数参数包」:… 夹在「类型包」和变量名中间

• 作用:定义一组变量,类型和上面的模板参数包一一对应。
• 语法格式:类型包名… 变量名

在这里插入图片描述

III.展开「参数包」:… 跟在包名后面

• 作用:把打包的参数,拆成一个一个独立的参数,传给下一层函数 / 初始化列表
• 语法格式:包名…
在这里插入图片描述
• 理解:args 是一整包参数,后面加 … 就是「拆包」,拆成 参数1, 参数2, 参数3… 的形式

在这里插入图片描述

我们再来看一个代码例子
在这里插入图片描述

c.方式2 :初始化列表 + 逗号表达式展开

利用数组初始化列表的顺序执行特性,配合逗号表达式,一次性展开所有参数,写法更简洁

在这里插入图片描述
这种方式没有递归,代码更简洁,编译速度更快

(3)emplace 系列接口:容器原地构造的秘密

C++11 为所有 STL 容器新增了emplace_back、emplace接口,基于可变参数模板完美转发实现,支持直接在容器节点的内存上构造对象,比push_back更高效

它的底层完全由你刚学的三项技术组合实现:

  1. 可变参数模板:接收任意数量、任意类型的构造函数参数
  2. 完美转发:完整保留每个参数的左值 / 右值、const 属性
  3. 定位 new(placement new):在已有内存地址上直接调用构造函数,不额外申请内存

以vector<pair<string, int>>为例,对比两种插入方式的底层调用:

在这里插入图片描述
可以看到,emplace_back 少了一次移动构造,对于移动成本高的对象,收益更明显

a. vector 中 emplace_back 底层实现

在这里插入图片描述

如上图,普通的 push_back 要创建临时变量,而 emplace_back 是直接通过定位new直接在目标内存中构造对象,大大提高了效率

核心:定位 new + 完美转发展开
在这里插入图片描述
① 定位 new 语法: new (地址) T(构造参数)
• 不会申请新内存,直接在你指定的 data_ + size_ 这个地址上,调用 T 的构造函数
• 这就是「原地构造」的底层实现,也是 emplace_back 省开销的核心

② 完美转发参数包展开:std::forward(args)…
• … 跟在后面表示参数包展开,会把每个参数分别做完美转发
• 展开后等价于:std::forward(arg1), std::forward<const char*>(arg2)
• 完整保留每个参数的原始值类别,传给构造函数时该移动还能移动,该拷贝就拷贝

b.定位new

我们平时写的 new T() 叫new 表达式,它在底层会自动拆成两步执行:

  1. 分配内存:调用 operator new 向堆申请一块大小为 sizeof(T) 的空闲内存,得到一个裸地址
  2. 构造对象:在这块刚申请的内存上,调用 T 的构造函数,初始化出一个有效对象

普通 new = 先买一块空地(申请内存) + 在空地上盖房子(构造对象)

定位 new 是 new 表达式的特殊用法,它完全**跳过了「申请内存」**的第一步,只做第二步:在你已经提供的现成内存地址上,直接调用构造函数初始化对象。
在这里插入图片描述
核心特点
只构造,不分配:内存必须由你提前准备好,定位 new 只负责在上面构造对象,零额外内存申请;
地址由你指定:对象会精确生成在你传入的地址上,不会偏移;
析构必须手动:因为内存不是它申请的,所以不能直接 delete,必须先手动调用析构函数 p->~Test() 清理对象,再由内存的所有者去释放底层内存
在这里插入图片描述

4.类的新功能

(1)默认的移动构造与移动赋值

C++11 后,类的默认成员函数从 6 个增加到 8 个,新增了移动构造和移动赋值。但编译器不会随便生成默认移动函数,有非常严格的条件:

生成默认移动函数的条件

  1. 用户没有显式定义拷贝构造函数
  2. 用户没有显式定义拷贝赋值运算符
  3. 用户没有显式定义析构函数
    当且仅当以下三个条件全部满足时,编译器才会自动生成默认的移动构造和移动赋值

默认移动函数的行为
• 内置类型成员:逐字节拷贝(浅拷贝)
• 自定义类型成员:调用该成员自己的移动构造 / 移动赋值;如果成员没有移动语义,就调用拷贝构造 / 赋值

在这里插入图片描述

(2)=default 与 =delete

=default:显式要求生成默认函数

C++ 类有 6 个编译器会自动生成的特殊成员函数:

  1. 默认构造函数
  2. 拷贝构造函数
  3. 拷贝赋值运算符
  4. 移动构造函数(C++11)
  5. 移动赋值运算符(C++11)
  6. 析构函数

规则是:只要你手动定义了其中某一个,编译器就会停止自动生成对应的默认版本。比如你写了一个带参构造,编译器就不再生成默认构造。这时候你又想要默认实现,就可以用 =default 显式恢复

在这里插入图片描述

=delete:显式禁用函数

将函数标记为「已删除」,任何对该函数的调用都会在编译期直接报错
一共有4种场景:

a.场景 1:禁用类拷贝(最常用,不可复制资源类)

在这里插入图片描述

b.场景 2:禁止隐式类型转换,消除歧义

函数重载中删除特定类型版本,阻止编译器自动隐式转换。
例:只接收 int,禁止浮点自动转 int:

在这里插入图片描述

c.场景 3:禁用模板指定类型实例化

禁止模板用某一类类型实例化,编译期拦截非法类型:
在这里插入图片描述

d.场景 4:限制对象创建方式

① 禁止堆创建(只能栈上对象):删除全局 operator new,不能 new 创建对象
在这里插入图片描述
② 禁止无参构造(必须传参)
在这里插入图片描述

(3)final 和 override

final:
final 有两个完全独立的用法:修饰类、修饰虚函数。

  1. 修饰类:禁止该类被继承
    语法:在类名后加 final
    效果:这个类不能作为基类被任何类继承,尝试继承会直接编译报错。
    适用场景:设计上不希望被扩展的工具类、最终实现类、底层封装类
    在这里插入图片描述
  2. 修饰虚函数:禁止该函数被继续重写
    语法:在虚函数声明的尾部加 final
    效果:该虚函数在当前类之后的所有派生类中,都不允许再被重写,尝试重写会编译报错。
    适用场景:基类 / 中间派生类已经实现了标准逻辑,不希望子类修改;锁定多态行为
    在这里插入图片描述
    override:
    显式声明派生类的成员函数正在重写基类的虚函数,强制编译器校验这次重写是否合法;如果不满足重写规则,直接编译报错,简单来说被 override 修饰的函数必须进行重写,否则报错

在这里插入图片描述

5.C++11中STL的变化

(1)新增容器组件

在这里插入图片描述

(2)原有容器的核心增强

a.emplace 系列原地构造

所有序列 / 关联容器均新增 emplace / emplace_back / emplace_front / emplace_hint 等成员:
• 基于完美转发直接在容器内存中构造对象,彻底避免临时对象的构造 + 拷贝开销

b.全面支持移动语义

• 所有容器新增移动构造、移动赋值运算符,容器间资源转移为 O (1) 复杂度(仅交换内部指针、大小等元数据)
• push_back / insert 等接口新增右值引用重载,支持直接移入对象,大幅提升容器传参、返回值场景的性能

c.初始化列表支持

所有容器新增 std::initializer_list 构造与赋值,支持花括号列表初始化

d.访问与迭代器增强

• 新增 cbegin() / cend() / crbegin() / crend() 成员,显式获取 const 迭代器,不依赖对象本身的 const 属性
• 支持范围 for

(3)function / bind 和智能指针

下面我们逐步进行学习

(C++11中STL不仅仅增加了这些,感兴趣的同学可以自己查询)

6.lambda

Lambda(匿名函数)是 C++11 引入的核心语言特性,本质是一个就地定义的匿名函数对象(闭包),最大的特点是可以捕获当前作用域的局部变量,同时保持代码紧凑。

(1)基本语法

在这里插入图片描述

各部分说明:

在这里插入图片描述

在这里插入图片描述
如上图,实现了一个加法的匿名函数,每个 lambda 都有一个唯一的、不可见的类型,使用 auto 类型来接收,其中返回值类型可以不写,编译器会自动推导,如果函数没有参数,参数列表可以省略,但是即使捕捉列表为空也不能省略,以下写法均可以

在这里插入图片描述

(2)捕捉列表

捕获列表是 lambda 区别于普通函数的核心,它决定了 lambda 能否访问外部局部变量、以及访问方式。全局变量、静态变量、常量无需捕获,可直接使用。

a. 基础捕获方式

在这里插入图片描述

显示值捕获和显示引用捕获
在这里插入图片描述

隐式值捕获和隐式引用捕获
在这里插入图片描述

b.混合捕获

可以组合「隐式默认捕获 + 显式例外捕获」,规则是隐式写在最前,显式变量作为例外,不能重复捕获:
• [=, &x]:默认所有变量按值捕获,只有 x 单独按引用捕获
• [&, x]:默认所有变量按引用捕获,只有 x 单独按值捕获

在这里插入图片描述

c.类内 this 捕获

在类的成员函数中,[this] 可以捕获当前对象的指针,从而访问类的成员变量和成员函数:

在这里插入图片描述

(3)mutable

默认情况下,按值捕获的变量在 lambda 内部是只读的(const),如果要修改值捕获的副本,必须加上 mutable 关键字。

在这里插入图片描述

如上图,没有mutable修饰,在lambda函数中进行对变量的修改会报错

**加粗样式**

7.包装器

「包装器」指的是 C++11 引入的 std::function 通用可调用对象包装器,定义在 <\functional> 头文件中

在 C++ 里,能像函数一样被 “调用” 的东西有很多:普通函数、函数指针、lambda、仿函数(重载了()的类)、类成员函数……
它们类型全都不一样,没法用同一个变量接收,也没法放进同一个容器里

std::function(包装器)就是为了解决这个问题而生的:
它是一个通用的 “可调用对象容器”。只要「返回值 + 参数列表」一致,不管是什么类型的可调用实体,都能被它装起来,变成同一种类型,统一存储、传递、调用。

(1)基本语法与用法

在这里插入图片描述

a.包装普通全局函数

最基础用法,直接赋值函数名即可

在这里插入图片描述

b.包装 lambda 表达式(最常用)

无论带不带捕获都能装,这是它碾压函数指针的核心优势

在这里插入图片描述
相当于给匿名函数起了个名字

c.包装仿函数(函数对象)

在这里插入图片描述

d.类的静态成员函数

静态成员函数没有隐式 this 指针,和普通函数用法完全一致:

在这里插入图片描述
对于静态成员函数,&可加可不加

e.类的非静态成员函数

在这里插入图片描述
对于非静态的成员函数,有隐含的 this 指针,在 function 的参数列表需要显示的写类型指针,必须加 &。在使用时,也要显示传地址

(2)std:: bind

std::bind 是 C++11 引入的函数适配器,同样定义在 <\functional> 头文件中
核心作用:对一个已有的可调用对象(普通函数、lambda、仿函数、类成员函数),提前固定部分参数、调换参数顺序、压缩参数数量,生成一个全新的可调用对象

通俗说:不用你手写包装函数,就能把一个函数 “改造” 成另一个参数规则不一样的函数

语法格式
在这里插入图片描述
两种参数绑定项
bind 会按顺序对应原函数的每一个参数,每个位置都有两种写法:
固定值:直接写常量 / 变量,调用新函数时,这个位置永远用这个固定值,不需要调用者传参
占位符:std::placeholders::_1、_2、_3…… 代表「新函数的第 N 个参数」,调用时会把用户传的第 N 个参数,原样传给原函数对应位置

a.用法1:固定参数,减少参数数量

在这里插入图片描述

b.用法2 :调整参数顺序

占位符的顺序可以和原函数参数顺序不一致,以此实现参数调换

在这里插入图片描述

如上图,bind 中_1,_2,_3 … 代表的是函数传参时参数的位置,然后按照从左到右的顺序给到函数的参数列表中

c.用法 3:引用参数的处理(高频大坑)

std::bind 默认对所有绑定的参数执行值拷贝—— 哪怕原函数形参是引用,bind 也会先把变量拷贝一份,再传给原函数

在这里插入图片描述

如果想要真正把外部变量按引用传进去,必须用:
• std::ref(变量):传递可修改的左值引用
• std::cref(变量):传递只读的 const 左值引用

在这里插入图片描述

d.用法4:绑定类的非静态成员函数

这是 bind 最经典的使用场景之一,也是和 std::function 配合最多的场景
上面我们也见识过,类的非静态成员函数,有一个隐式的第一个参数:this 指针

所以用 bind 绑定成员函数时,必须遵守:

  1. 成员函数名前必须加 &(成员函数必须加)
  2. 绑定列表的第一个位置,必须传对象的指针或对象本身(对应隐式的 this 参数)
  3. 后面的位置再对应成员函数显式的参数

在这里插入图片描述

可以看到使用 bind 绑定非静态的成员函数,利用了 bind 可以减少参数的特性,此时的 function 的参数列表中也不用写类类型的指针了

(3)包装器的核心能力(为什么它好用)

a.统一类型,可存入容器

所有签名一致、但类型不同的可调用对象,都能放进同一个vector里,批量执行

在这里插入图片描述

b.可空、可判空

包装器可以不绑定任何东西,处于 “空” 状态。
直接调用空包装器会抛出 std::bad_function_call 异常,所以调用前建议判空:

在这里插入图片描述

c.可随时重新赋值

同一个包装器变量,可以反复绑定不同的可调用对象:
在这里插入图片描述

二.异常

异常是 C++ 提供的一套运行时错误处理机制,核心思想是**「错误检测与错误处理分离」**:底层函数检测到错误时直接抛出异常,无需关心谁来处理;上层调用者通过 try-catch 捕获并处理错误

异常处理机制允许程序中独⽴开发的部分能够在运⾏时就出现的问题进⾏通信并做出相应的处理,异常使得我们能够将问题的检测与解决问题的过程分开,程序的⼀部分负责检测问题的出现,然后解决问题的任务传递给程序的另⼀部分,检测环节⽆须知道问题的处理模块的所有细节。

C语⾔主要通过错误码的形式处理错误,错误码本质就是对错误信息进⾏分类编号,拿到错误码以后还要去查询错误信息,⽐较⿇烦。异常时抛出⼀个对象,这个对象可以函数更全⾯的各种信息

1.异常的抛出和捕获:throw /try/catch

异常机制由三个关键字构成完整闭环:
• throw:检测到错误时,抛出一个异常对象
• try:包裹可能抛出异常的代码块
• catch:捕获并处理指定类型的异常

(1)基础执行流程与执行规则

在这里插入图片描述
执行规则:

  1. 若 try 块内代码正常执行完毕,直接跳过所有 catch 块
  2. 若抛出异常:立即暂停当前执行,创建异常对象,沿着函数调用栈向上查找匹配的 catch 块
  3. 找到匹配的 catch 后,执行处理逻辑,执行完继续运行 catch 之后的代码
  4. 直到最外层(main 函数)都没找到匹配的 catch,调用 std::terminate() 直接终止程序

(2)catch 的匹配规则

• 支持多个 catch 块,按从上到下的顺序依次匹配,匹配到第一个就停止
• 支持基类类型匹配派生类对象(多态特性)
• catch(…) 是兜底语法,捕获所有类型的异常,必须放在所有 catch 的最后

⚠️ 高频易错:派生类异常必须写在基类前面,否则基类会先匹配到,子类 catch 永远不会执行

在这里插入图片描述

如上图,如果存在多个 catch ,会自动 throw 到最匹配的 catch

catch(…)可以匹配所有的类型,写在所有 catch 的最后,防止存在抛异常没有 catch 接收

(3)异常重新抛出

在 catch 块内写 throw;(不带任何参数),可以将当前捕获的异常原样向上传递,通常用于记录日志后继续交给上层处理,依旧要匹配类型

在这里插入图片描述

2.核心原理:栈展开

异常抛出后,从抛出点开始,沿着函数调用栈向上回溯匹配 catch 的过程,叫做栈展开

• 每退出一层函数,都会按「构造逆序」销毁该层栈上的所有局部对象,自动调用它们的析构函数
• 直到找到匹配的 catch 块,停止展开;
• 全程保证局部对象一定会被正确析构

在这里插入图片描述

栈展开是 C++ RAII(资源获取即初始化) 机制的核心基础:用对象管理资源(智能指针、锁、文件句柄),即使发生异常,栈展开也会自动调用析构函数释放资源,从根本上避免内存泄漏、句柄泄漏

3.异常安全问题

异常抛出后,后⾯的代码就不再执⾏,如果前⾯申请了资源(内存、锁等),抛异常就会导致资源没有释放,常就引发了资源泄漏

在这里插入图片描述
如上图,像这种有资源申请的,抛异常可能就直接跳过了 delete资源释放,导致了资源泄漏,产⽣安全性的问题。

其次析构函数中,如果抛出异常也要谨慎处理,⽐如析构函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后⾯的5个资源就没释放,也资源泄漏了

为了解决这种问题,C++11 有了智能指针

4.noexcept 关键字

noexcept 是 C++11 引入的全新异常规范,用于承诺函数不会抛出异常,是编译期 + 运行期结合的保证。

核心规则:
• 如果被 noexcept 标记的函数内部抛出了异常,直接调用 std::terminate() 终止程序,不会完整栈展开;
• 它只是「开发者的承诺」,编译器不会强制检查函数内部是否真的不抛异常,违反承诺后果自负

在这里插入图片描述
noexcept 运算符
编译期运算符,用于判断一个表达式是否承诺不抛异常,返回 bool,常用于模板元编程。

在这里插入图片描述

三.智能指针

智能指针是 C++ 中基于 RAII(资源获取即初始化) 思想设计的资源管理工具,本质是用类对象包裹原生指针,在对象构造时接管资源,析构时自动释放资源,彻底解决手动管理内存的泄漏、重复释放、异常安全等问题。

C++ 标准库共提供 4 种智能指针,均定义在 头文件中:
• auto_ptr(C++98,已废弃)
• unique_ptr(C++11)
• shared_ptr(C++11)
• weak_ptr(C++11)

1.核心基础:为什么需要智能指针?

(1)原生指针的核心痛点

手动管理动态内存时,极易出现两个问题:

内存泄漏:忘记 delete,或异常发生时跳过了 delete 语句(栈展开直接跳出函数)。
重复释放 / 悬空指针:多个指针指向同一块内存,释放顺序混乱,导致 double free 或野指针访问。

(2)RAII 思想

RAII(Resource Acquisition Is Initialization)资源获取即初始化的核心逻辑:

用对象的生命周期绑定资源的生命周期。对象创建时获取资源,对象销毁时自动释放资源

智能指针就是 RAII 在内存管理上的落地实现:

  1. 构造函数中接收并托管原生指针
  2. 析构函数中自动调用释放逻辑(默认 delete)
  3. 重载 *、->、[] 等运算符,让它用起来和原生指针几乎一致

无论函数正常返回还是异常抛出,栈上的智能指针对象一定会被析构,资源必然被释放,从根源上避免泄漏

2.auto_ptr:C++98 初代智能指针(已废弃)

auto_ptr 是 C++ 最早的智能指针,核心解决「异常导致内存泄漏」的基础问题
它的核心设计是管理权转移:当一个 auto_ptr 拷贝 / 赋值给另一个时,资源的所有权会从原对象完全转移给新对象,原对象自动置为 nullptr

在这里插入图片描述
核心缺陷(被淘汰的根本原因)

  1. 隐式管理权转移,语义极其危险
    拷贝 / 赋值的行为和常规认知完全相反:拷贝后原对象失效,非常容易误操作导致悬空指针访问,bug 隐蔽性极强
  2. 无法存入 STL 容器 无法将元素存入 STL 容器中
    STL 容器要求元素拷贝后原对象仍然有效(可正常复制、赋值),但 auto_ptr 拷贝后原对象会变空,完全不满足容器要求,在容器中使用会引发各种未定义行为
  3. 不支持数组 不支持数组形式输入
    析构默认调用 delete,无法正确释放 new[] 分配的数组,也没有 operator[] 下标访问
  4. 不支持自定义删除器
    只能管理 new 出来的内存,无法管理文件句柄、互斥锁等其他资源

结论:C++11 正式废弃 auto_ptr,C++17 从标准中移除,所有场景均由 unique_ptr 替代

在这里插入图片描述

3.unique_ptr

(1)设计定位

unique_ptr 是 auto_ptr 的正统替代,严格遵守独占所有权语义:同一时刻只能有一个 unique_ptr 持有资源

它彻底修复了 auto_ptr 的设计缺陷:禁止隐式拷贝,只允许通过 std::move 显式转移所有权,语义清晰、零运行时开销,是日常开发的首选智能指针

(2)核心实现:禁用拷贝,仅支持移动

在 C++11 中,拷贝构造和拷贝赋值运算符被直接定义为 = delete ,从而在语法层面上彻底禁止了拷贝操作
在这里插入图片描述
只能通过移动构造 / 移动赋值转移所有权,且必须显式使用 std::move,转移后原对象自动置空,所有行为都是明确可控的

(3)常用接口

在这里插入图片描述

(4)数组与自定义删除器

数组特化:unique_ptr<T[]> 专门管理 new[] 数组,析构自动调用 delete[],支持下标访问
在这里插入图片描述
自定义删除器:删除器是 unique_ptr 模板参数的一部分,属于类型本身。可用于管理文件、socket 等特殊资源
在这里插入图片描述

(5)对比 auto_ptr 的核心改进

**加粗样式**

(6)代码实现

在这里插入图片描述

4.shared_ptr:共享式智能指针(引用计数深度拆解)

(1)核心思想:引用计数

当需要多个指针共同管理同一块资源时,独占语义不再适用。shared_ptr 采用引用计数机制实现共享所有权:

• 每新增一个指向该资源的 shared_ptr,引用计数 +1
• 每销毁一个指向该资源的 shared_ptr,引用计数 -1
• 当引用计数减到 0 时,说明没有任何对象持有资源,自动调用析构,释放资源

在这里插入图片描述

(2)底层结构:控制块(引用计数的载体)

很多人误以为引用计数是 shared_ptr 的成员变量,这是完全错误的。一份资源对应唯一的一份引用计数,所有指向该资源的 shared_ptr 必须共享同一个计数,表示的是同一块资源被几个指针管理(指向)

因此 shared_ptr 对象内部只存两个指针:
• 资源指针:指向实际管理的堆内存;
• 控制块指针:指向堆上分配的「控制块」结构体(这里我们先简单认为是 int* 的指针)

在这里插入图片描述

(3)引用计数的增减规则

在这里插入图片描述
在这里插入图片描述

(4)make_shared:引用计数的最优创建方式

make_shared(args…) 是创建 shared_ptr 的标准推荐方式,核心优势和引用计数直接相关:

  1. 一次内存分配
    • 普通构造 shared_ptr(new T) 需要两次堆分配:先分配资源,再分配控制块
    • make_shared 一次性分配一块连续的堆内存,同时存放「资源对象 + 控制块」,减少内存分配次数、降低内存碎片、提升缓存局部性
  2. 异常安全
    避免函数参数求值顺序导致的内存泄漏:
    在这里插入图片描述

(5)自定义删除器

shared_ptr 的删除器存储在控制块中,不属于模板类型的一部分。这意味着:即使两个 shared_ptr<\T> 使用完全不同的删除器,它们也属于同一种类型,可以互相赋值、存入同一个容器,灵活性远高于 unique_ptr

在这里插入图片描述
C++17 新增 shared_ptr<T[]> 数组特化,可直接管理 new[],无需手写删除器。

(6)代码实现

在这里插入图片描述

5.循环引用问题与 weak_ptr

(1)循环引用

hared_ptr 的引用计数机制有一个致命缺陷:对象间互相持有对方的 shared_ptr,会形成循环依赖,导致引用计数永远无法减到 0,最终内存泄漏。

最典型的双向链表场景:
在这里插入图片描述

在这里插入图片描述

如上图,好像没啥问题啊,n1 指向的资源中的 next 指向 n2 的资源,所以 n2 的引用计数要+1,同理 n2 指向的资源中的 pre 指向 n1 的资源,所以 n1 的引用计数要+1

但是问题就出在了析构上

在这里插入图片描述
当函数结束,n1 ,n2 被 delete 后,各自资源的引用计数都 -1 ,变成了 1 ,但是资源中还存在着 next ,pre 指针指向对方的资源,引用计数不可能变为 0,就不可能调用析构函数来 delete 这两块资源,就造成了内存泄漏,这就是循环引用的问题

(2) weak_ptr:弱引用打破循环

weak_ptr 是专门配合 shared_ptr 的辅助指针,核心定位是观察者:
• 不拥有资源所有权,绑定 shared_ptr 时只增加弱引用计数不增加强引用计数
• 不实现 RAII,不能直接访问资源(没有重载 * 和 ->)
• 只负责检测资源是否存活,需要访问时必须先提升为 shared_ptr

在这里插入图片描述
此时 n1->next = n2 以及 n2->prev = n1,就不会增加强引用计数,强引用计数就只是 1,函数结束时两个节点的强引用计数都会减到 0,资源正常释放

常用接口:
在这里插入图片描述

弱引用计数的作用
控制块的生命周期由弱引用计数管理:
• 强引用计数 = 0 → 释放资源本身
• 弱引用计数也 = 0 → 释放控制块本身

6.控制块(Control Block)

控制块是 std::shared_ptr / std::weak_ptr 体系的核心管理结构体,是堆上分配的一块独立内存。所有指向同一份资源的智能指针,都共享同一个控制块,资源的生命周期、引用计数、删除逻辑全部由它统一管控

很多人有一个认知误区:以为引用计数是 shared_ptr 的成员变量。实际上每个 shared_ptr 对象本身只存两个指针,并不直接存计数:
• 一个指向实际资源(T*),用于正常访问对象
• 一个指向堆上的控制块,用于管理资源生命周期

(1)控制块的内部结构

在这里插入图片描述
关键认知:weak_ptr 不会延长资源的生命周期,只会延长控制块的生命周期。保证即使资源已经释放,weak_ptr 依然能安全地检查资源状态,不会出现野指针访问

(2)make_shared 对控制块的内存优化

这是面试高频考点,也是 make_shared 性能更优的核心原因,本质就是控制块的分配方式不同

a.普通构造:两次独立分配

在这里插入图片描述
执行两次堆分配:

  1. 第一次 new:分配 int 资源的内存
  2. 第二次 new:分配控制块的内存

两块内存地址不连续,带来两个问题:内存碎片多、CPU 缓存命中率低

b.make_shared:一次连续分配

在这里插入图片描述只分配一块足够大的连续内存,前半部分存放控制块,后半部分(或中间)就地构造资源对象

c.优势和副作用

优势:
性能更高:减少一次内存分配开销,降低内存碎片
缓存友好:资源和控制块地址相邻,CPU 缓存命中率更高
异常安全:一步完成分配 + 构造,不会出现「资源分配成功、控制块分配失败」的内存泄漏

副作用

因为资源和控制块在同一块连续内存里,必须等**「强引用 + 弱引用都归零」**时,整块内存才能一起释放
如果有大量 weak_ptr 长期存活(比如缓存、观察者场景),即使资源早就被释放了,整块内存(包括资源占用的空间)也无法回收,会产生额外的内存占用

(3)两段生命周期

控制块设计了两套计数,对应「资源」和「控制块自身」两段独立的生命周期,这也是 weak_ptr 能安全工作的基础

a.第一段:资源的生命周期(由强引用计数管理)

• 强引用计数 > 0 → 资源存活
• 强引用计数== 0 → 立即调用删除器,销毁资源对象
• 此时控制块本身还在,weak_ptr 依然可以通过控制块判断资源已过期

b.第二段:控制块自身的生命周期(由弱引用计数管理)

• 弱引用计数 > 0 → 控制块存活
• 弱引用计数也减到 0 → 释放控制块本身的内存

四.再谈内存泄漏

内存泄漏的核心定义:程序申请内存后,因逻辑错误丢失了对该内存的引用 / 指针,导致无法主动释放内存;这块内存会持续占用系统资源,直到进程退出才被操作系统回收

两个必须纠正的常见认知:

  1. 不是内存 “物理消失” 了,内存依然存在,只是程序中没有任何指针能定位到它,再也无法通过 delete 主动释放(脱管了)
  2. 进程正常退出时,操作系统会回收该进程的全部内存空间,因此短生命周期程序的泄漏不会永久影响系统,但是,⻓期运⾏的程序出现内存泄漏,影响很⼤,如操作系统、后台服务、⻓时间运⾏的客⼾端等等,不断出现内存泄漏会导致可⽤内存不断变少,各种功能响应越来越慢,最终卡死

1.C++ 中最经典的泄漏场景

(1)场景 1:裸指针 + 异常跳过释放

这是最基础也最容易忽略的场景,也是 RAII 和智能指针要解决的核心问题
在这里插入图片描述

(2)场景 2:基类析构函数非虚函数(多态场景)

当用基类指针管理派生类对象时,如果基类析构不是虚函数,delete 基类指针时只会调用基类析构,派生类的资源不会被释放,造成泄漏
在这里插入图片描述
修复:多态基类的析构函数必须加 virtual

(3)场景 3:容器存储裸指针,清空时未释放元素

STL 容器只会销毁自身的元素空间;如果元素是裸指针,容器销毁 / 清空时只会销毁指针本身,不会释放指针指向的堆内存

在这里插入图片描述

(4)场景 4:shared_ptr 循环引用(智能指针专属泄漏)

这是使用共享指针最容易踩的坑,也是 weak_ptr 存在的核心原因。
当两个对象互相持有对方的 shared_ptr 时,会形成循环引用,双方的强引用计数永远无法降到 0,资源永远不会释放
在这里插入图片描述

2.内存泄漏的危害

短生命周期程序:危害极小,进程退出后系统自动回收内存,但会留下不良编码习惯,埋下长期隐患。
常驻后台服务 / 守护进程:危害致命。内存持续缓慢增长,可用内存越来越少,触发系统频繁换页(swap),服务响应速度急剧下降;最终触发系统 OOM 机制,进程被强制杀死,造成业务中断
嵌入式 / 资源受限设备:内存总量小,少量泄漏就会导致程序崩溃、设备宕机
安全风险:攻击者可以通过触发内存泄漏,耗尽服务端内存,造成拒绝服务攻击(DoS)

3.常见内存错误

在这里插入图片描述

结尾

这一期对C++11,异常以及智能指针知识的学习就结束了,希望通过本文的学习,对你有所帮助,大家可以学习到知识有所进步。欢迎点赞、收藏、关注!如果有任何问题,也欢迎在评论区留言交流。我会持续更新更多的C++技术文章,敬请期待!我主页里有更好康的呦!

往期回顾

  1. 【Linux】第4期 Linux 进程与进程控制:一篇搞定所有核心知识点
  2. 【学习篇】第22期 带你手撕红黑树
  3. 【学习篇】第21期 超详解 AVL树

更多推荐