By ead


2018-10-22 13:53:22 8 Comments

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?

3 comments

@luk32 2018-10-22 14:12:26

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.

@Toby Speight 2018-10-22 19:35:24

This is slightly different - it's the destructor of a different object (the Writer) that has side effects that possibly affect the returned value.

@luk32 2018-10-23 08:54:53

@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.

@luk32 2018-10-23 09:22:16

@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.

@Shloim 2018-10-22 14:00:19

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.

@ead 2018-10-22 14:04:06

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.

@Shloim 2018-10-22 14:11:50

added a quote of the standard

@Konrad Rudolph 2018-10-22 16:13:33

“destructor” in your first sentence is supposed to be “copy constructor”, isn’t it? Otherwise I think the answer makes no sense.

@tomsmeding 2018-10-22 19:24:50

@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).

@Konrad Rudolph 2018-10-22 20:30:25

@tomsmeding 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).

@Shloim 2018-10-23 06:30:27

@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.

@Shloim 2018-10-23 06:31:28

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.

@ead 2018-10-23 08:37:17

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.

@Shloim 2018-10-23 08:44:01

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.

@luk32 2018-10-23 09:18:46

@ead 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).

@ead 2018-10-23 09:23:08

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".

@Konrad Rudolph 2018-10-23 09:25:09

@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.

@ead 2018-10-23 09:27:50

@luk32 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.

@luk32 2018-10-23 10:28:40

@ead 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.

@Shloim 2018-10-23 10:39:23

@ead, I've added the passage from the C++ standard regarding RVO optimizations

@Yakk - Adam Nevraumont 2018-10-22 14:12:14

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().

@ead 2018-10-22 18:59:37

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?

@Yakk - Adam Nevraumont 2018-10-22 19:08:31

@ead 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.

@Passer By 2018-10-23 09:58:54

I got confused for a moment when you said second merge. Might want to consider reordering the paragraphs.

Related Questions

Sponsored Content

22 Answered Questions

[SOLVED] What is the "-->" operator in C++?

37 Answered Questions

27 Answered Questions

[SOLVED] Easiest way to convert int to string in C++

16 Answered Questions

[SOLVED] How can I profile C++ code running on Linux?

  • 2008-12-17 20:29:24
  • Gabriel Isenberg
  • 490985 View
  • 1729 Score
  • 16 Answer
  • Tags:   c++ unix profiling

13 Answered Questions

[SOLVED] What is the effect of extern "C" in C++?

1 Answered Questions

[SOLVED] The Definitive C++ Book Guide and List

  • 2008-12-23 05:23:56
  • grepsedawk
  • 2239719 View
  • 4246 Score
  • 1 Answer
  • Tags:   c++ c++-faq

10 Answered Questions

17 Answered Questions

[SOLVED] What should main() return in C and C++?

25 Answered Questions

[SOLVED] Why do we need virtual functions in C++?

Sponsored Content