A look at the dual imperative and declarative nature for the copy and move behaviour in the light of compiler optimisations.

Introduction

Imperative coding style is explicit about the actions to execute and their sequence. If we call several C++ functions in sequence, we expect the compiler and CPU to generate code that would execute “as if” they are executed in sequence. The “as if” means that we’re happy if the compiler and the CPU re-order low level operations and speed up execution, as long as the observable side effects (e.g. messages printed in the console, operations to a database, network traffic) do not get reordered.

Declarative coding style is when we state the expected results, and something else takes care of the actions happening. For example in a makefile we specify the targets, the dependencies and the actions to build targets from dependencies, but we expect make to generate a dependency graph, and execute only the required actions in a sequence of it’s choosing.

In the examples below we’ll use a heavily instrumented class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

struct X
{
  X() { std::cout << "Constructor\n"; }
  ~X() { std::cout << "Destructor\n"; }
  X(const X &) { std::cout << "Copy constructor\n"; }
  X& operator=(const X &) { std::cout << "Copy assignment\n"; return *this; }
  X(X &&) noexcept { std::cout << "Move constructor\n"; }
  X& operator=(X &&) noexcept { std::cout << "Move assignment\n"; return *this; }
};

int main()
{
  X a;
}

In the example above we declare a variable of type X. The imperative aspect is that we say “construct a instance” and the compiler calls the constructor. However the destructor is called automatically, and it has a declarative nature here: we declare a destructor, and it’s up to the compiler to call it when required.

So for the example above we expect:

1
2
Constructor
Destructor

Copy elision

If we use the class X as below to create a temporary instance of X and assign it to b.

1
2
3
4
int main()
{
  X b = X();
}

One naive expectation would be that we would get (with my added comments after :):

1
2
3
4
5
Constructor : construct b
Constructor : construct temporary
Copy assignment : copy asign from temporary to b
Destructor : destruct temporary
Destructor : destruct b

But, even if the syntax looks like an assignment, what’s the point to construct an object and then copy assign to it, when the compiler could do it in one go, using the copy constructor:

1
2
3
4
Constructor : construct temporary
Copy constructor : copy construct b from temporary
Destructor : destruct temporary
Destructor : destruct b

And then you might notice that we’re talking about a temporary, and we have move constructor, so since C++11 the compiler could use that so that b takes over the content of the temporary (which is not used after that other than to call it’s destructor), in which case we would have:

1
2
3
4
Constructor : construct temporary
Copy constructor : move construct b from temporary
Destructor : destruct temporary
Destructor : destruct b

What we actually get from most compilers is:

1
2
Constructor
Destructor

Then one could say: thanks, but how about side effects? The printed text is different from the choices above.

Enter copy elision. Even before C++11 the compiler was allowed to optimize the code even if it would have side effects and remove the copy, on the idea that copy is supposed to copy, not to have side effects. C++17 guarantees copy elision in some cases.

So in addition to the destructor, the copy and move (constructor and assignment) have a declarative nature: they declare how to copy and move, and the compiler is allowed to optimise as it pleases, regardless of side effects in this case.

Other copy/move optimisations

Another optimisation situation is the return value optimisation for:

1
2
3
4
5
6
7
8
9
X fn()
{
  return X();
}

int main()
{
  X c = fn();
}

That does not generate additional temporary objects (e.g. for the return value) and instead it prints:

1
2
Constructor
Destructor

And the same happens for the named return value optimisation (when there is a named variable tmp in this case).

1
2
3
4
5
6
7
8
9
10
X fn()
{
  X tmp;
  return tmp;
}

int main()
{
  X d = fn();
}

Not all temporaries are optimised

In the code below at line 4, one could expect that at least tmp could be moved into input because it’s no longer used past the assignment, it is semantically a temporary. However that does not happen, and copy is called instead.

1
2
3
4
5
6
7
8
9
10
11
void fn(X & input)
{
  X tmp;
  input = tmp;
}

int main()
{
  X e;
  fn(e);
}

Prints:

1
2
3
4
5
Constructor : construct e
Constructor : construct tmp
Copy assignment : copy asign from tmp to e
Destructor : destruct tmp
Destructor : destruct e

However the programmer knows that the tmp variable is not used past the assignment, so we could move it into the input instead (by being imperative):

1
  input = std::move(tmp);

Which then prints:

1
Move assignment : copy asign from tmp to e

Conclusion

Unlike normal functions, the destructor, copy and move (constructor and assignment) have a declarative nature. You declare what should happen if the compiler destructs, copies or moves an instance, and the compiler calls them as it needs to, on the assumption that they define how to destruct, copy or move, ignoring side effects.

However we’ve also seen that the compiler does not identify all the cases when a variable is no longer used, and we had to use std::move to communicate this intent.