Skip to content

Instantly share code, notes, and snippets.

@snawaz
Created August 4, 2024 12:43
Show Gist options
  • Save snawaz/58044f28b57caa5fe41da466b5ac2c9e to your computer and use it in GitHub Desktop.
Save snawaz/58044f28b57caa5fe41da466b5ac2c9e to your computer and use it in GitHub Desktop.
Understanding decltype(auto) and auto as return types in the presence of std::forward

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.

Puzzle

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).

Analysis of the issue

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 type decltype(auto) is inferred to be wrapper_a_t<T> — because decltype(prvalue) yields X (see the documentation at cppreference).
  • then the returned value is passed as rvalue reference to wrap_b() whose return type decltype(auto) is inferred to be X&& — because std::forward converts the argument to xvalue and decltype(xvalue) yields X&&.
  • Since wrap_b() returns X&& (xvalue), wrap_ab() returns X&& as well — because decltype(xvalue) yields X&&.

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 the problem is .. ?

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;
    }
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment