Вашему вниманию предлагается четвёртая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.
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 станет висячей ссылкой!
};
}
То промолчат уже оба компилятора:
Аналогично проблему можно создать и для методов объектов:
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!
}
Но в этом примере можно заметить 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");
С версии C++11 в стандартной библиотеке есть замечательный шаблон класса std::tuple. Кортеж. Гетерогенный список. Отличная и полезная штука. Вот только создать кортеж, ничего не сломав и при этом получив именно то, что вы хотели — задача совершенно нетривиальная.
Явно указывать типы элементов очень длинного контейнера — занятие не из приятных.
С++11 дал нам целых три способа сэкономить на указании типов (разные функции создания кортежей):
С++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.
Был тёплый весенний денёк. Попивая чай, я медленно и лениво пролистывал студенческие работы. Я бы мог сказать, что ничего не предвещало беды, но, увы, работы были выполнены на C++.
Внезапно глаз зацепился за безобидную строчку, используемую для диагностического логирования:
printf("from %s -- to %s",
storage[from].value.c_str(), storage[to].value.c_str());
Ничего страшного в ней нет, верно? Но в тот момент меня охватил ужас. И сейчас я поделюсь этим ужасом с вами.
В этой строке спрятана невероятная возможность для багов, неожиданных падений и неопределённого поведения!
Любая C++ строка кода очень сильно зависит от контекста, в котором расположена. Что мы можем предположить, глядя на один лишь этот printf?
Хорошо. Теперь начинается плохое: последнее предположение в любой момент может быть случайно нарушено в дальнейшей жизни кодовой базы. И при нарушении этого предположения нас ждут самые удивительные последствия! И они будут тем более удивительными, если этот printf спрятан под макросом и существует только при конкретных опциях компиляции, например, если максимальный уровень логгирования задаётся в compile time.
Если from или to нет в списке ключей storage, то всё зависит от того, как этот самый storage обрабатывает обращение к отсутствующему ключу. Для этого надо пойти и посмотреть, какой тип имеет storage:
Всё? Никого не забыли?
Мы забыли, что стандартными контейнерами из 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)
Победа? Мы разобрали баг до конца?
На самом деле нет. Ведь выше сознательно был опущен вызов 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 определяется, где хранятся символы:
И снова возвращаемся к извлечению строк:
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 было достаточно просто передвинуть границу блока памяти.
Какие выводы из этого всего нужно сделать?
Мне не известны настройки статических анализаторов, которые бы тут помогли. Подобные баги может выявить только очень тщательное тестирование.
Андрей Карпов:
— Я выписал ряд идей для доработки 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;
// если какой-то из ключей отсутствует — будет выброшено исключение
Мы в 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 (иногда переводят как Заместитель) — объект, который перехватывает обращения к другому объекту с тем же самым (или похожим) интерфейсом, чтобы сделать что-то. Что именно — зависит от конкретной задачи и реализации.
В стандартной библиотеке C++ есть самые разные proxy-объекты (иногда не чистые proxy, а c добавлением функционала):
Вот последний нам и нужен.
В стандарте C++98 приняли ужасное решение, казавшееся тогда разумным: сделать специализацию для std::vector<bool>. Обычно sizeof(bool) == sizeof(char), но вообще для bool достаточно одного бита. Но адресовать память по одному биту 99.99% всех возможных платформ не могут. Давайте для более эффективной утилизации памяти в vector<bool> будем паковать биты и хранить CHAR_BIT (обычно 8) булевых значений в одном байте (char).
Как итог — работать с std::vector<bool> нужно совершенно по-особому:
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_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 — это очень удобно, но только если вы знаете, что делаете.
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 состояние для умных указателей гарантированно, то для других классов из стандартной библиотеки (и не только) это вообще-то не так! Совсем не так!
Поведение move-оператора перемещения для вектора описывается очень хитро и учитывает параметр, о котором вспоминают только те, кто о нём знают и заинтересованы в его настройке — аллокатор.
В каждом экземпляре std::vector запрятан объект-аллокатор. Это может быть как по умолчанию (std::allocator) пустой объект, использующий глобальные malloc/operator new, так и что-то более специфичное. Например, вы хотите, чтобы каждый ваш вектор использовал свой уникальный предвыделенный кусок одного большого буфера, который полностью под вашим контролем.
Стандартная библиотека просит от типа-аллокатора определить свойство propagate_on_container_move_assignment, влияющее на то, как будет вести себя move-присваивание. Если вы пишете A = std::move(B), есть три варианта:
В реализации вектора в 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;
}
}
}
Компилируем и запускам:
Обратите внимание, проблема только с move-присваиванием! Ну а ещё это замечательный пример того, как разрыв объявления и инициализации переменной может менять поведение C++ программы!
Кстати, элементами вектора были строки. И последний цикл обращается к moved-out строкам!
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 состоянием объектов может быть четыре уровня гарантий:
Читайте документацию, прежде чем переиспользовать незнакомый moved-out объект! А лучше в принципе так не делать. И многие статические анализаторы способны выдать предупреждение, если у вас произошло обращение к moved-out объекту в функции, где вы вызвали на нём std::move.
А ещё при реализации оператора перемещения стоит использовать move_and_swap паттерн (как в UniquePtr в самом начале), так у вас больше шансов без больших усилий оставлять свои объекты в действительно пустом состоянии.
Продление времени жизни временных объектов — тема широкая. И в этой серии заметок она встречалась не раз. Ведь работает эта особенность в довольно ограниченном числе случаев, и чаще всего можно получить висячую ссылку. Однако в этом разделе я хочу остановиться на менее очевидном случае с не совсем ожидаемыми последствиями.
В 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 активности.
0