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

Путеводитель C++ программиста по неопределённому поведению: часть 3 из 11

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

1149_book_pt_3_ru/image1.png

Нарушение lifetime объектов: висячие ссылки, указатели и use-after-free - общие случаи

80% случаев неопределённого поведения в C++ связаны с ними.

Объект жил на стеке и умер. Или объект жил в куче и умер. Разница, по сути, не очень большая: обобщённый сценарий воспроизведения ошибки один и тот же, — где-то остались указатель или ссылка на уже мёртвый объект. А потом этой ссылкой (или указателем) воспользовались, чтобы обратиться к мёртвому объекту. Такой спиритический сеанс заканчивается неопределённым поведением. Если повезёт, будет ошибка сегментации с возможностью узнать, кто именно обратился.

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

Конечно, в жизни почти никто и никогда явно не пишет некорректный код вида:

int main() {
  int* p = nullptr;
  {
    int x = 5;
    p = &x;
  }
  return *p;
}

Но проблема в том, что подобный код в языке С++ может быть ловко замаскирован под слоем абстракций из классов и функций.

Простой пример:

int main() {
  const int x = 11;
  auto&& y = std::min(x, 10);
  std::cout << y << "\n";
}

В этом коде неопределённое поведение из-за висячей ссылки. Такая программа, будучи собранной, например, компилятором GCC 14.1 с ключами -std=c++17 -O3 не упадёт, но неожиданно напечатает 0.

Проблема в том, что std::min объявлен как:

template<class T> const T& min(const T& a, const T& b);

Число 10 является временным объектом (prvalue), который умирает сразу же при выходе из функции std::min.

В C++ разрешено присваивать временные объекты константным ссылкам. В таком случае константная ссылка продлевает временному объекту жизнь (объект "материализуется") и живёт до выхода ссылки из области видимости. Дальнейшие присваивания константным ссылкам эффекта продления времени жизни не имеют.

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

template <class T>
void append_n_copies(std::vector<T>* elements,
                     const T& x, int N)
{
  for (int i = 0; i < N; ++i) {
    elements->push_back(x);
  }
}

void foo() {
  std::vector<int> v; v.push_back(10);
  ...
  append_n_copies(&v, v.front(), 5); // будет UB при реаллокации вектора!
}

У такого кода есть все шансы появиться в реальном проекте и доставить множество неприятностей.

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

template <class T>
std::vector<T> append_n_copies(std::vector<T> elements,
                               T x, int N) {
  for (int i = 0; i < N; ++i) {
    elements.push_back(x);
  }
  return elements;               // implicit move
}

void foo() {
  std::vector<int> v; v.push_back(10);
  ...
  // v = append_n_copies(std::move(v), v.front(), 5);
  // UB, use-after-move, порядок вычисления аргументов неопределен:
  // v.front() может быть вызван на пустом векторе

  auto el = v.front();
  v = append_n_copies(std::move(v), std::move(el), 5);
}

Если нужно работать со ссылками, стоит озаботиться их безопасностью.

Например, можно использовать std::reference_wrapper, которому нельзя присваивать временные объекты.

#include <utility>

template <class T>
std::reference_wrapper<const T>
  safe_min(std::reference_wrapper<const T> a,
           std::reference_wrapper<const T> b)
{
  return std::min(a, b);
}

int main() {
  const int x = 11;
  auto&& y = safe_min<int>(x, 11);   // compilation error
}

Или с помощью forwarding references проанализировать категорию (rvalue/lvlaue) переданного аргумента и решить, что с ним делать. На С++20 это выглядит так:

#include <type_traits>

template <class T1, class T2>
requires
  std::is_same_v<std::decay_t<T1>,
                 std::decay_t<T2>> // std::min требует одинаковых типов
decltype(auto) // выводим тип без отбрасывания ссылок
safe_min(T1&& a, T2&& b) // forwarding reference на каждый аргумент.
{
  if constexpr (std::is_lvalue_reference_v<decltype(a)> &&
                std::is_lvalue_reference_v<decltype(b)>) {
      // оба аргумента были lvalue — можно безопасно вернуть ссылку
      return std::min(a, b);
  } else {
    // один из аргументов — временный объект.
    // возвращаем по значению.
    // для этого делаем копию
    auto temp = std::min(a,b); // auto&& нельзя!
                               // иначе return выведет ссылку
    return temp;
  }
}

Конкретно для функций std::min и std::max в стандартной библиотеке есть безопасные версии, принимающие аргументы по значению и также возвращающие результат по значению. Более того, они "поддерживают" более двух аргументов.

const int x = 11;
const int y = 20;
auto&& y = std::min({x, 10, 15, y}); // OK

Может показаться, что проблема возврата ссылок касается только const ссылок. С неконстантными ссылками никаких паразитных продлений жизни нет, и всё должно быть хорошо. Однако это не совсем так.

Все вышеописанное рассматривало только свободные функции и, что то же самое, статические методы классов.

Но с методами классов возврат ссылок — обычное дело. И проблемы с ними те же, но менее явные. Неявность связана с передачей указателя this на текущий объект.

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

class VectorBuilder {
  std::vector<int> v;

public:
  VectorBuilder& Append(int x) {
    v.push_back(x);
    return *this;
  }

  const std::vector<int>& GetVector() { return v; }
};

int main() {
  auto&& v = VectorBuilder{}
              .Append(1)
              .Append(2)
              .Append(3)
              .GetVector(); // dangling reference
}

Проблема опять в умирающем объекте, вернувшем ссылку на своё содержимое.

Если мы перегрузим лишь GetVector, чтобы различать lvalue и rvalue, проблема не исчезнет:

class VectorBuilder {
  ....
  const std::vector<int>& GetVector() & {
    std::cout << "As Lvalue\n";
    return v;
  }

  std::vector<int> GetVector() && {
    std::cout << "As Rvalue\n";
    return std::move(v);
  }
};

Мы получим сообщение "As Lvalue". Цепочка Append неявно превратила безымянный временный объект в не совсем временный.

Append также нужно перегрузить для разбора случаев rvalue и lvalue:

class VectorBuilder {
  ....
  // lvalue
  VectorBuilder& Append(int x) & {
    v.push_back(x);
    return *this;
  }

  VectorBuilder&& Append(int x) && {
    v.push_back(x);
    return std::move(*this);
  }
};

Мы справились с висячей ссылкой на содержимое вектора.

Однако если мы захотим написать так:

auto&& builder = VectorBuilder{}.Append(1).Append(2).Append(3);

Опять получим висячую ссылку, но уже на сам объект VectorBuilder. Добавленная перегрузка Append тут ни при чём — неявный this и в исходном случае успевал прибиваться ко временному объекту и единоразово продлевать ему жизнь.

Чтобы этого избежать, нам нужно:

  • либо настраивать анализатор кода, запрещающий использовать auto&& и const auto& c этим классом в правой части;
  • либо жертвовать производительностью. Rvalue версия Append может всегда возвращать VectorBuilder по значению (с перемещением). Если VectorBuilder состоит из большого числа примитивных объектов, просадка производительности может быть заметной.

Либо в принципе запретить использовать VectorBuilder в rvalue контексте:

class VectorBuilder {
  ....
  auto Append() && = delete;
}

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

Также не стоит никогда играть с цепочками операций op= (+=, -=, /=) над временными объектами. Для них редко когда обрабатывают rvalue случай:

struct Min {
  int x;

  Min& operator += (const Min& other) {
    x = std::min(x, other.x);
    return *this;
  };
};


int main() {
  auto&& m = (Min{5} += Min {10});
  return m.x; // dangling reference
}

Программа возвращает нулевое значение. Что интересно, неопределённое поведение проявляет себя здесь тем, что компилятор сразу генерирует возврат нулевого значения. Ассемблерный код (GCC 14.1, -std=c++20 -O3):

main:
  xor eax, eax
  ret

Или с использованием типов стандартной библиотеки:

int main() {
    using namespace std::literals;
    auto&& m = "hello"s += "world";
    std::cout << m; // dangling reference
}

Эта программа, собранная GCC 10.1, -std=c++20 -O3, не падает, но и ничего не печатает. А если взять GCC 14.1 и те же ключи, то мы вдруг получим на выходе "helloworld". Прям классика неопределённого поведения.

Нарушение lifetime объектов: хоть auto, хоть не auto, всё равно ссылка висячая!

При работе со стандартными контейнерами приходится иметь дело с очень длинными и громоздкими именами типов (std::vector<std::pair<T1, T2>>::const_iterator).

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

Не все любят автоматическое выведение типов в C++. Призывают указывать явно, так как код становится более понятным. Особенно в случае возвращаемого значения функции:

template <class T>
auto find(const std::vector<T>& v, const T& x) {
  ....
  // очень длинное тело со множеством return
  ....
}

Что такое это auto в конкретном случае: bool? индекс? итератор? ещё что-то более страшное и сложное? Лучше б явно указали...

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

Проблема явного указания типа

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

И все могло быть хорошо: поменяли где-то в объявлении, получили ошибки компиляции, исправили во всех местах, на которые указали ошибки. Но в C++ есть неявное приведение типов, которое особенно жестоко наказывает при использовании ссылок.

std::map<std::string, int> counters = { {"hello", 5}, {"world", 5} };

// Получаем список ключей, используем string_view,
// чтобы не делать лишних копий
std::vector<std::string_view> keys;
keys.reserve(counters.size());
std::transform(std::begin(counters),
               std::end(counters),
               std::back_inserter(keys),
               [](const std::pair<std::string, int>& item) ->
                 std::string_view {
                   return item.first;
               });

// как-то обрабатываем список ключей:
for (std::string_view k : keys) {
  std::cout << k << "\n";         // UB! dangling reference!
}

Мы немного ошиблись в аргументе лямбда-функции и получили ссылку на временный объект, а вместе с ней — неопределённое поведение.

Пример проявления на конкретных опциях компилятора GCC 14.1:

  • -std=c++17 -O3: печатает два раза "world";
  • -std=c++17: печатает пустоту.

Исправляем ошибку, добавляя const перед string:

[](const std::pair<const std::string, int>& item) -> std::string_view

И получаем желаемый:

hello
world

Проходят недели, код рефакторится. Словарь counters отъезжает в поле какого-нибудь класса. Получение и обработка ключей — в его второстепенный метод. А потом внезапно выясняется, что тип значений в ассоциативном массиве надо бы поменять на меньший — пусть short.

Вы меняете его. Уже не помните про метод обработки ключей. Компилятор не ругается.

int main() {
  std::map<std::string, short> counters = { {"hello", 5}, {"world", 5} };

  // получаем список ключей, используем string_view,
  // чтобы не делать лишних копий
  std::vector<std::string_view> keys; 
  keys.reserve(counters.size());
  std::transform(std::begin(counters), 
                 std::end(counters),
                 std::back_inserter(keys),
                 [](const std::pair<const std::string, int>& item) ->
                   std::string_view {
                     return item.first;
                 });
  // как-то обрабатываем список ключей:
  for (std::string_view k : keys) {
    std::cout << k << "\n"; // UB! dangling reference!
  }  
}

Вывод программы, собранной компилятором gcc 14.1:

  • с ключами -std=c++17 -O3: опять два раза "world".
  • с ключами -std=c++17: печатает пустоту.

В этом примере решением будет замена явного типа аргумента лямбда-функции на const auto&.

Другой пример, но уже не с аргументом, а с возвращаемым значением:

template <class K, class V>
class MapWrapper : private std::map<K, V> {
public:
  template <class Predicate>
  const std::pair<K, V>& find_if_or_throw(Predicate p) const {
    auto item = std::find_if(this->cbegin(), this->cend(), p);
    if (item == this->cend()) {
      throw std::runtime_error("not found");
    }
    return *item;
  }
}

Опять ошиблись. Опять надо исправлять. std::map может в будущем поменяться на что-то другое, где итератор возвращает уже не настоящий pair, а прокси-объект. Универсальным решением будет в этом случае decltype(auto) в качестве возвращаемого значения.

