DEV Community

llzzz
llzzz

Posted on Edited on AI-assisted

Everything about `std::forward`

std::forward is similar to std::move. It is just a static_cast to rvalue reference. But it is slightly different than std::move, std::forward is a conditional cast that only cast the type to rvalue-reference if some condition is satisify. Let dive deeper in it!

First of all, let look at a custom implementation of std::forward, this is not the exact definition in the STL, but it is close enough.

namespace std {
    template<typename T>
    __attribute__((always_inline))
    constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
        return static_cast<T&&>(t);
    }

    template<typename T>
    __attribute__((always_inline))
    constexpr T&& forward(std::remove_reference_t<T>&& t) noexcept {
        static_assert(!std::is_lvalue_reference_v<T>);
        return static_cast<T&&>(t);
    }
};
Enter fullscreen mode Exit fullscreen mode

the implementation might make absolutely no sense to you right now, but it will make sense soon.

Reference Collapsing

In C++, you are not allowed to explicitly write a reference to a reference (e.g., int& & will result in a compilation error). However, the compiler can generate references to references behind the scenes during template argument deduction, typedef/ using type aliases, decltype, and auto type deduction.

To handle these scenarios gracefully, C++11 introduce reference collapsing, a strict set of rules that simplifies a "reference to a reference" down to a single valid reference.

The easiest way to understand this is to think lvalue reference is infectious. If an lvalue reference is in anywhere in the combination, the result will collapse to a lvalue reference. The only way to get an rvalue reference (&&) is if both references are rvalue references.

Let look at the following table to understand what does it actually mean:

Existing / Target Type Applied Reference Resulting Collapsed Type
T& (lvalue ref) & (lvalue ref) T&
T& (lvalue ref) && (rvalue ref) T&
T&& (rvalue ref) & (lvalue ref) T&
T&& (rvalue ref) && (rvalue ref) T&&

Let's look at an example to understand what it means in practice:

using LRef = int&;
using RRef = int&&;

int x = 10;

LRef&  r1 = x;  // int& &   collapses to int&
LRef&& r2 = x;  // int& &&  collapses to int&
RRef&  r3 = x;  // int&& &  collapses to int&
RRef&& r4 = 20; // int&& && collapses to int&&
Enter fullscreen mode Exit fullscreen mode

This is a very important rule that make sure your template argument deduction work as expected!

std::forward

Now let us get back to the main topic of today: What is std::forward? Based on the reference collapsing rule, we can finally demystify the magic behind perfect forwarding.

The Problem: Named Rvalue References Are Actually Lvalues

Before we dissect std::forward, consider this classic forwarding dilemma:

void process(const std::string& s) { std::cout << "lvalue\n"; }
void process(std::string&& s)      { std::cout << "rvalue\n"; }

template <typename T>
void wrapper(T&& arg) {
    // What happens if we call process(arg)?
    process(arg); 
}
Enter fullscreen mode Exit fullscreen mode

If we pass a temporary rvalue to wrapper:

wrapper(std::string("hello"));
Enter fullscreen mode Exit fullscreen mode

You might expect process(std::string&&) to be called. But process(const std::string&) is called instead!

Why? Because even though arg might bind to an rvalue, the variable arg itself has a name.

Crucial Rule: In C++, if an expression has a name, it is an lvalue.

Inside wrapper, arg is an lvalue. If you pass it directly to another function, you will always pass an lvalue—losing the original temporary/moveable nature of the caller's argument.


Universal References (Forwarding References)

In the template signature template <typename T> void wrapper(T&& arg):

  • If you pass an lvalue of type Widget (e.g., Widget w; wrapper(w);):
  • The compiler deduces T as Widget&.
  • The parameter type becomes Widget& &&, which collapses to Widget&.

  • If you pass an rvalue of type Widget (e.g., wrapper(Widget{});):

  • The compiler deduces T as Widget (or non-referenccepted as an lvalue reference, and forces the developer to explicitly provide template parameter T (disabling automatic template argument deduction).
    The return type is a static cast to static_cast<T&&>(t).

  • The parameter type remains Widget&&.

Notice what happened: T encodes whether the argument was originally an lvalue (T = Widget&) or an rvalue (T = Widget)!


How std::forward<T> Uses Reference Collapsing

Now re-examine the primary implementation of std::forward:

template <typename T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
    return static_cast<T&&>(t);
}
Enter fullscreen mode Exit fullscreen mode

Notice two critical details:

  1. The parameter is std::remove_reference_t<T>& t. This ensures t is always accepted as an lvalue reference, and forces the developer to explicitly provide template parameter T (disabling automatic template argument deduction).
  2. The return type is a static cast to static_cast<T&&>(t).

Let’s trace the two cases when we write std::forward<T>(arg):

Case 1: Caller passed an Lvalue

  • T was deduced as Widget&.
  • We call std::forward<Widget&>(arg).
  • Inside forward:
  • Return type is T&& → Widget& &&, which collapses to `Widget&`.
  • The cast evaluates to static_cast<Widget&>(t).

  • Result: An lvalue reference is returned. No move occurs.

Case 2: Caller passed an Rvalue

  • T was deduced as Widget (non-reference).
  • We call std::forward<Widget>(arg).
  • Inside forward:
  • Return type is T&& → Widget&& (no collapse needed).
  • The cast evaluates to static_cast<Widget&&>(t).

  • Result: An rvalue reference is returned. The caller's temporary is restored as a moveable rvalue!

This is why people say std::forward is a conditional cast:

  • std::move<T>(x) unconditionally casts x to T&&.
  • std::forward<T>(x) only casts to T&& if T is not an lvalue reference type.

What About the Second Overload?

Recall the second overload in the definition:

template <typename T>
constexpr T&& forward(std::remove_reference_t<T>&& t) noexcept {
    static_assert(!std::is_lvalue_reference_v<T>, 
                  "std::forward cannot convert an rvalue to an lvalue.");
    return static_cast<T&&>(t);
}
Enter fullscreen mode Exit fullscreen mode

Why is this needed?

This overload accepts an rvalue argument (&&). It exists primarily as a safety net:

  1. Perfect forwarding of expressions / temporaries directly: e.g., forwarding return values like std::forward<T>(make_widget()).
  2. Preventing dangerous dangling references: If someone accidentally writes:
std::forward<Widget&>(Widget{});
Enter fullscreen mode Exit fullscreen mode

They are trying to bind a temporary rvalue to an lvalue reference. The static_assert(!std::is_lvalue_reference_v<T>) triggers a clear compile-time error instead of silently binding to a destroyed temporary.


Putting It All Together

With std::forward, our wrapper function preserves value categories perfectly:

template <typename T>
void wrapper(T&& arg) {
    // Restores original value category:
    process(std::forward<T>(arg)); 
}

std::string name = "Alice";

wrapper(name);                 // Calls process(const std::string&) -> preserved as lvalue
wrapper(std::string("Bob"));   // Calls process(std::string&&)      -> preserved as rvalue
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Names mean lvalues: An rvalue reference parameter is itself an lvalue because it has an identifier.
  2. T remembers: Universal references capture the original category inside template parameter T.
  3. Collapsing makes it work: static_cast<T&&> collapses to & when T = U&, and remains && when T = U.

Top comments (0)