- А можно нам lifetime checker?
- У нас есть lifetime checker дома
Lifetime checker дома:
Собственно, этому и посвящена данная заметка. В C++, начиная с 11 стандарта, есть механизмы, позволяющие немного снизить вероятность влететь в проблему с обращением по ссылкам к уже мертвым объектам
Начнем издалека, чтобы обозначить суть проблемы, которая намного ближе, чем может казаться.
В прекрасном C никаких ссылок не было. И чтобы изменить значение аргумента функции используется передача значения по указателю. Так милую функцию swap
писали примерно следующим образом:
void swap(int* x, int* y) {
// возможно, проверки на NULL
int tmp = *x;
*x = *y;
*y = tmp;
}
Все здорово, все прекрасно, никаким образом временное значение в аргументы передать нельзя. Его надо сначала сохранить в переменную, а потом уже брать на нее указатель.
int x = 5; int y = 6;
swap(&x, &y); // ок
swap(&x, &6); // compilation error
В C++ появились ссылки. И символов стало чуть поменьше. И проверять NULL не надо.
void swap(int& x, int& y) {
int tmp = x;
x = y;
y = tmp;
}
И тоже все было здорово. Временное значение также не подсунешь (если только -fpermissive не включать)
int x = 5; int y = 6;
swap(x, y); // ок
swap(x, 6); // compilation error
И вроде ничего нового. Но указатели в C использовались не только для того, чтобы что-то поменять, но еще и для того, чтобы не копировать что-то тяжелое. Указатели на const данные:
struct BigStruct {
char data[1024];
};
void process(const struct BigStruct* arg) {...}
И опять в функцию никак нельзя передать временное значение -- от него просто нельзя взять адрес. Сохраняй в переменную.
struct BigStruct calc_data() {...};
process(&calc_data()); // compilation error
struct BigStruct arg = calc_data();
process(&arg); // ok
В C++ также можно использовать ссылки, чтобы просто избежать копирования.
void process(const BigStruct& arg) {...}
...
BigStruct arg = calc_data();
process(arg);
Но тут появилось важное и очень страшное отличие -- через ссылку на const можно передать временное значение напрямую. Не создавая вспомогательную переменную:
process(calc_data());
Более того, мы даже можем объявлять ссылку на const, сразу присваивая ей временный объект:
const BigStruct& arg1 = calc_data(); // ok
BigStruct& arg2 = calc_data(); // compilation error
Говорят, что const-ссылка продливает жизнь временному объекту. Или материализовывает временный объект.
И казалось бы, логично. Со временным значением же действительно можно работать только как с константой. А хочешь не как с константой -- дай ему имя. Стало круто, удобно, нужно меньше писать. Не надо вводить имен для переменных, используемых лишь в следующей строчке.
Однако это особенное свойство ссылок на const проложило дорогу в Ад. А изменившееся значение ключевого слова auto
, принесенное C++11
, распахнуло врата этого Ада, и в него начали со свистом проваливаться.
Очень часто, чтобы было наиболее эффективно, мы напишем:
const auto& value = func(...);
Если функция возвращает ссылку - у нас не будет копирования.
Если возвращает значение, то особенность const-ссылок продлит ему жизнь. Мы убили двух зайцев.
Особенно хорошо, если мы пишем тело шаблона, в котором заранее не известно, что возвращает func
.
Но что если в качестве такой функции будет выступать безобидный std::min
?
Например, как-то так std::min(x, 10)
;
int x = ...;
...
const auto& value = std::min(x, 10);
Для x < 10
все точно хорошо. А вот для других значений, на одном из компиляторов, меняя параметры оптимизации, мы внезапно обнаруживаем, что происходит что-то неадекватное
В чем же проблема? Посмотрим на сигнатуру функции std::min
:
template< class T >
constexpr const T& min( const T& a, const T& b );
Оба параметра принимаются по const-ссылке! Второй аргумент, 10, -- временное значение (а если быть точным это prvalue
, но об этом потом). Константная ссылка в аргументе продлит ему жизнь.
И функция возвращает ссылку. И мы принимаем результат в константную ссылку. Которая как бы продливает жизнь... Или нет...
Константная ссылка продливает жизнь временному объекту только один раз! Вернее, только первая встреченная на пути у временного объекта ссылка продливает ему жизнь. Материализует временный объект. Все последующие ссылки работают с ним уже не как со временным, а вполне себе нормальным объектом, у которого есть адресс (lvalue).
Поэтому при выходе из функции std::min
при достижении ;
, временное значение 10 умирает, и ссылка, которую мы сохранили в value
инвалидируется. Становится висячей (dangling).
Возникает вопрос: а кто виноват? Такая простая и очевидная функция таит в себе опасность? Или это просто ошибка пользователя этой функции?
Функия std::min
появилась задолго до C++11. И сейчас ее изменять, скорее всего, поздно. Ее проблема, приводящая к неожиданной боли пользователя, заключается во всепоглощающей const-ссылке. В нее можно запихнуть все что угодно.
В C++11
появился новый тип ссылок -- rvalue-ссылки. И с их появлением видоизменилась простая и понятная классификация:
- lvalue -- значения, у которых можно брать адрес, и которые могут стоять слева от операциц
=
. - rvalue -- значения, которые могут стоять только справа от операции
=
.
Мы не будем рассматривать семантику перемещения, потому можем ограничиться старой классификацией. Нам важно сейчас то, что появившиеся в C++11 rvalue-ссылки позволяют отличить временные объекты от невременных.
int x = 15;
int& lvalue = x; // ok
int&& rvalue1 = x; // compilation error
int&& rvalue2 = 15; // ok
И мы, разрабатывая свои собственные функции, может использовать эту информацию, чтобы уберечь себя и пользователей наших функций от возможных проблем.
Если бы функция std::min
писалась сейчас, она бы могла бы предусмотреть варианты:
- Если оба аргумента lvalue, то можно вернуть ссылку
- Если хотя бы один аргумент rvalue, то никакой ссылки -- возвращай копию.
Функция была бы безопаснее!
Вопрос: Как же сделать эту проверку? Ответ: Используя перегрузку функций. Стандарт гарантирует, что если есть перегрузка и для rvalue, и для lvalue, то будет выбираться наиболее подходящая.
Мы бы могли определить четыре перегрузки
const int& min(const int&, const int&);
int min(int&&, int&&);
int min(int&&, const int&);
int min(const int&, int&&);
И радоваться. Но что если аргументов будет больше?
Да и std::min
-- это шаблон. Он работает с любым типом. Нам бы хотелось, чтобы наша безопасная версия также работала со всеми типами.
Решение есть. И заключается оно в использовании особенности обработки ссылок в контектсе шаблонов. Какие особенности и почему они возникают?
Начнем с того, что ссылка в C++ -- это не настоящий объект. Нельзя присвоить ссылке другой адрес. И самое главное нельзя взять ссылку на ссылку. А раз нельзя, то возникает вопрос, а что делать вот в таком случае:
template <class T>
void fun(T& x) {...};
...
int y = 5;
fun<int&>(y); // ???
При явной подстановке int&
вместо T
у нас как раз получается попытка взять ссылку от ссылки. Что не возможно.
Но тем не менее этот код валиден.
Решили проблему взытия ссылок на ссылки в шаблонах, введя правило сжатия ссылок (reference collapsing)
Суть правила: остается только одна ссылка и lvalue всегда побеждает. То есть:
T&& && -> T&&
T&& & -> T&
T& && -> T&
T& & -> T&
Есть еще некоторые неожиданные нюансы при наличии const (который может отвалиться!), но нам они сейчас не интересны.
Благодаря правилу сжатия ссылок, запись T&&
в контексте шаблона (T - параметр шаблона) получает интересные свойства:
Если передадут lvalue, то агрумент функции станет lvalue-ссылкой. Если rvalue, то будет rvalue-ссылка.
То есть сохраняется изначальная суть объекта. И эту информацию можно передать дальше. Потому такие ссылки (в контексте шаблона) называют forwarding. Или universal. Но forwarding подходит лучше.
И как же теперь реализовать безопасный std::min
?
Будем пользоваться C++17, чтобы не писать жуткий SFINAE-выбор реализации, а воспользоваться constexpr if
и автовыводом возвращаемого значения.
#include <type_traits> // предикаты над типами
namespace safe_std {
template<class T1,
class T2, // нам нужно 2 параметра. По одному на каждую ссылку.
class T1_pure = std::decay_t<T1>, // отбрасываем все ссылки и const
class T2_pure = std::decay_t<T2>, // чтобы проверить совпадение типов
class = std::enable_if_t<std::is_same_v<T1_pure, T2_pure>> // как в std:: версии,
> // типы аргументов должны быть одинаковыми
decltype(auto) // будем полностью автоматически выводить тип возвращаемого значения
min(T1&& x, T2&& y) {
using X_type = decltype(x); // узнаем, какая ссылка вывелась
using Y_type = decltype(y); // узнаем, какая ссылка вывелась
if constexpr (
std::is_lvalue_reference_v<X_type> &&
std::is_lvalue_reference_v<Y_type>
) {
// обе ссылки lvalue. можно просто вызывать std::min
return std::min(x, y); // decltype(auto) выведет const-ссылку
} else {
// один из аргументов -- временный объект
// нужно вернуть новый объект, а не ссылку
auto tmp = std::min(x, y); // голый auto отбрасывает ссылки
return tmp; // tmp объявлен без ссылки, decltype(auto) выведет также без ссылки
}
} // min
} // namespace safe_std
Громоздко, уродливо, но на такой версии функции min никто не подорвется.
А при чем тут std::dangling?
Все просто. В C++20 при использовании ranges::view мы сталкиваемся с такой же проблемой, как и с функцией min. ranges::view ленивы и в процессе преобразования последовательности, например, с помощью std::ranges::view::transform, мы можем получать последовательность временных объектов. Если мы начнем натягивать на них какие-либо алгоритмы, принимающие ссылки на элементы и возвращающие ссылки на элементы, мы рискуем вляпаться в висячие ссылки.
Однако, библиотека ranges-v3, ставшая частично std::ranges, писалась уже после C++11, и в ней учли описанную возможность проверки ссылок. Поэтому, комбинируя алгоритмы с std::ranges::view, мы можем внезапно получить тип
std::ranges::dangling
, который повалит нам компиляцию заведомо небезопасного кода.
Так что, если вы собираетесь писать эффективные алгоритмы, максимально ничего не копирующие и возвращающие ссылки, откажитесь от const reference в интерфейсе. Используйте forwarding reference, напишите чуть больше кода и проверьте, что же вам тут подсунули. Откажите в компиляции коду с ошибками времени жизни. Так жить будет проще.