Проблемы автоматического вывода типа

Мы можем использовать автовывод как минимум в четырёх различных формах.

1. Голый auto. Минимум проблем. В результате всегда получается тип без ссылок:

auto x = f(...); // но может быть не то, чего вы хотите:
                 // копия вместо ссылки

class C {
public:
   auto Name() const { return name; } // копия вместо ссылки
private:
   T name;
};

2. const auto& — может забиндиться к висячей ссылке:

const auto& x = std::min(1, 2);
// x — dangling reference

При правильно настроенных предупреждениях компилятора в 90% случаев не выйдет использовать const auto& в качестве возвращаемого значения функции.

Компилятор GCC (-std=c++17 -O3 -Werror):

<source>: In function 'const auto& add(int, int)':
<source>:9:14: error: returning reference to temporary
  [-Werror=return-local-addr]
    9 |     return x + y;
      |            ~~^~~
cc1plus: all warnings being treated as errors
Compiler returned: 1

3. auto&& — universal/forwarding reference. Точно так же может забиндиться к висячей ссылке:

auto&& x = std::min(1, 2);
// x — dangling reference

С возвращаемым значением — аналогично варианту const auto&. Получаем ошибку компиляции при сборке с -Werror.

4. decltype(auto) — автовывод "как объявлено". Справа — ссылка, слева — ссылка. Справа нет ссылки, слева нет ссылки. В каком-то смысле то же самое, что и auto&& при объявлении переменных, но не совсем:

auto&& x = 5;
static_assert(std::is_same_v<decltype(x), int&&>);
decltype(auto) y = 5;
static_assert(std::is_same_v<decltype(y), int>);

Разница в том, что auto&& — всегда ссылка, а decltype(auto) — "как объявлено в возвращаемом значении". Что может быть важно при дальнейших вычислениях над типами.

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

class C {
public:
  decltype(auto) Name1() const {
    return name; // копия. name объявлен как T
  }

  decltype(auto) Name2() const {
    return (name); // ссылка. Выражение (name) имеет тип const T&:
                   // само по себе (name) — T&,
                   // но this помечен const, поэтому
                   // получается const T&
  }

  decltype(auto) ConstName() const {
    return const_name; // const копия. const_name объявлен как const T
  }

  decltype(auto) DataRef() const {
    return data_ref; // DataT&, как объявлено.
    // return (data_ref); будет то же самое.
    //                    const от this не распространяется дальше под
    //                    поля-ссылки и указатели.
  }

  decltype(auto) DanglingName1() const {
    auto&& local_name = Name1(); // возвращает копию.
                                 // Копия прибивается к rvalue ссылке
    return local_name; // local_name — ссылка на локальную переменную
  }

  decltype(auto) DanglingName2() const {
    auto local_name = Name1(); // возвращает копию.
    return (local_name); // (local_name) — ссылка на локальную переменную
  }

  decltype(auto) NonDanglingName() const {
    decltype(auto) local_name = Name1(); // возвращает копию.
    return local_name; // возвращает копию
  }

private:
  T name;
  const T const_name;
  DataT& data_ref;
};

decltype(auto) — это хрупкий и тонкий механизм, способный перевернуть всё с ног на голову с помощью минимального изменения в коде: "лишних" скобок или &&.

Полезные ссылки

Нарушение lifetime объектов: string_view — тот же const&, только больнее

С++17 подарил нам тип std::string_view, призванный убить сразу двух зайцев:

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

Итак, проблема: функция хочет считать количество вхождений какого-то символа в строку:

int count_char(const std::string& s, char c) {
  ....
}

count_char("hello world", 'l');
// создастся временный объект std::string,
// выделится память, скопируется строка, а потом строка умрёт и память
// деаллоцируется — плохо, много лишних операций

Так что нам нужна перегрузка для С-строк:

int count_char(const char* s, char c) {
  // мы тут не знаем ничего про длину строки
  // она вообще null-териминированная?

  // Можем только написать код,
  // наивно рассчитывающий, что его
  // будут вызывать правильно.
  ....
}

