Подводные камни С++. Решаем загадки неопределённого поведения, ч. 1
Изучение и понимание неопределённого поведения — важный шаг для разработчика C++, поскольку undefined behavior бывает источником серьёзных ошибок и проблем в программах. UB может проявляться в разных аспектах языка, включая операции с памятью, многопоточность, арифметические вычисления, работу с указателями и так далее.
Под катом мы погрузимся в мир неопределённого поведения в C++ и рассмотрим некоторые примеры ситуаций, в которых оно может возникать.
P.S.: Часть приведённых в статье примеров вдохновлены материалами, которые можно посмотреть в разделе «Полезные ссылки».
Привет, Хабр! Меня зовут Владислав Столяров, в МойОфис я аналитик безопасности продуктов. Чаще всего я взаимодействую с командами, которые создают решения на C и C++, и сегодня хочу обратиться к теме неопределённого поведения — рассказать, что это, в чем проявляется и как с ним работать. Это первая часть моего мини-цикла статей по UB: в ней я наглядно обозначу проблематику с помощью набора практических примеров.
Необходимая теория
Для начала приведу несколько определений из стандарта С++ (в моём авторском переводе):
Корректно составленная программа (Well-formed program) — программа, созданная в соответствии с правилами синтаксиса, диагностируемыми семантическими правилами и правилом одного определения.
Некорректно написанная программа (Ill-formed program) — программа, которая нарушает либо синтаксические, либо семантические правила (либо и те, и другие). Она не должна компилироваться.
Неуточнённое поведение (Unspecified behavior) — поведение программы, где стандарт языка допускает два или более варианта и не налагает никаких других требований на выбор в каждом конкретном случае. Классическим примером неуточнённого поведения является порядок вычисления аргументов функции:
#include
int f()
{
std::cout << "F\n";
return 0;
}
int h()
{
std::cout << "H\n";
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
неуточнён и может быть любым.
Поведение, зависящее от реализации (implementation defined behavior) — неуточнённое поведение, которое задокументировано в компиляторе или среде исполнения. Это очень интересная особенность языка, различные реализации по-разному описывают значение функции
pow(0,0)
, типvector::iterator
и даже количество бит в байте. Подробнее можно почитать тут.
Неуточнённое поведение и поведение, зависящее от реализации объединяет один печальный фактор. Программы, которые содержат их, непереносимы.
Неопределённое поведение (undefined behavior или просто UB) — поведение программы, которое может привести к абсолютно непредсказуемым последствиям. При этом программа корректна синтаксически и семантически.
Для более детального понимания, что это такое, рассмотрим пример:
#include
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"
, вот подтверждение. О том, почему это происходит, подробнее мы поговорим ниже.
Зачем UB в компиляторе?
Когда задумываешься о проблеме неопределённого поведения, одним из первых в голову приходит вопрос: зачем оно вообще нужно? Есть же промышленные языки вроде 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;
}
Порой бывает очень грустно отлаживать падение на релизной сборке, по которой уже прошелся оптимизатор, в своем отладочном окружении, в котором уже даже нет такого кода.
Примеры неопределённого поведения
Рассмотрим несколько паттернов неопределённого поведения.
Неправильная работа с памятью
Большинство ошибок при работе с С и C++ связанно с неправильной работой с памятью. Часть из них отлавливается компилятором и операционной системой. Например, знаменитый Segfault — следствие неправильной работы с памятью. В итоге программист видит надпись segmentation fault (core dumped) под Linux.
Первая из проблем, которую можно рассмотреть — выход за границу массива. Она обычно актуальна для массивов или контейнеров, которые хранят элементы в непрерывном куске памяти. Работа с такими контейнерами при помощи operator[]
является весьма распространенным действием. Вот синтетический пример, который демонстрирует это:
#include
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
void foo(unsigned len)
{
auto inv = std::unique_ptr(new char [len]);
//...
}
Здесь мы решили обернуть выделение динамической памяти в умный указатель, который очистит её самостоятельно, после выхода из области видимости. Однако стоит обратить внимание, что std::unique_ptr
инстанцирован типом char
, а выделяется память для char[]
. При вызове деструктора std::unique_ptr
, он вызовет деструктор именно для типа, которым он инстанцируется, а не для массива объектов. Соответственно, удаление объекта будет производиться другой deallocation-функцией, что согласно стандарту будет неопределенным поведением; вот ссылка на соответствующий пункт стандарта.
Знаковое целочисленное переполнение
Также довольно часто возникают ситуации со знаковым целочисленным переполнением. Например, мы хотим написать простую функцию, которая выводит числа на экран:
#include
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
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
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
Сперва я хотел написать большой абзац о том, что такое Integral promotion, как он работает в рамках usual arithmetic conversions и зачем он нужен, однако вовремя вспомнил, что уже делал это в одной из своих статей. Там я рассказал, как писал механизм для вывода общего типа в одном известном статическом анализаторе. Если вам интересна тема, ознакомиться можно тут: Статья для тех, кто как и я не понимает, зачем нужен std: common_type.
Вот ссылка на соответствующий пункт стандарта. В нём говорится, что при операциях над целыми числами может произойти целочисленное продвижение. Например, если перемножить 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
, то результат — неопределённое поведение. Для деления на 0 вещественных чисел работают уже совсем другие правила, подробнее про это можно почитать тут в разделе Additive operators. Важно не перепутать вещественные числа с целыми и не написать, например, такой код для генерации значения «бесконечность»:
auto create_inf (unsigned x)
{
return x / 0;
}
Выводы
Неопределённое поведение в C++ — феномен, результат которого невозможно предсказать. Никто не знает, как будет вести себя код, содержащий UB. Из этого следует, что при разработке ПО следует придерживаться простого и понятного кода. Сложные и запутанные конструкции могут привести к непредсказуемым последствиям. Важным аспектом профессионализма в программировании является способность написать безопасный и надежный код, который легко читать и поддерживать. Это подразумевает использование ясных и понятных конструкций, а также следование лучшим практикам программирования.
Конечно, количество ситуаций, которые могут привести к неопределённому поведению огромно. Мы рассмотрели всего несколько распространенных случаев.
Скоро выйдет вторая часть статьи, в ней мы поговорим о том, как можно защититься от неопределённого поведения. И разберём еще больше примеров UB.
Полезные ссылки
Список материалов, которые стоит изучить, чтобы глубже понять тему неопределённого поведения: