Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Продление жизни временных значений в С+…

Продление жизни временных значений в С++: рецепты и подводные камни

01 Ноя 2022
Автор:

Прочитав эту статью, вы узнаете следующее: способы, которыми можно продлить время жизни временного объекта в С++; рекомендации и подводные камни этого механизма, с которыми может столкнуться С++ программист, и с которыми сталкивался на работе я.

1006_lifetime_extension_ru/image1.png

Информация из статьи может быть полезна как новичкам, так и профессионалам.

Если заинтересовало, то самое время налить чая, и погнали разбираться, где тут референсы висят.

Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи Евгений Неручек (cheshirwr@mail.ru). Оригинал опубликован на сайте Habr.

Они на деревьях, Джонни!

Для начала давайте обозначим набор проблем, которые вероятнее всего могут встретиться при обращении со ссылками:

  • Висячие ссылки.
  • Всё.

Возможно, в вашем варианте подобного списка будет больше пунктов, но вряд ли они смогут потягаться с первым номером по частоте, с которой вы можете встретить их в продакшене.

Для тех, кто не знает, висячая ссылка (dangling reference) — это ссылка на область памяти, в которой нет "живого" объекта. Это возможно, когда время жизни ссылки дольше, чем время жизни объекта, на который она указывает. Ссылка становится висячей в тот момент, когда компилятор разрушает объект (вызывает деструктор объекта и потом освобождает память, которая объектом занималась), а ссылку ещё нет.

Опасность висячей ссылки в том, что если кто-нибудь в это время воспользуется данной ссылкой, то может произойти всё что угодно, что описывается стандартом как неопределённое поведение или UB.

Неопределённым оно является относительно стандарта, а не вообще, то есть стандарт не описывает, что конкретно должно произойти. На самом же деле это поведение зависит от самой программы и окружения, в котором она запускается. То есть оно зависит от:

  • Реализованного в программе алгоритма.
  • Используемого компилятора. В частности, от поддерживаемых компилятором оптимизаций.
  • Выбранного уровня оптимизации.
  • Имплементации стандартной библиотеки.
  • Операционной системы.

И так далее...

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

Хоть теоретически мы и можем предполагать, что произойдёт в том или ином случае, провоцирующем UB, но что бы вы выбрали: расследовать убийство, или не допускать его? Я считаю, что намного важнее — научиться избегать UB в своих программах, потому что программа с неопределённым поведением — горе для заказчика, а горе заказчика — наше горе.

Один из спонсоров появления висячих ссылок — нюансы механизма продления времени жизни временных объектов. Данный механизм существует с C++03 (продление через константные lvalue ссылки). В C++11 его доработали (добавили rvalue ссылки) и он стал звучать так:

Если вы приняли временный объект по константной lvalue ссылке или rvalue ссылкe, то его время жизни будет продлено до времени жизни ссылки.

Исходя из этого утверждения можно заключить, что продление жизни работает только для временных объектов, на которые указывают ссылки. А ещё, если прибавить к этому определению сохранение по значению, то получается следующий список:

  • создание константной lvalue ссылки на временное значение;
  • создание rvalue ссылки на временное значение;
  • сохранение по значению.

Хотя, с точки зрения языка, сохранение по значению — не механизм продления жизни, поскольку временное значение в таком случае должно скопироваться или переместится (время жизни оригинального временного значения при этом не продлевается). Но, забегая вперёд:

  • Оптимизация copy elision приводит ко внешне похожим эффектам, как при сохранении по ссылке.
  • Сохранение по значению тоже имеет нюансы, связанные с продлением времени жизни через ссылки.

Давайте теперь поговорим про каждый из этих вариантов поподробнее. Но сперва рассмотрим класс Xray, при помощи которого мы будем отслеживать порядок вызова конструкторов и деструкторов.

struct Xray
{
  Xray(std::string value)
    : mValue(std::move(value))
  {
      std::cout << "Xray ctor, value is " << mValue << std::endl;
  }
  
  Xray(Xray&& other)
    : mValue(std::move(other.mValue))
  {
      std::cout << "Xray&& ctor, value is " << mValue << std::endl;
  }
  
  Xray(const Xray& other)
    : mValue(other.mValue)
  {
      std::cout << "Xray const& ctor, value is " << mValue << std::endl;
  }
  
  ~Xray()
  {
      std::cout << "~Xray dtor, value is " << mValue << std::endl;
  }
  
  std::string mValue;
};

1. Способы продления времени жизни и сохранения временных значений

1006_lifetime_extension_ru/image4.png

Для разогрева начнём с самого простого варианта: ситуации, в которой мы вообще не сохраняем временное значение, а только создаем его.

Если мы так сделаем, то компилятор вызовет деструктор в той же строке. Пример на godbolt:

void main()
{
  // Вывод: Xray ctor, value is 1
  // Вывод: Xray dtor, value is 1
  Xray{"1"};
  
  std::cout << "Wait a sec" << std::endl;
}

1.1 Константная lvalue ссылка

Левосторонние ссылки (lvalue references) — ссылки вида: Xray& и const Xray& (тип не обязательно должен быть Xray, можно и любой другой, например int). Левосторонняя ссылка всегда ссылается только на именованные, в некотором смысле постоянные значения (более подробно разберём этот вопрос в сравнении с rvalue ссылками).

Понять, что ссылка левосторонняя можно по её типу (const T&T&), либо же так: значения на которые она ссылается всегда находятся слева от = при объявлении переменной:

int i = 3;
int& iRef = i;

Здесь i — lvalue, поэтому и ссылка на него iRef называется lvalue reference.

Теперь же рассмотрим вариант с созданием константной lvalue ссылки на временный объект:

void main()
{
  // Вывод: Xray ctor, value is 1
  const Xray& xrayRef = Xray{"1"}; 
  
  // Вывод: xrayRef value is 1
  std::cout << "xrayRef value is " << xrayRef.mValue << std::endl;
  
} // Вывод: Xray dtor, value is 1

Всё чисто и просто: мы создаём временное значение при помощи Xray{"1"} и сохраняем константную ссылку на него в xrayRef. После чего временное значение разрушается при выходе из функции main (после достижения потоком исполнения программы конца тела функции — фигурной скобки } ).

Аналогично работает и следующий пример, за исключением того, что теперь при создании временного объекта произойдёт неявное преобразование типа из std::string в Xray:

void main()
{
  // Вывод: Xray ctor, value is 1
  const Xray& xrayRef = std::string("1"); 
} // Вывод: Xray dtor, value is 1

Данное преобразование не является ошибочным потому что в Xray объявлен конструктор, принимающий t std::string, и по умолчанию все конструкторы разрешают неявные преобразования.

При желании мы можем запретить неявное приведение типов, если пометим конструктор Xray(const std::string&) как explicit, в таком случае нам нужно будет явно вызывать конструктор Xray{std::string("1")}.

Плюс такого подхода — нет лишних вызовов конструкторов копирования. Но в этом плане это не единственный способ, обладающий таким плюсом.

1.2 rvalue ссылка

Правосторонняя ссылка, это ссылка вида: Xray&& (тип не обязательно должен быть Xray, можно и любой другой, например int). Данный вид ссылки может указывать только на временные значения. Правосторонней же она называется, потому что временные значения, на которые она указывает, всегда находятся справа от = при объявлении переменной:

int&& iRvalueRef = 3;

Здесь (int)3 — rvalue (временное значение), поэтому и ссылка на него iRvalueRef называется rvalue reference.

Отличия между rvalue и lvalue

Чтобы разобраться в отличиях, давайте рассмотрим пример:

int three = 3; 
int& threeLvalueRef = three;
int&& fourRvalueRef = 4;

Здесь:

1. (int)3 и (int)4 — rvalue, временные значения. У данных значений нет имени, по которому к ним можно обратиться;

2. int three — lvalue. У неё есть имя 'three', по которому к ней можно обратиться;

3. int& threeLvalueRef — lvalue reference. Она ссылается на lvalue three;

4. int&& fourRvalueRef — rvalue reference. Она ссылается на rvalue (int)4.

Более подробно про виды ссылок и их различия также можно прочитать в статьях:

rvalue reference так же можно принять переместив объект (если в классе такого типа реализован конструктор перемещения, как в Xray::Xray(Xray&&)). Для этого нужно вызвать std::move:

Xray xray = Xray{′123″};
Xray&& xrayRef = std::move(xray);

Сам std::move не занимается какой-то магией, он только приводит тип аргумента Xray к типу правосторонней ссылки Xray&&, чтобы таким образом, вызвался конструктор с параметром Xray&&, который называется конструктором перемещения, и в котором программист должен описать логику: какие поля класса нужно переместить и как.

А нужно это, потому что существуют тяжеловесные типы, значения которых лучше перемещать, чем копировать.

Пример: скопировать значение строки Xray::mValue из одного места в другое — не всегда лучший выбор, поскольку это подразумевает:

  • Выделение, обычно, не маленького куска памяти размером mValue.size() байт.
  • Побайтовое копирование значения каждого байта.

Такое копирование может быть очень долгим, ведь строка может быть длинной и в 10000 символов (и больше). Поэтому переместить её значение будет намного быстрее, в таком случае указатель на данные (const char*), хранящийся под капотом std::string, просто будет отдан другому экземпляру std::string, без всяких дополнительных аллокаций памяти и копирования значений байт.

Упрощённая реализация std::move для lvalue значений:

template<typename T>
T&& move(T& value) 
{
return (T&&)value;
}

rvalue ссылка тоже подходит, если нужно ссылаться на временное значение, при этом не будет вызвано никаких дополнительных конструкторов перемещения или копирования:

void main()
{
  // Вывод: Xray ctor, value is 1
  Xray&& xrayRef = Xray{"1"};
} // Вывод: Xray dtor, value is 1

1.3 Сохранение по значению

Сохранение по значению выглядит следующим образом:

void main()
{
  // Вывод: Xray ctor, value is 1
  Xray xray = Xray{"1"}; 
} // Вывод: Xray dtor, value is 1

Возможно, глядя на этот пример у вас возникает вопрос: "Разве не будет вызова копирующего конструктора?".

Дело в том, что благодаря оптимизации copy elision, предотвращающей избыточное копирование, конструктор копирования/перемещения вызван не будет. И более того, даже в таком виде, будет вызван всего 1 конструктор (который создает объект Xray):

void main()
{
  // Вывод: Xray ctor, value is 1
  Xray xray = Xray{Xray{Xray{"1"}}}; 
} // Вывод: Xray dtor, value is 1

В С++ оптимизация copy elision появилась начиная с С++98, но поддерживалась не во всех компиляторах. Когда пришёл С++17, он навел порядок, и, начиная с него, все компиляторы обязаны поддерживать эту оптимизацию.

2. Выведение типов компилятором (type deduction)

1006_lifetime_extension_ru/image5.png

В С++ существуют механизмы, которые позволяют нам самим не определять тип переменной, или определять его только частично. В таком случае компилятор, если это возможно, выведет тип самостоятельно, на основе инициализатора (присваиваемого выражения).

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

Далее, для проверки того какой же именно компилятор вывел тип, я буду использовать бесплатный сервис — cppinsights.

2.1 auto

Начиная с С++11 у нас появилась возможность использовать ключевое слово auto для выведения типа переменной через её инициализатор. Если мы используем его без дополнительных квалификаторов и & (as is), то при выведении типа переменной будут проигнорированы ссылочность и квалификаторы constvolatile. Это означает, что произойдёт сохранение по значению. Смотрите вывод типов на cppinsights:

