const rvalue references

Sandor Dargo - Aug 18 '21 - - Dev Community

Recently I facilitated a workshop at C++OnSea. It went well, but there was one topic that I couldn't deliver as well as I wanted. You might have guessed it right, it was about const rvalue references.

What are rvalue references?

Rvalue references were introduced to C++ with C++11. Since then, we refer to the traditional references (marked with one &) as lvalue references.

With the use of rvalue (&&) references, we can avoid logically unnecessary copying by moving the values instead of making an extra copy with the sacrifice of potentially leaving the original value in an unusable state.

MyObject a{param1, param2};
MyObject b = std::move(a);
a.foo() // Don't do this, it's unsafe, potentially a is in a default constructed state or worse
Enter fullscreen mode Exit fullscreen mode

As said, with the help of rvalue references we can limit unnecessary copying and implement perfect forwarding functions, thus achieving higher performance and more robust libraries.

If we try to define rvalue references in contrast with lvaule references, we can say that an lvalue is an expression whose address can be taken, as such an lvalue reference is a locator value.

At the same time, an rvalue is an unnamed value that exists only during the evaluation of an expression.

#include <iostream>

int f() { return 13; }

int main() {
    int i = 42; // i is an lvalue

    int& lvri = i;     // lvri is an lvalue reference

    int&& rvrt = f(); // rvrt is rvalue reference to temporary rvalue returned by f()

    int&& rvrl = 1;   // rvalue reference to a literal!

    // int&& rv3 = i;   // ERROR, cannot bind int&& to int lvalue
    std::cout << i << " " <<  lvri << " " 
              << rvrt << " " << rvrl << '\n';
}
Enter fullscreen mode Exit fullscreen mode

In other terms, "an lvalue is an expression that refers to a memory location and allows us to take the address of that memory location via the & operator. An rvalue is an expression that is not an lvalue." (source)

From one point of view, we might say that if you have a temporary value on the right, why would anyone want to modify it.

But on the other hand, we said that rvalue references are used for removing unnecessary copying, they are used with move semantics. If we "move away" from a variable, it implies modification.

Why would anyone (and how!) make such move-away variables const?

Binding rules

Given the above constraint, not surprisingly, the canonical signatures of the move assignment operator and of the move constructor use non-const rvalue references.

But that doesn't mean that const T&& doesn't exist. It does, it's syntactically completely valid.

It's not simply syntactically valid, but the language has clear, well-defined binding rules for it.

For our binding examples, we'll use the following four overloads of a simple function f.

void f(T&) { std::cout << "lvalue ref\n"; }  // #1
void f(const T&) { std::cout << "const lvalue ref\n"; }  // #2
void f(T&&) { std::cout << "rvalue ref\n"; } // #3
void f(const T&&) { std::cout << "const rvalue ref\n"; } // #4
Enter fullscreen mode Exit fullscreen mode

