This commit is contained in:
Dmis
2020-12-17 22:41:53 +03:00
parent a4eac7f62f
commit 9f793b86ba
5 changed files with 318 additions and 13 deletions

View File

@@ -6,19 +6,10 @@
-------------- --------------
Все начинается просто и незатейливо: обычный десятиклассник увлекается программированием, знакомится с алгоритмическими задачками, решения которых должны быть быстрыми. Узнает о языке C++, учит минимальный синтаксис, основные конструкции, контейнеры, решает задачи с предопределенным и всегда корректным форматом ввода и вывода, и горя не знает...
В это же время, где-то в большом мире, матерые разработчики каждый день ругают то одни языки программирования, то другие. По самым разным причинам:
не удобно, нет какой-то возможности, много лишних букв писать, ошибки в стандартной библиотеке... Но есть язык, который ругают за все и особенно за такую непонятную и таинственную вещь как неопределенное поведение (undefined behavior, UB).
Спустя лет пять или шесть наш простой десятиклассник, горя не видавший в море оторванных от реальности программ, внезапно узнает, что тем самым горячо нелюбимым языком всегда был, остается и будет его C++.
А потом еще в течение нескольких лет он наткнется на самые кошмарные и невероятные ужасы, поджидающие программистов на C++ почти на каждом шагу. Так и появится эта серия заметок, собирающая наиболее отвратительные примеры, на которые очень легко наткнуться при решении повседневных задач.
"Преждевременная оптимизация -- корень всех зол" (Д. Кнут или Э. Хоар -- в зависимости от того, какой источник смотрите). Язык С++, пожалуй, наиболее яркая тому демонстрация: огромное количество ошибок в C++ программах свзязаны "Преждевременная оптимизация -- корень всех зол" (Д. Кнут или Э. Хоар -- в зависимости от того, какой источник смотрите). Язык С++, пожалуй, наиболее яркая тому демонстрация: огромное количество ошибок в C++ программах свзязаны
с неопределенным поведением, заложенным в фундаменте языка просто для того, чтобы дать простор оптимизациям. с неопределенным поведением, заложенным в фундаменте языка просто для того, чтобы дать простор оптимизациям на этапе компиляции.
Если вы собираетесь писать на C++ код, в работоспособности которого вы хотите быть хоть немного уверенными, вам стоит знать о существовании различных подводных камней, ловко расставленных мин в стандарте языка и всячески их избегать. Иначе ваши программы будут работать правильно только на конкретной машине и только по воле случая. Если вы собираетесь писать на C++ код, в работоспособности которого хотите быть хоть немного уверенными, вам стоит знать о существовании различных подводных камней и ловко расставленных мин в стандарте языка, и всячески их избегать. Иначе ваши программы будут работать правильно только на конкретной машине и только по воле случая.
@@ -27,5 +18,8 @@
# Содержание # Содержание
0. [Как искать UB?](how_to_find_ub.md) 0. [Как искать UB?](how_to_find_ub.md)
1. Целые и вещественные числа 1. [Сужающие преобразования](numeric/narrowing.md)
1. [Переполнение знаковых целых чисел](integers/overflow.md) 2. Целые и вещественные числа
1. [Переполнение знаковых целых чисел](numeric/overflow.md)
2. [Числа с плавающей точкой](numeric/floats.md)
3. Нарушения lifetime объектов

270
lifetime/use_after_free.md Normal file
View File