void main()
{
  // Вывод: Xray ctor, value is 1
  auto xray = Xray{"1"}; // type = Xray
} // Вывод: Xray dtor, value is 1

Смотрите вывод типов на cppinsights:

int i = 0;
int& iRef = i;
const int& iConstRef = i;
volatile int& iVolatileRef = i;
const volatile int& iCVRef = i;
int* iPtr = &i;

auto _1 = i;            // type = int
auto _2 = iRef;         // type = int
auto _3 = iConstRef;    // type = int
auto _4 = iVolatileRef; // type = int
auto _5 = iCVRef;       // type = int
auto _6 = iPtr;         // type = int*

Чтобы добавить квалификаторы и ссылочность (или сохранить их при выводе типа) нужно указать их рядом с auto. При их добавлении в данном случае произойдет продление жизни через константную lvalue ссылку (cppinsights):

void main()
{
  // Вывод: Xray ctor, value is 1
  const auto& xray = Xray{"1"}; // type = const Xray&
} // Вывод: Xray dtor, value is 1

Также есть возможность указать auto&&, тогда механизм вывода будет очень похож на perfect forwarding. При его использовании в данном случае произойдет продление жизни через rvalue ссылку (cppinsights):

void main()
{
  // Вывод: Xray ctor, value is 1
  auto&& xray = Xray{"1"}; // type = Xray&&
} // Вывод: Xray dtor, value is 1

Чтобы узнать, что такое perfect forwarding смотрите п.2.4 template.

Смотрите вывод типов на cppinsights:

int i = 0;
const int& iConstRef = i;

auto&& _ = i;           // type = int&
auto&& _1 = iConstRef;  // type = const int&
auto&& _2 = 4;          // type = int&&

2.2 decltype

С++11 принёс нам ключевое слово decltype, которое позволяет получить тип переданного ему выражения в compile time. Примеры вывода типов с использованием decltype на cppinsights:

int i = 0;
const int& iConstRef = i;
int&& iRvalueRef = 1;

decltype(i) _1 = i;                              // type = int
decltype(iConstRef ) _2 = iConstRef;             // type = const int&
decltype(iRvalueRef) _3 = std::move(iRvalueRef); // type = int&&
decltype(3) _4 = 3;                              // type = int

При его использовании в данном случае произойдет сохранение по значению:

void main()
{
  // Вывод: Xray ctor, value is 1
  decltype(Xray{"1"}) xray = Xray{"1"}; // type = Xray
} // Вывод: Xray dtor, value is 1

Если вам при этом нужно вывести тип объекта, у которого нет нужного конструктора (в частности конструктора по умолчанию), то можно воспользоваться std::declval:

decltype(std::declval<Xray>()) xray = Xray{"1"}; // type = Xray&&

То, что вычисление типа происходит в compile time означает, что переданное в decltype выражение вычисляется не во время исполнения программы, а во время компиляции. Компилятор только смотрит на то какой тип получается в результате выражения и подставляет его.

Пример: В выражении decltype(2+2) не будет вычисляться результат сложения 2+2, компилятор будет рассматривать это выражение только с точки зрения типов: (int)+(int), результат — int.

При этом вы вероятно заметили, что использовать decltype в таком виде неудобно:

1. Появляется много лишней информации, которая полностью или частично дублирует присваиваемое выражение.

2. Иногда приходится использовать воркэраунды (вроде std::declval) чтобы вывести тип.

Видимо по этим причинам в следующем стандарте этот механизм доработали и выдали нам decltype(auto).

2.3 decltype(auto)

Начиная с С++14 у нас появилась возможность передавать как параметр в decltype ключевое слово autodecltype(auto) позволяет вывести ровно такой-же тип, как у присваиваемого выражения, то есть ссылочность и квалификаторы при таком выводе будут сохранены.

