Hillsoft Blog

Exploring Maths and Computing

C++ Lifetime Quiz Part 2

This post is part of the C++ Lifetime Quiz series.

Copies and moves may seem to have obvious lifetime implications but the reality is unfortunately not quite so simple.

To explore them, we’ll need to add a few methods to our Speaker class:


class Speaker {
...

Speaker(const Speaker& other)
: word_(other.word_),
depth_(other.depth_ + 1) {
std::cout << "Copy constructing: "
<< depth_ << " "
<< word_ << std::endl;
}

Speaker(Speaker&& other)
: word_(other.word_),
depth_(other.depth_ + 1) {
other.word_ = "DANGER Moved";
std::cout << "Move constructing: "
<< depth_ << " "
<< word_ << std::endl;
}

Speaker& operator =(const Speaker& other) {
word_ = other.word_;
depth_ = other.depth_ + 1;
std::cout << "Copying: "
<< depth_ << " "
<< word_ << std::endl;
return *this;
}

Speaker& operator =(Speaker&& other) {
word_ = other.word_;
other.word_ = "DANGER Moved";
depth_ = other.depth_ + 1;
std::cout << "Moving: "
<< depth_ << " "
<< word_ << std::endl;
return *this;
}

...
};

Let’s start with a simple example to demonstrate a copy:


int main() {
Speaker s1{"A"};
Speaker s2{s1};
return 0;
}
Spoiler

Constructing: 1 A
Copy constructing: 2 A
Destructing: 2 A
Destructing: 1 A

What if we construct s2 using the = symbol instead?


int main() {
Speaker s1{"A"};
Speaker s2 = s1;
return 0;
}
Spoiler

Constructing: 1 A
Copy constructing: 2 A
Destructing: 2 A
Destructing: 1 A

Interestingly, it uses the constructor here and not the assignment operator. The assignment operator can only be used if we have already constructed the object.


int main() {
Speaker s1{"A"};
Speaker s2{std::move(s1)};
return 0;
}
Spoiler

Constructing: 1 A
Move constructing: 2 A
Destructing: 2 A
Destructing: 1 DANGER Moved

In this example, we see one of the surprising behaviours of moving objects; their destructors still get called. Strictly speaking, ‘move’ is a bit of a misnomer. What’s really happening is not a move at all, but a destructive copy. The objects s1 and s2 are always completely separate, we are just asserting that when s2 is constructed by ‘moving’ s1, it is acceptable for s1 to be left in an invalid state. To support this, the standard asserts that any access to a variable after it has been moved—except for its destructor—results in undefined behaviour. The destructor, however, is still run for objects that have been ‘moved’.

Copy Elision

Now, what happens if we try to make the previous example a little more concise? After all, it is invalid to access s1 after s2 is constructed, so why bother giving it a name at all? Instead, let’s construct s2 from a temporary Speaker instead of a named Speaker.


int main() {
Speaker s2{Speaker{"A"}};
return 0;
}
Spoiler

Constructing: 1 A
Destructing: 1 A

Oh, that’s a surprise. Only one Speaker gets constructed even though we explicitly try to construct two of them.

It turns out that C++ permits one kind of optimisation that can actually modify the behaviour of a program when used: copy (and move) elision. Essentially, the compiler can see that the temporary Speaker here is a little bit pointless; it would be immediately ‘moved’ into s2 and then be deleted. The copy elision optimisation skips this intermediate temporary object and instead constructs s2 directly. In our example, however, the constructor and destructor of Speaker has some clearly visible side effects, so the behaviour of the program is very obviously modified.

In this particular case, the standard actually demands that copy elision be performed. We will see some more examples of mandatory copy elision later and also examples of optional copy elision where the standard allows this optimisation to be used, but does not require it.

To understand some other cases of copy elision, let’s look at how returning values from functions works without this optimisation. You’ll have to forgive the complexity of this one; the standard allows (but does not require) copy elision when returning a named local variable (plus a few extra conditions, which we don’t need to worry about now). Modern compilers are quite good at doing copy elision of this type, so a little work was necessary to avoid it.


Speaker getSpeaker(bool b) {
Speaker s1{"A"};
Speaker s2{"B"};
if (b) {
return s1;
}
else {
return s2;
}
}

int main() {
getSpeaker(true);
return 0;
}
Spoiler

Constructing: 1 A
Constructing: 1 B
Move constructing: 2 A
Destructing: 1 B
Destructing: 1 DANGER Moved
Destructing: 2 A

Where did that move constructor come from? It actually comes from the return statement; returning by value may require a copy or move. This copy or move will be performed by the return statement itself, so the fact we don’t use the returned value in main is irrelevant.

Interestingly, as far as the standard is concerned, returning by value may equally not require a copy or move. It is entirely up to the compiler to decide and is not even required to be consistent: one call to getSpeaker may require this move but another may not. In practice, this inconsistency is possible and would typically be a result of inlining.

