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!

Avoiding Copies And Moves With auto

When C++11 introduced auto, it opened up a whole range of useful techniques and improved the life of C++ developers in a variety of ways. There’s no shortage of simple examples and tutorials teaching how to start using auto, but as is often the case, many of these well intentioned examples have left readers with an incomplete picture of how to use auto effectively and in some cases have resulted in poor habits becoming common practice. Since auto essentially hides away some of the details of how objects are created, there is a risk that developers don’t think as carefully about what is actually taking place. This article aims to arm the reader with information often omitted so that common pitfalls can be avoided.

Typical auto Examples

Tutorials teaching how to use auto tend to use examples like the following:

// Case 1: Assign a value to a variable
auto myVar = 1;
auto result = someFunction();

// Case 2: Iterate over a container of items, eg
// std::vector<Item> container;
for (auto item : container)
{
    ...
}

The above examples are nice and simple, demonstrating the basic features of auto.

  • Case 1 shows how to let the compiler determine a variable’s type based on the value used to initialize it.
  • Case 2 demonstrates how auto can be used to avoid having to know the exact value type of a container’s items.

Both cases provide considerable convenience when working with templated code, but even for non-templates, the code reads better, is more robust and is easier to maintain if the types involved ever change. There’s nothing technically wrong with the above examples, they do what the various tutorials say they do, it’s what is not said which sells the reader short. These sorts of simple examples rarely talk about how objects are or could be getting copied, moved or assigned. Since the tutorials typically use a simple, built in type such as int, the cost of copying, moving or assigning essentially becomes unimportant, but when the type involved is one which may be expensive to copy or move, that’s a completely different story.

Rather than using a built in type, this article will make use of the following class to help reveal what the compiler is really doing. The function implementations are defined in a separate file from the class definition so that compilers won’t inline them in our later examples.

// File: A.h
class A
{
public:
    A();
    A(int i);
    A(const A& other);
    A(A&& other);
    ~A();

    A& operator=(const A& other);
    A& operator=(A&& other);

    int value;
};
// File: A.cpp
#include "A.h"
#include <iostream>

A::A() : value(0)
{
    std::cout << "Default constructor" << std::endl;
}

A::A(int i) : value(i)
{
    std::cout << "Value constructor" << std::endl;
}

A::A(const A& other) : value(other.value)
{
    std::cout << "Copy constructor" << std::endl;
}

A::A(A&& other) : value(std::move(other.value))
{
    std::cout << "Move constructor" << std::endl;
}

A::~A()
{
    std::cout << "Destructor" << std::endl;
}

A& A::operator=(const A& other)
{
    value = other.value;
    std::cout << "Copy assignment" << std::endl;
    return *this;
}

A& A::operator=(A&& other)
{
    value = std::move(other.value);
    std::cout << "Move assignment" << std::endl;
    return *this;
}

Simple Uses Of auto

Consider the following basic example:

#include "A.h"
#include <iostream>

int main(int argc, char* argv[])
{
    std::cout << "-----------------" << std::endl;
    auto a = A(42);
    std::cout << "Value: " << a.value << std::endl;
    std::cout<< "-----------------" << std::endl;
    return 0;
}

What would the expected output be? A trap for C++ newcomers is that the expression auto a = A(42) is not an assignment, it actually calls the copy or move constructor. Taking auto out of the picture for a moment to help highlight this, note the following:

A a = A(42); // Calls copy or move constructor
             // Equivalent to: A a(A(42))

a = A(23);   // a already exists, so this one calls
             // the (copy or move) assignment operator

With that in mind, since the A class has a move constructor defined, one might assume that the expected output of the basic example would be:

----------------
Value constructor
Move constructor
Destructor
Value: 42
-----------------
Destructor

If there was no move constructor and only a copy constructor, the expectation might instead be:

-----------------
Value constructor
Copy constructor
Destructor
Value: 42
-----------------
Destructor

While the above two sets of output might indeed be what a particular compiler does, the following would also be a legitimate possible output and arguably is more likely:

-----------------
Value constructor
Value: 42
-----------------
Destructor

In this last set of output, there is no sign of the copy or move constructor being invoked anywhere. The reason this is permissible is that the C++ standard allows compilers to elide (i.e. omit or skip) the copy/move constructor when initializing an object with a temporary. This is permitted even if the copy/move constructor has side effects, like logging something to std::cout as our A copy and move constructors do. This form of copy/move elision is very likely to be implemented by most decent compilers, some of which may even do so when optimizations are disabled (GCC has the -fno-elide-constructors option to disable this, for those who wish to perform compiler experiments).

