Exact moment of "return" in a C++-function
Asked Answered
B

3

69

It seems like a silly question, but is the exact moment at which return xxx; is "executed" in a function unambiguously defined?

Please see the following example to see what I mean (here live):

#include <iostream>
#include <string>
#include <utility>

//changes the value of the underlying buffer
//when destructed
class Writer{
public:
    std::string &s;
    Writer(std::string &s_):s(s_){}
    ~Writer(){
        s+="B";
    }
};

std::string make_string_ok(){
    std::string res("A");
    Writer w(res);
    return res;
}


int main() {
    std::cout<<make_string_ok()<<std::endl;
} 

What I naively expect to happen, while make_string_ok is called:

  1. Constructor for res is called (value of res is "A")
  2. Constructor for w is called
  3. return res is executed. The current value of res should be returned (by copying the current value of res), i.e. "A".
  4. Destructor for w is called, the value of res becomes "AB".
  5. Destructor for res is called.

So I would expect "A"as result, but get "AB" printed on the console.

On the other hand, for a slightly different version of make_string:

std::string make_string_fail(){
    std::pair<std::string, int> res{"A",0};
    Writer w(res.first);
    return res.first;
}

the result is as expected - "A" (see live).

Does the standard prescribes which value should be returned in the examples above or is it unspecified?

Bebeeru answered 22/10, 2018 at 13:53 Comment(11)
Handy reading: What are copy elision and return value optimization?Goodsized
A prime example of why side effects in destructors should be used very carefully, if at all.Mastodon
On the subject of when return happens, until C++14(!) the wording for return didn't say that local temporaries lasted long enough to be used in constructing the return value.Lorianne
@MatthewRead: What you actually try to say is that whole RAII concept needs to be avoided?Frondescence
@MichałŁoś no. RAII concept actually ignores side-effects.Perfect RAII code doesn't have any code in constructors except initialization. Side-effect is something that is changed by constructor outside of object. But life is never perfectCausalgia
@Swift-FridayPie So how does the unique_ptr works? What it does in destructor?Frondescence
@MichałŁoś actually , good example where elision allows something unexpected. You can't copy unique_ptr, but you can return it, exactly because of copy elision.Causalgia
It would be not destroyed if not due to copy-elision, then due to move-semantics (new moved to object would take pointer, and destructor of old object would ignore it's null pointer, but it would be called after all). What I want to say is that in RAII-handler, what destructor does is more important than what constructor does.Frondescence
@MichałŁoś no, not return std::move(pointer), but also return pointer; and it works without move semantic active, Copy elision is in some way an optimization of move semantics on its own, when you don't really need a temporal object. It never existed, so there was no extra resource acquisition, so there is nothing to destroy. Only case when it hurts RAII is when "resource acquisition" treated as "interface ownership", which was a point of argument for a while.Causalgia
If you use an IDE like Visual Studio you can step through the code in C++ with an Assembly window open, and visa-versa, to see for yourself.Deterioration
@MatthewRead "why side effects in destructors should be used very carefully" So what can a dtor ever do? Modify *this? Of course dtors are all about side effects.Ostmark
F
29

It's RVO (+ returning copy as temporary which fogs the picture), one of the optimization that are allowed to change visible behaviour:

10.9.5 Copy/move elision (emphases are mine):

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the constructor selected for the copy/move operation and/or the destructor for the object have side effects**. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object.

This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

  • in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function parameter or a variable introduced by the exception-declaration of a handler) with the same type (ignoring cv-qualification) as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function call's return object
  • [...]

Based on whether it's applied your whole premise gets wrong. At 1. the c'tor for res is called, but the object might live inside of make_string_ok or outside.

Case 1.

Bullets 2. and 3. might not happen at all, but this is a side point. Target got side effects of Writers dtor affected, was outside of make_string_ok. This happened to be a temporary created by using the make_string_ok in the context of evaluation operator<<(ostream, std::string). The compiler created a temporary value, and then executed the function. This is important because temporary lives outside of it, so the target for Writer is not local to make_string_ok but to operator<<.

