DEV Community

Paul J. Lucas
Paul J. Lucas

Posted on • Updated on

C++ References

Introduction

In addition to pointers inherited from C, C++ has references that serve a similar purpose:

int i;
int *p = &i;  // p points to i
*p = 42;      // i = 42
int &r = i;   // r refers to i
++r;          // ++i
Enter fullscreen mode Exit fullscreen mode

Like a pointer, a reference is a type of variable that refers (is bound) to some other object. However, unlike a pointer, a reference:

  • Must be initialized (bound) to an object when defined.
  • Can not be rebound to a different object. (There’s no syntax to do it.)
  • Can never be null.

But if C++ already has pointers, why were references added to C++?

Cheaply-Copied Types

Before we get to why references were added to C++, we need to define the term cheaply-copied type:

A C++ type T is considered “cheaply copied” when the cost of making a copy is less than or on par with the cost of indirecting through a pointer or reference.

What constitutes “cheaply copied” varies by machine architecture, but generally includes any type T where sizeof(T) ≤ 16, so:

  • Any built-in type (e.g., unsigned).
  • Any enum.
  • Any pointer.
  • Any small struct, union, or class.
  • Any typedef thereof.

As an exception, a class that manage resources (for example, an object T that contains a pointer to another object U where copying T includes copying U) is not cheaply copied even if sizeof(T) ≤ 16.

Motivation for References

According to Bjarne, references were added to C++ primarily to support operator overloading. The goal is to be able to pass arguments to overloaded operators efficiently even when the argument types are not cheaply-copied. The only way to do that without references is with pointers, for example:

T operator+( T const *lhs, T const *rhs );

void f() {
   T x, y;
   // ...
   T z = &x + &y;  // ugly
}
Enter fullscreen mode Exit fullscreen mode

That’s ugly. With references, it gets better:

T operator+( T const &lhs, T const &rhs );

void f() {
   T x, y;
   // ...
   T z = x + y;    // better
}
Enter fullscreen mode Exit fullscreen mode

References are also useful when passing non-cheaply-copied types to or returning such types from functions in general:

std::string const* get_string();
void print_string( std::string const *s );

void f() {
    std::string s{ *get_string() };  // ugly
    // ...
    print_string( &s );              // also ugly
}
Enter fullscreen mode Exit fullscreen mode

Not for Output Parameters

References can also be used instead of pointers for “output parameters” of functions to receive “return values”:

bool parse_T( std::string const &s, T &t_out );

void f( std::string const &s ) {
    T t;
    if ( !parse_T( s, t ) )   // Is 't' modified?
        // complain
}
Enter fullscreen mode Exit fullscreen mode

The problem with this you can’t tell by looking at uses of parse_T() whether t is modified or not because there’s no visible difference between passing t by value versus by non-const reference. To find out, you have to find the declaration of parse_T() to see whether the reference is const or not.

Even though there’s no visible difference between passing t by value versus by const& either, you don’t care because t can’t be modified. From your point of you, it would make no semantic difference whether t were passed by const& or by value. The use of const& is an optimization to pass a non-cheaply-copied type efficiently.

However, if you use a pointer instead:

bool parse_T( std::string const &s, T *t_out );

void f( std::string const &s ) {
    T t;
    if ( !parse_T( s, &t ) )  // '&' hints 't' is modified.
        // complain
}
Enter fullscreen mode Exit fullscreen mode

then the presence of & is a visual hint that t is likely modified.

Consequently, I personally recommend that non-const references not be used for output parameters and that pointers be used instead. To add weight to this recommendation, it happens that Bjarne agrees:

“My personal style is to use a pointer when I want to modify an object because in some contexts that makes it easier to spot that a modification is possible.” — Bjarne Stroustrup

Not for Data Members Either

References can be used as data members of classes:

class C {
public:
    C( T &t ) : _t{ t } { }
    // ...
private:
    // ...
    T &_t;
};
Enter fullscreen mode Exit fullscreen mode

One advantage of using references instead of pointers for data members is that you can’t forget to initialize references in every constructor. However, there are disadvantages:

  • The existence of at least one reference data member will suppress the implicitly defined assignment operators because references can not be reassigned (rebound).

  • The existence of at least one reference data member means you can not copy (rebind) references in your own assignment operators either.

  • Have a problem similar to the use of non-const references for output parameters in that you can’t tell by looking at uses that they’re not part of the class itself:

    _t.f();   // Is '_t' part of C?
    

    However, if you use a pointer instead:

    _t->f();  // Says '_t' points elsewhere.
    

Lvalues & Rvalues

Before continuing, we need to digress a bit to define the terms lvalue and rvalue that are types of value category:

  • An lvalue is a value, has a name, can (but not must) appear on the left-hand-side (LHS) of = (the assignment operator), and can have its address taken via & (the address-of operator).

  • An rvalue is a temporary value, has no name, can only appear on the right-hand-side (RHS) of =, and can not have its address taken via &.

  • The key thing that distinguishes and lvalue from an rvalue is: if something has a name, it’s not an rvalue.

These value categories were actually updated in C++11 by being renamed and expanded, but they’re quite a bit more complicated now — unnecessarily so for this article. The old terms of lvalue and rvalue are sufficient here.

Some examples:

int a, b, *p;

a = b;         // "a" and "b" are lvalues (both have names)
a = a + b;     // "a" = lvalue; "a + b" = rvalue
a + b = 42;    // error: rvalue on left-hand-side

p = &a;        // address of lvalue
p = &(a + b);  // error: address of rvalue
Enter fullscreen mode Exit fullscreen mode

Motivation for Rvalue References

Given:

class container {
public:
    // ...
    void push_back( std::string const &s );
};

int main() {
    container c;
    c.push_back( "hello" );
}
Enter fullscreen mode Exit fullscreen mode

This code will compile and work as expected despite not having:

    void push_back( char const *s );
Enter fullscreen mode Exit fullscreen mode

because std::string has a constructor:

    string( char const *s );
Enter fullscreen mode Exit fullscreen mode

that the compiler will use to construct a temporary string from "hello" that can then be passed to the push_back() that exists as if:

int main() {
    container c;
    {
        std::string __tmp{ "hello" };
        c.push_back( __tmp );
    }
}
Enter fullscreen mode Exit fullscreen mode

More explicitly:

  1. For __tmp, string::string( char const* ) is called that dynamically allocates a buffer and copies the string literal "hello" into it.
  2. push_back( std::string const& ) adds that string to its container via string::string(std::string const&) that dynamically allocates another buffer and copies the string into it.
  3. string::~string() destroys __tmp by deallocating the first buffer.

That’s inefficient. If __tmp is going to be destroyed anyway, why not move its buffer by moving only the string’s internal pointer rather than copying the whole buffer? This is precisely why rvalue references were added to C++.

Rvalue References

C++11 added rvalue references that refer to temporary objects for the purpose of “stealing” their resources for efficiency. (Plain references have since been known as lvalue references.) Rvalue references are declared with &&.

The previous example can be augmented:

class container {
public:
    // ...
    void push_back( std::string const &s );
    void push_back( std::string &&s );  // rvalue reference
};

int main() {
    container c;
    std::string s{ "hello" };
    c.push_back( s );        // push_back( std::string const& );
    c.push_back( "world" );  // push_back( std::string&& );
}
Enter fullscreen mode Exit fullscreen mode

As before, in the case of "world", the compiler will create a temporary string object. But now, the new overload of push_back( std::string &&s ) allows the compiler to call that function preferentially with the temporary object bound to the rvalue reference. The implementation of that push_back() will “steal” (move) the string rather than copy it.

However, this requires cooperation from std::string that must provide its own handling of rvalue references. A simplified partial declaration is:

class string {
public:
    string();
    string( char const *s );
    string( string const &s );             // copy constructor
    string( string &&s );                  // move constructor

    string& operator=( char const *s );
    string& operator=( string const &s );  // copy assignment
    string& operator=( string &&s );       // move assignment

    // ...
private:
    char *_buf;
    size_type _cap, _len;                  // capacity & length
};
Enter fullscreen mode Exit fullscreen mode

Specifically, string now also has a move constructor and move assignment operator that constructs or assigns from an rvalue reference bound to a temporary string. The move constructor is implemented something like:

string::string( std::string &&from ) :
    _buf{ std::exchange( from._buf, nullptr ) },
    _cap{ std::exchange( from._cap, 0 ) },
    _len{ std::exchange( from._len, 0 ) }
{
}
Enter fullscreen mode Exit fullscreen mode

That is it copies _buf (just the pointer, not the string), _cap, and _len and sets from._buf to nullptr and both from._cap and from._len to 0. This effectively “steals” (moves) from’s string leaving it empty — which is fine because, remember, it’s a temporary object that will soon be destroyed anyway. (The move assignment operator is implemented similarly.)

The addition of move constructors and move assignment operators to classes improves their efficiency with temporary objects, but only when at least one of their data members is a non-cheaply-copied type. A class like:

class point2d {
    int _x, _y;
public:
    point2d() : point2d{ 0, 0 } { }
    point2d( int x, int y ) : _x{ x }, _y{ y } { }
    // ...
};
Enter fullscreen mode Exit fullscreen mode

wouldn’t benefit from adding a move constructor or move assignment operator because copying ints is already cheap and can’t be made more efficient.

Default Move Constructors & Assignment Operators

Just as the compiler will automatically synthesize copy constructors and copy assignment operators for your class when possible, it will also synthesize move constructors and move assignment operators for your class when possible.

So when should you implement them yourself? The general rule is: if it’s necessary to implement the copy constructor or copy assignment yourself, you should at least think about whether it would be more efficient to implement their move counterparts yourself. This is part of the rule of 3/5/0.

std::move(T)

Consider the following:

void f( container *pc ) {
   std::string s{ "hello" };
   pc->push_back( s );        // push_back( std::string const& )
}
Enter fullscreen mode Exit fullscreen mode

This will call push_back( std::string const& ) and copy the string even though we can see that s is about to be destroyed by returning from the function. Why doesn’t the compiler call push_back( std::string&& ) instead? Because s has a name (remember: if something has a name, it’s not an rvalue) and the compiler isn’t smart enough to see that s will soon be destroyed anyway.

To get the compiler to call push_back( std::string&& ), we have to convert the lvalue reference that is s to an rvalue reference. This is precisely what std::move() does:

void f( container *pc ) {
   std::string s{ "hello" };
   pc->push_back( std::move( s ) ); // push_back( std::string&& )
}
Enter fullscreen mode Exit fullscreen mode

Using std::move() tells the compiler that you want to call a function that accepts an rvalue reference, if one exists. (If no such function exists, the use of std::move() will have no effect and the compiler will call a function that accepts an lvalue reference instead.)

A typical implementation of std::move() is:

template<typename T>
inline std::remove_reference_t<T>&& move( T &&arg ) {
    return static_cast<std::remove_reference_t<T>&&>( arg );
}
Enter fullscreen mode Exit fullscreen mode

Hence, it’s really just a shorthand for a verbose static_cast to an rvalue reference. Being just a cast, std::move():

  1. Incurs zero run-time performance penalty — it’s strictly compile-time.
  2. Is a misnomer since it doesn’t actually move anything.
  3. Does not guarantee that the argument will actually be moved.

The cast to an rvalue reference allows the compiler to call functions that have an rvalue reference parameter making the argument eligible to be moved, but the called functions are the ones that actually do the moving.

Rvalue References in Class Hierarchies

Suppose you have a base and a derived class and each provides a move constructor in addition to its copy constructor:

class B {
public:
    B( B const& );
    B( B&& );
    // ...
};

class D : public B {
public:
    D( D const& );
    D( D&& );
    // ...
};
Enter fullscreen mode Exit fullscreen mode

You might think the implementation of D’s constructors would be:

D::D( D const &from ) :
    B{ from }
{
}

D::D( D &&from ) :
    B{ from }               // WRONG: calls B( B const& )
{
}
Enter fullscreen mode Exit fullscreen mode

However, the implementation of D::D( D&& ) is wrong because even though the type of from is an rvalue reference causing it to receive rvalues, once received and bound to a name, it “decays” into a lvalue. (Remember: if something has a name, it’s not an rvalue.) To convert it back to an rvalue, you need to use std::move() again:

D::D( D &&from ) :
    B{ std::move( from ) }  // correct: calls B( B&& )
{
}
Enter fullscreen mode Exit fullscreen mode

Motivation for Forwarding References

Suppose you want to implement a wrapper function to create objects of any type T and pass a single argument. You might write something like:

template<typename T, typename Arg>
T create_T( Arg &arg ) {
    // ...
    return T{ arg };
}
Enter fullscreen mode Exit fullscreen mode

However, if you attempt to use it with a literal like:

T x = create_T( 42 );  // error
Enter fullscreen mode Exit fullscreen mode

you’d get an error because you can’t initialize a non-const& with a literal. To fix this, you can add an overload:

template<typename T, typename Arg>
T create_T( Arg const &arg ) {
    // ...
    return T{ arg };
}

T x = create_T( 42 );  // ok (now)
Enter fullscreen mode Exit fullscreen mode

While that works, suppose you now want to allow rvalue references as well. To do that, you’d have to add yet a third overload:

template<typename T, typename Arg>
T create_T( Arg &&arg ) {
    // ...
    return T{ std::move( arg ) };
}

T x = create_T( std::move( arg ) );
Enter fullscreen mode Exit fullscreen mode

