std::exchange 的原理及应用

教育   科技   2023-07-25 22:34   美国  


本篇谈一下 std::exchange ,其实还有一个姊妹篇讲 copy-and-swap 手法的,这篇算是接着那篇写的。那篇属于群内分享,没有放到公众号上来,原文请阅读 https://www.cppmore.com/2023/07/23/copy-and-swap-idiom-and-more-tricks/

如果你已经读懂了姊妹篇,那就可以接着阅读本篇内容了。

1

What and Why

这次单独说一下 std::exchange,它是 C++14 <utility> 提供的一个函数模板,实现很简单。
1template<class Tclass U = T>
2constexpr // since C++20
3T exchange(TobjU&& new_value)
4    noexcept( // since C++23
5        std:
:is_nothrow_move_constructible<T>::value &&
6        std::is_nothrow_assignable<T&, U>::value
7    )
8{
9    T old_value = std::move(obj);
10    obj = std::forward<U>(new_value);
11    return old_value;
12}
看实现可知其逻辑就是设置新值、返回旧值。但却难以顾名思义,它实际上并不会交换数据,想实现那种效果,得这样写:
1y = std::exchange(x, y);
姊妹篇说过,std::exchangei++ 的逻辑相同。让我们重新再来看一下自增运算符,一个例子:
1struct S {
2    int val{};
3
4    // prefix increment operator
5    constexpr autooperator++() {
6        ++val;
7        return *this;
8    }
9
10    // postfix increment operator
11    constexpr auto operator++(int) {
12        auto old_value = *this;
13        ++val;
14        return old_value;
15    }
16};
标准通过 operator++()operator++(int) 来区别前自增和后自增,后自增有一个 int 参数,如果滥用一下,那不就是一个非泛化版的 std::exchange
1struct S {
2    // ...
3
4    // postfix increment operator
5    // same as std::exchange
6    constexpr auto operator++(int new_value) {
7        auto old_value = *this;
8        val = new_value;
9        return old_value;
10    }
11};
12
13int main() {
14    S s;
15    auto old_value = s.operator++(5);
16
17    return old_value.val;
18}
因此,完全可以将 std::exchange 理解为是泛化版本的后自增,它能支持任意类型。

2

Use Case 1: Implementing move semantics

第一个典型的使用场景就是姊妹篇中所使用的,实现移动语义。
一个小例子:
1struct S
2{

3    int n{42}; // default member initializer
4    S() = default;
5
6    S(S&& other) noexcept
7        : n { std::exchange(other.n, 0) }
8    {}
9
10    S& operator=(S&& other) noexcept
11    {
12        // safe for this == &other
13        n = std::exchange(other.n, 0); // move n, while leaving zero in other.n
14        return *this;
15    }
16};
17
18int main() {
19    S s;
20    // s = s; // 1. Error! does not match the move assigment operator
21    s = std::move(s); // 2. OK! explicitly move
22
23    std::cout << s.n << "\n"// Outputs: 42
24}
为何需要这样写?在姊妹篇中已经讲解清楚了。这里再解释一下 self-assignment check,有些实现可能还会额外检查一下:
1S& operator=(S&& other) noexcept
2{
3    if (this != &other)
4        n = std::exchange(other.n, 0); // move n, while leaving zero in other.n
5    return *this;
6}
不检查也是完全安全的,看前面那个示例。自赋值的情况非常罕见,你也无法在不经意间使用,因为你必须得显式写出 std::move 才能实现自赋值。为了一个几乎不可能出现的操作,每次都多做一次检查,实属浪费。做这么一次检查,只是避免在自赋值时做一次无谓的交换,安全性来说都一样。
一面是为极其罕见的情况每次都做一次检查,一面是省掉每次的检查,如果真有自赋值,也只是做一次无谓的交换。孰优孰劣,你觉得呢?

3

Use Case 2: As helper for delimiting output

第二场景是能够简化格式化输出时的代码,不借助 fmt,平常的写法是这样的:
1int main() {
2    std::vector<int> vec(10);
3    std::ranges::iota(vec, 0);
4
5    std::cout << "[";
6    const char* delim = "";
7    for (auto val : vec) {
8        std::cout << delim;
9        std::cout << val;
10        delim = ", ";
11    }
12
13    // Outputs: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
14    std::cout << "]\n";
15}
借助 std::exchange 可以简化成这样:
1int main() {
2    std::vector<int> vec(10);
3    std::ranges::iota(vec, 0);
4
5    std::cout << "[";
6    const char* delim = "";
7    for (auto val : vec) {
8        std::cout << std::exchange(delim, ", ") << val;
9    }
10
11    // Outputs: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
12    std::cout << "]\n";
13}
当然这种简化是有代价的,每次都会交换一下。这里只是抛砖引玉,提及一下类似这种需要使用旧值新值的场景可以考虑使用 std::exchange

4

Use Case 3: Ensuring the transfer of object ownership

第三个场景是确保对象所有权的转移。
一个小示例:
1void transfer_ownership(auto& obj) {
2    [o = std::move(obj)] {}();
3}
功能是通过 transfer_ownership() 里面的 lambda 来转移对象的所有权。
我们可以这样使用:
1std::vector<int> v1(10);
2std::ranges::iota(v1, 0);
3transfer_ownership(v1);
4// Outputs: []
5fmt::print("v1: {}\n", v1);
一切正常,对吧?
假如换一个对象呢
1std::optional<int> foo{ 42 };
2transfer_ownership(foo);
3// true
4fmt::print("has_value: {}\n", foo.has_value());
虽然在主流 STL 实现中许多对象移动后都会置空,比如 std::vectorstd::stringstd::function等等,但是标准并未规定对象移动后一定要置空。因此这种做法并不具备可维护性,当你更换一个类型后,它的所有权可能并没有完全转移。
解决方法之一就是使用 copy-and-swap 手法,可以这样实现:
1void transfer_ownership(auto& obj) {
2    std::decay_t<decltype(obj)> tmp{};
3    using std::swap;
4    swap(obj, tmp);
5    [o = std::move(tmp)] {}();
6}
7
8std::optional<int> foo{ 42 };
9transfer_ownership(foo);
10// false
11fmt::print("has_value: {}\n", foo.has_value());
现在能够确保转移对象的所有权。
另一个解决之法要更加优雅,就是使用 std::exchange,实现超级简单:
1void transfer_ownership(auto& obj) {
2    [o = std::exchange(obj, {})] {}();
3}
4
5std::optional<int> foo{ 42 };
6transfer_ownership(foo);
7// false
8fmt::print("has_value: {}\n", foo.has_value());
通过 std::exchange 能够避免定义临时变量,它的第二个模板参数类型默认与第一个模板参数相同,所以可以非常简单地直接以 {} 构建。此外,其内部存在一次 move construction 和一次 move assignment,较 std::swap 省去了一次 move,不仅同样保证了安全,代码更优雅、更快速。

5

总结

总结一下,当遇到设新值、取旧值的情况下,可以考虑使用 std::exchange,它往往能够优雅地代替原先的几行代码。
这是一个非常小的工具,使用情境并不算多,主要是本文中据说的情境一和情境三,在这些情境下,它能够保证代码安全的同时,使其更精确、快速。
本文的难度等级是算上姊妹篇给的。




CppMore
Dive deep into the C++ core, and discover more!
 最新文章