@@ -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<class T> const T& min(const T& a, const T& b);
```
Число `10` является временным объектом (_prvalue_), который умирает сразу же по выходе из функции `std::min`.
В C++ разрешено присваивать временные объекты константным ссылкам. В таком случае константная ссылка продлевает временному объекту жизнь (объект "материализуется") и живет до выхода ссылки из области видимости. Дальнейшие присваивания константным ссылкам эффекта продлевания времени жизни не имеют.
Любой код, возвращающий из функции или метода ссылку или сырой указатель, является потенциальным источником проблем где угодно.
Код, который только принимает аргументы по ссылке и никуда эти ссылки не сохраняет, также может быть источником проблем, но в куда более неочевидных ситуациях.
```C++
template <class T>
void append_n_copies(std::vector<T>* elements, const T& x, int N) {
for (int i = 0; i < N; ++i) {
elements->push_back(x);
}
}
void foo() {
std::vector<int> v; v.push_back(10);
...
append_n_copies(&v, v.front(), 5); // будет UB при реаллокации вектора!
}
```
У такого кода есть все шансы появиться в реальном проекте и доставить множество неприятностей.
В некритичных к производительности учатсках кода лучше использовать передачу по значению и перемещение вместо передачи по ссылке. Это, увы, также не снимает всех проблем, но ошибка в программе будет явно локализована в точке вызова функции, а не размазана по ее телу.
```C++
template <class T>
std::vector<T> append_n_copies(std::vector<T> elements, T x, int N) {
for (int i = 0; i < N; ++i) {
elements.push_back(x);
}
return elements; // implicit move
}
void foo() {
std::vector<int> v; v.push_back(10);
...
// v = append_n_copies(std::move(v), v.front(), 5);
// UB, use-after-move, порядок вычисления аргументов неопределен:
// v.front() может быть вызван на пустом векторе
auto el = v.front();
v = append_n_copies(std::move(v), std::move(el), 5);
}
```
Если нужно работать со ссылками, стоит озаботиться их безопасностью.
Например, можно использовать `std::reference_wrapper`, которому нельзя присваивать временные объекты.
```C++
#include <utility>
template <class T>
std::reference_wrapper<const T> safe_min(std::reference_wrapper<const T> a,
std::reference_wrapper<const T> b){
return std::min(a, b);
}
int main() {
const int x = 11;
auto&& y = safe_min<int>(x, 11); // compilation error
}
```
Или, с помощью _forwarding references_ проанализировать категорию (rvalue/lvlaue) переданного аргумента и решить, что с ним делать. На С++20 это выглядит так:
```C++
#include <type_traits>
template <class T1, class T2>
requires std::is_same_v<std::decay_t<T1>,
std::decay_t<T2>> // std::min требует одинаковых типов
decltype(auto) // выводим тип без отбрасывания ссылок
safe_min(T1&& a, T2&& b) { // forwarding reference на каждый аргумент.
if constexpr (std::is_lvalue_reference_v<decltype(a)> &&
std::is_lvalue_reference_v<decltype(b)>) {
// оба аргумента были lvalue -- можно безопасно вернуть ссылку
return std::min(a, b);
} else {
// один из аргументов -- временный объект.
// возвращаем по значению.
// для этого делаем копию
auto temp = std::min(a,b); // auto&& нельзя!
// иначе return выведет ссылку
return temp;
}
}
```
Конкретно для функций `std::min` и `std::max` в стандартной библиотеке есть безопасные версии, принимающие аргументы по значению и также возвращающие результат по значению.
Более того, они "поддерживают" более двух аргументов.
```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<int> v;
public:
VectorBuilder& Append(int x) {
v.push_back(x);
return *this;
}
const std::vector<int>& GetVector() { return v; }
};
int main() {
auto&& v = VectorBuilder{}
.Append(1)
.Append(2)
.Append(3)
.GetVector(); // dangling reference
}
```
Проблема опять в умирающем объекте, вернувшем ссылку на свое содержимое.
Если мы перегрузим лишь `GetVector`, чтобы различать lvalue и rvalue, проблема не исчезнет:
```C++
class VectorBuilder {
...
const std::vector<int>& GetVector() & {
std::cout << "As Lvalue\n";
return v;
}
std::vector<int> GetVector() && {
std::cout << "As Rvalue\n";
return std::move(v);
}
};
```
Мы [получим](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
}
```
---------------

33
numeric/floats.md Normal file
View File

@@ -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<FloatType>::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

View File

@@ -168,6 +168,14 @@ ErrorOrInteger<I> mod(I a, std::type_identity_t<I> b) {
Также проблемы неопределенного поведения при переполнении касаются битовых сдвигов влево для отрицательных чисел (или при сдвиге положительного числа с залезанием в знаковый бит). Начиная с C++20, стандарт требует фиксированной единой реализации отрицательных чисел -- через дополнительный код ([_two's complement_](https://en.wikipedia.org/wiki/Two%27s_complement)), и многие проблемы сдвигов сняты. Тем не менее все равно стоит следовать общей рекомендации: любые битовые операции выполнять только в `unsigned` типах. Также проблемы неопределенного поведения при переполнении касаются битовых сдвигов влево для отрицательных чисел (или при сдвиге положительного числа с залезанием в знаковый бит). Начиная с 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 1. https://wiki.sei.cmu.edu/confluence/display/c/INT32-C.+Ensure+that+operations+on+signed+integers+do+not+result+in+overflow
2. 2.