🔴 🟡 🟢Perfect Forwarding
← Back to Posts

Perfect Forwarding

I'm feeling generous. I'll make a generic factory-like function that constructs an object for you. Whatever arguments you give me, I'll forward (foreshadowing!) to that specific object's constructor:

template <typename T, typename Arg>
T make(Arg arg) {return T(arg); }

Box b = make<Box>(42); // returns Box(42)

Simple, right? I mean, this example is probably a bit contrived (why not just use the Box constructor itself) but you can imagine how this is a pared-down example of what something like std::make_unique or emplace_back might have to do. So let's keep digging.

What does this do?

make<Box>(Box{});

Hmm, assuming Box has copy and move constructors, this actually triggers a copy, not a more-efficient move. Why? Well, the fact that Arg is named turns the temporary into an Lvalue, triggering the copy constructor. Actually, move-only types don't even compile:

auto p = make<std::unique_ptr<int>>(std::make_unique<int>(5));
// error: call to deleted constructor of 'std::unique_ptr<int>'
//        (return T(arg);  <- tries to COPY arg, but unique_ptr has no copy ctor)

Why does it ultimately matter if make moves or copies, if the final Box contents are correct? Well, one of the canonical reasons to move is for performance. If we move a big std::string or std::vector that's an O(1) pointer reassignment instead of O(n) deep copy. So if our make doesn't move when it can, we're not being very performant.

Aside from optimization, there's also correctness concerns. Some types are move-only, like std::unique_ptr! For exclusive ownership of a managed resource it really wouldn't make sense to have copy constructors.

In essence, the make wrapper function here can't preserve the value category of its arguments. This post will be dedicated to exploring what that means exactly, and how perfect forwarding works to help us solve this problem.

What are L and R values?

The oversimplified way to think about this is that

  • Lvalues appear on the left hand side of assignment operator =, meaning they represent objects that have an identifiable location in memory ("locator" values).
  • Rvalues are temporaries that appear on the right hand side of assignment operator = and don't persist beyond that expression. So these temporaries have no name and no address ("read" values).

Given this, we know that taking the address (&) of an Lvalue gives you the memory address of its location, and trying to do the same of an Rvalue will make the compiler error.

Well, I lied above a little bit. I said that L and R values can sit on the left and right side of assignment, respectively, but there are some special cases:

const int x = 1;
x = 2;

Here, x is an lvalue -- it has a specific memory address. But it's const, so it actually can't sit on the LHS in the second line -- it can't be modified. The compiler will error. Remember, Lvalue-ness is a distinct concept from const-ness.

And of course, lvalues can sit on the right too, like assignment int y = x.

