Когда temporary-объекты долго живут

Когда temporary-объекты долго живут

Sehnsucht (https://t.me/cxx95)
Пенсии не хватает, работает на C++

В C++ есть такое понятие как "temporary объекты". Все с ними сталкивались, человеческим языком их появление описывается как "ну функция вернула объект, мы его как-то заиспользовали, но не сохранили в переменную", нечеловеческим языком что-то вроде "when a prvalue is converted to an xvalue".


В обычной ситуации temporary объекты живут столько же, сколько живет "full-expression" где оно создалось, и разрушаются после конца его выполнения. "full-expression" это выражение которое не входит в состав какого-то другого выражения, то есть этакий цельный кусок кода. Там есть и другие свойства, например гарантируется что все сайд-эффекты внутри full-expression выполнятся до того как начнется исполнение следующего full-expression.


Пример:

void foo(const Twine& T);
// ...
foo(Twine(Major) + "." + Minor + "." + VersionPatch);

Здесь "full-expression" это вызов функции foo. В неоптимизированном виде это выглядит как что при подготовке вызова foo на стеке создается 5 объектов:

Twine tmp1 = Twine(Major);
Twine tmp2 = tmp1 + ".";     // aka `Twine(Major) + "."`
Twine tmp3 = tmp2 + Minor;    // aka `Twine(Major) + "." + Minor`
Twine tmp4 = tmp3 + ".";     // aka `Twine(Major) + "." + Minor + "."`
Twine tmp5 = tmp4 + VersionPatch; // aka `Twine(Major) + "." + Minor + "." + VersionPatch`

И пятый объект tmp5 используется в foo. Все tmp-объекты это temporary объекты, они живут пока выполняется foo, и уничтожаются после выполнения.


У этого правила "temporary-объекты живут пока исполняется их full-expression" есть несколько исключений. Они описаны в разделе [class.temporary] стандарта C++.

1й и 2й кейсы - про массивы

Первые два кейса не супер интересные. Первый про, что если мы создаем массив, и объекты в нем создаются с использованием дефолтных конструкторов (т.е. типо Obj obj[3];), и в этих конструкторах есть дефолтный аргумент где создается temporary, то этот temporary дохнет до того, как начинается инициализация следующего элемента массива. Второй кейс аналогичный про копирование массива (т.е. типо Obj obj2[3] = obj;).

3й кейс - Reference Lifetime Extension

Одно из самых лютых правил стандарта. Если инициализировать ссылку (const T&) "временным объектом" (скорее всего rvalue), то этот временный объект не уничтожается как ему было положено, а продолжает жить ровно столько, сколько живет ссылка, пример:

std::string Foo::GetName();
// ...
const std::string& name1 = obj.GetName(); // объект не уничтожается, ссылка не висячая
std::string&& name2 = obj.GetName(); // то же самое

Наверное, самое популярное использование этого правила - дефолтные значения ссылочных аргументов:

void foo(const std::string& s = "default_text");

Это правило супер легко сломать - как только вызовете метод у временного объекта (obj.GetName().data()), или если будет сделан неявный каст, и так далее; в стандарте есть перечисление условий, когда это работает.

4й кейс - Lifetime Extension in Range-Based For Loops (C++23)

Выражение в range-based for loop, которое создает сущность, поверх чего надо итерироваться, называется for‐range‐initializer, и начиная с C++23 все temporary объекты созданные в нем умирают не после этого выражения, а после конца цикла.

Но чтобы понимать, на что именно это повлияло, надо помнить, как для компилятора выглядит такой цикл. Вот такая запись:

std::vector<int> GetInts();
// ...
for (int i : GetInts()) { std::cout << i << " "; }

Работала без висячих лайфтаймов всю дорогу, потому что для компилятора это выглядит так:

{
  auto&& __range = GetInts();
  auto __begin = std::begin(__range);
  auto __end = std::end(__range);
  for (; __begin != __end; ++__begin) {
    int i = *__begin;
    std::cout << i << " ";
  }
}

То есть здесь работал "3й кейс". А "4й кейс" начинает работает, например если мы начинаем засовывать в for-range-initializator temporary объекты, пример:

// создает string_view по одному символу из строки
std::vector<std::string_view> Explode(const string& s);
// ...
for (std::string_view s : Explode("ab" + "oba")) {
  std::cout << s << " ";
}

Здесь "ab" + "oba" это temporary объект, который в C++20 (и раньше) сдыхал бы до начала цикла, и приготовленные std::string_view были бы висячими, указывая на память уже освобожденной строки.


В пропозале к этой фиче больше всего прикалывает лютый пример в конце пропозала с мьютексами и другим бешеным правилом стандарта.

5й и 6й кейсы - template for (C++26)

Эти кейсы относятся к рефлексии (которую, кстати, на момент написания заметки нифига не реализовали в компиляторах... смотрю в cxx_status.html) Они относятся к template for-выражениям и по сути копируют 4й кейс.

7й кейс - structured bindings (C++26)

Сами structured bindings появились еще в C++17, но там была похожая проблема с temporary - вот такая запись:

auto [a, b] = f(X{});

Если функция возвращает std::tuple, до компилятора выглядит примерно так:

auto e = f(X{});
T1 &a = get<0>(std::move(e));
T2 &b = get<1>(std::move(e));

До C++26 объект X{} уничтожался до того как инициализировались a и b, и мы могли иметь зависания, а начиная с C++26 он уничтожается только после инициализации a и b.

Report Page