Примеры вывода типов с использованием decltype(auto) на cppinsights:

int i = 2;
const int& iConstRef = 0;

decltype(auto) _1 = 1;            // type = int
decltype(auto) _2 = iConstRef;    // type = const int&
decltype(auto) _3 = std::move(i); // type = int&&

При его использовании в данном случае произойдёт сохранение по значению (cppinsights):

void main()
{
  // Вывод: Xray ctor, value is 1
  decltype(auto) xray = Xray{"1"}; // type = Xray
} // Вывод: Xray dtor, value is 1

2.4 template

Выведение типа через шаблон очень похоже на выведение типа через auto, и наоборот.

Если мы укажем тип шаблона T (из template<typename T>) без дополнительных квалификаторов и (as is), то при выведении типа шаблонного аргумента функции будут проигнорированы ссылочность и квалификаторы constvolatile. Это означает, что произойдёт сохранение по значению (cppinsights в данном случае показывает все типы, с которыми был инстанцирован шаблон):

template<typename T> 
void foo(T param)
{}

void main()
{
  // Вывод: Xray ctor, value is 1
  foo(Xray{"1"}); // type = Xray
} // Вывод: Xray dtor, value is 1

Смотрите вывод типов на cppinsights:

template<typename T> 
void foo(T param)
{}

int i = 0;
int& iRef = i;
const int& iConstRef = i;
volatile int& iVolatileRef = i;
const volatile int& iCVRef = i;
int* iPtr = &i;

foo(i);            // type = int
foo(iRef);         // type = int
foo(iConstRef);    // type = int
foo(iVolatileRef); // type = int
foo(iCVRef);       // type = int
foo(iPtr);         // type = int*

Чтобы добавить квалификаторы или ссылочность (или сохранить их при выводе типа) нужно указать их рядом с именем параметра шаблона.

В данном случае при этом произойдет продление жизни через константную lvalue ссылку (cppinsights):

template<typename T> 
void foo(const T& param)
{}

void main()
{
  // Вывод: Xray ctor, value is 1
  foo(Xray{"1"}); // type = const Xray&
} // Вывод: Xray dtor, value is 1

Также есть возможность указать T&& (идеальную ссылку), механизм вывода типа и передачи значения через которую называется perfect forwarding.

В данном случае произойдет продление жизни через rvalue ссылку (cppinsights):

template<typename T> 
void foo(T&& param)
{}
void main()
{
  // Вывод: Xray ctor, value is 1
  foo(Xray{"1"}); // type = Xray&&
} // Вывод: Xray dtor, value is 1

Смотрите вывод типов на cppinsights:

template<typename T> 
void foo(T&& param)
{}

int i = 0;
const int& iConstRef = i;

foo(i);            // type = int&
foo(4);            // type = int&&
foo(std::move(i)); // type = int&&
foo(iConstRef);    // type = const int&

Более подробное рассмотрение шаблонов и их отличий от других способов выведения типов выходит из рамок данной статьи, но вы можете ознакомиться с некоторыми статьями:

3. Рекомендации и подводные камни

3.1 Прежде чем объявить ссылку на другую ссылку убедитесь, что последняя не указывает на временный объект

Возврат ссылки из функции не продлевает время жизни, с чем связана одна из самых распространенных проблем у начинающих — они возвращают ссылку на временное значение из функции. Смотрите вывод типов на cppinsights:

const Xray& foo() 
{ 
  return Xray(′′1′′); 
}

// Все примеры ниже — неверные. Время жизни не будет продлено.
const Xray& _1 = foo(); // Висячая ссылка

auto _2 = foo(); // Тип Xray. Значение с неопределённым
                 // содержимым в Xray::mValue.
const auto& _3 = foo(); // Тип const Xray&, висячая ссылка
auto&& _4 = foo();      // Тип const Xray&, висячая ссылка
  
decltype(auto) _5 = foo();  // Тип const Xray&, висячая ссылка
decltype(foo()) _6 = foo(); // Тип const Xray&, висячая ссылка

В данном случае временный объект разрушится перед выходом из функции, поэтому возвращаемая ссылка будет висячей. В связи с этим компилятор даже выдаст предупреждение (запуск программы на godbolt):

warning: returning reference to local temporary object

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

3.2 Не используйте std::move там где может использоваться NRVO

NRVO (оптимизация именованного возвращаемого значения) — одна из форм copy elision, которая позволяет не копировать и не перемещать именованное значение при возврате его из функции.

В данном примере str — именованное значение (у него есть имя "str"), и если компилятор умеет делать NRVO, то не будет ни копирования, ни перемещения, объект прямо поместится в value:

std::string foo() 
{
    std::string str;
    // .. изменение str
    return str; 
}

std::string value = foo();

Так же существует оптимизация RVO (оптимизация возвращаемого значения), это более простая форма той же оптимизации, когда мы не даём возвращаемому значению имя.

В данном примере std::string{"1"} — неименованное значение (у него нет имени, по сравнению с str из предыдущего примера), и если компилятор умеет делать RVO, то не будет ни копирования ни перемещения, объект прямо поместится в value:

std::string foo() 
{
    return std::string{"1"}; 
}

std::string value = foo();

Если хотите узнать больше про NRVO и RVO, то можете изучить статью @BykoIanko RVO и NRVO в C++17.

В целях оптимизации производительности иногда может возникать желание написать так:

std::string&& foo() 
{
    std::string str;
    // .. изменение str
    return std::move(str); 
}

В данном примере проблема в том, что возвращенная ссылка будет висячей, поскольку она будет указывать на локальный объект, который разрушится при выходе из функции. В связи с этим компилятор даже выдаст предупреждение, но вы можете его не заметить среди сотен других (запуск программы на godbolt):

warning: returning address of local variable or temporary: str

Если вы сильно переживаете за то, что не сработает NRVO (но вероятнее всего оно сработает, почти все современные компиляторы уже умеют его делать), то лучше возвращать по значению:

std::string foo() 
{
    std::string str;
    // .. изменение str
    return std::move(str);
}

А ещё лучше — положиться на NRVO и не делать move.

3.3 Прежде чем продлевать время жизни убедитесь, что значение не является xvalue (Xray&&)

В C++03 время жизни временных объектов продлевалось при сохранении его по константной ссылке. Начиная с C++11 появились xvalue у которых время жизни объекта продлить нельзя. Разбор всех категорий значений выходит за рамки этой статьи, но чтобы говорить более предметно, я уточню: существуют несколько категорий значений вроде prvalue (то что мы до этого рассматривали как rvalue), lvalue, xvalue.

В данном случае нас интересуют xvalue. В перегрузках функций, xvalue ведут себя как rvalue, то есть xvalue будет передано в функцию как правосторонняя ссылка T&& (если такая перегрузка есть).

Пример: если у класса есть перемещающий конструктор, то будет вызван он, а не конструктор копирования (который бы был вызван в случае передачи lvalue):

Xray& lvalue();
Xray prvalue();
Xray&& xvalue();

Xray _1 = lvalue();  // Копирование Xray(Xray)
Xray _2 = prvalue(); // copy elision
Xray _3 = xvalue();  // Перемещение Xray(Xray&&)

Время жизни xvalue нельзя продлить (как и lvalue). Попытка сделать это приведёт к висячим ссылкам:

Xray const& _1 = prvalue(); // время жизни как у ссылки
Xray&& _2 = prvalue();      // время жизни как у ссылки
    
Xray& _3 = lvalue();       // висячая ссылка, не продлевает время жизни
const Xray& _4 = lvalue(); // висячая ссылка, не продлевает время жизни
Xray&& _5 = xvalue();      // висячая ссылка, не продлевает время жизни
const Xray& _6 = xvalue(); // висячая ссылка, не продлевает время жизни

3.4 При создании RAII объекта всегда сохраняйте его значение в переменную, либо же объявляйте ссылку на него

Нужно быть внимательным ко времени жизни объекта, если он является RAII оберткой. Например, не стоит объявлять std::lock_guard временным не сохранив ссылку на него (или не сохранив по значению), потому что это приведёт к преждевременному освобождению мьютекса, а значит появятся гонки:

void main()
{
  // Так можно и нужно
  std::lock_guard<std::mutex> lock{someMutex};
  
  /* Так нельзя:
     std::lock_guard<std::mutex>{mutex};
     Потому что компилятор разрушит lock_guard ещё до выхода из main,
 что приведёт к гонкам (собственно, к тому, от чего мы и защищаемся мьютексом)
  */ 

  // .. многопоточно безопасные вызовы
  // .. изменение защищенных мьютексом значений
}

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

3.5 Прежде чем объявить ссылку на другую ссылку убедитесь, что последняя не указывает на временный объект

Продлевать жизнь временного объекта можно лишь один раз — при первой привязке к ссылке. Схема вроде &-указывает на>&-указывает на>временный объект не продлевает время жизни повторно, а приводит к висячей ссылке и неопределённому поведению при её использовании:

template<class T> 
const T& foo(const T& in) 
{ 
  return in; 
}

const Xray& ref1 = X(1); // Верно, время жизни будет продлено.

Xray& ref2 = foo(X(2)); // Неверно, время жизни не будет продлено, 
                        // ref2 — висячая ссылка.
std::cout << ref2.mValue; // Неопределённое поведение

3.6 Не продлевайте жизнь через тернарный оператор ?:

При сохранении ссылки на выражение, полученное через тернарный оператор, будет продлено время жизни одного из временных значений, в зависимости от условия:

Xray&& rvalRef = cond 
                ? Xray{′′1′′}  // Один из временных объектов
                : Xray{′′2′′}; // будет иметь время жизни rvalRef

const Xray& constLvalRef = cond 
                ? Xray{′′1′′}  // Один из временных объектов
                : Xray{′′2′′}; // будет иметь время жизни constLvalRef

Данный механизм не интуитивен, поэтому я не рекомендую использовать его в вашем коде.

3.7 Не используйте ссылки в полях классов (особенно если они указывают на временные объекты) и не используйте std::reference_wrapper для продления жизни

Во-первых, создание ссылок в полях класса на объекты, временем жизни которых класс не управляет, с большей вероятностью (по сравнению с передачей по значению) может привести к висячим ссылкам.

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

struct X 
{ 
  const int& lvalRef; 
};

const X& lvalRef = X{1}; // Временные значения X и (int)1
                         // будут иметь время жизни lvalRef 
X&& rvalRef = X{1};      // Временные значения X и (int)1
                         // будут иметь время жизни rvalRef 

auto&& _1 = X{1};         // Тоже ок, тип Xray&& 
                          // (продление по rvalue ссылке)
decltype(auto) _2 = X{1}; // Тоже ок, тип Xray 
                          // (сохранение по значению)

Но похоже, что этот трюк срабатывает только если у вас aggregate-initialization (в случае вызова Xray x{1}) или если компилятор поддерживает copy elision (в случае вызова Xray x = Xray{1}). Возвращаясь к нашему примеру, добавив конструктор для инициализации нашего значения, он начинает вести себя нестабильно на разных компиляторах:

struct X 
{ 
  template<typename T>
  X(T&& l)
    : val(l)
  {}
  
const int& val;
};

const X& lvalRef = X{1}; // Висячая ссылка, значение lvalRef.val == 0
X&& rvalRef = X{1};      // Висячая ссылка, значение rvalRef .val == 0
auto&& _1 = X{1};        // Висячая ссылка, значение _1 .val == 0, 
                         // тип X&& (продление по rvalue ссылке)