Case 2.

Meanwhile, your second example does not fit the criterion (nor the ones omitted for brevity) because the types are different. So the writer dies. It would even die, if it were a part of the pair. So here, a copy of res.first is returned as a temporary object, and then dtor of Writer affects the original res.first, which is about to die itself.

It seems pretty obvious that the copy is made before calling destructors, because the object returned by copy is also destroyed, so you'd not be able to copy it otherwise.

After all it boils down to RVO, because the d'tor of Writer either works on the outside object or on the local one, according to whether the optimization is applied or not.

Does the standard prescribes which value should be returned in the examples above or is it unspecified?

No, the optimization is optional, though it can change the observable behaviour. It's at the compiler's discretion to apply it or not. It's an exempt from the "general as-if" rule which says compiler is allowed to make any transformation which does not change observable behaviour.

A case for it became mandatory in c++17, but not yours. The mandatory one is where the return value is an unnamed temporary.

Freehold answered 22/10, 2018 at 14:12 Comment(3)
This is slightly different - it's the destructor of a different object (the Writer) that has side effects that possibly affect the returned value.Hylo
@TobySpeight Point taken. I have expanded the answer a bit. And bolded "the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same objec" The Writer d'tor works exactly the same, it's just different target object. Also, the order of copy-for-return and apply dtors for local values upon return seems pretty obvious... you can't destroy object that is about to be copied for return.Freehold
@TobySpeight Also, I've noticed that it might be important to stress out that d'tor elision might be smoke and screens. What's important is where the elided copy lives and what is target for Writer.Freehold
D
38

Due to Return Value Optimization (RVO), a destructor for std::string res in make_string_ok may not be called. The string object can be constructed on the caller's side and the function may only initialize the value.

The code will be equivalent to:

void make_string_ok(std::string& res){
    Writer w(res);
}

int main() {
    std::string res("A");
    make_string_ok(res);
}

That is why the value return shall be "AB".

In the second example, RVO does not apply, and the value will be copied to the returned value exactly upon the call to return, and Writer's destructor will run on res.first after the copy occurred.

6.6 Jump statements

On exit from a scope (however accomplished), destructors (12.4) are called for all constructed objects with automatic storage duration (3.7.2) (named objects or temporaries) that are declared in that scope, in the reverse order of their declaration. Transfer out of a loop, out of a block, or back past an initialized variable with automatic storage duration involves the destruction of variables with automatic storage duration that are in scope at the point transferred from...

...

6.6.3 The Return Statement

The copy-initialization of the returned entity is sequenced before the destruction of temporaries at the end of the full-expression established by the operand of the return statement, which, in turn, is sequenced before the destruction of local variables (6.6) of the block enclosing the return statement.

...

12.8 Copying and moving class objects

31 When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the copy/move constructor and/or destructor for the object have side effects. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object, and the destruction of that object occurs at the later of the times when the two objects would have been destroyed without the optimization.(123) This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

— in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) with the same cvunqualified type as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function’s return value

123) Because only one object is destroyed instead of two, and one copy/move constructor is not executed, there is still one object destroyed for each one constructed.

