- PVSM.RU - https://www.pvsm.ru -
Изучение и понимание неопределённого поведения — важный шаг для разработчика C++, поскольку undefined behavior бывает источником серьёзных ошибок и проблем в программах. UB может проявляться в разных аспектах языка, включая операции с памятью, многопоточность, арифметические вычисления, работу с указателями и так далее.
Под катом мы погрузимся в мир неопределённого поведения в C++ и рассмотрим некоторые примеры ситуаций, в которых оно может возникать.
P.S.: Часть приведённых в статье примеров вдохновлены материалами, которые можно посмотреть в разделе «Полезные ссылки».
Привет! Меня зовут Владислав Столяров, в МойОфис я аналитик безопасности продуктов. Чаще всего я взаимодействую с командами, которые создают решения на C и C++, и сегодня хочу обратиться к теме неопределённого поведения — рассказать, что это, в чем проявляется и как с ним работать. Это первая часть моего мини-цикла статей по UB: в ней я наглядно обозначу проблематику с помощью набора практических примеров.
Для начала приведу несколько определений из стандарта С++ (в моём авторском переводе):
Корректно составленная программа (Well-formed program) — программа, созданная в соответствии с правилами синтаксиса, диагностируемыми семантическими правилами и правилом одного определения.
Некорректно написанная программа (Ill-formed program) — программа, которая нарушает либо синтаксические, либо семантические правила (либо и те, и другие). Она не должна компилироваться.
Неуточнённое поведение (Unspecified behavior) — поведение программы, где стандарт языка допускает два или более варианта и не налагает никаких других требований на выбор в каждом конкретном случае. Классическим примером неуточнённого поведения является порядок вычисления аргументов функции:
#include <iostream>
int f()
{
std::cout << "Fn";
return 0;
}
int h()
{
std::cout << "Hn";
return 1;
}
int foo(int i, int j)
{
return j - i;
}
int main()
{
return foo(f(), h());
}
В данном примере у нас есть 2 функции f
и h,
которые возвращают 0
и 1
и выводят в консоль F
и H
соответственно. Также у нас есть функция foo
, которая принимает 2 числа и возвращает их разницу. При вызове функции foo
из функции main
порядок вызова функций f
и h
неуточнён и может быть любым [1].
Поведение, зависящее от реализации (implementation defined behavior) — неуточнённое поведение, которое задокументировано в компиляторе или среде исполнения. Это очень интересная особенность языка, различные реализации по-разному описывают значение функции pow(0,0)
, тип vector::iterator
и даже количество бит в байте. Подробнее можно почитать тут [2].
Неуточнённое поведение и поведение, зависящее от реализации объединяет один печальный фактор. Программы, которые содержат их, непереносимы.
Неопределённое поведение (undefined behavior или просто UB) — поведение программы, которое может привести к абсолютно непредсказуемым последствиям. При этом программа корректна синтаксически и семантически.
Для более детального понимания, что это такое, рассмотрим пример:
#include <iostream>
int main()
{
while(1);
}
void unreachable()
{
std::cout << "Hello" << "/n";
}
В функции main
есть бесконечный цикл while(1)
, который означает, что программа будет выполняться бесконечно. В данном случае цикл не имеет никакого условия выхода, поэтому программа будет выполняться до тех пор, пока не будет принудительно прервана. Функция unreachable
определена, но не вызывается из функции main
, поэтому она никогда не будет выполнена. Код внутри функции unreachable
, который выводит строку "Hello"
на стандартный вывод с помощью std::cout
, не будет выполнен ни разу.
На самом же деле, всё это не совсем так — вернее даже, совсем не так. Вывод данной программы может быть любым. Всё из-за того, что по стандарту С++ бесконечный цикл в программе вызывает неопределённое поведение (в случае С11 бесконечный цикл с константой в условии не является UB). Если запустить данный код на компиляторах clang и gcc, то можно увидеть, что clang запустит недостижимую функцию unreachable
, она выведет на экран "Hello"
, вот подтверждение [3]. О том, почему это происходит, подробнее мы поговорим ниже.
Когда задумываешься о проблеме неопределённого поведения, одним из первых в голову приходит вопрос: зачем оно вообще нужно? Есть же промышленные языки вроде Java, C# и множества других, обходящихся без этой фичи. Между тем это именно фича, и вот почему.
С моей точки зрения, С и С++ довольно продуманные языки, и наличие в них UB, конечно же, логически обосновано. Среди прочего оно позволяет:
Не реагировать компилятору на некоторые ошибки, трудные в диагностике
Избегать определения запутанных мест в пользу одной из стратегий реализации и в ущерб другой
Иметь своё определение неопределённого поведения в случае с каждой реализацией компилятора
Устранить накладные расходы на проверку разных граничных случаев
В целом же неопределённое поведение даёт компилятору неограниченный простор для оптимизаций. Наличие в коде UB создаёт так называемые «серые зоны», право не видеть которые оставляет за собой компилятор. Ниже — пара примеров, как это работает на практике.
Вот упрощённый пример, написанный на основе реальной ошибки из ядра операционной системы Linux.
void foo(int *ptr)
{
int d = *ptr;
if (ptr == NULL)
return;
*ptr = 777;
}
Здесь функция foo
присваивает значение, на которое указывает переданный указатель, в локальную переменную d
. Затем, если указатель не является нулевым, она изменяет значение, на которое он указывает, на 777
.
На представленном фрагменте кода можно применить 2 оптимизации: Dead Code Elimination (DCE) и Redundant Null Check Elimination (RNCE). Вопрос только в порядке применения :)
Например, оптимизатор применяет DCE на локальную переменную d
, которая определяется, но не используется. Тогда фрагмент кода после оптимизаций станет таким:
void foo(int *ptr)
{
if (ptr == NULL)
return;
*ptr = 777;
}
Но если первой отработает RNCE, то код станет таким (оптимизатор видит, что ptr
проверяется на NULL
уже после разыменования, соответственно, проверка бессмысленна):
void foo(int *ptr)
{
int d = *ptr;
if (false)
return;
*ptr = 777;
}
Далее на данном фрагменте кода может запуститься DCE:
void foo(int *ptr)
{
*ptr = 777;
}
Порой бывает очень грустно отлаживать падение на релизной сборке, по которой уже прошелся оптимизатор, в своем отладочном окружении, в котором уже даже нет такого кода.
Рассмотрим несколько паттернов неопределённого поведения.
Большинство [4]ошибок при работе с С и C++ связанно с неправильной работой с памятью. Часть из них отлавливается компилятором и операционной системой. Например, знаменитый Segfault — следствие неправильной работы с памятью. В итоге программист видит надпись segmentation fault (core dumped) под Linux.
Первая из проблем, которую можно рассмотреть — выход за границу массива. Она обычно актуальна для массивов или контейнеров, которые хранят элементы в непрерывном куске памяти. Работа с такими контейнерами при помощи operator[]
является весьма распространенным действием. Вот синтетический пример, который демонстрирует это:
#include <iostream>
int main()
{
const int SIZE = 5;
int* dynamicArray = new int[SIZE];
for (int i = 0; i <= SIZE; i++)
{
dynamicArray[i] = i;
}
for (int i = 0; i <= SIZE; i++)
{
std::cout << dynamicArray[i] << std::endl;
}
delete[] dynamicArray;
return 0;
}
В данном примере мы создаем динамический массив dynamicArray
с помощью оператора new
. Размер массива задается константой SIZE
, равной 5
. Затем мы выполняем два цикла for
. В первом цикле мы пытаемся присвоить значения элементам массива в диапазоне от 0
до 5
. Однако последний элемент массива имеет индекс 4
, так как индексация массивов в C++ начинается с 0
. В результате, при выполнении цикла происходит выход за границы массива.
Затем во втором цикле мы также пытаемся обратиться к элементам массива с индексами от 0
до 5
. Опять же, это приводит к выходу за границы массива.
Для исправления данной ошибки необходимо изменить условия циклов for
на i < SIZE
, чтобы гарантировать, что индексы остаются в допустимых пределах массива.
Также довольно часто возникает проблема с выделением и очисткой памяти. Для работы с динамической памятью язык С предлагает несколько функций: malloc
, calloc
, realloc
и free
для очистки памяти. Для языка С всё просто: функции, выделяющие память, возвращают указатель на начало выделенной памяти в случае удачи и NULL
в случае неудачи, память чистится функцией free
.
C++ предлагает операторы new
и delete
и их различные версии:
При использовании оператора new
, вначале выделяется память для объекта. В случае успешного выделения памяти, вызывается конструктор объекта. Однако, если конструктор выбрасывает исключение, выделенная память немедленно освобождается.
При вызове оператора delete
, всё происходит в обратном порядке. Сначала вызывается деструктор объекта для его очистки, а затем освобождается память. Важно отметить, что деструктор не должен бросать исключения.
Оператор new[]
используется для создания массива объектов, сначала выделяется память для всего массива. В случае успешного выделения памяти, вызывается конструктор по умолчанию (или другой конструктор, если есть инициализатор) для каждого элемента массива, начиная с нулевого индекса. Если какой-либо конструктор выбрасывает исключение, для всех созданных элементов массива вызывается деструктор в обратном порядке, согласно порядку, обратному вызову конструктора. После этого освобождается выделенная память.
Для удаления массива необходимо использовать оператор delete[]
. При вызове данного оператора, для каждого элемента массива вызывается деструктор в порядке, обратном вызову конструктора, после чего выделенная память освобождается.
Операторы new
/new[]
возвращают указатель/массив указателей для доступа к новому объекту/объектам в случае успешного выделения памяти или бросают исключение std::bad_alloc
в случае неудачного выделения. Также у операторов есть перегрузки, принимающие std::nothrow
, они вместо броска исключения возвращают нулевой указатель. И в случае С++17 у операторов выделения/освобождения памяти есть перегрузки, принимающие std::align_val_t
, для указания выравнивания.
Важно использовать соответствующую форму оператора delete
в зависимости от того, удаляется ли одиночный объект или массив. Это правило не должно быть нарушено ни при каких обстоятельствах, поскольку это может привести к возникновению неопределенного поведения, в результате которого могут произойти самые разные ситуации: утечки памяти, аварийное завершение программы.
Подытоживая, можно сказать, что довольно много ошибок происходит при неправильном комбинировании операторов для выделения/очистки памяти. Например:
new→delete[]
new[]→free
new→free
new[]→delete
etc
При использовании оператора new[]
для выделения памяти под массив объектов, их количество должно где-то храниться. Обычно в компиляторах существует 2 стратегии для этого: Over-Allocation для записи количества элементов перед самим массивом и хранение количества элементов в обособленном ассоциативном контейнере. Таким образом, когда зовётся оператор delete[]
, он знает, в каком месте смотреть на количество объектов, для которых нужно позвать деструкторы и почистить память.
Частая проблема возникает при неправильном комбинировании данных операторов. Например, напишем такой фрагмент кода:
#include <memory>
void foo(unsigned len)
{
auto inv = std::unique_ptr<char>(new char [len]);
//...
}
Здесь мы решили обернуть выделение динамической памяти в умный указатель, который очистит её самостоятельно, после выхода из области видимости. Однако стоит обратить внимание, что std::unique_ptr
инстанцирован типом char
, а выделяется память для char[]
. При вызове деструктора std::unique_ptr
, он вызовет деструктор именно для типа, которым он инстанцируется, а не для массива объектов. Соответственно, удаление объекта будет производиться другой deallocation-функцией, что согласно стандарту будет неопределенным поведением; вот ссылка [5]на соответствующий пункт стандарта.
Также довольно часто возникают ситуации со знаковым целочисленным переполнением. Например, мы хотим написать простую функцию, которая выводит числа на экран:
#include <iostream>
int main(int argc, const char *argv[])
{
for (int i = 0; i < 10; ++i)
{
std::cout << 1'000'000'000 * i << std::endl;
}
}
Если мы скомпилируем и запустим данный код с O0 (флаг gcc для компиляции без оптимизаций), то произойдёт переполнение типа int
, программа выведет на экран 1'000'000
, 2'000'000
, 8 случайных чисел — и остановится (на самом деле программа опять-таки может повести себя как угодно, всё зависит от компилятора, его версии и среды). Однако, если включить оптимизации (например, скомпилировать с флагом O3), то под gcc программа завершится аварийно, из-за того, что цикл станет бесконечным.
Почему это происходит? На самом деле, когда программист пишет код на C++, он заключает определённый «контракт» с компилятором. Разработчик обязуется писать корректный с точки зрения стандарта С++ код, а компилятор — компилировать и оптимизировать код наилучшим образом. Тогда как в примере компилятор, видя, что условие цикла ведёт к переполнению типа int
и зная, что случиться этого не может, делает условие всегда true
.
#include <iostream>
int main(int argc, const char *argv[])
{
for (int i = 0; true; ++i)
{
std::cout << 1'000'000'000 * i << std::endl;
}
}
Стоит отметить, что большинство компиляторов под оптимизациями сделают из такого кода:
bool foo(int x)
{
return (x + 1) > x;
}
такой:
bool foo(int x)
{
return true;
}
Также отмечу, что если заменить int
на unsigned
, то оптимизация выполняться не будет, например, у GCC это поведение контролируется флагом -fwrapv (он включен в ядре Linux).
А из такого кода:
int foo(int x)
{
return (2 * x)/2;
}
получится такой:
int foo(int x)
{
return x;
}
По стандарту С++, использование неинициализированной переменной приводит к неопределённому поведению. Давайте рассмотрим пример:
#include <iostream>
int foo(bool c)
{
int x,y;
y = c ? x : 777;
return y;
}
int main()
{
std::cout << foo(true) << std::endl;
}
Внутри функции foo
объявляются две целочисленные переменные x
и y
. Затем переменной y присваивается значение, зависящее от условия. Условие c ? x : 777
означает, что если значение переменной c
истинно, то в y
будет присвоено значение переменной x
. В противном случае, если c
ложно, то в y
будет присвоено значение 777
. В функции main
происходит вызов функции foo
с аргументом true
.
Кажется, что итоговым результатом выполнения данного кода будет вывод в консоль числа, которое зависит от значения переменной x
, если c
истинно, или 777
, если c
ложно. Однако x
— неиницализированная переменная, использование которой ведёт к неопределённому поведению. Компилятор знает об этом и может оптимизировать код на основе этого знания. Таким образом, на подавляющем большинстве компиляторов данный код выведет на экран значение 777
. Не самый очевидный исход, верно?
Сперва я хотел написать большой абзац о том, что такое Integral promotion, как он работает в рамках usual arithmetic conversions [6] и зачем он нужен, однако вовремя вспомнил, что уже делал это в одной из своих статей. Там я рассказал, как писал механизм для вывода общего типа в одном известном статическом анализаторе. Если вам интересна тема, ознакомиться можно тут: Статья для тех, кто как и я не понимает, зачем нужен std::common_type [7].
Вот ссылка [8]на соответствующий пункт стандарта. В нём говорится, что при операциях над целыми числами может произойти целочисленное продвижение. Например, если перемножить 2 операнда, размерностью меньше, чем int
, результат будет приведён к типу int
. Вот к чему это может привести:
int main()
{
unsigned short a = 65535;
unsigned short b = 65535;
auto c = a * b;
return 0;
}
Да, переполнение unsigned
числа — это не UB, однако автоматически выведенный тип переменной c
будет int
. Результат выражения 65535 * 65535
больше, чем INT_MAX
, соответственно, данный код приведёт к неопределённому поведению — результат программы непредсказуем.
Согласно стандарту С++, если вторым операндом бинарной операции с целыми числами / или % будет 0
, то результат — неопределённое поведение. Для деления на 0 вещественных чисел работают уже совсем другие правила, подробнее про это можно почитать тут [9] в разделе Additive operators. Важно не перепутать вещественные числа с целыми и не написать, например, такой код для генерации значения «бесконечность»:
auto create_inf (unsigned x)
{
return x / 0;
}
Неопределённое поведение в C++ — феномен, результат которого невозможно предсказать. Никто не знает, как будет вести себя код, содержащий UB. Из этого следует, что при разработке ПО следует придерживаться простого и понятного кода. Сложные и запутанные конструкции могут привести к непредсказуемым последствиям. Важным аспектом профессионализма в программировании является способность написать безопасный и надежный код, который легко читать и поддерживать. Это подразумевает использование ясных и понятных конструкций, а также следование лучшим практикам программирования.
Конечно, количество ситуаций, которые могут привести к неопределённому поведению огромно. Мы рассмотрели всего несколько распространенных случаев.
Скоро выйдет вторая часть статьи, в ней мы поговорим о том, как можно защититься от неопределённого поведения. И разберём еще больше примеров UB.
Список материалов, которые стоит изучить, чтобы глубже понять тему неопределённого поведения:
Standard C++ (in Russian) :: Часть 2, Неопределённое поведение [10]
What Every C Programmer Should Know About Undefined Behavior.1 [11]
What Every C Programmer Should Know About Undefined Behavior.2 [12]
What Every C Programmer Should Know About Undefined Behavior.3 [13]
Статья для тех, кто как и я не понимает, зачем нужен std::common_type [7]
Автор: Vladislav Stolyarov
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/385635
Ссылки в тексте:
[1] любым: https://godbolt.org/#z:OYLghAFBqd5QCxAYwPYBMCmBRdBLAF1QCcAaPECAMzwBtMA7AQwFtMQByARg9KtQYEAysib0QXABx8BBAKoBnTAAUAHpwAMvAFYTStJg1DIApACYAQuYukl9ZATwDKjdAGFUtAK4sGIM6SuADJ4DJgAcj4ARpjE/gDMpAAOqAqETgwe3r7%2ByanpAiFhkSwxcWaJdpgOGUIETMQEWT5%2BAVU1AnUNBEUR0bEJtvWNzTltwz2hfaUDFQCUtqhexMjsHObxocjeWADUJvFuTgoExJisB9gmGgCC1zehBLtUEHP3JgDsVre7v7sn6BAKCWTwObjB%2BzMZgAYiYAKxuBjmMwHb43P67M4EZYMXYaVHvD4AEXet0euwQr0JaIxAKBaC8oMOEORAAl4YjkQSfn8sTjdlxuXdiaSHoJnqhUBByXhSLtydo3rdPjTeZhscRcdpdgBaeVCz4k5Vk8UsJihKnKr73DF8zUSqUvBYU15veJow0cBa0Thw3h%2BDhaUioTjgyzWf5LFaYSHxHikAiaL0LADWIDhGgAdAAWSQfeKSDTxeIaD5Q%2BIfbP6TjZ3gsCQaDSkANBkMcXgKEBNxOBr2kOCwGCIYEsJJ0WLkShoUfjuLbQzALgANkbpCwADc8KsAGp4TAAdwA8klGJx4zRaARYp2IFEk6QoqEGgBPM%2B8R/MYjPw9RbTVHtBtObCCIeDC0K%2BvZrpgUReMAbhiLQnbcLwWBmkY4iQfgZw1OumBIUGmCqNUjJrEGjyYD6kG0HgUTEC%2BHhYPepx4PWyELFQBjAAou4Hsep7ITIggiGI7BcFW/CCIoKjqJBugBAYRgoNY1j6DRnaQAsqBJI4AhITqh7xLwqC4cQxB4Fg6mvLYFH/hkLgMO4ngtHowRTCUZR6CkaQ6ZkTk5FweTeRkvTuQMAXtD5XQjH5fjhTZHQMFFkzFP0cThRMoyxUM3QhalEgLAoUarHo%2B6GAQx4EAAkgw/BJt6vr%2Bveba7KokhLjqS7Zrs85GAKS6ZlmGi7BAuCECQsZcHMvAAX2EBICCSSMpOEDTmO9DEOErBrK17WdbswDIMgw2nF4DAplNgT4EQZmAgFEnCKI4hiYJ8hKGo95yaQ%2B50Ukb71RwfrNk1nCHoyi1PKgVAtW1HVdT1wB9QNA3DR4M7rbGZgXTNcwLAg5xYHEVlptmACcmZwlwZhLlwJNLkuJNmB8HyddWHC1qQ9YZkDkFth2ejY/2Q5zSOa0ThQK2oGjAyLnGUGbjue5HieAbnnQV7EDed6QR%2BL5vg%2BT5fj%2Bf4OPeQGMOVYEQUGWAwXBCFIfGqELhh1t4Nhji4fhvCEcRV73uRlFBtRtH0RgpHTWZrE8OxnHcYrfEq7w93CU94myFJ72yXoCnGMpliqVElmadpGRIUZJk3Xh8AFfFPn2Y52RZa5KUzGlgUFL5jeefkPm5a3egRbUGUxQPtdDzlbl5el3SZQPEx9x5k2LMsxUBaVIHadVtW9v9gMtkZnDQ7tXUHUdXDxJmXDDaN10TVjdX9vNYNLeLq2zptbCcDtsP7Ydx3EKdc63srokHMnoZOj1RJp0km9GSQZdBrx%2Bn9Vme9gYcFBgQcGuxIZHx/qfAUF8r4QFRqLYgd9pp1X%2BuzTmTZ97Bk4HzAK2NUwgFJuTSm1Nab00ZszKslFDLc1bAwhMlDWZmEajzYRzDSAmTSM4bMQA%3D%3D
[2] тут: https://timsong-cpp.github.io/cppwp/n4861/impldefindex
[3] подтверждение: https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(filename:
[4] Большинство : https://msrc.microsoft.com/blog/2019/07/a-proactive-approach-to-more-secure-code/
[5] ссылка : https://timsong-cpp.github.io/cppwp/n4659/expr.delete#2
[6] usual arithmetic conversions: https://timsong-cpp.github.io/cppwp/n4868/expr.arith.conv
[7] Статья для тех, кто как и я не понимает, зачем нужен std::common_type: https://habr.com/ru/companies/pvs-studio/articles/592217/
[8] ссылка : https://eel.is/c++draft/conv.prom
[9] тут: https://en.cppreference.com/w/cpp/language/operator_arithmetic
[10] Standard C++ (in Russian) :: Часть 2, Неопределённое поведение: https://www.youtube.com/watch?v=D0BgTtunCno
[11] What Every C Programmer Should Know About Undefined Behavior.1: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
[12] What Every C Programmer Should Know About Undefined Behavior.2: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_14.html
[13] What Every C Programmer Should Know About Undefined Behavior.3: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_21.html
[14] Источник: https://habr.com/ru/companies/ncloudtech/articles/743930/?utm_source=habrahabr&utm_medium=rss&utm_campaign=743930
Нажмите здесь для печати.