The above example used a fairly trivial initialization of a. A slight variation is to initialize a with the result of a function call:

// File: elsewhere.cpp
// Implementation in separate file so won't be inlined
A getA()
{
    A result(42);
    // A real program may make further
    // changes to result here...
    return result;
}
// File: main.cpp
#include "A.h"
#include <iostream>

A getA(); // Declaration only, cannot see implementation

int main(int argc, char* argv[])
{
    std::cout << "-----------------" << std::endl;
    auto a = getA();
    std::cout << "Value: " << a.value << std::endl;
    std::cout << "-----------------" << std::endl;
    return 0;
}

The expected output could be one of the following legitimate possibilities (again, if A had no move constructor, the move constructors in the following would be copies instead):

// No eliding
-----------------
Default constructor
Move constructor
Destructor
Move constructor
Destructor
Value: 42
-----------------
Destructor
// One move elided
-----------------
Default constructor
Move constructor
Destructor
Value: 42
-----------------
Destructor
// Both moves elided
-----------------
Value constructor
Value: 42
-----------------
Destructor

Once more, the C++ standard allows the compiler to eliminate certain copy/move operations. In this case, the copy/move for taking the value to be returned and providing it for the initialization of a can be elided. In fact, this form of copy elision is so common it has a well known name, the return value optimization, and again it is possible it will be performed even when compiler optimizations are disabled. In this case, instead of copying/moving the result, the compiler constructs it directly in the memory location used for passing the function return value back to the caller.

The use of auto doesn't change the rules for the allowable compiler optimizations mentioned above. Think of auto as just a placeholder for a type which the compiler is responsible for deducing. The above optimizations might or might not then be performed once that type has been determined.

Using auto With Ranged For Loops

Things get more interesting when considering how auto is often used with ranged for loops. Ranged for loops are a perfect situation where auto can be used to let the compiler determine the loop variable's type. For some of the examples to follow, a helper function for creating a container of A objects will be useful:

// File: elsewhere.cpp
#include "A.h"
#include <vector>

std::vector<A> getAVector()
{
    // See comments in the addendum for why
    // an initializer list is not used here
    std::vector<A> result;
    result.reserve(3);
    result.emplace_back(1);
    result.emplace_back(2);
    result.emplace_back(3);
    return result;
]}

The above returns a vector of three A objects where each object is value-constructed. The use of reserve() ensures no extra copies are required for expanding the result vector when the three items are added, while emplace_back() constructs the objects in place, thus avoiding an unnecessary copy or move. The main function of the previous basic example can be modified to confirm the behaviour of the helper function:

// File: main.cpp
#include <iostream>

std::vector<A> getAVector(); // Declaration only

int main(int argc, char* argv[])
{
    std::cout << "-----------------" << std::endl;

    auto a = getAVector();

    std::cout << "-----------------" << std::endl;
    return 0;
}

Not surprisingly, once more there are multiple legitimate possible sets of output, but the most likely one involves eliding the copy/move constructor for the return value optimization and also the copy/move constructor for initialization of a from that return value:

-----------------
Value constructor
Value constructor
Value constructor
-----------------
Destructor
Destructor
Destructor

Now imagine that, confident in this new-found and compiler-tested knowledge, the main function was modified again, this time using a ranged for statement to iterate over the vector of A objects and print the value of each one:

int main(int argc, char* argv[])
{
    auto container = getAVector();

    std::cout << "-----------------" << std::endl;
    for (auto a : container)
    {
        std::cout << "+++ " << a.value << std::endl;
    }
    std::cout << "-----------------" << std::endl;
    return 0;
}

It may be surprising to learn that the best output one could hope for with the least number of copies or moves is:

Value constructor
Value constructor
Value constructor
-----------------
Copy constructor
+++ 1
Destructor
Copy constructor
+++ 2
Destructor
Copy constructor
+++ 3
Destructor
-----------------
Destructor
Destructor
Destructor

It would be understandable to expect that if there were no copies involved when writing auto a = ... in the previous examples, then the use of auto in the ranged for statement would follow a similar pattern and there would be no copying as a result of iterating over the container. But as is often the case, the devil is in the details. Consider a general ranged for statement and its equivalent traditional form (simplified, but close enough for the purpose of discussion here):

// Ranged for statement
for (Type a : c)
{
    // ...
}

// Equivalent traditional form
for (auto i = begin(c), e = end(c); i != e; ++i)
{
    Type a = *i;
    // ...
}

