What and Why

这次单独说一下 std::exchange,它是 C++14 <utility> 提供的一个函数模板,实现很简单。

template<class T, class U = T>
constexpr // since C++20
T exchange(T& obj, U&& new_value)
    noexcept( // since C++23
        std::is_nothrow_move_constructible<T>::value &&
        std::is_nothrow_assignable<T&, U>::value
    )
{
    T old_value = std::move(obj);
    obj = std::forward<U>(new_value);
    return old_value;
}

看实现可知其逻辑很简单,就是设置新值、返回旧值。但却难以顾名思义,它实际上并不会交换数据,那得这样写:

y = std::exchange(x, y);

上篇说过,std::exchangei++ 的逻辑相同。让我们重新再来看一下自增运算符,一个例子:

struct S {
    int val{};

    // prefix increment operator
    constexpr auto& operator++() {
        ++val;
        return *this;
    }

    // postfix increment operator
    constexpr auto operator++(int) {
        auto old_value = *this;
        ++val;
        return old_value;
    }
};

标准通过 operator++()operator++(int) 来区别前自增和后自增,后自增有一个 int 参数,如果滥用一下,那不就是一个非泛化版的 std::exchange

struct S {
    // ...

    // postfix increment operator
    // same as std::exchange
    constexpr auto operator++(int new_value) {
        auto old_value = *this;
        val = new_value;
        return old_value;
    }
};

int main() {
    S s;
    auto old_value = s.operator++(5);

    return old_value.val;
}

因此,完全可以将 std::exchange 理解为是泛化版本的后自增,它能支持任意类型。

Use Case 1: Implementing move semantics

第一个典型的使用场景就是上篇中所使用的,实现移动语义。

一个小例子:

struct S
{
    int n{42}; // default member initializer
    S() = default;

    S(S&& other) noexcept
        : n { std::exchange(other.n, 0) }
    {}

    S& operator=(S&& other) noexcept
    {
        // safe for this == &other
        n = std::exchange(other.n, 0); // move n, while leaving zero in other.n
        return *this;
    }
};

int main() {
    S s;
    // s = s; // 1. Error! does not match the move assigment operator
    s = std::move(s); // 2. OK! explicitly move

    std::cout << s.n << "\n"; // Outputs: 42
}

为何需要这样写,在上篇中已经讲解清楚了。这里再解释一下 self-assignment check,有些实现可能还会额外检查一下:

S& operator=(S&& other) noexcept
{
    if (this != &other)
        n = std::exchange(other.n, 0); // move n, while leaving zero in other.n
    return *this;
}

不检查也是完全安全的,看前面那个示例。自赋值的情况非常罕见,你也无法在不经意间使用,因为你必须得显式写出 std::move 才能实现自赋值。为了一个几乎不可能出现的操作,每次都多做一次检查,实属浪费。做这么一次检查,只是避免在自赋值时做一次无谓的交换,安全性来说都一样。一面是为极其罕见的情况每次都做一次检查,一面是省掉每次的检查,如果真有自赋值,也只是做一次无谓的交换。孰优孰劣,你觉得呢?

Use Case 2: As helper for delimiting output

第二场景是能够简化格式化输出时的代码,不借助 fmt,平常的写法是这样的:

int main() {
    std::vector<int> vec(10);
    std::ranges::iota(vec, 0);

    std::cout << "[";
    const char* delim = "";
    for (auto val : vec) {
        std::cout << delim;
        std::cout << val;
        delim = ", ";
    }

    // Outputs: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    std::cout << "]\n";
}

借助 std::exchange 可以简化成这样:

int main() {
    std::vector<int> vec(10);
    std::ranges::iota(vec, 0);

    std::cout << "[";
    const char* delim = "";
    for (auto val : vec) {
        std::cout << std::exchange(delim, ", ") << val;
    }

    // Outputs: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    std::cout << "]\n";
}

当然这种简化是有代价的,每次都会交换一下。这里只是抛砖引玉,提及一下类似这种需要使用旧值新值的场景可以考虑使用 std::exchange

Use Case 3: Ensuring the transfer of object ownership

第三个场景是确保对象所有权的转移。

一个小示例:

void transfer_ownership(auto& obj) {
    [o = std::move(obj)] {}();
}

功能是通过 transfer_ownership() 里面的 lambda 来转移对象的所有权。

我们可以这样使用:

std::vector<int> v1(10);
std::ranges::iota(v1, 0);
transfer_ownership(v1);
// Outputs: []
fmt::print("v1: {}\n", v1);

一切正常,对吧?

假如换一个对象呢

std::optional<int> foo{ 42 };
transfer_ownership(foo);
// true
fmt::print("has_value: {}\n", foo.has_value());

虽然在主流 STL 实现中许多对象移动后都会置空,比如 std::vectorstd::stringstd::function等等,但是标准并未规定对象移动后一定要置空。因此这种做法并不具备可维护性,当你更换一个类型后,它的所有权可能并没有完全转移。

解决方法之一就是使用 copy-and-swap 手法,可以这样实现:

void transfer_ownership(auto& obj) {
    std::decay_t<decltype(obj)> tmp{};
    using std::swap;
    swap(obj, tmp);
    [o = std::move(tmp)] {}();
}

std::optional<int> foo{ 42 };
transfer_ownership(foo);
// false
fmt::print("has_value: {}\n", foo.has_value());

现在能够确保转移对象的所有权。

另一个解决之法要更加优雅,就是使用 std::exchange,实现超级简单:

void transfer_ownership(auto& obj) {
    [o = std::exchange(obj, {})] {}();
}

std::optional<int> foo{ 42 };
transfer_ownership(foo);
// false
fmt::print("has_value: {}\n", foo.has_value());

通过 std::exchange 能够避免定义临时变量,它的第二个模板参数类型默认与第一个模板参数相同,所以可以非常简单地直接以 {} 构建。此外,其内部存在一次 move construction 和一次 move assignment,较 std::swap 省去了一次 move,不仅同样保证了安全,代码更优雅、更快速。

总结

总结一下,当遇到设新值、取旧值的情况下,可以考虑使用 std::exchange,它往往能够优雅地代替原先的几行代码。

这是一个非常小的工具,使用情境并不算多,主要是本文中据说的情境一和情境三,在这些情境下,它能够保证代码安全的同时,使其更精确、快速。

本文的难度等级是算上上篇给出的。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.