移动构造不标 noexcept,std::vector 扩容就会悄悄退化成拷贝

目录

1.背景

2.核心机制

[3.析构函数:唯一"不标也 noexcept"的特殊成员](#3.析构函数:唯一"不标也 noexcept"的特殊成员)

4.底层实现:std::move_if_noexcept

5.总结


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::vectorpush_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_ptrstd::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 就悄悄退化成拷贝」这句话最底层的代码依据。