Critically, whatever is specified as the Type in the for statement will be used within the equivalent for loop body. If Type is simply auto, then inside the for loop body, a local variable would be created as a copy of the container item for that iteration. Because the value used to initialize that local variable is not a temporary object, the compiler is not allowed to elide the copy constructor.

Now consider changing the for loop in main() to the following:

for (const auto& a : container)

In this form, the Type becomes const auto& and inside the for loop body, only a reference to the item for that iteration would be created. This means there would be no copy, so the most likely output would then be:

Value constructor
Value constructor
Value constructor
-----------------
+++ 1
+++ 2
+++ 3
-----------------
Destructor
Destructor
Destructor

The Lifetime Of Temporaries

Consider the following:

const A& a = getA();

The getA() function was defined as returning by value, so defining a as a reference to whatever getA() returns may seem like a is left referring to an object which has been deleted after the above line. This is not the case, however, as the C++ standard specifies that when a reference variable is created with a temporary like this, the lifetime of the temporary object is extended to that of the reference variable. In other words, the temporary object that a is referencing is guaranteed to live at least as long as a itself. Developers probably wouldn't write code like this intentionally, but this becomes very important when working with templates. Consider the following:

template<typename T>
void func(const T& t)
{
    const auto& result = getObject(t);
    // Do something with result
}

Consider that getObject() might not return a T object directly, but rather it could return a variety of different things and the above code would still be valid. A selection of possibilities include:

  • A temporary object of type T.
  • A temporary object of a type derived from T.
  • A temporary object of a type not derived from T but which can be converted T.
  • A temporary object which has no relationship to T.
  • A const or non-const reference to an object of type T.
  • A const or non-const reference to an object of a type derived from T.
  • A const or non-const reference to an object of a type not derived from T but which can be converted to T.
  • A const or non-const reference to an object which has no relationship to T.

In all of the above cases, using const auto& for result will always succeed and result will never be left referring to a deleted object. Even better, it avoids making any unnecessary copies or moves. Where getObject() returns by value, the lifetime of the returned object is extended to the lifetime of result.

Again, most of the above is not specific to auto, the same principles apply even if a specific type is named instead. The lifetime of a temporary is extended regardless of whether the reference type it is assigned to was from an auto statement or a specifically typed statement. That said, the use of auto is more prevalent inside templates as a way to make those templates more generic and its use can lead to developers thinking less about potential moves/copies, so understanding the above is especially important for template authors.

General Advice For auto

In many contexts, using a bare auto may seem okay, but sometimes that can lead to copies or moves that the developer may not have expected.

  • Consider whether using auto& or const auto& is more appropriate when defining a ranged for loop. Most of the time, one of the two reference forms is likely to be a better choice, i.e. const auto& when the container items don't need to be modified and auto& if they do need to allow modification in-place. Only use a bare auto if the loop body really needs a copy of the item so it can make local modifications without affecting the items being iterated over, or if the items are known to always be built in types like int, double, etc. which are trivially cheap to copy.
  • If assigning the result of a function to a variable, consider whether making that variable a reference makes more sense. This is especially important when the code is inside a template and the function's return type depends on the template parameter(s). Assigning the result to a reference has the potential to avoid unnecessary copies without sacrificing generality. The C++ rules guarantee that even if the function returns by value, the lifetime of the returned object will be extended to that of the reference to which it is assigned.

For those wanting to get into the nitty-gritty of how auto works, Scott Meyers' book Effective Modern C++ has an excellent treatment of the subject. The first two chapters of that book are available for viewing online and Item 2: Understand auto type deduction is a worthwhile read.

Addendum: Copies And Initializer Lists

The implementation of getAVector() above could have been naively implemented like this:

std::vector<A> getAVector()
{
    return { A(1), A(2), A(3) };
}

As this article explains, doing so would actually involve creating and then copying each item of the returned vector of A objects. While initializer lists may be convenient, developers should be aware that they come with the cost of an additional copy, since a temporary array is always created.

4 thoughts on “Avoiding Copies And Moves With auto”

  1. For getAVector in the addendum, would it be possible to create a static vector with the initializer list, and return the static vector by name? The vector would only be initialized once and there would be a new copy on each return.

    Reply
    • Sure, you could do that, although then you need to consider whether the function could be called from different threads and how that might affect the initialization of the static object (static object initialization and lifetimes can be a tricky subject). I’m not sure if any compilers would impose additional cost after initialization with this approach (e.g. any locking or checks).

      Reply
  2. In last 3 output examples of “Simple Uses Of auto” chapter “Default constructor” text in output should be replaced to “Value constructor”. Thanks for the article!

    Reply

Leave a Comment

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