Alexander Pochanin posted an interesting C++ code snippet on LinkedIn. Although the snippet is already a simplified version of what he has in his codebase, I've further simplified it by removing unnecessary details so we can focus on the root cause of the issue.
Here is the code. Find the bug:
template <typename T>
struct wrapper_a_t {
T _value;
};
template <typename T>
constexpr decltype(auto) wrap_a(T &&arg) {
return wrapper_a_t<std::remove_cvref_t<T>>{std::forward<T>(arg)};
}
template <typename T>
constexpr decltype(auto) wrap_b(T &&arg) {
return std::forward<T>(arg);
}
template <typename T>
decltype(auto) wrap_ab(T &&arg) {
return wrap_b(wrap_a(std::forward<T>(arg)));
}
int main(){
auto d = wrap_ab(std::string{"Hello World"});
}
If you didn't find the issue, here's a hint: the code triggers undefined behavior (UB) by referring to a dangling reference (accessing an object that has already been destroyed).
The issue lies in the return type of this function:
template <typename T>
decltype(auto) wrap_ab(T &&arg) {
return wrap_b(wrap_a(std::forward<T>(arg)));
}
which is declared to be decltype(auto)
. In our case, it is always inferred as an rvalue reference. Thus, the
following declarations are equivalent (for our case, not in general). We can choose any of these without
changing the meaning of the code
decltype(auto) wrap_ab(T &&arg)
wrapper_a_t<T>&& wrap_ab(T &&arg)
auto&& wrap_ab(T &&arg)
It's to easy to figure out why that is the case:
wrap_a()
creates an object (the wrapper) and returns it (prvalue
), so the return typedecltype(auto)
is inferred to bewrapper_a_t<T>
— becausedecltype(prvalue)
yieldsX
(see the documentation at cppreference).- then the returned value is passed as rvalue reference to
wrap_b()
whose return typedecltype(auto)
is inferred to beX&&
— becausestd::forward
converts the argument toxvalue
anddecltype(xvalue)
yieldsX&&
. - Since
wrap_b()
returnsX&&
(xvalue),wrap_ab()
returnsX&&
as well — becausedecltype(xvalue)
yieldsX&&
.
So we can say these:
static_assert(std::is_same_v<decltype(wrap_a(std::string{})), wrapper_a_t<std::string>>);
static_assert(std::is_same_v<decltype(wrap_b(std::string{})), std::string&&>);
static_assert(std::is_same_v<decltype(wrap_b(wrap_a(std::string{}))), wrapper_a_t<std::string>&&>);
static_assert(std::is_same_v<decltype(wrap_ab(std::string{})), wrapper_a_t<std::string>&&>);
See the demo
So, what exactly is the issue with returning X&&
from wrap_ab()
? And why isn't it a problem when wrap_b()
returns X&&
?
On the surface, it seems like either both should be an issue or neither should be. This is the most interesting part of the puzzle.
The issue is
struct mystring {
mystring() {
std::cout << "mystring(): " << this << std::endl;
}
~mystring() {
std::cout << "~mystring(): " << this << std::endl;
}
mystring(mystring&& other) {
std::cout << "mystring(mystring&&): " << &other << " -- moved to --> " << this << std::endl;
}
mystring(mystring const&) {
std::cout << "mystring(mystring const&): " << this << std::endl;
}
};
struct scope {
char const * name;
scope(char const * name): name(name) {
std::cout << "<<< ENTERED: " << name << std::endl;
}
~scope() {
std::cout << ">>> EXITED: " << name << std::endl;
}
};