Professional CMake:

A Practical Guide

Learn to use CMake effectively with practical advice from a CMake co-maintainer. You can also have the author work directly with your team!

Member Function Overloading: Choices You Didn’t Know You Had

Let’s explore your understanding of member function overloading. For a given class, how many different non-template overloads can you define for a given function where the function takes no arguments? Putting aside exception specifications (since allowing them would make the answer to this essentially infinite), let’s make this a multiple choice, pick your answer from: 1, 2, 4 or 8. I’ll even provide a clue that the return types of the functions don’t matter. For extra points, how many of the function overloads are likely to be useful?

At the risk of ruining the plot, there are no less then 8 different overloads one could define even without considering exception specifications. Here is an example showing them in all their glory:

class A
{
public:
    void f() &;
    void f() const&;
    void f() &&;
    void f() const&&;
    void f() volatile&;
    void f() const volatile&;
    void f() volatile&&;
    void f() const volatile&&;
};

The function overloads differ only in what the C++ standard calls their cv-qualifiers and ref-qualifiers. For the rest of us mere mortals, we’re talking about the various bits that come after the function parameter list in the above example. The cv-qualifiers are just the const and volatile keywords and these have been part of C++ for a long time. The ref-qualifiers are the & parts and are a new addition that arrived with C++11. Both refer to characteristics of the object on which the member function is being called.

The basics

Consider the commonly encountered simpler scenario where just two overloads are present:

class A
{
public:
    void f()       { std::cout << "non-const" << std::endl; }
    void f() const { std::cout << "const"     << std::endl; }
};

This sort of arrangement is very common, especially in STL classes. The two functions differ only in their constness. The choice of which function gets called depends on the object on which the function is called, specifically whether the object itself is const or not. To illustrate:

A        a1;
const A  a2 = A();
const A& a3 = a1;

a1.f();
a2.f();
a3.f()

This results in the following output:

non-const
const
const

The STL uses such overloads heavily in its container classes to, for example, return an iterator or const_iterator depending on whether the object is non-const or const respectively. A container’s begin() and end() functions are two classic examples of this.

Ref qualifiers

With the arrival of C++11, there is more importance for whether the object is an lvalue or an rvalue. In simple terms, lvalues are always named objects, while rvalues are unnamed temporaries or lvalues which have been explicitly marked as rvalues using std::move or similar (yes, this is an oversimplification, but it will serve our purposes here). A member function can be overloaded based on whether the object is an lvalue or an rvalue by appending & or && respectively. Thus:

class A
{
public:
    void f() &  { std::cout << "lvalue" << std::endl; }
    void f() && { std::cout << "rvalue" << std::endl; }
};

A a;

a.f();            // (1)
std::move(a).f(); // (2)
A().f()           // (3)

which results in the following output:

lvalue
rvalue
rvalue

The reasoning is as follows:

  • At (1), we define an ordinary (named) object a which is an lvalue, so calling f() on it predictably calls the f()& overload.
  • At (2), we force a to be treated as an rvalue, so f()&& gets called instead.
  • At (3), we create a temporary unnamed object and call f() on it. Since the object is unnamed, it is an rvalue and f()&& is called.

A fair question would be to ask how a function with no ref-qualifiers is treated. The C++ standard dictates that if any of a function’s overloads for a given parameter list has a ref-qualifier, then all overloads with that same parameter list must have ref-qualifiers. Otherwise, if no ref-qualifiers are provided, they will be treated as lvalues (i.e. as though they had a single trailing & on the function declaration). This preserves the behaviour of pre-C++11 code.

Volatile

The v part of cv-qualifiers is volatile. Unless you are working with low level device drivers or other hardware-interfacing code, it’s unlikely you will come across much need for volatile. It is essentially an optimization hint to the compiler that the object can change in ways not detectable by the implementation. This would be useful, for example, for a value which changes as a result of hardware drivers updating addresses in memory. It is arguably less useful when talking about class objects rather than built-in types like int, etc. For completeness, I only want to note that the volatileness of an object is another way you can differentiate an object and have different overloads called as a result. If you can think of a real-world example where this is actually useful in practice, please do share it in the comments at the end of this article. I won’t discuss volatile further here.

Combining cv- and ref-qualifiers

Consider now if we overload on both the cv-qualifiers and ref-qualifiers:

class A
{
public:
    void f() &       { std::cout << "non-const lvalue" << std::endl; }
    void f() &&      { std::cout << "non-const rvalue" << std::endl; }
    void f() const&  { std::cout << "const lvalue"     << std::endl; }
    void f() const&& { std::cout << "const rvalue"     << std::endl; }
 };

A a1;
const A a2 = A();

a1.f();            // (1)
a2.f();            // (2)
std::move(a1).f(); // (3)
std::move(a2).f(); // (4)

which results in the following output:

non-const lvalue
const lvalue
non-const rvalue
const rvalue

Let us consider what f() may do for each of the four calls.

  • At (1), f() is called on a non-const lvalue object. That means the object can be modified and its state is expected to be well defined when f() returns.
  • For (2), f() is called on a const lvalue, so while it must also return with the object in a well defined state, it additionally is not permitted to modify the object (except for mutable members, but won’t affect the discussion here).
  • The call to f() at (3) is on a non-const rvalue object. This means the object can be modified by f() and the state of the object at the end of the call does not have to be well defined other than it must be safe to destroy the object. This may offer an implementation advantage over (1). For example, any members used internally can be left in an arbitrary state at the end of a call, so it may possible to do things like modify members in place rather than take copies to be modified, etc. This freedom to modify members in place and not have to worry about their final state is a primary reason for the ability to overload on the lvalue– or rvalue-ness of an object.
  • Which brings us to (4). Here, f() is called on a const rvalue object, so although it is not required to leave the object in a well defined state, it is also not allowed to modify the object! This would seem to be somewhat of a contradiction and have limited usefulness. I would argue that it offers no real advantage over (2), since both have to leave the object unmodified, but (2) can be called on a wider range of objects. Perhaps there are some rare corner cases where (4) may have some usefulness, but I must admit I have yet to come across any.

Putting it all together

So it would seem that although there are no less than eight different overloads one could define for the question posed at the start of this article, for the typical developer only three will typically be of relevance. Even then, it is quite likely that overloading on the ref-qualifier is only going to offer benefit in specific cases. Situations where this may be useful will typically involve avoiding copies by modifying data members in place where the lvalue overload would otherwise need to preserve the member values on entry.

If a function is called on an rvalue object and no rvalue overload is provided, the const lvalue overload will be called instead. A compile error would result if no such overload was provided either. This highlights why the rvalue overload can be useful, since it allows more efficient code especially when calling functions on temporaries. Consider the following:

auto result = GnarlyProblem(a, b, c).solve();

If solve() needs to modify values taken from member variables, those members could be used in-place if solve() has an rvalue overload available. Otherwise, a const lvalue overload would be needed, which means those members would need to be copied, even though we don’t actually care about the final state of those members in the above example. Such situations could arise in practice under a variety of scenarios. In the above example, the constructors of the GnarlyProblem class may set up some problem with initial data which the solve() function then uses to compute a solution. The solve() function could have various overloads to provide the most efficient implementation based on whether or not it is allowed to modify the object and whether the final state of the object even matters when solve() returns.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.