И будем либо дублировать код, немного адаптируя его под C-строки, либо сделаем функцию:

int count_char_impl(const char* s, size_t len, char c) {
  ....
}

В которую поместим весь дублирующийся код и вызовем её из перегрузок:

int count_char(const std::string& s, char c) {
  return count_char_impl(s.data(), s.size(), c);
}

int count_char(const char* s, char c) {
  return count_char_impl(s, strlen(s), c);
}

И тут на помощь приходит string_view, как раз-таки являющийся парой "указатель и размер". И убивает обе перегрузки:

int count_char(std::string_view s, char c) {
  ....
}

И все здорово, хорошо и замечательно, кроме одного "но":

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

auto GetString = []() -> std::string { return "hello"; };
std::string_view sv = GetString();
std::cout << sv << "\n"; // dangling reference!

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

std::string_view common_prefix(std::string_view a, std::string_view b) {
  auto len = std::min(a.size(), b.size());
  auto common_count = [&]{
    for (size_t common_len = 0; common_len < len; ++common_len) {
        if (a[common_len] != b[common_len]) {
            return common_len;
        }
    }
    return len;
  }();
  return a.substr(0, common_count);
}

int main() {
  using namespace std::string_literals;
  {
    auto common =
      common_prefix("helloW",
                    "hello"s + "World111111111111111111111");
   std::cout << common << "\n"; // ok
  }
  {
    auto common =
      common_prefix("hello"s + "World111111111111111111111111",
                    "helloW");
    std::cout << common << "\n"; // dangling ref
  }
}

Ситуация такая же, как с ранее рассмотренным std::min. Только защититься от такой функции common_prefix, обернув её в шаблон с помощью анализа rvalue/lvalue, намного сложнее: нам нужно разобрать случаи const char* и std::string для каждого аргумента, — в общем, всё то, от чего нас введение std::string_view "избавило".

Влететь в string_view можно ещё изящнее:

struct Person {
  std::string name;

  std::string_view Initials() const {
    if (name.length() <= 2) {
      return name;
    }
    return name.substr(0, 2); // copy — dangling reference!
  }
};

Причём видно, что Clang (18.1.0) хотя бы выдаёт предупреждение, а GCC (14.1) — нет.

<source>:16:16: warning: returning address of local temporary object
  [-Wreturn-stack-address]
   16 |         return name.substr(0, 2); // copy -- dangling reference!
      |                ^~~~~~~~~~~~~~~~~

Всё потому, что std::string_view настолько легендарный, что в Clang сделали хоть какой-то lifetime checker сперва для него.

Полезные ссылки

Нарушение lifetime объектов: синтаксический сахар с ложкой дёгтя (range-based for)

Как мы уже выяснили ранее, константные lvalue (да и rvalue) ссылки доставляют много радости в C++ благодаря правилу продления жизни для временных объектов.

Правило хитрое и состоит не только в том, что const&& или && продляют жизнь временному объекту (но только первая такая ссылка). На самом деле, правило такое: все временные объекты живут до окончания выполнения всего включающего их выражения (statement). Грубо говоря, до ближайшей точки с запятой (;). Или же до окончания области видимости первой попавшейся на пути у этого временного объекта const& или && ссылки, если область видимости ссылки больше, чем время жизни этого самого временного объекта.

То есть:

const int& x = 1 + 2; // временные объекты 1, 2,
// порождают временный объект 3 (сумма).
// Их время жизни закончится на ;
// Но мы присваиваем 3 константной ссылке,
// Её область видимости простирается ниже, дальше ;
// Так что время жизни продлевается.
// Таким образом: 1, 2 — умирают, 3 — продолжает жить


const int& y =
  std::max([](const int& a, const int& b) -> const int&
  {
    return a > b ? a : b;
  } (1 + 2, 4), 5); // временные объекты 1,2,3(сумма),4,5 живут до ЭТОЙ ;

// 3, 4 присваиваются константным ссылкам в аргументах лямбда-функции.
// область видимости этих ссылок заканчивается после return
// — она МЕНЬШЕ времени жизни временного объекта.
// ссылки ничего не продлили, но лишили временных объект будущего.

// 5 прибивается к константной ссылке в аргументе std::max
// Со ссылками на 4, 5 успешно отрабатывает std::max —
// их время жизни ещё не закончилось. Ссылки валидны.

// Ссылка-результат присваивается `y`. Продлений жизни не происходит —
// все временные объекты уже безуспешно попытали
// счастья на аргументах функций.
// Дело доходит до ; Время жизни всех объектов 1,2,3,4,5 заканчивается.
// `y` становится висячей. Занавес.

Вооружившись полученным пониманием, рассмотрим другой пример и перестанем опять всё понимать:

struct Point {
  int x;
  int y;
};

struct Shape {
public:
  using VertexList = std::vector<Point>;
  VertexList vertexes;
};

Shape MakeShape() {
  return { Shape::VertexList{ {1,0}, {0,1}, {0,0}, {1,1} } };
}

int main() {
  for (auto v : MakeShape().vertexes) {
    std::cout << v.x << " " << v.y << "\n";
  }
}

Собираем код с помощью (GCC 14.1, -std=c++20 -O3) и убеждаемся, что он печатает:

1 0
0 1
0 0
1 1

Повысим инкапсуляцию, проведём минимальный рефакторинг и сделаем vertexes приватным полем с read-only доступом:

struct Shape {
public:
  using VertexList = std::vector<Point>;
  explicit Shape(VertexList v) : vertexes(std::move(v)) {}

  const VertexList& Vertexes() const {
    return vertexes;
  }

private:
  VertexList vertexes;
};

....

int main() {
  for (auto v : MakeShape().Vertexes()) {
    std::cout << v.x << " " << v.y << "\n";
  }
}

Код, собранный стареньким GCC 10.2 (-std=c++20 -O3), печатает мусорные значения:

5741 0
243908863 -1980499632
0 0
1 1

А если собрать с помощью GCC 14.1 (-std=c++20 -O3), то дело кончается "Program terminated with signal: SIGSEGV".

Как же так? Разгадка в том, что, несмотря на то что заголовок range-based for выглядит как единое выражение, пишется и воспринимается как единое выражение, единым выражением он не является.

С C++17 стандарта и дальше конструкция:

for (T v : my_cont) {
  ....
}

Разворачивается примерно в следующее:

auto&& container_ = my_cont; // sic!
auto&& begin_ = std::begin(container_);
auto&& end_ = std::end(container_);
for (; begin_ != end_; ++begin_) {
  T v = *begin_;
}

В первом случае:

auto&& container_ = MakeShape().vertexes;

Временный объект Shape живёт до ;. Он не встретил ещё ни одной const& или && ссылки. Подобъект vertexes считается таким же временным. Его время жизни закончится на ;. Но он встречает && ссылку, область видимости которой простирается ниже и продлевает ему жизнь. Причём продлевается жизнь не только подобъекту vertexes, а целиком временному объекту Shape, его содержащему.

Во втором случае:

auto&& container_ = MakeShape().Vertexes();

Временный объект Shape живёт до ;. Но он встречает неявную const& ссылку в методе Vertexes(). Её область видимости ограничена телом метода. Продления жизни не происходит. Возвращается ссылка на часть временного объекта и присваивается ссылке container_. Дело доходит до ;. Временный Shape умирает. container_ становится висячей ссылкой. Занавес.

Вот так всё просто и сломано.

Как избежать проблемы с range-based for?

  • Никогда не забывать делать rvalue перегрузку для любых const-методов.
  • Никогда не использовать никакие выражения после двоеточия (:) в заголовке цикла. Только переменные или их поля.
  • В C++20 использовать синтаксис range-based for с инициализатором: for (auto cont = expr; auto x : cont).
  • При использовании синтаксиса с инициализатором думать, прежде чем использовать auto&& или const auto& для инициализатора. Впрочем, это не только про for...
  • Использовать std::ranges::for_each;
  • Не использовать range-based for в C++, пока его не починят.

С++23

Продление времени жизни объекта в заголовке range-based-for было наконец-то исправлено. И теперь получить висячую ссылку стало тяжелее. Но теперь стало проще получить другую проблему — Lifetime extension.

Полезные ссылки

Нарушение lifetime объектов: ещё не мёртв, но ещё и не жив (Self-reference)

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

// просто и прямолинейно
int x = x + 5; // UB

//--------------
// менее явно
const int max_v = 10;

void fun(int y) {
 const int max_v = [&]{
   // локальный max_v перекрывает глобальный max_v
   return std::min(max_v, y);
 }();
 ....
}

Конечно, такой код вряд ли кто-то будет писать целенаправленно. Но он может возникать самопроизвольно при применении средств автоматического рефакторинга. Локальный max_v во втором примере мог изначально называться как-то по-другому. Применили автоматическое переименование и получили вместо некомпилирующегося кода код с неопределённым поведением.

Причём в следующей версии никакой проблемы не возникает:

const int max_v = 10;

void fun(int y) {
 const int max_v = [y]{
   // тут виден только глобальный max_v
   return std::min(max_v, y);
 }();
 ....
}

Код, уходящий в область неопределённого поведения при добавлении лишь одного символа. Всё как мы любим.

Такой код синтаксически валиден, и никто не собирается его запрещать. Более того, он ещё и не всегда приводит к UB. Грубо говоря к UB приводит только использование с разыменованием ссылки на этот объект. Почему грубо? Потому что правила такие же, как и с разыменованием nullptr, то есть довольно путанные, а не просто "никогда нельзя — всегда UB". Хотя использование такой радикальной трактовки убережёт вас от многих бед.

Кстати, разыменование нулевого указателя — это, можно сказать, прямо-таки философская тема. Если не верите в глубину вопроса, предлагаю на досуге посмотреть весьма интересный доклад JF Bastien — *(char*)0 = 0; - What Does the C++ Programmer Intend With This Code?

Вернёмся к lifetime.

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

struct ExtremelyLongClassName {

  using UnspeekableInternalType = size_t;

  UnspeekableInternalType val;

  static UnspeekableInternalType Default() { return 5;}
};

// вместо ExtremelyLongClassName::Default()
ExtremelyLongClassName x { x.Default() + 5 }; // Ok, well-defined

Или, например, в неисполняемом decltype контексте: у типов слишком длинные имена, не проблема, спросим их у самих себя!

ExtremelyLongClassName z {
  [] ()-> decltype(z.Default()) { // Ok, well-defined
    // сложные вычисления
    return 1;
  }()
};
// Код выше эквивалентен более многословному:
ExtremelyLongClassName y {
  [] ()-> ExtremelyLongClassName::UnspeekableInternalType {
    // сложные вычисления
    return 1;
  }()
};

Эти примеры прекрасно компилируются и совершенно корректны.

Также возможность сослаться на переменную в процессе её инициализации может оказаться полезна в каких-то специфических случаях, в которых вам зачем-то нужен объект, внезапно ссылающийся сам на себя. А такие случаи хоть и специфичны, но совсем не редки! Self-referential objects — широко встречающийся паттерн: кольцевые списки, графические интерфейсы с вложенными виджетами, уведомляющими друг друга, и многое другое.

struct Iface {
  virtual ~Iface() = default;
  virtual int method(int) const = 0;
};

struct Impl : Iface {
  explicit Impl(const Iface* other_ = nullptr) : other(other_) {
  };

  int method(int x) const override {
    if (x == 0) {
      return 1;
    }
    if (other){
      return x * other->method(x - 1);
    }
    return 0;
  }
  const Iface* other = nullptr;
};

int main() {
  Impl impl {&impl};
  std::cout << impl.method(5);
}

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

Избежать использования объекта при инициализации его же самого можно, следуя правилу AAA (almost always auto):

Всегда, если это возможно, использовать запись auto x = T {....} для объявления и инициализации переменных.

В такой записи использование объявляемой переменной внутри инициализирующего выражения даёт ошибку компиляции. Например, для:

auto x = x + 1;

Компиляторы дружно скажут:

  • GCC: error: use of 'x' before deduction of 'auto';
  • MSVC: error C3536: 'x': cannot be used before it is initialized;
  • Clang: error: variable 'x' declared with deduced type 'auto' cannot appear in its own initializer.

Полезные ссылки

Нарушение lifetime объектов: std::vector и инвалидация ссылок

В стандартной библиотеке C++ не очень много последовательных контейнеров с динамической длиной:

  • std::list;
  • std::forward_list;
  • std::deque;
  • std::vector.

Из них std::vector используется в большинстве случаев., а остальные — только если их особенности становятся действительно необходимыми и дают заметную разницу в улучшении производительности. Так, например, возможность вставки в произвольную позицию за константное число операций в std::list не даёт преимущества в сравнении с std::vector (требует линейного времени), пока контейнеры недостаточно большие или размер элементов мал.

std::vector, будучи самым эффективным контейнером, является ещё и самым небезопасным, из-за инвалидации ссылок и итераторов.

Неосторожное использование std::vector вкупе с обилием засахаренных синтаксических конструкций очень легко приводит к неопределённому поведению.

Простенький пример с очередью задач:

std::optional<Action> evaluate(const Action&);

void run_actions(std::vector<Action> actions) {
  for (auto&& act: actions) { // UB
    if (auto new_act = evaluate(act)) {
      actions.push_back(std::move(*new_act)); // UB
    }
  }
}

Красиво, коротко, с неопределённым поведением и неправильно:

  • push_back может вызвать реаллокацию вектора. Итераторы begin/end инвалидируются — цикл продолжится по уничтоженным данным;
  • если реаллокации не произойдёт, цикл пройдёт только по тому набору элементов, что были в векторе изначально. До добавленных в процессе дело не дойдёт.

Корректный код:

void run_actions(std::vector<Action> actions) {
  for (size_t idx = 0; idx < actions.size(); ++idx) {
    const auto& act = actions[idx];
    if (auto new_act = evaluate(act)) {
      actions.push_back(std::move(*new_act));
    }
  }
}

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

void run_actions(std::vector<Action> actions) {
  for (size_t idx = 0; idx < actions.size(); ++idx) {
    const auto& act = actions[idx];
    if (auto new_act = evaluate(act)) {
      actions.push_back(std::move(*new_act));
    }
    std::cerr << act.Id() << "\n"; // UB!
  }
}

И у нас опять неопределённое поведение: push_back может вызвать реаллокацию вектора, и тогда ссылка act станет висячей.

Корректный код:

void run_actions(std::vector<Action> actions) {
  for (size_t idx = 0; idx < actions.size(); ++idx) {
    if (auto new_act = evaluate(actions[idx])) {
      actions.push_back(std::move(*new_act));
    }
    std::cerr << actions[idx].Id() << "\n";
  }
}

Этот простой паттерн с инвалидацией ссылок в векторе может очень легко спрятаться под слоем абстракций. Например, цикл обработки является публичным методом класса TaskQueue, а обработка одной задачи — его приватный метод. В таком случае изменение в одном методе, совершенно корректное в рамках него, приведёт к UB из-за неявного влияния на другой метод.

Кое-как защититься от подобной неприятности можно с помощью статических анализаторов, работающих с потоком исполнения программы. Например, в PVS-Studio есть диагностика V789, выявляющая именно такие случаи. Также проблема точно ловится санитайзерами или утилитами проверки памяти (например, valgrind). Если, конечно, у вас достаточно хорошие тесты.

В языке Rust проблема ловится на этапе компиляции с помощью borrow checker'а.

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

Например, адаптеры std::queue и std::stack по умолчанию используют контейнер std::deque, который не инвалидирует ссылки при добавлении новых элементов. А также ни std::queue, ни std::stack нельзя неосторожно использовать в range-based-for: у них нет итераторов и методов begin/end.

Полезные ссылки

Автор Дмитрий Свиридкин

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.

Редактор — Андрей Карпов

Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.

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


Комментарии (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
Ваше сообщение отправлено.

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


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

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