Constant references are not always your friends

Author: ChloĂ© Lourseyre
Editor: Peter Fordham

Early on, when we teach modern C++, we teach that every non-small1 data should be passed, by default, as constant reference:

void my_function(const MyType & arg);

This avoids the copy of these parameters in situations where they don’t need to be copied.

Other situations call for other needs, but today we will focus on constant references.

I found out that people tend to over-do const refs, thinking they are the best choice in every situation, and should be used everywhere they can be used.

But are they always better than the alternatives? What are the dangers and hidden traps within them?

NB: In the whole article, I use “constant reference” (or the shorter “const ref”) for what is, really, a reference to a constant. This is a convention that, though technically inaccurate, is way more practical.

First situation: const ref as a parameter

This is kind of a textbook case, involving a non-optimal usage of a const ref.

Take this class:

struct MyString
{
     // Empty constructor
    MyString()
    { std::cout << "Ctor called" << std::endl; }
    
    // Cast constructor
    MyString(const char * s): saved_string(s) 
    { std::cout << "Cast ctor called" << std::endl; }
    
    std::string saved_string;
};

This is basically a std::string wrapper, with outputs to see if and when the constructors are called. We will use it to see if there are unnecessary calls to constructors and if there are any implicit conversions3. From now on, we’ll consider that constructing a MyString is heavy and unwanted.

Using a constant reference

Let’s take a function that takes a constant reference to MyString as a parameter:

void foo(const MyString &)
{
    // ...
}

And now, let’s call it with, let’s say, a literal string:

int main()
{
    foo("toto");
}

It compiles, it works, and it prompts the following message on the standard output:

Cast ctor called

The cast constructor is called. How come?

The thing is, const MyString & can’t refer to the "toto" we pass down to foo(), because "toto" is a const char[]. So, naively, it shouldn’t compile. However, since the reference is constant, and so won’t modify the source object, the compiler reckons it can be copied, somewhere in memory, with the correct type. Thus, it performs an implicit conversion.

This is not neat, because this conversion is heavy for a lot of types, and in the collective unconscious passing down a const ref does not copy the object. It’s the fact that it is implicit (thus not clear) that is thus unwelcome.

Using the explicit keyword

In C++, we can use the explicit keyword to specify that a constructor or a conversion function cannot be used implicitly.

explicit MyString(const char * s): saved_string(s) 
{ std::cout << "Cast ctor called" << std::endl; }

With that keyword, you cannot use the foo() function with a literal string anymore:

foo("toto"); // Does not compile

You have to cast it:

foo(static_cast<MyString>("toto")); // Does compile

However, there is a major downside: you can’t use explicit on STD types (such as std::string) or types you import from external libraries. How can we work around that?

Using a plain reference

Let’s put aside the explicit keyword and consider that MyString in external and can not be edited.

We’ll tune the foo() function so that the reference it takes as a parameter is not constant anymore:

void foo(MyString &)
{
    // ...
}

So what happens now? If we try to call foo() with a literal string we got the following compilation error:

main.cpp: In function 'int main()':
main.cpp:24:9: error: cannot bind non-const lvalue reference of type 'MyString&' to an rvalue of type 'MyString'
   24 |     foo("toto");
      |         ^~~~~~
main.cpp:11:5: note:   after user-defined conversion: 'MyString::MyString(const char*)'
   11 |     MyString(const char * s): saved_string(s)
      |     ^~~~~~~~
main.cpp:17:10: note:   initializing argument 1 of 'void foo(MyString&)'
   17 | void foo(MyString &)
      |          ^~~~~~~~~~

Here, the compiler cannot perform an implicit conversion any more. Because the reference is not constant, and thus may be modified within the function, it cannot copy and convert the object.

This is actually a good thing, because it warns us that we are trying to perform a conversion and asks us to explicitly perform the conversion.

If we want this code to work, we do have to call the cast constructor4 explicitly:

int main()
{
    MyString my_string("toto");
    foo(my_string);
}

This compiles, and gives us the following message on the standard output:

Cast ctor called

But this is better than the first time, because here the cast constructor is called explicitly. Anyone who reads the code knows that the constructor is called.

However plain references have downsides. For one, it discards the const-qualifier.

Using template specialization

Finally, an other way to prevent implicit conversion is to use template specialization:

template<typename T>
void foo(T&) = delete;

template<>
void foo(const MyString& bar)
{
    // …
}

With this code, when you try to call foo() with anything that isn’t a MyString, you’ll try to call the generic templated overload of foo(). However, this function is deleted and will cause a compilation error.

If you call it with a MyString, though, it is the specialization that will be called. Thus, you’ll be sure that no implicit conversion can be done.

Conclusion of the first situation

Sometimes, constant references can perform implicit conversions. Depending on the type and the context, this may be undesirable.

To avoid that, you can use the explicit keyword. This forbids implicit conversion.

When you can’t use explicit (because you need it on an external type), you can use a plain reference instead or a template specialization as seen above, but both have implications.

Second situation: const ref as an attribute

Let’s take (again) a wrapper to a std::string, but this time, instead of storing the object, we’ll store a constant reference to the object:

struct MyString
{    
    // Cast constructor
    MyString(const std::string & s): saved_string(s) {}
    
    const std::string & saved_string;
};

Using a constant reference stored in an object

Let’s use it now, and see if it works:

int main()
{
    std::string s = "Toto";
    MyString my_string(s);

    std::cout << my_string.saved_string << std::endl;
    
    return 0;
}

With that code, we get the following standard output:

Toto

So this seems to work fine. However, if we try to edit the string from outside the function, like this:

int main()
{
    std::string s = "Toto";
    MyString my_string(s);

    s = "Tata";

    std::cout << my_string.saved_string << std::endl;
    
    return 0;
}

The output changes to that:

Tata

It seems that the fact that we stored a constant reference does not mean the value cannot be modified. In fact, it means that it can not be modified by the class. This is a huge difference that can be misleading.

Trying to reassign a constant reference

With that in mind, you might want to try and reassign the reference stored in the object rather than modifying its value.

But in C++, you can’t reseat a reference. As it is said in the IsoCpp wiki: Can you reseat a reference? No way. (Source: References, C++ FAQ (isocpp.org)).

So beware, because if you write something like this:

int main()
{
    std::string s = "Toto";
    MyString my_string(s);

    std::string s_2 = "Tata";
    my_string.saved_string = s_2;

    std::cout << my_string.saved_string << std::endl;
    
    return 0;
}

This won’t compile, because you are not trying to reseat my_string.saved_string to the reference of s_2, you are actually trying to assign the value of s_2 to the object my_string.saved_string refers to, which is constant from MyString‘s point of view (and thus can’t be assigned).

If you try to work around that and de-constify the reference stored inside MyString, you may end up with this code:

struct MyString
{    
    // Cast constructor
    MyString(std::string & s): saved_string(s) {}
    
    std::string & saved_string;
};

int main()
{
    std::string s = "Toto";
    MyString my_string(s);

    std::string s_2 = "Tata";
    my_string.saved_string = s_2;

    std::cout << my_string.saved_string << std::endl;
    
    return 0;
}

The output is, as expected, Tata. However, try and print the value of s and you’ll have a little surprise:

std::cout << s << std::endl;

And you’ll see that it prints Tata again!

Indeed, as I said, by doing that you do try to reassign the value referred by my_string.saved_string, which is a reference to s. So by reassigning my_string.saved_string you reassign s.

Conclusion of the second situation

In the end, the keyword const for the member variable const std::string & saved_string; does not mean “saved_string won’t be modified”, it actually means that “a MyString can’t modify the value of its saved_string“. Beware, because const does not always mean what you think it means.

Types that should be passed by value and not by reference

Using constant references is also a bad practice for some types.

Indeed, some types are small enough that passing by const ref instead of passing by value is actually not an optimization.

Here are examples of types that should not be passed by const ref:

  • int (and short, long, float etc.)
  • pointers
  • std::pair<int,int> (any pair of smal types)
  • std::span
  • std::string_view
  • … and any type that is cheap to copy

The fact that these types are cheap to copy tells us that we can pass-by-copy, but it does doesn’t tell us why we should pass them by copy.

There are three reasons why. These three reasons are detailed by Arthur O’Dwyer in the following post: Three reasons to pass `std::string_view` by value – Arthur O’Dwyer – Stuff mostly about C++ (quuxplusone.github.io)

Short version:

  1. Eliminate a pointer indirection in the callee. Passing by reference forces the object to have an address. Passing by value enables the possibility to pass using only registries.
  2. Eliminate a spill in the caller. Passing by value and using registries sometimes eliminates the need for a stack frame in the caller.
  3. Eliminate aliasing. Giving a value (i.e. a brand new object) to the callee give it greater opportunities for optimization.

