std::exchange use cases
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::exchange
和 i++
的逻辑相同。让我们重新再来看一下自增运算符,一个例子:
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::vector
, std::string
, std::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
,它往往能够优雅地代替原先的几行代码。
这是一个非常小的工具,使用情境并不算多,主要是本文中据说的情境一和情境三,在这些情境下,它能够保证代码安全的同时,使其更精确、快速。
本文的难度等级是算上上篇给出的。