utility_ops
std::exchange
Taking notes from std::exchange Patterns: Fast, Safe, Expressive, and Probably Underused by Ben Deane
:brain: Whenever you find yourself writing
std::swap, consider: do you really need that temporary? If not, usestd::exchangeinstead.
- When we write
i++, it’s exactly the same as writingstd::exchange(i, i+1) - Although postfix increment may not be nearly as widespread in a typical codebase as prefix increment, we usually have no problems using it or reasoning about its use where it leads to concise, readable code. And so it should be with .
Use case 1: The “swap-and-iterate” pattern
This pattern occurs a lot in event-driven architectures; one might typically have a vector of events to dispatch or, equivalently, callbacks to invoke. But we want event handlers to be able to produce events of their own for deferred dispatch.
void process() {
std::vector<Callback> tmp{};
using std::swap; // the "std::swap" two-step
std::swap(tmp, callbacks_);
for (const auto& callback : tmp) {
std::invoke(callback);
}
}
- doing above, we’re also guilty of incurring the “ITM antipattern” [4]. First, we construct an empty vector (
tmp), then — withswap— we have 3 move assignments before we get to the business of iterating. - Refactoring with std::exchange solves these problems:
void process() {
for (const auto& callback : std::exchange(callbacks_, {}) {
std::invoke(callback);
}
}
- Now we don’t have to declare a temporary. Inside std::exchange we have one move construction and one move assignment, saving one move compared to swap.
- compiler is really good at optimising the call to std::exchange, so of course we get the copy elision we would normally expect. As a result, the code overall is more concise, faster, and provides the same safety as before.
Use case 2: Posting to another thread
- A similar pattern occurs in any multithreaded setting where we want to capturean object in a lambda expression and post it to another thread. std::exchange allows us to efficiently transfer ownership of an object’s “guts.”
void post_event(Callback& cb) {
Callback tmp{};
using std::swap;
swap(cb, tmp);
PostToMainThread([this, cb_ = std::move(tmp)] {
callbacks_.push_back(cb_);
});
}
- Here we are taking ownership of the passed-in callback by swapping it into a temporary, then capturing that temporary in a lambda closure. We are capturing by move in an attempt to improve performance, but ultimately we’re still doing a lot more than we need to.
void post_event(Callback& cb) {
PostToMainThread([this, cb_ = std::exchange(cb, {})] {
callbacks_.push_back(cb_);
});
}
std::exchangeuses one move fewer thenstd::swap, and copy elision, a.k.a. the return value optimization, constructs the return value directly into the lambda expression’s closure.
Why not just move?
void post_event(Callback& cb) {
PostToMainThread([this, cb_ = std::move(cb)] {
callbacks_.push_back(cb_);
});
}
- The answer is to ensure future maintainability and flexibility. It may well be true that a moved-from Callback is considered just as empty as if we had explicitly emptied it with std::exchange, but is that obvious? Is it always going to be true? Will we ever need to update that assumption — or this code — if we change the type of Callback later on?
- In the major STL implementations, it is currently the case that a moved-from container is empty. More specifically, sequenced containers like std::vector; associative containers like std::unordered_map; and other “containers” like std::string or std::function are empty after move, even when they are small-buffer-optimised
- But this is not necessarily true of every single container type we might use. There is no particular reason why a homegrown small-buffer-optimised vector should be empty after we move from it. We find a notable standard counterexample of the “normal” behaviour in std::optional, which is still engaged after being moved from. So yes, using std::move — obviously — only incurs one move, whereas std::exchange incurs two, but at the cost of abstraction leakage. Using only std::move, we need to know and be able to reason about the move-related properties of the container we use; future maintainers (usually ourselves, in 6 months’ time) also need to know about that “empty after move” constraint on the code, which is not explicitly expressed anywhere and not obvious from inspection.
- For this reason, I recommend being explicit about clearing objects that are supposed to be empty, and std::exchange can do just that.
- In fact cppreference.com notes a primary use case for std::exchange in writing the move special member functions to leave the moved-from object cleared.'
Can we use std::exchange with locks?
- it may seem at first that std::exchange isn’t a great option when we need to access something under mutex protection:
void process() {
std::vector<Callback> tmp{};
{
using std::swap;
std::scoped_lock lock{mutex_};
swap(tmp, callbacks_);
}
for (const auto& callback : tmp) {
std::invoke(callback);
}
}
- Here, the vector of callbacks is protected by a mutex. We cannot afford to hold this lock while iterating, because any event handler that wants to generate an event will try to lock the mutex in order to queue its event.
- So we can’t use our std::exchange pattern naively:
void process() {
std::scoped_lock lock{mutex_};
for (const auto& callback : std::exchange(callbacks_, {})) {
std::invoke(callback);
}
}
- because that would break our ability to queue events from callbacks. The solution, as is so often the case, is to use a function. In this instance, an immediately-invoked lambda expression fits the bill nicely.
// All events are dispatched when we call process
void process() {
const auto tmp = [&] {
std::scoped_lock lock{mutex_};
return std::exchange(callbacks_, {});
}();
for (const auto& callback : tmp) {
std::invoke(callback);
}
}
-
We reap the benefits of holding the lock for as short a time as possible; taking advantage of return value optimization; saving a move; and concision of expression.
-
We could also consider below. Here, the
scoped_locklives until the semicolon, and the result of the comma operator is the result of std::exchange, used to construct tmp. I concede that many people would recoil in horror at this use of the comma operator, but that’s a topic for another article
// All events are dispatched when we call process
void process() {
const auto tmp = (std::scoped_lock{mutex_}, std::exchange(callbacks_, {}));
for (const auto& callback : tmp) {
std::invoke(callback);
}
}