If you have a non-const rvalue reference, it can be used with any of these, but the non-const lvalue reference (#1). The first choice is f(T&&), then f(const T&&) and finally f(const T&).

But if none of those is available, only f(T&), you'll get the following error message:

#include <iostream>

struct T {};

void f(T&) { std::cout << "lvalue ref\n"; }  // #1
// void f(const T&) { std::cout << "const lvalue ref\n"; }  // #2
// void f(T&&) { std::cout << "rvalue ref\n"; } // #3
// void f(const T&&) { std::cout << "const rvalue ref\n"; } // #4


int main() {
    f(T{}); // rvalue #3, #4, #2
}
/*
main.cpp:12:8: error: cannot bind non-`const` lvalue reference of type 'T&' to an rvalue of type 'T'
   12 |     f (T{}); // rvalue        #3, #4, #2
      |    
*/
Enter fullscreen mode Exit fullscreen mode

So an rvalue can be used both with rvalue overloads and a const lvalue reference. It's a little bit of a mixture.

If we have an lvalue, that can be used only with f(T&) and f(const T&).

#include <iostream>

struct T {};

void f(T&) { std::cout << "lvalue ref\n"; }  // #1
void f(const T&) { std::cout << "const lvalue ref\n"; }  // #2
void f(T&&) { std::cout << "rvalue ref\n"; } // #3
void f(const T&&) { std::cout << "const rvalue ref\n"; } // #4


int main() {
    T t;
    f(t); // #1, #2
}
Enter fullscreen mode Exit fullscreen mode

There is a little bit of asymmetry here.

Can we "fix" this asymmetry? Is there any option that can be used only with the rvalue overloads?

No. If we take a const rvalue reference, it can be used with the f(const T&&) and f(const T&), but not with any of the non-const references.

#include <iostream>

struct T {};

void f(T&) { std::cout << "lvalue ref\n"; }  // #1
void f(const T&) { std::cout << "const lvalue ref\n"; }  // #2
void f(T&&) { std::cout << "rvalue ref\n"; } // #3
void f(const T&&) { std::cout << "const rvalue ref\n"; } // #4

const T g() { return T{}; }

int main() {
    f(g()); // #4, #2
}
Enter fullscreen mode Exit fullscreen mode

By the way, don't return const values from a function, because you make it impossible to use move semantics. Find more info here.

When to use const rvalue references?

Let's turn it around a bit. A lvalue overload can accept both lvalues and rvalues, but an rvalue overload can only accept rvalues.

The goal of rvalue references is sparing copies and using move semantics. At the same time, we cannot move away from const values. Therefore the usage of const rvalue references communicates that

  • a given operation is only supported on rvalues
  • but we still make a copy, as we cannot move.

We haven't seen a lot the need for this. For a potential example with unique pointers check out this StackOverflow answer.

What is important to note is that f(const T&&) can take both T&& and const T&&, while f(T&&) can only take the non-const rvalue reference and not the const one.

Therefore if you want to prohibit rvalue references, you should delete the f(const T&&) overload.

What would happen otherwise?

If you delete the non-const overload, the compilation will fail with rvalue references, but even though it doesn't make much sense in general to pass const rvalue references, the code will compile.

#include <iostream>

struct T{};

void f(T&) { std::cout << "lvalue ref\n"; }
void f(const T&) { std::cout << "const lvalue ref\n"; }
void f(T&&) = delete; //{ std::cout << "rvalue ref\n"; }
// void f(const T&&) { std::cout << "const rvalue ref\n"; }

const T g() {
 return T{};
}

int main() {
    f(g());
}
/*
const lvalue ref
*/
Enter fullscreen mode Exit fullscreen mode

However, if we delete the const T&& overload, we make sure that no rvalue references are accepted at all.

#include <iostream>

struct T{};

void f(T&) { std::cout << "lvalue ref\n"; }
void f(const T&) { std::cout << "const lvalue ref\n"; }
// void f(T&&) = delete; //{ std::cout << "rvalue ref\n"; }
void f(const T&&) = delete; //{ std::cout << "const rvalue ref\n"; }

const T g() {
 return T{};
}

int main() {
    f(g());
    f(T{});
}
/*
main.cpp: In function 'int main()':
main.cpp:15:6: error: use of deleted function 'void f(const T&&)'
   15 |     f(g());
      |     ~^~~~~
main.cpp:8:6: note: declared here
    8 | void f(const T&&) = delete; //{ std::cout << "const rvalue ref\n"; }
      |      ^
main.cpp:16:6: error: use of deleted function 'void f(const T&&)'
   16 |     f(T{});
      |     ~^~~~~
main.cpp:8:6: note: declared here
    8 | void f(const T&&) = delete; //{ std::cout << "const rvalue ref\n"; }
      |      ^
*/
Enter fullscreen mode Exit fullscreen mode

So due to the binding rules, we can only make sure by deleting the const version that no rvalue references are accepted.

You can observe this the standard library too, for example with std::reference_wrapper::ref and std::reference_wrapper::cref.

Conclusion

Today we discussed const rvalue references. We saw that although at a first glance they don't make much sense, they are still useful. Rvalue references in general are used with move semantics which implies modifying the referred object, but in some rare cases, it might have a good semantic meaning. At the same time, it's also used with =delete to prohibit rvalue references in a bulletproof way.

Let me know if you've ever used const rvalue references in your code!

References

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .