Когда temporary-объекты долго живут
Sehnsucht (https://t.me/cxx95)
В 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.