decltype(auto) _2 = X{1};  // Тип X (сохранение по значению),
                           // На msvc(trunk) значение _2 .val == 1,
                           // но на gcc(trunk) это висячая ссылка,  
                           // значение _2 .val == 0

Наиболее интересной выглядит часть decltype(auto) _2 = X{1}, которая компилируется в X _2 = X{1} и её результаты. Исходная формулировка, которая описывает выражения у которых расширяется время жизни несколько туманна, и по ней не до конца понятен весь список ситуаций, которые имеются в виду:

the initializer expression is used to initialize the destination object

Но я предполагаю, что в случае decltype(auto) _2 =  Xray{1} время жизни должно быть продлено, потому что временный объект используется в выражении, которое является инициализатором поля Xray. Поэтому думаю, что то, что время жизни не продлевается — баг компилятора.

В связи с вышеописанным я не рекомендую использовать ссылки на временные значения в полях класса, поскольку этот механизм:

  • Работает нестабильно в зависимости от компилятора;
  • Имеет туманную формулировку, на основе которой приходится строить догадки.

3.8 При передаче в new временных значений, убедитесь, что ни одно из них не сохраняется по ссылке

Временные объекты, переданные как параметры при инициализации в new, будут жить до тех пор, пока не закончится вызов new. Это означает, что не стоит сохранять ссылки на временные значения в полях класса, который создается через new, поскольку это приведет к висячим ссылкам:

struct S 
{ 
  int i; const std::pair<int,int>& pair; 
};

S a { 1, {2,3} };         // верно, хоть и работает нестабильно (см. п.3.7)
S* p = new S{ 1, {2,3} }; // неверно, p->pair — висячая ссылка

3.9 Не продлевайте время жизни временного массива через ссылку на его элементы

В С++ есть возможность продлить время жизни временного массива, создав ссылку на один из его элементов. Я рекомендую использовать этот механизм только в полемике на кухне с коллегами (и, возможно, в метапрограммировании на старых стандартах С++), поскольку он интуитивно не понятен.

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

int id = 0;
int&& a = int[2]{1, 2}[id];

К нашему счастью, код в таком виде не скомпилируется. Чтобы он заработал нужно будет создавать массив чуть менее очевидным способом (cppinsights):

template<typename T>
using dummy = T;

int main()
{
  int i = 1;
  const int& a = dummy<int[2]>{1, 2}[i]; // тип const int&
}

При этом можно так же продлить жизнь через rvalue ссылку:

int&& a = dummy<int[3]>{1, 2, 3}[i];

Хотя пример с rvalue ссылкой уже ведёт себя нестабильно на разных компиляторах:

На gcc всё работает как надо (godbolt).

А на msvc ошибка компиляции cannot convert from 'int' to 'int &&' (godbolt).

Судя по всему, это баг msvc. Но мы можем обойти его если будем принимать значение через auto&& (godboltcppinsights):

auto&& a = dummy<int[3]>{1, 2, 3}[i]; // тип int&&

Заключение

Сохранение значения по константной ссылке создает смешанные ощущения у каждого, кто хотя-бы раз сталкивался с висячими ссылками. Контроль времени жизни — важная штука, которую лучше не перекладывать на компилятор.

Сжальтесь над программистом, который будет читать то, что вы написали, он может не знать всех этих нюансов. Да даже если он где-то про них и слышал и даже знает некоторые из них, то ему может не хватить концентрации чтобы уследить за каждым. Вероятнее всего даже самый образованный специалист, читающий ваш код, через раз всё равно будет сомневаться не висячая ли это ссылка.

Большинство данных механизмов и связанных с ними подводных камней стоит использовать только в теоретических изысканиях и операциях по обезвреживанию опасного кода.

Избегайте остроумия и HolyHandGrenade, всем KISS.

1006_lifetime_extension_ru/image6.png

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам