Skip to content

Instantly share code, notes, and snippets.

@Nekrolm
Last active February 21, 2024 12:48
Show Gist options
  • Save Nekrolm/27ef2c1cb284c47e875115f90a5d5c21 to your computer and use it in GitHub Desktop.
Save Nekrolm/27ef2c1cb284c47e875115f90a5d5c21 to your computer and use it in GitHub Desktop.
cpp_11_17_avoid_dangling_reference

std::dangling, или как хоть иногда не отстрелить себе ногу

- А можно нам 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-ссылки. И с их появлением видоизменилась простая и понятная классификация:

  1. lvalue -- значения, у которых можно брать адрес, и которые могут стоять слева от операциц =.
  2. rvalue -- значения, которые могут стоять только справа от операции =.

Мы не будем рассматривать семантику перемещения, потому можем ограничиться старой классификацией. Нам важно сейчас то, что появившиеся в C++11 rvalue-ссылки позволяют отличить временные объекты от невременных.

    int x = 15;
    int& lvalue = x; // ok
    int&& rvalue1 = x; // compilation error
    int&& rvalue2 = 15; // ok

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

Если бы функция std::min писалась сейчас, она бы могла бы предусмотреть варианты:

  1. Если оба аргумента lvalue, то можно вернуть ссылку
  2. Если хотя бы один аргумент 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, напишите чуть больше кода и проверьте, что же вам тут подсунули. Откажите в компиляции коду с ошибками времени жизни. Так жить будет проще.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment