Подводные камни С++. Решаем загадки неопределённого поведения, ч. 1

b0d7860a5f8ee6cc96330fcdc0108ac6.png

Изучение и понимание неопределённого поведения — важный шаг для разработчика 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.

Полезные ссылки

Список материалов, которые стоит изучить, чтобы глубже понять тему неопределённого поведения:

© Habrahabr.ru