目录
[3.析构函数:唯一"不标也 noexcept"的特殊成员](#3.析构函数:唯一"不标也 noexcept"的特殊成员)
1.背景
先看一个实验。两个类,成员完全一样,都持有一块 1KB 的堆内存。唯一的区别:一个移动构造标了 noexcept,另一个没标。
cpp
struct Good {
std::unique_ptr<char[]> data = std::make_unique<char[]>(1024);
Good() = default;
Good(Good&& o) noexcept : data(std::move(o.data)) {} // 标了
Good(const Good& o) : data(std::make_unique<char[]>(1024)) {
std::memcpy(data.get(), o.data.get(), 1024);
}
};
struct Bad {
std::unique_ptr<char[]> data = std::make_unique<char[]>(1024);
Bad() = default;
Bad(Bad&& o) : data(std::move(o.data)) {} // 没标
Bad(const Bad& o) : data(std::make_unique<char[]>(1024)) {
std::memcpy(data.get(), o.data.get(), 1024);
}
};
往 std::vector里 push_back100 万个对象,测量扩容总耗时:
cpp
Good(移动构造 noexcept): 18ms
Bad (移动构造无 noexcept): 2371ms
差了 130 倍。不是算法差异,不是数据结构差异,就差一个关键字。
你可能会想:这不合理吧?我的移动构造明明只是交换两个指针,编译器看得到函数体,它为什么不能自己判断?
因为标准库不看你的函数体。它看的是你的类型特征。具体来说,std::vector在扩容时用的不是 std::move,而是 std::move_if_noexcept------一个根据编译期类型特征决定移动还是拷贝的"保险开关"。
2.核心机制
std::vector 扩容时需要将旧内存中的元素搬运到新内存。为了满足「扩容失败时原容器数据完全完好」的强异常安全承诺,标准库做了保守选择:
- 若元素类型的移动构造函数声明了
noexcept:直接移动元素,时间复杂度 O (n) 且无额外开销; - 若移动构造未声明
noexcept,且类型支持拷贝构造:放弃移动,退化为调用拷贝构造函数搬运元素。
背后逻辑很直观:移动构造如果抛出异常,旧元素已经被移走,无法恢复原状态;而拷贝构造失败时,旧元素完整保留,只需释放新内存即可回滚到扩容前状态。
下面是一段可直接编译运行的验证代码,通过打印日志可以直观看到 noexcept 对 vector 扩容行为的影响:
cpp
#include <iostream>
#include <vector>
// 场景1:移动构造未加 noexcept
class ObjNoNoexcept {
public:
int id;
explicit ObjNoNoexcept(int i) : id(i) {
std::cout << "普通构造 id=" << id << '\n';
}
// 拷贝构造
ObjNoNoexcept(const ObjNoNoexcept& other) : id(other.id) {
std::cout << " → 拷贝构造 搬运 id=" << id << '\n';
}
// 移动构造:未声明 noexcept
ObjNoNoexcept(ObjNoNoexcept&& other) : id(other.id) {
other.id = -1; // 标记资源已被移走
std::cout << " → 移动构造 搬运 id=" << id << '\n';
}
};
// 场景2:移动构造显式加 noexcept
class ObjWithNoexcept {
public:
int id;
explicit ObjWithNoexcept(int i) : id(i) {
std::cout << "普通构造 id=" << id << '\n';
}
// 拷贝构造
ObjWithNoexcept(const ObjWithNoexcept& other) : id(other.id) {
std::cout << " → 拷贝构造 搬运 id=" << id << '\n';
}
// 移动构造:显式声明 noexcept
ObjWithNoexcept(ObjWithNoexcept&& other) noexcept : id(other.id) {
other.id = -1;
std::cout << " → 移动构造 搬运 id=" << id << '\n';
}
};
int main() {
// 预分配2个容量,第3次插入必然触发扩容
std::cout << "===== 移动构造 无 noexcept =====" << '\n';
std::vector<ObjNoNoexcept> v1;
v1.reserve(2);
v1.emplace_back(1);
v1.emplace_back(2);
std::cout << "触发扩容:" << '\n';
v1.emplace_back(3);
std::cout << '\n';
std::cout << "===== 移动构造 有 noexcept =====" << '\n';
std::vector<ObjWithNoexcept> v2;
v2.reserve(2);
v2.emplace_back(1);
v2.emplace_back(2);
std::cout << "触发扩容:" << '\n';
v2.emplace_back(3);
return 0;
}
编译与运行
使用 C++11 及以上标准编译即可(所有主流编译器均支持):
cpp
g++ -std=c++11 test.cpp -o test && ./test
预期输出(标准库一致行为)
cpp
===== 移动构造 无 noexcept =====
普通构造 id=1
普通构造 id=2
触发扩容:
→ 拷贝构造 搬运 id=1
→ 拷贝构造 搬运 id=2
普通构造 id=3
===== 移动构造 有 noexcept =====
普通构造 id=1
普通构造 id=2
触发扩容:
→ 移动构造 搬运 id=1
→ 移动构造 搬运 id=2
普通构造 id=3
现象解释
- 无
noexcept时:vector 扩容搬运旧元素时,为了保证「扩容失败原数据完好」的强异常安全,放弃了可能抛异常的移动构造,退化为调用拷贝构造。 - 有
noexcept时:标准库确认移动不会抛出异常,安全地调用移动构造搬运元素,性能远高于拷贝(尤其是持有堆内存的类型)。
补充:仅移动类型的例外
如果类删除了拷贝构造(比如 std::unique_ptr 风格),即使移动构造不加 noexcept,vector 也只能调用移动构造 ------ 因为没有拷贝可选。此时容器不再提供强异常安全,仅保证基本合法状态。
3.析构函数:唯一"不标也 noexcept"的特殊成员
从 C++11 标准开始,析构函数是所有特殊成员中,唯一「用户哪怕不手动写 noexcept,默认也带有隐式 noexcept 约束」的函数。
无论是编译器自动生成、=default,还是你手写了函数体的析构,都会隐式推导 noexcept 属性;而移动 / 拷贝构造、移动 / 拷贝赋值这些特殊成员,只有编译器自动生成或 =default 时才会隐式推导,用户手动实现的版本默认就是可能抛出(noexcept(false))。
具体推导规则
析构函数的隐式 noexcept 遵循「全满足才成立」原则:
- 当且仅当所有直接基类、所有非静态成员变量的析构函数都是
noexcept(true)时,当前类的析构函数默认为noexcept(true) - 只要有一个基类 / 成员的析构可能抛出异常,当前类析构就自动变为
noexcept(false)
这条规则对「隐式生成、=default、用户手写实现」三种析构全部生效
和其他特殊成员的关键区别
| 特殊成员函数 | 编译器隐式生成 / 类内 =default | 用户手动实现 |
|---|---|---|
| 析构函数 | 隐式推导 noexcept |
隐式推导 noexcept |
| 默认 / 拷贝 / 移动构造 | 隐式推导 noexcept |
默认 noexcept(false) |
| 拷贝 / 移动赋值 | 隐式推导 noexcept |
默认 noexcept(false) |
这也呼应了上一个话题的经典坑:移动构造不标 noexcept 会退化 ------ 因为你手写的移动构造默认不带 noexcept;但析构你哪怕手写空函数体,默认也是 noexcept 的。
设计根源:栈展开的安全性
标准给析构开这个 "特例",本质是强制安全约定: 异常触发栈展开时,会按逆序销毁栈上对象。如果此时某个析构函数再抛出异常,就会出现「两个异常同时活跃」的情况,C++ 标准规定此时必须直接调用 std::terminate 终止程序。
因此语言层面直接把析构默认设为 noexcept,把 "允许析构抛异常" 变成了需要显式写 noexcept(false) 的极端特例 ------ 现实工程中几乎永远不应该这么做。
容易踩的两个坑
-
隐式推导会被成员 "带偏" 如果你类里新增了一个析构可能抛出的成员,整个类的析构会悄悄变成
noexcept(false),且无任何编译提示。因此工程上推荐:确定不抛的析构显式加noexcept,相当于加了一层编译期断言。 -
手写空析构不如
=default~MyClass() = default;会保留平凡析构(trivial)属性,编译器可以做更多优化;而~MyClass() {}虽然也是默认noexcept,但会变成非平凡析构,同时还会抑制编译器隐式生成移动构造。
最佳实践
- 遵循「零法则」(Rule of Zero):能不手写析构就不写,让编译器自动生成最优版本。
- 必须手写析构时(比如释放资源),显式加上
noexcept,既明确语义,也防止后续成员变更悄悄改变异常属性。 - 永远不要让析构抛出异常;如果内部操作可能失败,在析构内部捕获并处理,绝不能抛出到析构之外。
示例如下:下面是可直接编译的验证代码,通过标准库类型萃取精准检测不同写法下析构函数的 noexcept 属性,并和手写移动构造做对照,直观体现两者的规则差异。
cpp
#include <iostream>
#include <type_traits>
// 辅助成员类:显式声明析构可能抛出异常
struct BadMember {
~BadMember() noexcept(false) {}
};
// 1. 完全不写析构,编译器隐式生成
struct ImplicitDtor {
int data;
};
// 2. 类内显式 =default 析构
struct DefaultDtor {
int data;
~DefaultDtor() = default;
};
// 3. 用户手写空析构(未显式标注 noexcept)
struct UserWrittenDtor {
int data;
~UserWrittenDtor() {}
};
// 4. 用户手写析构 + 包含析构会抛异常的成员
struct DtorWithBadMember {
BadMember member;
~DtorWithBadMember() {}
};
// 对照组:用户手写移动构造(未显式标注 noexcept)
struct UserWrittenMoveCtor {
UserWrittenMoveCtor() = default;
UserWrittenMoveCtor(UserWrittenMoveCtor&&) {}
};
int main() {
std::cout << std::boolalpha;
std::cout << "=== 析构函数的 noexcept 默认属性 ===\n";
std::cout << "隐式生成析构: " << std::is_nothrow_destructible<ImplicitDtor>::value << '\n';
std::cout << "=default 析构: " << std::is_nothrow_destructible<DefaultDtor>::value << '\n';
std::cout << "用户手写空析构: " << std::is_nothrow_destructible<UserWrittenDtor>::value << '\n';
std::cout << "含异常成员的手写析构:" << std::is_nothrow_destructible<DtorWithBadMember>::value << '\n';
std::cout << "\n=== 对照:移动构造的 noexcept 默认属性 ===\n";
std::cout << "用户手写移动构造: " << std::is_nothrow_move_constructible<UserWrittenMoveCtor>::value << '\n';
return 0;
}
输出结果(所有标准编译器行为一致):
cpp
=== 析构函数的 noexcept 默认属性 ===
隐式生成析构: true
=default 析构: true
用户手写空析构: true
含异常成员的手写析构:false
=== 对照:移动构造的 noexcept 默认属性 ===
用户手写移动构造: false
- 析构的独有规则 :无论是隐式生成、
=default还是用户手写函数体,只要所有基类和成员的析构都不会抛异常,当前类析构就默认为noexcept(true)。这是所有特殊成员中唯一的特例。 - 推导规则生效 :当类包含一个析构为
noexcept(false)的成员时,整个类的析构会自动推导为false,符合「全满足才成立」的隐式推导原则。 - 与移动构造的核心差异 :用户手写的移动构造默认就是
noexcept(false),不会自动推导 ------ 这正是之前std::vector扩容退化问题的根源。
补充说明
- 虚析构同样遵循该规则,不会因为加了
virtual就改变默认的noexcept属性。 std::is_nothrow_destructible的检测结果,和noexcept(std::declval<T>().~T())表达式完全等价,本质都是编译期查询函数的异常规范。
4.底层实现:std::move_if_noexcept
MSVC(微软 STL)中 std::move_if_noexcept 的内部实现源码如下:
cpp
_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr //
conditional_t<!is_nothrow_move_constructible_v<_Ty> && is_copy_constructible_v<_Ty>, const _Ty&, _Ty&&>
move_if_noexcept(_Ty& _Arg) noexcept {
return _STD move(_Arg);
}
通过 std::conditional_t 在编译期根据类型属性决定返回值类型,没有任何运行时分支。
条件:!is_nothrow_move_constructible_v<_Ty> && is_copy_constructible_v<_Ty>
翻译成人话:该类型的移动构造不是 noexcept(可能抛出异常),并且该类型支持拷贝构造
- 条件成立 → 返回
const _Ty&(const 左值引用):最终匹配拷贝构造函数,放弃移动 - 条件不成立 → 返回
_Ty&&(右值引用):最终匹配移动构造函数,正常移动
vector 扩容搬运元素时,并不会直接使用 std::move,而是调用 std::move_if_noexcept(x)。它的行为可以概括为:
能安全移动就移动,不能安全移动就拷贝,不能拷贝就硬着头皮移动。
它的类型推导规则:
- 满足「移动构造可能抛异常 + 支持拷贝构造」→ 返回
const T&(左值),触发拷贝构造 - 其余所有情况 → 返回
T&&(右值),触发移动构造
例外情况
不是所有未标 noexcept 的移动构造都会触发退化: 如果类型是仅移动类型 (无拷贝构造,例如 std::unique_ptr、std::thread),即使移动构造没有 noexcept,vector 扩容也只能调用移动构造。此时容器不再提供强异常安全,仅保留基本异常安全保证(容器状态合法,但元素内容可能部分被移动)。
补充细节
- 编译器默认生成 /
=default的移动构造 ,会自动推导noexcept属性:只要所有成员、基类的移动构造都是noexcept,它就默认是noexcept的。 - 用户手动实现的移动构造 / 移动赋值 ,编译器不会自动添加
noexcept,必须显式声明才会生效。
最佳实践
只要移动构造逻辑不会抛出异常(绝大多数场景都应满足),务必显式加上 noexcept,这是 STL 容器性能优化的基础约定:
cpp
class MyClass {
public:
MyClass(MyClass&&) noexcept; // 移动构造
MyClass& operator=(MyClass&&) noexcept;// 移动赋值
};
5.总结
能安全移动或不能拷贝就移动,否则拷贝」,对应到实际场景正好覆盖三种情况:
| 类型场景 | 条件判断结果 | 返回类型 | vector 扩容行为 |
|---|---|---|---|
移动构造带 noexcept |
条件不成立(!nothrow 为假) |
T&& |
高效移动,O (n) 搬运 |
移动构造无 noexcept、支持拷贝 |
条件成立 | const T& |
退化为拷贝,保证强异常安全 |
仅移动类型(无拷贝构造,如 unique_ptr) |
条件不成立(不可拷贝) | T&& |
只能移动,放弃强异常安全 |
std::vector 扩容搬运元素时,底层就是对每个旧元素调用 std::move_if_noexcept,再用返回值在新内存上构造对象。这就是「移动构造不标 noexcept 就悄悄退化成拷贝」这句话最底层的代码依据。