That’s rather tedious. If you think that’s bad, if create_T() were to take two parameters, you’d need nine overloads to have every combination of non-const&, const&, and &&. In general, you need 3N overloads for N parameters. Clearly, this is unworkable. This is precisely why forwarding references (aka, universal references) were added to C++.

Formally, such references are known as “forwarding references”; however, Scott Meyers coined the term “universal references” that describes what they do rather than what they’re for.

Forwarding References

The way to define create_T() once and have it work with all non-const&, const&, and && combinations is:

template<typename T, typename Arg>
T create_T( Arg &&arg ) {
    // ...
    return T{ std::forward<Arg>( arg ) };
}
Enter fullscreen mode Exit fullscreen mode

While std::move() was replaced by std::forward(), the declaration of create_T() itself hasn’t changed. That’s because X&& for type X in a “type deduction context” (template) is a universal reference meaning it can bind to any kind of reference. That is the same syntax of && can either be an rvalue reference or a universal reference depending on the declaration. This can be summarized as:

Case Declaration Rvalue Reference?
1 T &&x = f();
2 void f( std::vector<T> &&arg );
3 void f( T &&arg );
4 void f( T const &&arg );

Cases 1, 2, and 4 declare rvalue references (as always). However, case 3 — that must exactly match the pattern T&& — declares a forwarding reference. Notice that even adding const as in case 4 does not match the pattern.

Why did the C++ Committee make && confusingly mean two different things depending on context? They didn’t. The fact that the syntax of T&& came to function as a forwarding reference is a happy (?) accident of the separate reference collapsing rules in C++. Adding a new syntax to declare forwarding references would have been a much bigger change to C++.

While that explains the Arg&& in the declaration of create_T(), what does std::forward() do? While std::move() always converts and lvalue reference to an rvalue reference, std::forward() forwards a reference as-is. In the line:

    return T{ std::forward<Arg>( arg ) };
Enter fullscreen mode Exit fullscreen mode

arg has “decayed” into a lvalue reference because it has a name. (Remember: if something has a name, it’s not an rvalue.) If Arg is an rvalue type, then std::forward() behaves like std::move() and converts arg (now an lvalue because it has a name) back into an rvalue. However, if Arg is an lvalue type, then std::forward() leaves it alone. This is known as perfect forwarding.

Returning by Rvalue Reference

You might wonder whether it helps to return by rvalue reference like:

T&& f() {                 // return by rvalue reference
    T v;
    // ...
    return std::move( v );
}
Enter fullscreen mode Exit fullscreen mode

The intent is to avoid copying v (that will be destroyed shortly anyway) and move it to its final destination instead. Unfortunately, this is equivalent to:

T& f() {                  // return by lvalue reference
    T v;
    // ...
    return v;             // WRONG: reference to temporary
}
Enter fullscreen mode Exit fullscreen mode

This should now be obviously wrong because it will create a dangling reference to a destroyed object! To avoid copying, you simply return by value:

T f() {                   // return by value
    T v;
    // ...
    return v;             // correct: uses NRVO
}
Enter fullscreen mode Exit fullscreen mode

In this one specific case, the compiler is smart enough to realize that v should be treated as a temporary (despite having a name) and to elide the copy via the named return value optimization (NRVO).

You might also wonder whether returning by value and using std::move() helps:

T f() {                   // return by value
    T v;
    // ...
    return std::move(v);  // WRONG: prevents NRVO
}
Enter fullscreen mode Exit fullscreen mode

The short answer is no. The reason is that using std::move() results in an rvalue expression (that has no name) and you can’t do NRVO with no name!

There are actually a very few legitimate cases for returning by rvalue reference. For example, both std::move() and std::forward() do. Generally, however, it’s very likely wrong.

Miscellaneous Reference Declarations

You might also wonder whether any of the following rvalue reference declarations are useful:

void f( T const &&p ) {  // rvalue reference to const parameter
    T &&x = g();         // local rvalue reference
}
Enter fullscreen mode Exit fullscreen mode

The short answer is no:

  • A const&& parameter defeats the entire purpose of an rvalue reference that is to move objects. The reference must be non-const since the move’d-from object is modified.

  • An rvalue reference local variable is also useless. While it can bind to an rvalue expression, it immediately “decays” into an lvalue because it has a name. (Remember: if something has a name, it’s not an rvalue.)

Conclusion

C++ has always had lvalue references:

  • As syntactic sugar for overloaded operator arguments.
  • To pass and return non-cheaply-copied types to and from functions.

C++11 added rvalue references:

  • For moving objects (to avoid copying).

C++11 also added forwarding references:

  • Forwards function parameter references as-is for perfect forwarding.

Top comments (0)