Dimetric answered 22/10, 2018 at 14:0 Comment(15)
I also thought about that, but what surprises me - the results are different, so it is not only about cutting out a copying. Would like to know, what the standard says about it.Bebeeru
added a quote of the standardDimetric
“destructor” in your first sentence is supposed to be “copy constructor”, isn’t it? Otherwise I think the answer makes no sense.Lemcke
@KonradRudolph Well if the copy constructor would be called, then there would be some instance left behind on which a destructor must be called at some point. If the copy constructor would not be called, then no destructor can be called either. So really, the two statements (one with "destructor" and one with "copy constructor") are equivalent (if I'm not mistaken).Spectral
@Spectral They’re not equivalent because we’re talking about different objects: the destructor of std::string doesn’t modify the object, the destructor of Writer does, and for that it’s relevant whether the string — being passed to the Writer constructor — was copied or not (but not whether it was subsequently destroyed).Lemcke
@KonradRudolph it's both a copy constructor from res into the return value AND a destructor for res. Both are not called due to RVO.Dimetric
The main thing to notice here is that Writer works on res, and when RVO is applied res and the return value are the same object.Dimetric
while this answer explains what is going on (thanks for that!), it doesn't answer the question (at least explicitly), whether the result of the function is specified by the standard. As matter of fact, MSVC yields different results depending on the optimization level.Bebeeru
The standard defines the order of execution, and I used the definition of RVO to predict the outcome successfully. MSVC probably allows you to disable RVO, and therefore change the outcome.Dimetric
@Bebeeru The standard does not have to specify anything more than it does. The result is that upon return a copy for return is made, unless elided, then d'tors are applied. Now based on whether elision happens at the target for Writer d'tor lives in a different place. D'tor for Writer is always executed d'tor for the std::string local to the make_string_ok does not matter at all. Maybe you can understand it clearer with the answer below (disclaimer: it's mine).Freehold
But a compiler is not obliged to do RVO, it might or might not do it. There is no guarantee that the outcome is "A" but also no guarantee that the outcome is "AB".Bebeeru
@Shloim I still don’t understand why you’re talking about the res destructor then. It has no observable effect in this case. The things that matter are the copy constructor of res, and the destructor of w.Lemcke
@Freehold My point is, that RVO is not obligatory for a compiler and thus (as far as I understand) the standard allows for both results, "A" and "AB", meaning the behavior/result of the function is not specified.Bebeeru
@Bebeeru Yes. The optimization is optional, and can change the observable behaviour. It's an exempt from the "general as-if" rule which say compiler is allowed to make any transformation which does not change observable behaviour. It's not only the side effects of the elided object c'tors and d'tor that are in question, the target for other side effect changes as well, which in your case is d'tor of Writer it works on a different object whether optimization is applied. It's applied at compilers discretion.Freehold
@ead, I've added the passage from the C++ standard regarding RVO optimizationsDimetric
F
29

It's RVO (+ returning copy as temporary which fogs the picture), one of the optimization that are allowed to change visible behaviour:

10.9.5 Copy/move elision (emphases are mine):

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the constructor selected for the copy/move operation and/or the destructor for the object have side effects**. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object.

This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

  • in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function parameter or a variable introduced by the exception-declaration of a handler) with the same type (ignoring cv-qualification) as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function call's return object
  • [...]

Based on whether it's applied your whole premise gets wrong. At 1. the c'tor for res is called, but the object might live inside of make_string_ok or outside.

Case 1.

Bullets 2. and 3. might not happen at all, but this is a side point. Target got side effects of Writers dtor affected, was outside of make_string_ok. This happened to be a temporary created by using the make_string_ok in the context of evaluation operator<<(ostream, std::string). The compiler created a temporary value, and then executed the function. This is important because temporary lives outside of it, so the target for Writer is not local to make_string_ok but to operator<<.

Case 2.

Meanwhile, your second example does not fit the criterion (nor the ones omitted for brevity) because the types are different. So the writer dies. It would even die, if it were a part of the pair. So here, a copy of res.first is returned as a temporary object, and then dtor of Writer affects the original res.first, which is about to die itself.

It seems pretty obvious that the copy is made before calling destructors, because the object returned by copy is also destroyed, so you'd not be able to copy it otherwise.

After all it boils down to RVO, because the d'tor of Writer either works on the outside object or on the local one, according to whether the optimization is applied or not.

Does the standard prescribes which value should be returned in the examples above or is it unspecified?

No, the optimization is optional, though it can change the observable behaviour. It's at the compiler's discretion to apply it or not. It's an exempt from the "general as-if" rule which says compiler is allowed to make any transformation which does not change observable behaviour.

A case for it became mandatory in c++17, but not yours. The mandatory one is where the return value is an unnamed temporary.

Freehold answered 22/10, 2018 at 14:12 Comment(3)
This is slightly different - it's the destructor of a different object (the Writer) that has side effects that possibly affect the returned value.Hylo
@TobySpeight Point taken. I have expanded the answer a bit. And bolded "the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same objec" The Writer d'tor works exactly the same, it's just different target object. Also, the order of copy-for-return and apply dtors for local values upon return seems pretty obvious... you can't destroy object that is about to be copied for return.Freehold
@TobySpeight Also, I've noticed that it might be important to stress out that d'tor elision might be smoke and screens. What's important is where the elided copy lives and what is target for Writer.Freehold
D
17

There is a concept in C++ called elision.

Elision takes two seemingly distinct objects and merges their identity and lifetime.

Prior to elision could occur:

  1. When you have a non-parameter variable Foo f; in a function that returned Foo and the return statement was a simple return f;.

  2. When you have an anonymous object being used to construct pretty much any other object.

In all (almost?) cases of #2 are eliminated by the new prvalue rules; elision no longer occurs, because what used to create a temporary object no longer does so. Instead, the construction of the "temporary" is directly bound to the permanent object location.

Now, elision isn't always possible given the ABI that a compiler compiles to. Two common cases where it is possible are known as Return Value Optimization and Named Return Value Optimization.

RVO is the case like this:

Foo func() {
  return Foo(7);
}
Foo foo = func();

where we have a return value Foo(7) which is elided into the value returned, which is then elided into the external variable foo. What appears to be 3 objects (the return value of foo(), the value on the return line, and Foo foo) is actually 1 at runtime.

Prior to the copy/move constructors must exist here, and the elision is optional; in due to the new prvalue rules no copy/move constructr need exist, and there is no option for the compiler, there must be 1 value here.

The other famous case is named return value optimization, NRVO. This is the (1) elision case above.

Foo func() {
  Foo local;
  return local;
}
Foo foo = func();

again, elision can merge the lifetime and identity of of Foo local, the return value from func and Foo foo outside of func.

Even , the second merge (between func's return value and Foo foo) is non-optional (and technically the prvalue returned from func is never an object, just an expression, which is then bound to construct Foo foo), but the first remains optional, and requires a move or copy constructor to exist.

Elision is a rule that can occur even if eliminating those copies, destructions and constructions would have observable side effects; it is not an "as-if" optimization. Instead, it is subtle change away from what a naive person might think C++ code means. Calling it an "optimization" is more than a bit of a misnomer.

The fact it is optional, and that subtle things can break it, is an issue with it.

Foo func(bool b) {
  Foo long_lived;
  long_lived.futz();
  if (b)
  {
    Foo short_lived;
    return short_lived;
  }
  return long_lived;
}

in the above case, while it is legal for a compiler to elide both Foo long_lived and Foo short_lived, implementation issues make it basically impossible, as both objects cannot both have their lifetimes merged with the return value of func; eliding short_lived and long_lived together is not legal, and their lifetimes overlap.

You can still do it under as-if, but only if you can examine and understand all side effects of destructors, constructors and .futz().

Deth answered 22/10, 2018 at 14:12 Comment(3)
Did I understood it correctly: It is NRVO in my case, thus c++17 doesn't guarantee the copy elision. That means, the returned value is actually unspecified, because the compiler is free to apply or not to apply the NRVO?Bebeeru
@Bebeeru Yes, elision is not guaranteed. A compiler could not do it; it would only not do it in your case if you demanded it not be done (with a flag passed to the compiler). It is, however, fragile; add another branch with another named object returned that overlaps in lifetime, and the result of your code would change.Deth
I got confused for a moment when you said second merge. Might want to consider reordering the paragraphs.Reider

© 2022 - 2024 — McMap. All rights reserved.