The destructor order is also a little odd; not the straightforward reverse of construction we are used to. The variables s1 and s2 are local to the scope of getSpeaker so are deleted when that scope exits. The return value, however, is in the scope of main (albeit as a temporary) so isn’t deleted until after control returns to main. This means that s1 and s2 must be deleted before the returned value.

Now, let’s tweak getSpeaker a little and see what happens.


Speaker getSpeaker(bool b) {
Speaker s1{"A"};
Speaker s2{"B"};
Speaker s3{"C"};
return s2;
}
Spoiler

Constructing: 1 A
Constructing: 1 B
Constructing: 1 C
Destructing: 1 C
Destructing: 1 A
Destructing: 1 B

Suddenly, the move constructor vanishes! There is a rule of thumb for when returning requires a move/copy and when it doesn’t: if a variable (s2) can be guaranteed to be returned when it’s constructed, then a copy or move will not be required. Remember, however, that the standard does not guarantee this rule of thumb will work so do not rely on it!

Now, that we’ve looked into returning values, we can also look at passing them in to a method.


void useSpeaker(Speaker s) {
s.speak();
}

int main() {
useSpeaker(Speaker{"A"});
return 0;
}
Spoiler

Constructing: 1 A
Speaking: 1 A
Destructing: 1 A

This example is pretty straightforward, but it is nice to notice here that we get copy elision saving the temporary from main having to be copied or moved to s.


int main() {
Speaker s{"A"};
useSpeaker(s);
return 0;
}
Spoiler

Constructing: 1 A
Copy constructing: 2 A
Speaking: 2 A
Destructing: 2 A
Destructing: 1 A

However, if we are passing in a named variable, we lose copy elision here. We could try moving s to get it back.


int main() {
Speaker s{"A"};
useSpeaker(std::move(s));
return 0;
}
Spoiler

Constructing: 1 A
Move constructing: 2 A
Speaking: 2 A
Destructing: 2 A
Destructing: 1 DANGER Moved

This hasn’t got us to the copy elision we really wanted, we’re just using the move constructor instead of the copy constructor. The only way around this is to pass in a temporary as in the earlier example or modify the useSpeaker method to take a reference instead.


void useSpeaker(Speaker s1, Speaker s2) {
s1.speak();
s2.speak();
}

int main() {
useSpeaker(Speaker{"A"}, Speaker{"B"});
return 0;
}
Spoiler

There are two possible outputs here:


Constructing: 1 B
Constructing: 1 A
Speaking: 1 A
Speaking: 1 B
Destructing: 1 A
Destructing: 1 B

or


Constructing: 1 A
Constructing: 1 B
Speaking: 1 A
Speaking: 1 B
Destructing: 1 B
Destructing: 1 A

Notice how the second parameter here actually gets constructed before the first parameter. As far as the standard is concerned, the order in which method parameters are evaluated is actually indeterminate and different compilers do choose different orders. While this has far broader implications than just object lifetime, we can see an example of how it impacts lifetime consideration in the following innocent looking example.


void useSpeaker(Speaker s1, Speaker s2) {
s1.speak();
s2.speak();
}

int main() {
Speaker s{"A"};
useSpeaker(s, std::move(s));
return 0;
}
Spoiler

There are two possible outputs here:


Constructing: 1 A
Move constructing: 2 A
Copy constructing: 2 DANGER Moved
Speaking: 2 DANGER Moved
Speaking: 2 A
Destructing: 2 DANGER Moved
Destructing: 2 A
Destructing: 1 DANGER Moved

or


Constructing: 1 A
Copy constructing: 2 A
Move constructing: 2 A
Speaking: 2 A
Speaking: 2 A
Destructing: 2 A
Destructing: 2 A
Destructing: 1 DANGER Moved

Notice how we might end up copying an object that has already been moved, something that is very definitely undefined behaviour. The problem here is obvious after having seen the last example, but is often very easy to miss in real code.

Temporaries


Speaker passthroughSpeaker(Speaker s) {
return s;
}

int main() {
passthroughSpeaker(Speaker{"A"}).speak();
return 0;
}
Spoiler

Constructing: 1 A
Move constructing: 2 A
Speaking: 2 A
Destructing: 2 A
Destructing: 1 DANGER Moved

This example has actually has a lot going on. Firstly, we’re not getting any copy elision when returning s here. This is because copy elision is actually forbidden when returning a method parameter.

Secondly, what is going on with the destructor order? The lifetime of the temporary used as the argument to passthroughSpeaker is surprisingly long. You might think its lifetime is tied to the s argument of passthroughSpeaker, but this is not the case. Instead, it is tied to the full expression in which it is constructed, i.e. the line of main in which passthroughSpeaker is called. This is also true of the temporary returned by passthroughSpeaker so they are both destroyed at the same time, after the speak method is finished. The common tie-breaking rule is applied, they are destroyed in the reverse order of their construction.

Posted 10th of September 2023
Filed under [series] C++ Lifetime Quiz, Practical Programming, C++



If you enjoyed this post, please follow us on Facebook for future updates!