std::move_if_noexcept
Alex 2024 年 8 月 15 日
(感谢读者 Koe 提供初稿)
在 22.4《std::move》中,我们介绍了 std::move
,它把左值实参强制转换为右值,从而触发移动语义。在 27.9《异常说明与 noexcept》中,我们又讨论了 noexcept
说明符与运算符。本节将综合这两个概念展开说明。
我们还曾提到“强异常保证”(strong exception guarantee):若函数被异常中断,既不会发生内存泄漏,也不会改变程序状态。特别地,所有构造函数都应满足该保证,以免对象构造失败时留下半成品对象。
移动构造函数的异常困境
考虑复制对象的情形:若复制失败(如内存耗尽),源对象不会受损,因为生成副本无需修改源对象。我们可以丢弃失败的副本,程序状态保持不变,强异常保证得以满足。
再考虑移动对象的情形:移动操作把资源所有权从源对象转移到目标对象。若移动过程中抛出异常,且异常发生在所有权已转移之后,源对象就会被置于已修改状态。若源对象是临时对象,随后即被销毁,这并无大碍;但若源对象是持久对象,我们实际上已损坏它。为满足强异常保证,需要把资源迁回源对象,然而若首次移动已失败,迁回操作亦未必成功。
如何使移动构造函数满足强异常保证?仅在移动构造函数体内避免抛异常是不够的,因为移动构造函数可能调用其他潜在的抛异常函数。以 std::pair
的移动构造函数为例,它必须分别移动源 pair 的两个子对象:
// std::pair 移动构造函数示意
template <typename T1, typename T2>
pair<T1, T2>::pair(pair&& old) noexcept
: first(std::move(old.first)),
second(std::move(old.second))
{}
下面用两个类 MoveClass
和 CopyClass
组合成 std::pair
,来演示移动构造函数的异常保证问题:
#include <iostream>
#include <utility> // std::pair, std::make_pair, std::move, std::move_if_noexcept
#include <stdexcept> // std::runtime_error
class MoveClass
{
private:
int* m_resource{};
public:
MoveClass() = default;
explicit MoveClass(int resource)
: m_resource{ new int{ resource } }
{}
// 复制构造:深拷贝
MoveClass(const MoveClass& that)
{
if (that.m_resource)
m_resource = new int{ *that.m_resource };
}
// 移动构造:noexcept
MoveClass(MoveClass&& that) noexcept
: m_resource{ that.m_resource }
{
that.m_resource = nullptr;
}
~MoveClass()
{
std::cout << "destroying " << *this << '\n';
delete m_resource;
}
friend std::ostream& operator<<(std::ostream& out, const MoveClass& moveClass)
{
out << "MoveClass(";
if (moveClass.m_resource == nullptr)
out << "empty";
else
out << *moveClass.m_resource;
out << ')';
return out;
}
};
class CopyClass
{
public:
bool m_throw{};
CopyClass() = default;
// 复制构造:当 m_throw 为 true 时抛异常
CopyClass(const CopyClass& that)
: m_throw{ that.m_throw }
{
if (m_throw)
throw std::runtime_error{ "abort!" };
}
};
int main()
{
// 正常构造 pair
std::pair my_pair{ MoveClass{ 13 }, CopyClass{} };
std::cout << "my_pair.first: " << my_pair.first << '\n';
try
{
my_pair.second.m_throw = true; // 触发复制构造函数异常
// 下面一行将抛异常
std::pair moved_pair{ std::move(my_pair) }; // 稍后注释掉此行
// std::pair moved_pair{ std::move_if_noexcept(my_pair) }; // 稍后取消注释
std::cout << "moved pair exists\n"; // 不会打印
}
catch (const std::exception& ex)
{
std::cerr << "Error found: " << ex.what() << '\n';
}
std::cout << "my_pair.first: " << my_pair.first << '\n';
return 0;
}
程序输出:
destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(empty)
destroying MoveClass(empty)
可见:
- 第一行:用于初始化
my_pair
的临时MoveClass
立即被销毁,值为 empty,表明其资源已移至my_pair.first
。 - 第三行:构造
moved_pair
时,CopyClass
复制构造抛异常,moved_pair
构造中断,已构造成员被销毁,因此打印 destroying MoveClass(13)。 - 最后再次打印
my_pair.first
已为空,说明资源已被移走,对象被永久损坏,强异常保证未满足。
std::move_if_noexcept 的救场
若 std::pair
选择复制而非移动,即可避免上述问题——构造失败时源对象保持不变。但若对所有对象一律复制,将牺牲性能。理想方案是:当且仅当移动操作安全(即不会抛异常)时才移动,否则复制。
C++ 提供了两种机制组合实现这一目标:
- 由于 noexcept 函数满足不抛/不失效保证,因此
noexcept
移动构造函数必然成功。 - 标准库算法可使用
std::move_if_noexcept
在移动和复制之间抉择。
std::move_if_noexcept
与 std::move
用法相同,但行为不同:
- 若编译器能确定对象移动构造不会抛异常(或对象仅有移动构造而无复制构造),则
std::move_if_noexcept
与std::move
行为一致,返回右值。 - 否则返回左值引用,触发复制构造。
关键洞察std::move_if_noexcept
在对象拥有 noexcept 移动构造函数时返回可移动右值,否则返回可复制左值。结合 noexcept 说明符,我们仅在满足强异常保证时使用移动语义,否则使用复制语义。
将上例改为:
// std::pair moved_pair{ std::move(my_pair) }; // 注释掉
std::pair moved_pair{ std::move_if_noexcept(my_pair) }; // 取消注释
再次运行输出:
destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(13)
destroying MoveClass(13)
可见异常抛出后 my_pair.first
仍为 13。
由于 std::pair
的移动构造函数并非 noexcept(截至 C++20),std::move_if_noexcept
返回 my_pair
的左值引用,于是 moved_pair
通过复制构造函数创建。复制构造可安全抛异常,因为它不会修改源对象。
标准库广泛使用 std::move_if_noexcept
以优化 noexcept 函数。例如,std::vector::resize
仅在元素类型拥有 noexcept 移动构造函数时使用移动语义,否则使用复制语义,从而在无异常风险时提高性能。
警告
若某类型同时具备
- 可能抛异常的移动语义,且
- 删除的复制语义(复制构造/复制赋值不可用),
则std::move_if_noexcept
会放弃强保证,选择移动语义。标准库容器普遍如此处理,因为它们大量使用std::move_if_noexcept
。