0

After a function is called, when the local (non-static) objects will be destroyed has been vague to me, especially after C++17 where prvalue is redefined. So I decide to ask this question.


(In C++14 and earlier)

Assume there is no optimization, consider the following toy code:

class Y
{
public:
    ~Y() { cout << "quitting f()\n"; }
};

class X
{
public:
    X& operator=(const X& x)
    {
        cout << "assignment\n";
        return *this;
    }
};

X f()
{
    X x;
    Y y;
    return x;
}

int main()
{
    X x;
    x = f();
}

The outputs are as follows:

quitting f()
assignment

Question 1:

In my current understanding, the local variables of f() are destroyed immediately after the returned temporary is created. May I ask if this is true?


(In C++17 and newer)

Consider the following toy code:

class Z { };

class Ya
{
public:
    ~Ya() { cout << "quitting f1()\n"; }
};

class Yb
{
public:
    ~Yb() { cout << "quitting f2()\n"; }
};

class X
{
public:
    X() {}
    X(Z z) { cout << "constructing X\n"; }

    X& operator=(const X& x)
    {
        cout << "assignment\n";
        return *this;
    }
};

X f1()
{
    Z z;
    Ya y;
    return X(z);
}

X f2()
{
    Yb y;
    return f1();
}

int main()
{
    X x;
    x = f2();
}

The outputs are as follows:

constructing X
quitting f1()
quitting f2()
assignment

In my current understanding, X(z) is a prvalue which is used to initialize the prvalue represented by f1(), which is then used to initialize the prvalue represented by f2(). The prvalue represented by f2() is then materialized into a temporary which is then used in the copy assignment.

If my understanding to question 1 is correct, I would guess that the local variables z and y are destroyed immediately after the initialization of the prvalue represented by f1(). Assume this is true, there is a problem: before the prvalue represented by f2() is materialized, there is NO object constructed from X(z) exists, so how could the materialized temporary be created from X(z) at the point when z is already destroyed?

Question 2:

As a result, my guess is that the local variables of f1() are destroyed after the prvalue represented by f2() is materialized (or if the prvalue is used to initialize a variable, had we written X x = f2(); instead). May I ask if this is true?

CPPL
  • 726
  • 1
  • 10
  • 2
    At the end of the scope it's defined in. The next matching } – Pepijn Kramer Jul 22 '22 at 05:28
  • @PepijnKramer Thank you for your comment. I think then my question is down to how prvalue works so that all the prvalue passing stuffs are done before the evaluation of the `return` statement is finished? Sorry I'm not even sure if this is a well-formed question... I don't understand prvalue well... – CPPL Jul 22 '22 at 05:44
  • My understanding is that local variables on the stack are always destroyed after reaching the end of a function in the reverse order that they are declared. Use gdb to convince yourself, you can set break points at the destructors of interest. – CraigDavid Jul 22 '22 at 07:21

1 Answers1

0

Your question can be answered with a slightly more detailed example. Here is code that traces when every object is constructed and destroyed:

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <type_traits>

// Compile-time string suitable as a non-type template argument.
template<size_t N>
struct fixed_string {
    char value[N];
    consteval fixed_string(const char (&str)[N]) { std::copy_n(str, N, value); }
};

template<fixed_string Name>
class tracer {
  std::ostream &out() const { return std::cout << Name.value << " "; }
public:
  tracer() { out() << "default constructed\n"; }
  tracer(const tracer &) { out() << "copy constructed\n"; }
  tracer(tracer &&) { out() << "move constructed\n"; }
  template<typename T> tracer(T t) {
    out() << "template constructed [" << typeid(T).name() << "]\n";
  }
  ~tracer() { out() << "destroyed\n"; }
  tracer &operator=(const tracer &) {
    out() << "copy assigned\n"; return *this;
  }
  tracer &operator=(tracer &&) { out() << "move assigned\n"; return *this; }
};

tracer<"X">
f1()
{
  tracer<"Z"> z;
  tracer<"==== f1 temp"> y;
  return {z};
}

tracer<"X">
f2()
{
  tracer<"==== f2 temp"> y;
  return f1();
}

int
main()
{
  tracer<"X"> x = f2();
}

The output (with C++20) is as follows:

==== f2 temp default constructed
Z default constructed
==== f1 temp default constructed
Z copy constructed
X template constructed [6tracerIXtl12fixed_stringILm2EEtlA2_cLc90EEEEE]
Z destroyed
==== f1 temp destroyed
Z destroyed
==== f2 temp destroyed
X destroyed

So you can see that only one X is ever created, but that X is created before the Z it is constructed from is destroyed. How does that happen? Well, in the implementation you can think of the location in which to construct the X being passed as a kind of implicit argument to the functions that return a prvalue, so that the X is constructed inside f2, but it is located in main's stack.

Your example is slightly more complicated, but the same phenomenon is happening:

int
main()
{
  tracer<"X"> x;
  x = f2();
}

Produces the following output:

X default constructed
==== f2 temp default constructed
Z default constructed
==== f1 temp default constructed
Z copy constructed
X template constructed [6tracerIXtl12fixed_stringILm2EEtlA2_cLc90EEEEE]
Z destroyed
==== f1 temp destroyed
Z destroyed
==== f2 temp destroyed
X move assigned
X destroyed
X destroyed

So now obviously we have two X's, the one called x, and the temporary one that is materialized to pass into x's move assignment operator. However, the temporary X is created on main's stack, and its lifetime is the full expression, so if there were more code in main, the temporary X would be destroyed at the semicolon after the assignment, rather than at main's closing brace.

user3188445
  • 4,062
  • 16
  • 26