Wrapping up

Here are two dangers of constant references:

  • They can provoke implicit conversions.
  • When stored in a class, they can still be modified from the outside.

Nothing is inherently good or bad — thus nothing is inherently better or worse.

Most of the time, using constant references to pass down non-small parameters is best. But keep in mind that it has its own specificities and limits. That way, you’ll avoid the 1% situation where const refs are actually counter-productive.

They are several semantic meanings to the keyword const. Sometimes, you think it means something while in fact it means another thing. But I keep that for another article.

Thanks for reading and see you next time5!

Author: ChloĂ© Lourseyre
Editor: Peter Fordham

Addenda

Examples in Godbolt

First situation: const ref as a parameter: Compiler Explorer (godbolt.org) and Compiler Explorer (godbolt.org)

Second situation: const ref as an attribute: Compiler Explorer (godbolt.org)

Notes

  1. “Small data” refers, in that context, to PODs2 that are small enough to be passed down without losing efficiency — such as simple integers and floating values.
  2. POD means “Plain Old Data” and refers to data structures that are represented only as passive collections of field values without using object-oriented features.
  3. MyString is just a placeholder for heavier classes. There are classes -such as std::string that are costly to construct or copy.
  4. What I call “cast constructor” is the one with one parameter. These kinds of constructors are often called that way because it’s the ones that the static_cast use.
  5. Scientific accuracy has always been one of my goals. I don’t always reach it (I don’t often reach it) but I try to as much as I can. That’s why, from now on, I won’t say “See you next week” since according to the stats, I publish two-point-eight articles per month on average.

10 thoughts on “Constant references are not always your friends”

  1. in Using template specification: why specialization and not overloading (btw, you are saying ” though, it is the specified overload that will be” but this is not an overload, it
    s specialization, right?)

    1. > btw, you are saying ” though, it is the specified overload that will be” but this is not an overload, it
      s specialization, right?
      Yes, I’ll edit that mistake (thanks!). And I realize that the good term is not “specification” but “specialization”. Sorry for that.

      > why specialization and not overloading
      I don’t see how simple overloading could work here (Having a generic template that is deleted is how we forbid any call not using`MyString`). Do you have an example how it would work?

      1. thanks for the reply and thank you again for the great blogpost.
        What I meant is having the template, but instead the specialization part, we add an overload. The question is whether there is an advantage of a specialization over overloading. I dont know yet.

  2. *First situation: Const Ref As Parameter*
    > Conclusion of the first situation
    > Sometimes, constant references can perform implicit conversions.
    That would be incorrect. Constant reference do not perform implicit conversion. In your example, “const char*” is an *argument to constructor*. Function argument is const ref. The reason why it works is, C++ allows temporaries to be referenced *in local function* _by a const reference_. So a temporary is created. It is referenced, since it’s not changed, you’re fine. Fun begins when you do following; now all bets are off:

    void foo(const MyString &o)
    {
    const_cast(o).saved_string = “fun”;
    std::cout << o.saved_string < If you try to work around that and de-constify the reference..
    No, you are not de-constifying (that’s what I did above and mine is pretty crappy way to get around :)); you have changed data type.

    > Here are examples of types that should not be passed by const ref:
    > int (and short, long, float etc.)
    This is mainly based on size. If you want to make somethingn const but *avoid* copy, BUT size of data type is same as const ref, make it just const for example sizeof int and int& are both 4 for long it’s 8. But general rule of thumb is right.
    > pointers
    why not? It can be useful in completely contrived programming 🙂 :

    const char* p = “outer”;
    try_const_char_ptr_vs_ptr_refs(p, p, p);
    std::cout << "p: " << p << std::endl;

    void try_const_char_ptr_vs_ptr_refs(const char* const_ptr, const char* const_ptr_ref, const char* const& const_ref_to_ptr) {
    const char * q = "inner";
    const_ptr = q;
    std::cout << "const_ptr: " << const_ptr << std::endl;

    const_ptr_ref = q;
    std::cout << "const_ptr_ref: " << const_ptr_ref << std::endl;

    // const_ref_to_ptr = q; // <=== NOT allowed,const ref to const ptr

    }

Leave a Reply