程序和编程语言介绍

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))
{}

下面用两个类 MoveClassCopyClass 组合成 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++ 提供了两种机制组合实现这一目标:

  1. 由于 noexcept 函数满足不抛/不失效保证,因此 noexcept 移动构造函数必然成功。
  2. 标准库算法可使用 std::move_if_noexcept 在移动和复制之间抉择。

std::move_if_noexceptstd::move 用法相同,但行为不同:

  • 若编译器能确定对象移动构造不会抛异常(或对象仅有移动构造而无复制构造),则 std::move_if_noexceptstd::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

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"