>
>
>
Путеводитель C++ программиста по неопре…

Андрей Карпов
Статей: 671

Дмитрий Свиридкин
Статей: 9

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

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

Нарушение lifetime объектов: списки захвата лямбда-функций

C++11 подарил нам лямбда-функции и вместе с ними ещё один способ неявного получения висячих ссылок.

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

auto make_add_n(int n) {
    return [&](int x) {
        return x + n;      // n — станет висячей ссылкой!
    };
}

...
auto add5 = make_add_n(5);
std::cout << add5(5);      // UB!

Ничего принципиально нового — тут всё те же проблемы, что и с возвратом ссылки из функции. Clang иногда способен выдать предупреждение.

Приведённый выше код, скомпилированный с помощью GCC 14.1 (-O3 -std=c++20), выводит значение 5.

Если собрать с помощью Clang 18.1 (-O3 -std=c++20), то результатом будет 1711411576. Плюс предупреждение:

<source>:5:13: warning:
address of stack memory associated with parameter 'n' returned
    5 |     return [&](int x) {
      |             ^
<source>:6:20: note: implicitly captured by reference due to use here
    5 |     return [&](int x) {
      |             ~
    6 |         return x + n;
      |                    ^

Но стоит нам принять аргумент make_add_n по ссылке:

auto make_add_n(const int &n) {
    return [&](int x) {
        return x + n; // n станет висячей ссылкой!
    };
}

То промолчат уже оба компилятора:

  • Результат при сборке GCC 14.1 (-O3 -std=c++20): 5;
  • Результат при сборке Clang 18.1 (-O3 -std=c++20): 10.

Аналогично проблему можно создать и для методов объектов:

struct Task {
  int id;

  std::function<void()> GetNotifier() {
    return [this]{
      // this — может стать висячей ссылкой!
      std::cout << "notify " << id << "\n";
    };
  }
};

int main() {
  auto notify = Task { 5 }.GetNotifier();
  notify(); // UB!
}

  • GCC 14.1 (-O3 -std=c++20): "notify 0";
  • Clang 18.1 (-O3 -std=c++20): "notify 29863".

Но в этом примере можно заметить this в списке захвата и насторожиться. До C++20 можно отстрелить ногу чуть менее явно:

struct Task {
  int id;

  std::function<void()> GetNotifier() {
    return [=]{
      // this — может стать висячей ссылкой!
      std::cout << "notify " << id << "\n";
    };
  }
};

Символ = предписывает захватывать всё по значению, но захватывается не поле id, а сам указатель this.

Если видите лямбду, в списке захвата которой есть this, = (до С++20) или &, обязательно проверьте, как и где эта лямбда используется. Добавьте перегрузки проверки времени жизни захватываемых переменных.

struct Task {
  int id;

  std::function<void()> GetNotifier() && = delete;

  std::function<void()> GetNotifier() & {
    return [this]{
      // для this теперь намного сложнее стать висячей ссылкой
      std::cout << "notify " << id << "\n";
    };
  }
};

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

auto make_greeting(std::string msg) {
  return [message = std::move(msg)] (const std::string& name) {
    std::cout << message << name << "\n";
  };
}
...
auto greeting = make_greeting("hello, ");
greeting("world");

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

Нарушение lifetime объектов: кортежи, стреляющие по ногам

С версии C++11 в стандартной библиотеке есть замечательный шаблон класса std::tuple. Кортеж. Гетерогенный список. Отличная и полезная штука. Вот только создать кортеж, ничего не сломав и при этом получив именно то, что вы хотели — задача совершенно нетривиальная.

Явно указывать типы элементов очень длинного контейнера — занятие не из приятных.

С++11 дал нам целых три способа сэкономить на указании типов (разные функции создания кортежей):

  • make_tuple;
  • tie;
  • forward_as_tuple.

С++17 даёт ещё и возможность использовать автовыведение типов и просто писать:

auto t = tuple { 1, "string", 1.f };

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

std::make_tuple отбрасывает ссылки, приводит ссылки на массивы к указателям, отбрасывает const. В общем, применяет std::decay_t.

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

Если типом аргумента make_tuple является std::reference_wrapper<T>, то в кортеже он превращается в T&:

int x = 5;
float y = 6;
auto t = std::make_tuple(std::ref(x), 
                         std::cref(y), 
                         "hello");
static_assert(std::is_same_v<decltype(t), // Компилируется
              std::tuple<int&, 
                         const float&, 
                         const char*>>);

Конструктор с автовыводом типов особый случай std::reference_wrapper не рассматривает. Но decay происходит. Это тоже успешно компилируется:

int x = 5;
float y = 6;
auto t = std::tuple(std::ref(x), std::cref(y), "hello");
static_assert(std::is_same_v<decltype(t), 
                             std::tuple<std::reference_wrapper<int>,
                                        std::reference_wrapper<const float>,
                                        const char*>>);

std::forward_as_tuple всегда конструирует кортеж ссылок. И, соответственно, можно получить ссылку на мёртвый временный объект:

int x = 5;
auto t = std::forward_as_tuple(x, 6.f, std::move("hello"));
static_assert(
  std::is_same_v<
    decltype(t), 
    std::tuple<int&,
               float&&,
               const char (&&) [6]>>); // Да, это rvalue ссылка на массив
std::get<1>(t); // UB!

std::tie конструирует кортеж только из lvalue ссылок. И подорваться на нем сложнее, но всё равно можно, если вы захотите полученный кортеж возвращать из функции. Но эта ситуация совершенно аналогична случаям возврата любых ссылок из функций:

template <class... T>
auto tie_consts(const T&... args) {
  return std::tie(args...);
}

int main(int argc, char **argv) {
  auto t = tie_consts(1, 1.f, "hello");
  static_assert(std::is_same_v<decltype(t),
                               std::tuple<const int&, 
                                          const float&, 
                                          const char (&)[6]>>);
  std::cout << std::get<1>(t) << "\n"; // UB
}

Общие рекомендации:

1. Для создания возвращаемых кортежей использовать make_tuple с явным указанием cref/ref либо конструктор, если ссылки не нужны.

2. std::tie использовать только чтобы временно представить набор переменных в виде кортежа:

std::tie(it, inserted) = map.insert({x, y});  // распаковка кортежей
std::tie(x1, y1, z1) == std::tie(x2, y2, z2); // покомпонентное сравнение

3. std::forward_as_tuple использовать только при передаче аргументов. Нигде не сохранять получаемый кортеж.

И в конце бонус.

Особые любители Python могут захотеть попробовать использовать std::tie для выполнения обмена значений переменных:

// x, y = y, x
int x = 5;
int y = 3;
std::tie(x, y) = std::tie(y, x);
std::cout << x <<  " " << y;

У нас тут не Python, поэтому поведение этого кода неопределёно. Но не печальтесь. Всего лишь unspecified. В результате вы получите либо 5 5, либо 3 3.

Нарушение lifetime объектов: внезапная мутабельность

Был тёплый весенний денёк. Попивая чай, я медленно и лениво пролистывал студенческие работы. Я бы мог сказать, что ничего не предвещало беды, но, увы, работы были выполнены на C++.

Внезапно глаз зацепился за безобидную строчку, используемую для диагностического логирования:

printf("from %s -- to %s",
       storage[from].value.c_str(), storage[to].value.c_str());

Ничего страшного в ней нет, верно? Но в тот момент меня охватил ужас. И сейчас я поделюсь этим ужасом с вами.

В этой строке спрятана невероятная возможность для багов, неожиданных падений и неопределённого поведения!

Любая C++ строка кода очень сильно зависит от контекста, в котором расположена. Что мы можем предположить, глядя на один лишь этот printf?

  • storage — это какого-то толка ассоциативный контейнер;
  • В storage хранятся элементы, имеющие, по-видимому, строковое поле value. Очень вероятно, что поле имеет тип std::string;
  • Написавший этот printf, вероятно, полагает, что оба ключа from и to в контейнере присутствуют.

Хорошо. Теперь начинается плохое: последнее предположение в любой момент может быть случайно нарушено в дальнейшей жизни кодовой базы. И при нарушении этого предположения нас ждут самые удивительные последствия! И они будут тем более удивительными, если этот printf спрятан под макросом и существует только при конкретных опциях компиляции, например, если максимальный уровень логгирования задаётся в compile time.

Такие разные контейнеры

Если from или to нет в списке ключей storage, то всё зависит от того, как этот самый storage обрабатывает обращение к отсутствующему ключу. Для этого надо пойти и посмотреть, какой тип имеет storage:

  • это массив или вектор — привет, array overrun и неопределённое поведение;
  • это std::map или std::unordered_map — вам сегодня повезло, у вас вызвался default конструктор и получились пустые строчки. Хотя это, скорее всего, не то, чего вы хотели, и новосозданный элемент вам где-нибудь да навредит.

Всё? Никого не забыли?

Мы забыли, что стандартными контейнерами из STL дело не ограничивается. Контейнеры могут быть и из других библиотек. И в случае с ассоциативными контейнерами такое встречается крайне часто. Класс std::unordered_map в силу требования стандарта к стабильности ссылок на элементы и гарантий итерирования не может быть реализован эффективно. Он недружелюбен к кэшу и проигрывает в бенчмарках почти всегда. Поэтому в реальных приложениях часто используются альтернативные реализации, пренебрегающие теми или иными гарантиями.

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

А теперь снова возвращаемся к нашей строке:

printf("from %s -- to %s",
       storage[from].value.c_str(), storage[to].value.c_str());

Если storage — это хэш-таблица, имеющая схожие со стандартной поведение и интерфейс (например, abseil::flat_hash_map), обращение через operator[] модифицирует контейнер, и нас ждут разные варианты в зависимости от заполненности таблицы и наличия ключей. Но всех их нам нужно свести к одному вопросу: при обращении к какому ключу произойдёт реаллокация таблицы?

Но не спешите размышлять про from и to, ведь порядок вычисления аргументов функции не специфицирован! Обращение к ключам может быть в ЛЮБОМ порядке! Что только добавит остроты расследованию бага, если вы столкнётесь с ним в работе.

Но я позволю себе считать, что в нашем случае сначала идёт обращение к from, а затем уже к to.

Вариант отсутствия обоих ключей в принципе эквивалентен варианту отсутствия только ключа to. Поэтому им и ограничимся.

auto& from_value = storage[from].value; // (1)
auto& to_value = storage[to].value      // (2)
  • (1) — вернёт ссылку на поле в существующем или новосозданном элементе, всё нормально. Даже если мы возьмём c_str(), тоже ничего страшного не произойдёт. Контейнер управляет памятью, висячих указателей нет;
  • (2) — если to отсутствует, то либо контейнер реаллоцируется, либо нет. Если контейнер не реаллоцируется, баг останется незамеченным. Иначе ссылка from_value инвалидируется!

Короткие строки и длинные баги

Победа? Мы разобрали баг до конца?

На самом деле нет. Ведь выше сознательно был опущен вызов c_str(), присутствовавший в оригинальной строке. Благодаря ему баг мог оставаться незамеченным и не валить ваши тесты! Всё дело в SSO — small string optimization.

Если storage[from].value имеет тип std::string, то на большинстве современных реализаций страшное падение произошло бы только при использовании коротких строк!

Упрощённо std::string выглядит как:

class string {
  char* data;
  size_t data_size;
  size_t capacity_size;

  const char* c_str() const { return data; }
};

Здесь 3 * 8 байт на 64 битной платформе. И эти строки лежат в куче. Невиданное расточительство, если строка очень короткая — 0-15 символов! Поэтому при достаточном желании и упорстве, используя union, можно добиться того, что для коротких строк эта структура бы воспринималась, например, так:

class string
{
  size_t capacity;

  union
  {
    struct 
    {
      char *ptr;
      size_t size;
    } heapbuf;

    char stackbuf[sizeof(heapbuf)];
  };
  const char* c_str() const {
    if (capacity > sizeof(heapbuf))
      return heapbuf.ptr;
    else
      return stackbuf;
  }
};

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

  • если capacity превышает размер stackbuf, то объект управляет буфером на куче и хранит символы там;
  • иначе символы хранятся внутри самого объекта.

И снова возвращаемся к извлечению строк:

const char* from_value = storage[from].value.c_str(); (1)
const char* to_value = storage[to].value.c_str();

(1) — это указатель на данные в куче или в самой структуре строки? А кто ж его знает-то!

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

Иначе строка будет скопирована, и почти наверняка указатель storage[from].value.c_str() не будет равен from_value.

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

Какие выводы из этого всего нужно сделать?

  • С++ — страшный язык. Мы видим строчку на 80 символов и, когда отладчик укажет на происходящее в ней падение, чтобы разобраться в его причинах нужно учесть порядок вычисления аргументов, устройство контейнера, move-семантику и устройство элементов контейнера;
  • Проблема была бы куда менее чудовищной, если бы operator[] не изменял контейнер;
  • Любой рефакторинг на С++ нужно проводить крайне осторожно. Даже простую замену одной структуры данных на другую с точно таким же интерфейсом: наш баг скрыт при использовании std::map/std::unordered_map, но проявляется с другими таблицами.

Как бороться?

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

Андрей Карпов:

— Я выписал ряд идей для доработки PVS-Studio, чтобы выявлять ошибки описанного вида. Однако заранее понятно, что диагностика получится слабой, так как будет работать только для ограниченного набора простых случаев. Нужно точно знать значение элемента, который мы ищем, и чем наполнен контейнер. Это очень сложная задача для статических анализаторов как с точки зрения анализа потока данных, так и вычислительных затрат на подобный анализ. Так что соглашусь с Дмитрием, что всё равно в первую очередь программисту стоит надеяться только на свою внимательность при написании кода и тестирование.

Мы можем предотвращать такие баги, изменяя подход к написанию кода. Крайне советую попрограммировать на Rust (даже если вы не будете им пользоваться в рабочем проекте), чтобы выработать привычку писать код, удовлетворяющий требованиям его borrow checker'а.

Если мы гарантируем в C++ коде, что на контейнер и данные в нём единовременно могут быть либо только const ссылки, либо не более одной мутабельной ссылки, то ошибка станет почти невозможной. Но гарантировать мы этого не можем. Но можем установить ограничение, чтобы все ссылки у нас были константные:

const auto& const_storage = storage;

// operator[] недоступен из-за const
const auto& from_value = const_storage.at(from).value;

// operator[] недоступен из-за const
const auto& to_value = const_storage.at(to).value;

// если какой-то из ключей отсутствует — будет выброшено исключение

Нарушение lifetime объектов: proxy-объекты и неявные ссылки

Мы в C++ очень любим generic код. Да и не только в C++. Чтоб все было удобно, переиспользуемо и гибко. На то нам шаблоны и даны!

Давайте напишем немного такого generic кода:

template <class T>
auto pop_last(std::vector<T>& v) {
  assert(!v.empty());
  auto last = std::move(v.back());
  v.pop_back();
  return last;
}

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

Все ли хорошо с этой функцией? Конечно, на пустом векторе будет неопределённое поведение, но мы же написали assert, так что дальше всё на откуп пользователю. Пусть просто пишет корректный код, а некорректный не пишет... Ещё есть вопросы к гарантиям исключений, ведь из-за них стандартный pop_back() ничего не возвращает. Но это тема другой главы. А в остальном вроде всё в порядке, да?

Что ж, давайте воспользуемся этой функцией!

std::vector<bool> v(65, true);
auto last = pop_last(v);
std::cout << last;

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

На самом деле подвох есть. Число 65 выбрано не случайно и, скорее всего (зависит от реализации), в коде неопределённое поведение, которое никак не проявляется потому, что так устроены деструкторы тривиальных типов. Но обо всём по порядку.

Паттерн Proxy и proxy-объекты

Подробно о разных паттернах проектирования мы говорить не будем. Для этого есть отдельные хорошие книжки. Но в общих чертах: Proxy (иногда переводят как Заместитель) — объект, который перехватывает обращения к другому объекту с тем же самым (или похожим) интерфейсом, чтобы сделать что-то. Что именно — зависит от конкретной задачи и реализации.

В стандартной библиотеке C++ есть самые разные proxy-объекты (иногда не чистые proxy, а c добавлением функционала):

  • std::reference_wrapper;
  • std::in_ptr, std::inout_ptr в C++23;
  • std::osyncstream в C++20;
  • арифметические операции над valarray могут возвращать proxy-объекты;
  • std::vector<bool>::reference.

Вот последний нам и нужен.

В стандарте C++98 приняли ужасное решение, казавшееся тогда разумным: сделать специализацию для std::vector<bool>. Обычно sizeof(bool) == sizeof(char), но вообще для bool достаточно одного бита. Но адресовать память по одному биту 99.99% всех возможных платформ не могут. Давайте для более эффективной утилизации памяти в vector<bool> будем паковать биты и хранить CHAR_BIT (обычно 8) булевых значений в одном байте (char).

Как итог — работать с std::vector<bool> нужно совершенно по-особому:

  • в нём нельзя взять адрес (указатель) на конкретный элемент;
  • соседние элементы налезают друг на друга;
  • reference это не bool&;
  • при доступе к элементам используются похожие на bool proxy-объекты (знающие, к какому биту в байте обращаться). А значит, нужно быть аккуратным с автовыводом типов.

reference для vector<bool> выглядит примерно так:

class reference {
public:
  operator bool() const { return (*concrete_byte_ptr) & (1 << bitno); }
  reference& operator=(bool) {...}
  ....
private:
  uchar8_t* concrete_byte_ptr;
  uchar8_t  bitno;
}

В строке:

auto last = std::move(v.back());

auto отбрасывает ссылки, да. Но только настоящие C++ ссылки. T& и T&& превращаются в T. Тип reference в bool тут сам по себе никак не превратится, даже несмотря на наличие неявного operator bool!

И что же получается:

auto pop_last(std::vector<bool>& v) {
  // v.size() == 65
  auto last = std::move(v.back());
  // last это vector<bool>::reference; != bool
  v.pop_back();
  // v.size() == 64
  // Мы полностью выкинули последний uint8/uint32/uint64
  // (зависит от реализации) из вектора.
  // last продолжает ссылаться на выброшенный элемент.
  // Если vector<bool> при выбрасывании этого элемента вызвал
  // (псевдо)деструктор, то далее при обращении через
  // last к этому элементу мы нарушаем объектную
  // модель C++, получая доступ к уничтоженному объекту -> UB.
  return last;
}

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

  • pop_back не реаллоцирует внутренний буфер вектора;
  • ~bool ничего не делает.

Если же мы получим элемент из pop_last(), сохраним его, а потом сделаем с вектором ещё что-то, что приведёт к реаллокации буфера, UB начнёт проявляться.

Больше неожиданностей!

int main() {
  std::vector<bool> v;
  v.push_back(false);
  std::cout << v[0] << " ";
  const auto b = v[0];
  auto c = b;
  c = true;
  std::cout << c << " " << b;
}

Такой код выводит 0 1 1. Несмотря на const значение b поменялось. Но ведь это же очевидно, да? Ведь b это не ссылка, но объект, который ведёт себя как ссылка!

Этот код станет ещё более внезапным и интересным в C++23: если при переносе новинок в cppreference не ошиблись, нас ждёт перегрузка операции присваивания через const reference &. И можно будет написать даже так:

int main() {
  std::vector<bool> v;
  v.push_back(false);
  std::cout << v[0] << "\t";  // 0
  const auto b = v[0];
  b = true;
  std::cout << v[0];          // 1
}

Такое поведение вполне определено, но может быть неожиданным, если вы пишете какой-нибудь универсальный шаблонный код. Опытные C++ программисты с опаской относятся к явному использованию vector<bool>... Но всегда ли они проверяют в шаблонной функции, принимающей vector<T>, что T != bool? Скорее всего, почти никогда (если только они не пишут публичную библиотеку).

Ну ладно, понятно с этим вектором всё. В остальных-то случаях всё хорошо же?

Конечно!

Давайте возьмём совершенно невинную функцию (спасибо @sgshulman за пример)

template <class T>
T sum(T a, T b)
{   
  T res;
  res = a + b;
  return res;
}

И случайно засунем в неё... правильно, какой-нибудь proxy-тип (что же это может быть?):

std::vector<bool> v{true, false};
std::cout << sum(v[0], v[1]) << std::endl;

Если нам повезёт, мы получим ошибку компиляции. Так, например, в реализации MSVC у vector<bool>::reference нет конструктора по умолчанию. А GCC и Clang спокойненько компилируют нечто, падающее с ошибками обращения к памяти: T res ссылается на несуществующий вектор.

Также стоит отметить, как удивительно здесь работают неявные вызовы операторов приведения типов! Ведь на vector<bool>::reference не определён оператор +. И return a + b; не скомпилируется. Здесь a и b приводятся к bool, затем к int, чтобы просуммироваться и потом обратно привестись к bool.

Что ещё, и почему нужно быть бдительным

std::vector<bool> — это просто самый известный пример объекта, порождающего proxy. Вы можете всегда написать свой класс и, если он будет эмулировать поведение тривиальных типов, устроить кому-нибудь (например, коллегам) развлечение.

Стандарт может разрешать возвращать proxy и для других типов и операций. И разработчики стандартной библиотеки могут этим воспользоваться. А могут и не воспользоваться. В любом случае мы можем случайно или специально написать код, поведение которого будет зависеть от версии библиотеки. Например, согласно документации, операторы * у std::valarray в libstdc++ v12.1 и Visual Studio 2022 имеют разные типы возвращаемого значения.

В сторонних библиотеках также могут применяться proxy-объекты. И уж тем более в сторонних библиотеках их использование может меняться от версии к версии.

Например, proxy-объекты используются для операций над матрицами в библиотеке Eigen. Результатом произведения двух матриц оказывается не матрица, а специальный proxy-объект Eigen::Product. Транспонирование матрицы возвращает Eigen::Transpose. Да и многие другие операции порождают proxy-oбъекты. И если вы на одной версии написали:

const auto c = op(a, b);
b = d;
do_something(c);

И всё работало, то при обновлении всё вполне может сломаться. Вдруг op теперь возвращает ленивый proxy, а следующей строкой вы испортили один из аргументов?

Что делать и как бороться

В C++ — никак. Только повышенной внимательностью. А также тщательно описывать ограничения, накладываемые на типы в шаблонах (желательно в виде концептов C++20).

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

Если вы пользуетесь библиотекой, то, может, лучше всё-таки указать тип переменной явно? Если вы хотите bool — укажите bool. Хотите тип элемента вектора? Укажите vector<T>::value_type. auto — это очень удобно, но только если вы знаете, что делаете.

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

Нарушение lifetime объектов: use-after-move

Move-семантика C++11 — важная и нужная фича, позволяющая писать более производительный код, не делающий лишний копий, аллокаций, деаллокаций, а также явно выражать намерение передачи владения ресурсом из одной функции в другую. Всё как в уже многие годы любимом на Stack Overflow языке Rust. Но по-другому.

Про move-семантику почти наверняка спрашивают на любом сколько-нибудь серьёзном собеседовании. Хороший кандидат как-нибудь на пальцах да объяснит, что вот, мол, на примере вектора, один объект из другого что-то там забрать может, а эти && ну вот просто синтаксический костыль, потому что const& может принять временный объект, но под const потом ничего не поменяешь, а & принять временный объект не может, а у by value с конструктором копирования проблемы... В общем, так получилось. В конце концов, вы с кандидатом, может быть, напишете простенький unique_ptr, чтоб он точно продемонстрировал в коде, как воровать указатели из одного объекта в другой. И в теории этого должно хватать в 99% случаев.

А на практике потом встречается 1% интересного. Об этом интересном и пойдёт речь далее.

Move-семантика в C++ хоть и достаточно эффективна, но всё же не до конца. Её прилепили сверху как неплохой workaround, но оставили существенную проблему.

Давайте глянем на простенький unique_ptr:

template<class T>
class UniquePtr {
public:
  explicit UniquePtr(T* raw) : _ptr {raw} {}
  UniquePtr() = default;
  ~UniquePtr() {
       delete _ptr;
  }
  UniquePtr(const UniquePtr&) = delete;
  UniquePtr(UniquePtr&& other) noexcept :
    _ptr { std::exchange(other._ptr, nullptr) } {}
  UniquePtr& operator=(const UniquePtr&) = delete;
  UniquePtr& operator=(UniquePtr&& other) noexcept {
    UniquePtr tmp(std::move(other));
    std::swap(this->_ptr, tmp._ptr);
    return *this;
  } 
private:
  T* _ptr = nullptr;
};

....

UniquePtr<MyType> uptr = ...;
....
// что-нибудь важное с uptr
....
UniquePtr<MyType> b = std::move(uptr);
// а тут ничего не мешает сделать
// uptr = fun();

std::move, как известно, ничего не перемещает. Это просто преобразование ссылок, чтобы при вызове конструктора или оператора присваивания была выбрана нужная перегрузка с rvalue-ссылкой. Исходный объект, из которого произвели перемещение, никуда не девается (в отличие от Rust — там объект после перемещения становится недоступен для использования). У него когда-нибудь будет вызван деструктор. Потому мы обязаны оставить этот объект в каком-то адекватном для вызова деструктора состоянии. В нашем UniquePtr следует оставить nullptr, как это сделано в move-конструкторе.

Но что же происходит в операторе move-присваивания?

UniquePtr& operator=(UniquePtr&& other) noexcept {
  UniquePtr tmp(std::move(other));
  std::swap(this->_ptr, tmp._ptr);
  return *this;
}

Тут зачем-то используется move(copy)-and-swap... Ну как зачем: мы же, наверное, хотим грохнуть старый объект (T, а не указатель) и забрать владение новым. Или не хотим? Если нет, то почему бы не реализовать оператор перемещения так:

UniquePtr& operator=(UniquePtr&& other) noexcept {
  std::swap(this->_ptr, other._ptr);
  return *this;
}
  • Владение данными передано? Передано.
  • Старый объект-указатель в адекватном состоянии для вызова деструктора? Да, не хуже, чем тот, куда присваивали!

Всё отлично с точки зрения семантики перемещения C++!

Но такое поведение для UniquePtr как минимум неожиданное. Потому в стандартной реализации std::unique_ptr всё-таки зануляет исходный указатель. Это же верно и для std::shared_ptr, std::weak_ptr. И это гарантируется стандартом...

И тут скрывается главная ловушка: если пустое moved-out состояние для умных указателей гарантированно, то для других классов из стандартной библиотеки (и не только) это вообще-то не так! Совсем не так!

std::vector и другие похожие контейнеры

Поведение move-оператора перемещения для вектора описывается очень хитро и учитывает параметр, о котором вспоминают только те, кто о нём знают и заинтересованы в его настройке — аллокатор.

В каждом экземпляре std::vector запрятан объект-аллокатор. Это может быть как по умолчанию (std::allocator) пустой объект, использующий глобальные malloc/operator new, так и что-то более специфичное. Например, вы хотите, чтобы каждый ваш вектор использовал свой уникальный предвыделенный кусок одного большого буфера, который полностью под вашим контролем.

Стандартная библиотека просит от типа-аллокатора определить свойство propagate_on_container_move_assignment, влияющее на то, как будет вести себя move-присваивание. Если вы пишете A = std::move(B), есть три варианта:

  • propagate_on_container_move_assignment{} == true (да, это не константа, а структура как false_type/true_type). Вектор A деаллоцируется, аллокатор перемещается (опять-таки с помощью move-присваивания, так что тут уж надо позаботиться о каких-то гарантиях), и содержимое забирается целиком из B. B будет пуст.
  • propagate_on_container_move_assignment{} == false и аллокатор в A и B один и тот же (A.get_allocator() == B.get_allocator()). A деаллоцируется, аллокатор остаётся на месте. Содержимое забирается из A в B.
  • propagate_on_container_move_assignment{} == false и A.get_allocator() != B.get_allocator(). Вот тут начинается самое интересное: забрать ни аллокатор, ни данные целиком A не может. Единственный вариант — переносить каждый элемент отдельно. Но опустошать и деаллоцировать B не обязательно. Достаточно только перенести элементы. И в этом случае можно получить полный вектор, состоящий из moved-out элементов.

В реализации вектора в libc++ в третьем случае как раз-таки вектор не остаётся пустым. В libstdc++ же воткнут вызов clear().

В этом можно убедиться на примере:

template <class T>
struct MyAlloc {
  using value_type = T;
  using size_type = size_t;
  using difference_type = ptrdiff_t;
  using propagate_on_container_move_assignment = std::false_type;

  T* allocate(size_t n) {
    return static_cast<T*>(malloc(n * sizeof(T)));
  }

  void deallocate(T* ptr, size_t n) {
    free(static_cast<void*>(ptr));
  }


  using is_always_equal = std::false_type;
  bool operator == (const MyAlloc&) const {
    return false;
  }
};

int main() {
  using VectorString = std::vector<std::string, MyAlloc<std::string>>;

  {
    VectorString v = {
      "hello", "world", "my"
    };
    VectorString vv = std::move(v);
    std::cout << v.size() << "\n";
    // выведет 0. Это был move-конструктор
  }

  {
    VectorString v = {
      "hello", "world", "my"
    };
    VectorString vv;
    vv = std::move(v);
    std::cout << v.size() << "\n";
    // выведет 3. Было move-присваивание
    for (auto& x : v) {
      // но каждый элемент был перемещён — тут пусто
      std::cout << x;
    }
  }
}

Компилируем и запускам:

  • clang -std=c++20 -stdlib=libc++: 0 3.
  • clang -std=c++20: 0 0.

Обратите внимание, проблема только с move-присваиванием! Ну а ещё это замечательный пример того, как разрыв объявления и инициализации переменной может менять поведение C++ программы!

Кстати, элементами вектора были строки. И последний цикл обращается к moved-out строкам!

std::string

Moved-out состояние строк также не специфицировано.

На разных ресурсах, посвящённых C++, можно найти пример, выдающий неожиданный результат при компиляции старым Clang 3.7 c libc++:

void g(std::string v) {
  std::cout << v << std::endl;
}
 
void f() {
  std::string s;
  for (unsigned i = 0; i < 10; ++i) {
    s.append(1, static_cast<char>('0' + i));
    g(std::move(s));
  }
}

Начиная с C++11, строки в реализации тройки основных компиляторов используют SSO (Small String Optimization) — короткая строка хранится не в куче, а в самом объекте-строке (вместо/поверх указателей union). И её копирование становится тривиальным. А тривиальные объекты (примитивы, структуры из примитивов) ещё и перемещаются тривиально — простым копированием. С современными версиями GCC и Clang, с libc++, c lidstdc++ строка остаётся пустой после move. Но полагаться на это всё же не стоит.

Что же делать?

С moved-out состоянием объектов может быть четыре уровня гарантий:

  • Destructor only. Moved-out объект годится только на то, чтоб быть уничтоженным. И больше не использоваться. Никак. Это базовая гарантия, которую вы должны обеспечить, если уж решили добавлять move-семантику к своим объектам, чтобы весь механизм автовызова деструкторов не отстрелил никому ноги;
  • Destructor & assignment. Теперь ещё можно переиспользовать объект, присвоив ему новое значение (а потом уже пользуйтесь нормально). Объект, который можно перемещать, но нельзя потом переприсваивать — это очень редкое явление. Поэтому обычно эту гарантию объединяют с предыдущей;
  • Valid, but unspecified. Можно пользоваться, вызывать методы, не требующие предусловий, но что там внутри — чёрт его знает;
  • Valid, well defined. Всё и так ясно.

Читайте документацию, прежде чем переиспользовать незнакомый moved-out объект! А лучше в принципе так не делать. И многие статические анализаторы способны выдать предупреждение, если у вас произошло обращение к moved-out объекту в функции, где вы вызвали на нём std::move.

А ещё при реализации оператора перемещения стоит использовать move_and_swap паттерн (как в UniquePtr в самом начале), так у вас больше шансов без больших усилий оставлять свои объекты в действительно пустом состоянии.

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

Нарушение lifetime объектов: lifetime extension

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

В C++ при первом присваивании временного объекта const lvalue или rvalue ссылке, время жизни этого объекта расширяется до времени жизни ссылки:

std::string get_string();
void run(const std::string&);

int main() {
  const std::string& s1 = get_string(); 
  run(s1); // ok, ссылка валидна
  std::string&& s2 = get_string();
  run(s2); // ok, ccылка валидна
  // но
  std::string&& s3 = std::move(get_string()); // ссылка уже
                                              // не валидна!
  // первое присваивание — ссылка в аргументе std::move, 
  // её время жизни ограничено телом move
  // аналогично для любой другой функции, принимающей и
  // возвращающей ссылку (std::move тут взят только для примера)
}

Чуть менее очевидная особенность: не только ссылка на временный объект даёт такой эффект, но и на любой его подобъект!

#include <iostream>
#include <string>
#include <vector>

struct User {
  std::string name;
  std::vector<int> tokens;
};

User get_user() {
  return {
    "Dmitry",
    {1,2,3,4,5}
  };
}

int main() {
  std::string&& name = get_user().name;
  // some hacky address arithmetics:
  // User is alive, we can access data in it!
  // Build with -fsanitize=address to ensure!
  auto& v = *(std::vector<int>*)((char*)(&name) + sizeof(std::string));
  for (int x : v) {
    std::cout << x;
  }
}

Код выше выведет содержимое вектора tokens из объекта User. И в этом даже нет ничего противозаконного: никаких dangling references и use-after-free. Ссылка на поле обеспечивает продление жизни всего объекта. И это может быть ссылка на сколь угодно вложенное поле:

struct Name {
  std::string name;
};

struct User {
  Name name;
  std::vector<int> tokens;
};

....

int main() {
  std::string&& name = get_user().name.name;
  ....
}

И вложенные поля даже могут быть внутри массивов! Но массивы должны быть именно старыми добрыми C-style (T array[N]).

struct Name {
  std::string name;
};

struct User {
  Name name[2]; 
  std::vector<int> tokens;
};

User get_user() {
  return {
    { "Dmitry", "Dmitry" },
    {1,2,3,4,5}
  };
}

int main() {
  std::string&& name = get_user().name[1].name;
  ...
}

С std::array такой фокус не пройдёт из-за перегруженного operator []:

error: rvalue reference to type 'basic_string<...>' cannot bind to lvalue of type 'basic_string<...>'

23 |     std::string&& name = get_user().name[1].name;

А замена rvaluе ссылки std::string&& name на const std::string& name поможет коду скомпилироваться и упасть с ожидаемым stack-use-after-free:

....
struct User {
  std::array<Name, 2> name;
  std::vector<int> tokens;
};
....
int main() {
  const std::string& name = get_user().name[1].name;
  std::cout << name << "\n";
}

Результат запуска:

Program returned: 1
==1==ERROR: AddressSanitizer:
stack-use-after-scope on address0x7e6806200040 at
pc 0x5b1ce93dcf19 bp 0x7ffdc59e7770 sp 0x7ffdc59e7768
READ of size 8 at 0x7e6806200040 thread T0

Здорово! Но пытливый читатель уже, наверное, догадался, в чём проблема. Мы берём ссылку только на одно поле, и, наверное, собираемся работать только с ним, а объект остаётся жить целиком... А что, если остальные его поля держат выделенную память? А что, если нам критически важно, чтоб у них был вызван деструктор?

Для наглядной демонстрации проблемы я приведу пример, внезапно, не на C++, а на Rust, поскольку там необходимый для создания неприятностей тип можно взять из стандартной коробки. Ровно как и изысканно сломанную синтаксическую конструкцию.

use parking_lot::Mutex;

#[derive(Default, Debug)]
struct State {
  value: u64,
}

impl State {
  fn is_even(&self) -> bool {
    self.value % 2 == 0
  }

  fn increment(&mut self) {
    self.value += 1
  }
}

fn main() {
  let s: Mutex<State> = Default::default();

  match s.lock().is_even() {
    true => {
      s.lock().increment(); // oops, double lock!
    }
    false => {
      println!("wasn't even");
    }
  }
  dbg!(&s.lock());
}

Этот пример уходит в deadlock: временный объект LockGuard в операторе match остаётся жив по совершенной нелепости! Подробнее можно почитать тут. А мы же вернёмся к C++.

Если мы по какой-то причине решили последовать примеру Rust и сделать mutex явно ассоциированный с данными (как и должно быть в 95% случаев), то получим такую же проблему при неаккуратном использовании ссылок:

template <class T>
struct Mutex {
  T data;
  std::mutex _mutex;
    
  explicit Mutex(T data) : data {data} {}
 
  auto lock() {
    struct LockGuard {
    public:
      LockGuard(T& data,
                std::unique_lock<std::mutex>&& guard) :
        data(data), guard(std::move(guard)) {}
      std::reference_wrapper<T> data;
    private: 
      std::unique_lock<std::mutex> guard;
    };

    return LockGuard(this->data, std::unique_lock{_mutex});
  }
};


int main() {
  Mutex<int> m {15};

  // double lock (deadlock, ub) due to LockGuard
  // lifetime extension, remove && and it will be fine
  auto&& data = m.lock().data;
  std::cout << data.get() << "\n";
  auto&& data2 = m.lock().data;
  std::cout << data2.get() << "\n";
}

"Сам себе злобный Буратино" — скажут опытные защитники C++. — "Зачем ссылка, если там и так reference_wrapper?" И будут, разумеется, правы. Но не переживайте, в C++23 теперь есть такая же сломанная конструкция, как и match в Rust. И это... range-based-for!

Удивительнейшим образом изменения в стандарте, направленные на то, чтобы починить висячую ссылку в конструкции:

for (auto item : get_object().get_container()) { ... }

Теперь позволяют вляпаться в точно такой же дедлок, как в Rust:

template <class T>
struct Mutex {
  T data;
  std::mutex _mutex;
    
  explicit Mutex(T data) : data {data} {}
 
  auto lock() {
    struct LockGuard {
    public:
      LockGuard(T& data,
                std::unique_lock<std::mutex>&& guard) :
        data(data), guard(std::move(guard)) {}
      std::reference_wrapper<T> data;

      T& get() const {
        return data.get();
      }
      private: 
        std::unique_lock<std::mutex> guard;
    };

    return LockGuard(this->data, std::unique_lock{_mutex});
  }
};

struct User {
  std::vector<int> _tokens;

  std::vector<int> tokens() const {
    return this->_tokens;
  }
};

int main() {
  Mutex<User> m { { {1,2,3, 4,5} } };

  for (auto token: m.lock().get().tokens()) {
    std::cout << token << "\n";
    m.lock(); // deadlock C++23
  }
}

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

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

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

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на 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 активности.