diff --git a/README.md b/README.md index 58b5537..8ff510b 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,10 @@ -------------- -Все начинается просто и незатейливо: обычный десятиклассник увлекается программированием, знакомится с алгоритмическими задачками, решения которых должны быть быстрыми. Узнает о языке C++, учит минимальный синтаксис, основные конструкции, контейнеры, решает задачи с предопределенным и всегда корректным форматом ввода и вывода, и горя не знает... - -В это же время, где-то в большом мире, матерые разработчики каждый день ругают то одни языки программирования, то другие. По самым разным причинам: -не удобно, нет какой-то возможности, много лишних букв писать, ошибки в стандартной библиотеке... Но есть язык, который ругают за все и особенно за такую непонятную и таинственную вещь как неопределенное поведение (undefined behavior, UB). - -Спустя лет пять или шесть наш простой десятиклассник, горя не видавший в море оторванных от реальности программ, внезапно узнает, что тем самым горячо нелюбимым языком всегда был, остается и будет его C++. - -А потом еще в течение нескольких лет он наткнется на самые кошмарные и невероятные ужасы, поджидающие программистов на C++ почти на каждом шагу. Так и появится эта серия заметок, собирающая наиболее отвратительные примеры, на которые очень легко наткнуться при решении повседневных задач. - "Преждевременная оптимизация -- корень всех зол" (Д. Кнут или Э. Хоар -- в зависимости от того, какой источник смотрите). Язык С++, пожалуй, наиболее яркая тому демонстрация: огромное количество ошибок в C++ программах свзязаны -с неопределенным поведением, заложенным в фундаменте языка просто для того, чтобы дать простор оптимизациям. +с неопределенным поведением, заложенным в фундаменте языка просто для того, чтобы дать простор оптимизациям на этапе компиляции. -Если вы собираетесь писать на C++ код, в работоспособности которого вы хотите быть хоть немного уверенными, вам стоит знать о существовании различных подводных камней, ловко расставленных мин в стандарте языка и всячески их избегать. Иначе ваши программы будут работать правильно только на конкретной машине и только по воле случая. +Если вы собираетесь писать на C++ код, в работоспособности которого хотите быть хоть немного уверенными, вам стоит знать о существовании различных подводных камней и ловко расставленных мин в стандарте языка, и всячески их избегать. Иначе ваши программы будут работать правильно только на конкретной машине и только по воле случая. @@ -27,5 +18,8 @@ # Содержание 0. [Как искать UB?](how_to_find_ub.md) -1. Целые и вещественные числа - 1. [Переполнение знаковых целых чисел](integers/overflow.md) \ No newline at end of file +1. [Сужающие преобразования](numeric/narrowing.md) +2. Целые и вещественные числа + 1. [Переполнение знаковых целых чисел](numeric/overflow.md) + 2. [Числа с плавающей точкой](numeric/floats.md) +3. Нарушения lifetime объектов \ No newline at end of file diff --git a/lifetime/use_after_free.md b/lifetime/use_after_free.md new file mode 100644 index 0000000..5a36b41 --- /dev/null +++ b/lifetime/use_after_free.md @@ -0,0 +1,270 @@ +# Висячие ссылки, указатели и use-after-free + +80% случаев неопределенного поведения в C++ связаны с ними. + +Объект жил на стеке и умер. Или объект жил в куче и умер. Разница по сути не очень большая: обобщенный сценарий воспроизведения ошибки один и тот же -- где-то остались указатель или ссылка на уже мертвый объект. А потом этой ссылкой (или указателем) воспользовались, чтобы обратиться к мертвому объекту. Такой спиритический сеанс заканчивается неопределенным поведением. Если повезет -- будет ошибка сегментации с возможностью узнать, кто именно обратился. + +Но все же между мервым объектом со стека или мервым объектом из кучи есть разница в возможности обнаружения методами динамического анализа: +Для инструментации стека санитайзерами вообще говоря нужно перекомпилировать программу. Для инструментации кучи -- можно подменить библиотеку с аллокатором. + +------ +Конечно, в жизни почти никто и никогда явно не пишет некорректный код вида +```C++ + +int main() { + int* p = nullptr; + { + int x = 5; + p = &x; + } + return *p; +} + +``` + +Но проблема в том, что подобный код в языке С++ может быть ловко замаскирован под слоем +абстракций из классов и функций. + +Простой пример: + +```C++ +int main() { + int x = 11; + auto&& min_x_10 = std::min(x, 10); + return min_x_10; +} +``` + +В [этом](https://godbolt.org/z/zWh1j7) коде неопределенное поведение из-за висячей ссылки. + +Проблема в том, что `std::min` объявлен как +```C++ +template const T& min(const T& a, const T& b); +``` + +Число `10` является временным объектом (_prvalue_), который умирает сразу же по выходе из функции `std::min`. + +В C++ разрешено присваивать временные объекты константным ссылкам. В таком случае константная ссылка продлевает временному объекту жизнь (объект "материализуется") и живет до выхода ссылки из области видимости. Дальнейшие присваивания константным ссылкам эффекта продлевания времени жизни не имеют. + +Любой код, возвращающий из функции или метода ссылку или сырой указатель, является потенциальным источником проблем где угодно. +Код, который только принимает аргументы по ссылке и никуда эти ссылки не сохраняет, также может быть источником проблем, но в куда более неочевидных ситуациях. + +```C++ +template +void append_n_copies(std::vector* elements, const T& x, int N) { + for (int i = 0; i < N; ++i) { + elements->push_back(x); + } +} + +void foo() { + std::vector v; v.push_back(10); + ... + append_n_copies(&v, v.front(), 5); // будет UB при реаллокации вектора! +} +``` +У такого кода есть все шансы появиться в реальном проекте и доставить множество неприятностей. + +В некритичных к производительности учатсках кода лучше использовать передачу по значению и перемещение вместо передачи по ссылке. Это, увы, также не снимает всех проблем, но ошибка в программе будет явно локализована в точке вызова функции, а не размазана по ее телу. + +```C++ +template +std::vector append_n_copies(std::vector elements, T x, int N) { + for (int i = 0; i < N; ++i) { + elements.push_back(x); + } + return elements; // implicit move +} + +void foo() { + std::vector 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`, которому нельзя присваивать временные объекты. + +```C++ +#include + +template +std::reference_wrapper safe_min(std::reference_wrapper a, + std::reference_wrapper b){ + return std::min(a, b); +} + +int main() { + const int x = 11; + auto&& y = safe_min(x, 11); // compilation error +} +``` + +Или, с помощью _forwarding references_ проанализировать категорию (rvalue/lvlaue) переданного аргумента и решить, что с ним делать. На С++20 это выглядит так: + +```C++ +#include + +template +requires std::is_same_v, + std::decay_t> // std::min требует одинаковых типов +decltype(auto) // выводим тип без отбрасывания ссылок +safe_min(T1&& a, T2&& b) { // forwarding reference на каждый аргумент. + if constexpr (std::is_lvalue_reference_v && + std::is_lvalue_reference_v) { + // оба аргумента были lvalue -- можно безопасно вернуть ссылку + return std::min(a, b); + } else { + // один из аргументов -- временный объект. + // возвращаем по значению. + // для этого делаем копию + auto temp = std::min(a,b); // auto&& нельзя! + // иначе return выведет ссылку + return temp; + } +} +``` + + +Конкретно для функций `std::min` и `std::max` в стандартной библиотеке есть безопасные версии, принимающие аргументы по значению и также возвращающие результат по значению. +Более того, они "поддерживают" более двух аргументов. + +```C++ +const int x = 11; +const int y = 20; +auto&& y = std::min({x, 10, 15, y}); // OK +``` + +Может показаться, что проблема возврата ссылок касается только `const` ссылок. С неконстантными ссылками никаких паразитных продлений жизни нет, и все должно быть хорошо. Однако, это не совсем так. + +---- + +Все вышеописанное рассматривало только свободные функции и, что то же самое, статическим методы классов. + +Но с методами классов возврат ссылок -- обычное дело. И проблемы с ними те же, но менее явные. Неявность связана с передачей указателя `this` на текущий объект. + +Так, например, безопасная реализация условного _Builder_ с поддержкой вызовов методов по цепочке оказывается весьма нетривиальной. + +```C++ +class VectorBuilder { + std::vector v; + +public: + VectorBuilder& Append(int x) { + v.push_back(x); + return *this; + } + + const std::vector& GetVector() { return v; } +}; + +int main() { + auto&& v = VectorBuilder{} + .Append(1) + .Append(2) + .Append(3) + .GetVector(); // dangling reference +} +``` + +Проблема опять в умирающем объекте, вернувшем ссылку на свое содержимое. + +Если мы перегрузим лишь `GetVector`, чтобы различать lvalue и rvalue, проблема не исчезнет: + +```C++ +class VectorBuilder { + ... + const std::vector& GetVector() & { + std::cout << "As Lvalue\n"; + return v; + } + + std::vector GetVector() && { + std::cout << "As Rvalue\n"; + return std::move(v); + } +}; +``` + +Мы [получим](https://godbolt.org/z/3aGP53) сообщение "As Lvalue". Цепочка `Append` +неявно превратила безымянный временный объект в не совсем временный. + +`Append` также нужно перегрузить для разбора случаев rvalue и lvalue: + +```C++ +class VectorBuilder { + ... + VectorBuilder& Append(int x) & { + v.push_back(x); + return *this; + } + + VectorBuilder&& Append(int x) && { + v.push_back(x); + return std::move(*this); + } +}; +``` + +Мы справились с висячей ссылкой на содержимое вектора. + +Однако, если мы захотим написать так +```C++ +auto&& builder = VectorBuilder{}.Append(1).Append(2).Append(3); +``` + +Опять получим висячую ссылку, но уже на сам объект `VectorBuilder`. Причем выкидывание перегрузки `Append` тут не при чем -- неявный `this` успевал прибиваться к временному объекту, и единоразово продлевать ему жизнь. + +Чтобы этого избежать, нам нужно: + +Либо настраивать линтер, запрещающий использовать `auto&&` и `const auto&` c этим классом в правой части. + +Либо жертвовать производительностью, и в rvalue версии `Append` возвращать по значению (+ move) -- при большом количестве примитивных, всегда копируемых, объектов внутри просадка будет заметной. + +Либо в принципе запретить использовать `VectorBuilder` в rvalue контексте: +```C++ +class VectorBuilder { + ... + auto Append() && = delete; +} +``` +Но тогда строить цепочки, начиная с безымянного временного объекта, будет нельзя. + + +Также, не стоит никогда [играть](https://godbolt.org/z/6xbcrM) с цепочками операций `op=` (`+=`, `-=`, `/=`) над временными объектами. Для них редко когда обрабатывают rvalue случай: + +```C++ +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 +} +``` + +Или с [использованием](https://godbolt.org/z/Yc9MWj) типов стандартной бибилиотеки: +```C++ +int main() { + using namespace std::literals; + auto&& m = "hello"s += "world"; + std::cout << m; // dangling reference +} +``` +--------------- \ No newline at end of file diff --git a/numeric/floats.md b/numeric/floats.md new file mode 100644 index 0000000..f156997 --- /dev/null +++ b/numeric/floats.md @@ -0,0 +1,33 @@ +# Числа с плавающей точкой + +С `float` и `double` в принципе всегда все сложно. Особенно в C++. + +Стандарт C++ не требует следования стандарту IEEE 754, потому деление на ноль в вещественных числах также считается неопределенным поведением, несмотря на то, что +по IEEE 754 выражение `x/0.0` определяется как `-INF`, `NaN`, или `INF` в зависимости от знака числа `x` (`NaN` для нуля). + +Сравнение вещественных чисел -- излюбленная головная боль. +Вещественные числа до C++20 нельзя было использовать в качестве параметров-значений в шаблонах. Теперь же можно. Правда, ожидать, что вы насчитаете в run-time и в compile-time одно и то же -- [не стоит](https://godbolt.org/z/q55891). + +Выражение `x == y` фактически является кривым побитовым сравнением для чисел с плавающей точкой, по особенному работающее со случаями `-0.0` и `+0.0`, и `NaN`. +О существовании этого и `!=` операторов для вещественных чисел стоит забыть и никогда не вспонимать. + +Для побитового сравнения нужно использовать `memcmp`. +Для сравнения чисел -- приближенные варианты вида `std::abs(x - y) < EPS`, где `EPS` -- какое-то абсолютное или вычисляемое на основе `x` и `y` значение. А также различные манипуляции с [`ULP`](https://en.wikipedia.org/wiki/Unit_in_the_last_place) сравниваемых чисел. + +Так как стандарт C++ не форсирует IEEE 754, +проверки на `x == NaN` через его свойство `(x != x) == true` могут быть убраны компилятором, как заведомо ложные. Проверять нужно с помощью предназначенных для этого +функций `std::isnan`. + +Поддерживается или нет IEEE 754 можно проверить с помощью предопределенной константы +`std::numeric_limits::is_iec559` + +Сужающие преобразования из `float` в знаковые или беззнаковые целые могут повлечь неопределенное поведение, если значение непредставимо в целочисленном типе. Никаких обрезок по модулю `2^N` не предполагается. + +```C++ +constexpr uint16_t x = 1234567.0; // CE, undefined behavior +``` + + +### Полезные ссылки +1. https://en.cppreference.com/w/cpp/numeric/math/isnan +2. https://bitbashing.io/comparing-floats.html \ No newline at end of file diff --git a/integers/narrowing.md b/numeric/narrowing.md similarity index 100% rename from integers/narrowing.md rename to numeric/narrowing.md diff --git a/integers/overflow.md b/numeric/overflow.md similarity index 94% rename from integers/overflow.md rename to numeric/overflow.md index d02b63e..1fa9723 100644 --- a/integers/overflow.md +++ b/numeric/overflow.md @@ -168,6 +168,14 @@ ErrorOrInteger mod(I a, std::type_identity_t b) { Также проблемы неопределенного поведения при переполнении касаются битовых сдвигов влево для отрицательных чисел (или при сдвиге положительного числа с залезанием в знаковый бит). Начиная с C++20, стандарт требует фиксированной единой реализации отрицательных чисел -- через дополнительный код ([_two's complement_](https://en.wikipedia.org/wiki/Two%27s_complement)), и многие проблемы сдвигов сняты. Тем не менее все равно стоит следовать общей рекомендации: любые битовые операции выполнять только в `unsigned` типах. +Стоит заметить, что сужающее преобразование из целочисленного типа в другой целочисленный тип к неопределенному поведению не приводит, и выполнять побитовое `и` с маской перед присваиванием переменной меньшего типа не обязательно. Но желательно, чтобы избежать предупреждений компилятора +```C++ + constexpr int x = 12345678; + constexpr uint8_t first_byte = x; // Implicit cast. Warning +``` + + + ### Полезные ссылки 1. https://wiki.sei.cmu.edu/confluence/display/c/INT32-C.+Ensure+that+operations+on+signed+integers+do+not+result+in+overflow 2. \ No newline at end of file