Продление времени жизни временного объекта в присваивании

#include <iostream>

int destructions;
struct A { ~A() noexcept { ++destructions; } };

int main() {
  {
    const A& a = A{} = A{};
    std::cout << "Destructions in scope:     " << destructions << '\n';
    destructions = 0;
  }
  std::cout << "Destructions out of scope: " << destructions << '\n';
}

Почему данный код возвращает разные результаты на GCC, Clang и MSVC? Кто из них прав в соответствии со стандартом?


Ответы (3 шт):

Автор решения: DmitryK

Попробуйте вместо общего подсчета вызовов деструктора сделать лог конструкторов и деструкторов. Результат может удивить. Например у меня в онлайн-компиляторе создалось 3 объекта, а не 2.

#include <iostream>

int desctructions = 0;
unsigned int count = 0;

struct A 
{   
    unsigned int num;
    A() noexcept {  
        num = count++; 
        std::cout << "Construct " << num << " object\n";
    }
    ~A() noexcept { 
        ++desctructions; 
        std::cout << "Destruct " << num << " object\n";
    } 
};

int main() 
{
  {
    A& a = A{} = A{};
    a.num += 10;
    std::cout << "Destructions in scope1:    " << desctructions << '\n';
    desctructions = 0;
    std::cout << "Destructions in scope2:    " << desctructions << '\n';
  }
  std::cout << "Destructions out of scope: " << desctructions << '\n';
}

Вот теперь видно, когда конструируются и разрушаются объекты.

→ Ссылка
Автор решения: AlexGlebe

Для начала определим какие объекты называются временными.
Категория объектов A{} называется r-value если программа сама создала объект и знает время его жизни. Прикрепление r-value объекта к переменной с приставкой && или const & продлевает это время в блоке, где эта ссылка находится.

Примеры :

A && a = A{};
A const & a = A{};
A fun(){
  ..
}
..
A && y = fun();
A const & z = fun();

Но другие временные объекты, такие как

A && funt(){
  ..
}
..
A && x = std::move(A{});
A && y = funt ();
A const & z = (A{} = A{}) ;

и присваиваются к таким-же типам A && или A const & называются по-другому : x-value (an “eXpiring” value) и имеют очень короткое время жизни. Компилятор не обязан продлевать время жизни таких ссылок, так как этот объект был создан другими участками кода.

То-есть работа с переменными x,y,z прикреплённые к висячим ссылкам к x-value объектам имеют неопределённое поведение. Например функция funt могла возвращать ссылку на статический объект или на временный. И временный объект уже может быть удалён.

В вашем примере оператор присваивания A{} = A{} возвращает ссылку на объект x-value и прикрепление к ссылке на переменную не увеличивает время жизни объекта и ваша ссылка называется висячей. Время жизни данного объекта уже истекло.

Посмотрим стандарт про вызов деструктора :

11.4.7 Destructors [class.dtor]

15 A destructor is invoked implicitly (15.3)

— for a constructed object with automatic storage duration (6.7.5.4) when the block in which an object is created exits (8.8), (15.4)
— for a constructed temporary object when its lifetime ends (7.3.5, 6.7.7).

И он говорит, во что временном объекте должен быть вызван деструктор когда заканчивается его время жизни.

То-есть самый правильный компилятор - это clang, который удаляет оба временных объекта сразу.

→ Ссылка
Автор решения: tocic

clang прав, в соответствии со стандартом два деструктора должны быть вызваны в конце объявления ссылки до первой печати.


Для начала заметим, что сначала выполняется A{} = A{}, а потом результат этого выражения присваивается ссылке. Причём сначала выполняется A{} в правой части присваивания, после — в левой, после — само присваивание.

[expr.ass]/1:

The assignment operator (=) and the compound assignment operators all group right-to-left. ... In all cases, the assignment is sequenced after the value computation of the right and left operands, and before the value computation of the assignment expression. The right operand is sequenced before the left operand. ...

Выражение A{} = A{} эквивалентно A{}.operator=(A{}).

[over.binary]/1:

... For an expression x @ y with subexpressions x and y, the operator function is selected by overload resolution ([over.match.oper]). If a member function is selected, the expression is interpreted as

x . operator @ ( y )

Otherwise, if a non-member function is selected, the expression is interpreted as

operator @ ( x , y )

Т.к. у нашего класса нет явного объявления оператора присваивания, он будет объявлен неявно в виде A& A::operator=(const A&). Что интересно, перемещающий оператор присваивания не будет объявлен из-за наличия пользовательского деструктора, а объявление копирующего оператора присваивания в этом случае является deprecated поведением.

[class.copy.assign]/2:

If the class definition does not explicitly declare a copy assignment operator, one is declared implicitly. If the class definition declares a move constructor or move assignment operator, the implicitly declared copy assignment operator is defined as deleted; otherwise, it is defined as defaulted ([dcl.fct.def]). The latter case is deprecated if the class has a user-declared copy constructor or a user-declared destructor ([depr.impldec]). The implicitly-declared copy assignment operator for a class X will have the form

X& X::operator=(const X&)

if

  • each direct base class B of X has a copy assignment operator whose parameter is of type const B&, const volatile B&, or B, and
  • for all the non-static data members of X that are of a class type M (or array thereof), each such class type has a copy assignment operator whose parameter is of type const M&, const volatile M&, or M.

...

[class.copy.assign]/4.4:

If the definition of a class X does not explicitly declare a move assignment operator, one will be implicitly declared as defaulted if and only if

  • ...
  • X does not have a user-declared destructor.

Вспомним, что оба выражения A{} являются prvalue.

[expr.type.conv]:

A simple-type-specifier or typename-specifier followed by a parenthesized optional expression-list or by a braced-init-list (the initializer) constructs a value of the specified type given the initializer. ...

If the initializer is a parenthesized single expression, the type conversion expression is equivalent to the corresponding cast expression. Otherwise, if the type is cv void and the initializer is () or {} (after pack expansion, if any), the expression is a prvalue of the specified type that performs no initialization. Otherwise, the expression is a prvalue of the specified type whose result object is direct-initialized with the initializer. ...

Теперь понятно, что правое выражение A{} создаёт временный объект, т.к. необходимо выполнить binding ссылки const A& к prvalue A{}.

[class.temporary]/2.1:

... Temporary objects are materialized:

Левое выражение A{} также создаёт временный объект, т.к. необходимо выполнить доступ к функции-члену в A{}.operator=().

[class.temporary]/2.2:

... Temporary objects are materialized:

Для этих двух временных объектов обязаны быть вызваны 2 деструктора, т.к. они нетривиальны.

[class.temporary]/4:

When an implementation introduces a temporary object of a class that has a non-trivial constructor ([class.default.ctor], [class.copy.ctor]), it shall ensure that a constructor is called for the temporary object. Similarly, the destructor shall be called for a temporary with a non-trivial destructor ([class.dtor]). ...

[class.dtor]/8:

A destructor is trivial if it is not user-provided and if:

  • ...

Они должны быть вызваны в конце выражения, в котором содержатся (в вопросе это выражение до первой печати), за исключением трёх ситуаций.

[class.temporary]/4:

... Temporary objects are destroyed as the last step in evaluating the full-expression ([intro.execution]) that (lexically) contains the point where they were created. ...

[class.temporary]/5:

There are three contexts in which temporaries are destroyed at a different point than the end of the full-expression. ...

Из указанных в стандарте трёх ситуаций подозрительна только ситуация с продлением времени жизни временного объекта в случае binding ссылки const A& a к результату присваивания A{} = A{} (правый временный объект уже связан со ссылкой из оператора присваивания).

[class.temporary]/6:

The third context is when a reference is bound to a temporary object. ...

Однако ни один из случаев, когда такой binding может происходить, не подходит в нашей ситуации. Это неочевидно только в первом из 12 случаев (не буду приводить их все для краткости).

[class.temporary]/6.1:

... The temporary object to which the reference is bound or the temporary object that is the complete object of a subobject to which the reference is bound persists for the lifetime of the reference if the glvalue to which the reference is bound was obtained through one of the following:

  • a temporary materialization conversion ([conv.rval]),
  • ...

Но temporary materialization conversion также не срабатывает, потому что это преобразование из prvalue в xvalue, а результат сгенерированного оператора присваивания у нас не является prvalue (ранее было указано объявление A& A::operator=(const A&)).

[conv.rval]/1:

A prvalue of type T can be converted to an xvalue of type T. This conversion initializes a temporary object ([class.temporary]) of type T from the prvalue by evaluating the prvalue with the temporary object as its result object, and produces an xvalue denoting the temporary object. ...


Также можно рассмотреть следующий пример:

struct A { bool x; };

consteval bool foo() {
  const A& a = A{} = A{};
  return a.x;
}

int main() {
  static_assert(foo());
}

note: read of object outside its lifetime is not allowed in a constant expression

return a.x;

То есть временные объекты уже уничтожены после объявления.

Добавление пользовательского оператора присваивания приводит к согласию всех компиляторов с clang.

→ Ссылка