One other subtle (or maybe not, if you've experienced having to debug this) thing that can happen is you can set temporaries on the LHS of =. Here's what I mean:

class Box {
	public:
		int value;
		Box(int x) : value(x) {}
};

int main() {
	Box(1) = Box(2); // this compiles!!
}

You can't assign primitive types (1 = 2 would never work), but for classes the = call is a hidden member function call, so Box(1).operator=(Box(2)) isn't blocked. The result is that the temporary Box with 1 is modified at runtime to contain 2 and then immediately destroyed at the end of the line. This is kind of a pedantic Rvalue example, but many a bug has been caused by accidentally modifying a copy instead of the real object you want! So if you're writing a class and want to prevent the ability to assign values to temporaries of your class, you use lvalue ref-qualifiers on your assignment operator:

class StrictBox {
	public:
	    int value;
	    StrictBox(int v) : value(v) {}
	    StrictBox& operator=(const StrictBox& other) & {
	        value = other.value;
	        return *this;
	    }
};

// StrictBox(1) = StrictBox(2) will fail to compile!

STL components like std::optional or std::vector use lvalue ref-qualifiers to prevent this kind of temporary modification bug. And modern C++ (11+) allows for rvalue ref-qualifiers (&&) to detect when a member function is being called on a temporary that will die anyway, letting moves (instead of copies) happen and optimizing your performance. And in fact, under the hood std::move is just a type cast that forces a variable (lvalue) to become an rvalue again.

Anyway, the actual important caveat for our example is:

Named Rvalue references are actually Lvalues!

Here's something to be very clear on: named rvalue references are actually lvalues! Example:

void foo(int& x); // lvalue overload
void foo(int&& x); // rvalue overload

void bar(int&& arg) {
	foo(arg); // uses the lvalue overload, because this rvalue reference is named so is actually an lvalue!
}

Names turn rvalues into lvalues, destroying the original value category. This could be annoying in some use cases... (foreshadowing)

Okay, so hopefully it is clear to you that if we want to fix our make, we can't just go and change the parameter types to use Lvalue ref or const ref or by value or something.

Fixing with overloads?

Okay, what if we just write a bunch of overloads to handle all our cases? Sorta like this, but with an actual concrete Arg:

T make(Arg&  arg);        // lvalue
T make(const Arg& arg);   // const lvalue
T make(Arg&& arg);        // rvalue

Like sure, this technically works. But I'm not feeling generous enough to give you an overload for every combination of values and number of args you want -- that's exponential! Is there a way for me to write this function in a scalable way?

Let's take a look at forwarding/universal references, as some way to bind to anything in one parameter while also recording what type of value it bound to.

&& as a Universal/Forwarding Reference

Aside from being a normal rvalue reference (and a logical operator), && actually has another meaning: in templates it's a universal/forwarding reference, and you'll see it in contexts that look suspiciously like rvalue references. See here:

void strict(int&& arg); // only accepts rvalues!

template <typename T>
void wrap(T&& arg) // forwarding reference

When we deduce the template type T, a special forwarding deduction rule applies, and then the above reference collapsing rules takes over, and T&& can bind to anything: const/non-const L/R-values. Because the compiler has to deduce the type (it's not strictly calculated beforehand), this isn't a normal rvalue reference!

(By the way, this also means auto&& is a forwarding reference and triggers the reference collapsing rules, since the compiler is deducing what type the auto is! Using an explicit type alias like Result ref bypasses deduction and leaves it as a normal rvalue reference!)

The special forwarding deduction rule says that for wrap(T&& arg) called with an argument of type U:

  • if the argument is an Lvalue then T deduces to U& which is an Lvalue reference and also cv-preserved
  • if the argument is an Rvalue then T deduces to a plain U (no reference!)

It's special because this is the only context where deduction can produce a reference type (everywhere else, deduction would strip references and give a value type).

Reference Collapsing

In cases where the type is explicitly fixed (even calculated beforehand using using or typedef, and as opposed to when the compiler has to deduce the type at exact point of variable declaration), the compiler follows the below reference collapse rules:

  • & + & \rightarrow &
  • & + && \rightarrow &
  • && + & \rightarrow &
  • && + && \rightarrow &&

More concretely, here are usage examples of those rules:

using LRef = int&; // lvalue ref type

using Result1 = LRef&; // int& & -> int&
int x = 1; 
Result1 ref = x; // valid, binds to lvalue x
// Result1 ref = 2; errors -- cannot bind int& to an rvalue (2)

using Result2 = LRef&&; // int& && -> int&
int x = 1;
Result2 ref = x; // valid, ref just is int&
// Result2 ref = 2; error -- cannot bind lval ref int& to rvalue (2)

using RRef = int&&; // rvalue ref type

using Result3 = RRef&; // int&& & -> int&
int x = 1;
Result3 ref = x; // valid, collapsed to int&
// Result3 ref = 2; error -- cannot bind lval ref int& to rvalue (2)

using Result4 = RRef&&; // int&& && -> int&&
int x = 1;
// Result4 ref = x; error -- cannot bind rval ref to lvalue (1)
Result4 ref = 2; // valid! This is a normal rvalue reference! int&& binds to temporary 2 cleanly.

Basically, if there's any Lvalue reference in the mix, the result collapses into a normal Lvalue reference.

So for our Box example:

Box lv; const Box clv;
make<Box>(lv);       // lvalue       → Args = Box&        → param Box&
make<Box>(Box{});    // rvalue       → Args = Box         → param Box&&
make<Box>(clv);      // const lvalue → Args = const Box&  → param const Box&

And our make so far:

template <typename T, typename... Args>
T make(Args&&... args) {
    return T(args...);        // note: passing `args`, NOT std::forward<Args>(args)
}

The forwarding reference is just forwarding deduction rule + reference collapsing. The forwarding deduction rule serves to encode the value category into the type Args (Lvalue arg of type Box means Args is Box&, Rvalue arg of type Box means Args is Box). Then the reference collapsing is the mechanism that actually substitutes the encoded Args into Args&& and succinctly makes a correctly-binding parameter type (Box& → Box& && → Box& (an lvalue ref — binds the lvalue); Box → Box&& (an rvalue ref — binds the temporary)). Otherwise, Box& && would be a reference to a reference, which is useless and uncompilable.

Okay, so this forwarding reference is part of the solution: we get a parameter that can now bind to anything and store the original value category in Args -- we've solved the unscalable overload problem, and we've eliminated the unnecessary copies at the site of the parameter binding. But still, a named args turns into an Lvalue no matter what Args is -- we still haven't dealt with the unnecessary copies at object construction. So there's still some part of the pipeline missing. We probably want some kind of flexible cast to turn the named args for object construction into the respective value category we got from the forwarding reference. Enter std::forward!

std::forward

Yes, std::forward<T>(arg) actually is a conditional cast! If T comes in as an Lvalue (deduced as U&), then it forwards as an Lvalue (and hence invokes the copy constructor). If T comes in as an Rvalue (deduced as U), then it forwards as an Rvalue (invoking the move constructor). Contrast this with std::move, which you can think of as an unconditional cast that always casts to Rvalue. If you're passing along an Rvalue reference you own, you can directly std::move it, but if you're just passing along a forwarding reference (like in our make example), you're better off std::forwarding it!

template <typename T>
T&& forward(std::remove_reference_t<T>& arg) noexcept {
    return static_cast<T&&>(arg);
}

template <typename T>
std::remove_reference_t<T>&& move(T&& arg) noexcept {
	return static_cast<std::remove_reference_t<T>&&>(arg);
}

Forwarding reference + std::forward = solved!

So putting that all together, our final magical make looks like this:

template <typename T, typename... Args>
T make(Args&&... args) {
    return T(std::forward<Args>(args)...);
}

make<Box>(Box{})                            // 1 move, 0 copies
make<Box>(b)                                // 1 copy
make<unique_ptr<int>>(make_unique<int>(5))  // moves!

And as I alluded to in the beginning, our toy make is useful to understand what happens in the standard library: std::make_unique/std::make_shared both forward constructor args to T. vector::emplace_back forwards args to construct the element in place (without temporaries). Perfect forwarding is what allows std::function to function (LOL). And perfect forwarding is also related to how std::thread starts another thread running a callable, though it's technically a decay-copy + move... (post with common forwarding gotchas coming later...)

C++ is a language built around zero-cost abstractions and compatibility with C, which is why it's so complex, daunting to learn, and maybe even fun to dig around in. Perfect forwarding is precisely the feature that lets "generic pass-through code" be zero-overhead and behavior-preserving at the same time.

Sources

temp.deduct.call/3

dcl.ref/6 https://en.cppreference.com/w/cpp/language/reference

forward https://en.cppreference.com/w/cpp/utility/forward

https://developers.redhat.com/blog/2019/04/12/understanding-when-not-to-stdmove-in-c https://learn.microsoft.com/en-us/cpp/cpp/lvalues-and-rvalues-visual-cpp?view=msvc-170 https://en.cppreference.com/cpp/language/value_category https://www.modernescpp.com/index.php/perfect-forwarding/ https://www.justsoftwaresolutions.co.uk/cplusplus/core-c++-lvalues-and-rvalues.html https://www.studyplan.dev/pro-cpp/forwarding