Изучение и понимание неопределённого поведения — важный шаг для разработчика C++, поскольку undefined behavior бывает источником серьёзных ошибок и проблем в программах. UB может проявляться в разных аспектах языка, включая операции с памятью, многопоточность, арифметические вычисления, работу с указателями и так далее.

Под катом мы погрузимся в мир неопределённого поведения в C++ и рассмотрим некоторые примеры ситуаций, в которых оно может возникать.

P.S.: Часть приведённых в статье примеров вдохновлены материалами, которые можно посмотреть в разделе «Полезные ссылки».


Привет, Хабр! Меня зовут Владислав Столяров, в МойОфис я аналитик безопасности продуктов. Чаще всего я взаимодействую с командами, которые создают решения на C и C++, и сегодня хочу обратиться к теме неопределённого поведения — рассказать, что это, в чем проявляется и как с ним работать. Это первая часть моего мини-цикла статей по UB: в ней я наглядно обозначу проблематику с помощью набора практических примеров.

Необходимая теория

Для начала приведу несколько определений из стандарта С++ (в моём авторском переводе):

  • Корректно составленная программа (Well-formed program) — программа, созданная в соответствии с правилами синтаксиса, диагностируемыми семантическими правилами и правилом одного определения.

  • Некорректно написанная программа (Ill-formed program) — программа, которая нарушает либо синтаксические, либо семантические правила (либо и те, и другие). Она не должна компилироваться.

  • Неуточнённое поведение (Unspecified behavior) — поведение программы, где стандарт языка допускает два или более варианта и не налагает никаких других требований на выбор в каждом конкретном случае. Классическим примером неуточнённого поведения является порядок вычисления аргументов функции:

#include <iostream>

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 <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", вот подтверждение. О том, почему это происходит, подробнее мы поговорим ниже.

Зачем 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 <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-функцией, что согласно стандарту будет неопределенным поведением; вот ссылка на соответствующий пункт стандарта.

Знаковое целочисленное переполнение

Также довольно часто возникают ситуации со знаковым целочисленным переполнением. Например, мы хотим написать простую функцию, которая выводит числа на экран:

#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

Сперва я хотел написать большой абзац о том, что такое 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.

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

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

Комментарии (26)


  1. paluke
    28.06.2023 12:41
    +3

    Непонятно, почему вдруг появляется цикл


    for (int i = 0; true; ++i)
        {
            std::cout << 1'000'000'000 * i << std::endl;
        }

    На самом деле, оптимизатор сначала избавляется от умножения


    for (int i = 0; i < 10'000'000'000; i += 1'000'000'000)
        {
            std::cout << i << std::endl;
        }

    А потом, увидев, что 10 миллиардов в int не помещаются, заменяет условие на true.


    1. Stolyarrr Автор
      28.06.2023 12:41

      Хорошее замечание, однако, вы говорите о другой, более сложной оптимизации (вроде такой: https://en.wikipedia.org/wiki/Induction_variable). Тут гораздо проще оптимизировать условие.

      Однако, это, конечно, не отменяет того, что описанная вами оптимизация тут хороша и вполне может отработать.


      1. paluke
        28.06.2023 12:41

        При такой оптимизации по крайней мере логично, что цикл не заканчивается. А иначе получается, что компилятор из вредности условие убирает - "раз UB позволяет, сделаю гадость".

        И да, если в том же онлайн компиляторе посмотреть на asm, там есть прибавление миллиарда и нет умножения. Хотя кажется и какое-то условие выхода из цикла есть.


        1. Stolyarrr Автор
          28.06.2023 12:41

          Суть то в том, что условие заменилось и всё:)
          Думаю, что можно произвести бесчеловечный эксперимент: отключить оптимизацию Induction variable и увидеть тот же результат.


  1. blanabrother
    28.06.2023 12:41
    -2

    Пример RNCE + DCE дичь. Какое-то нарушение причинно-следственных связей. RNCE оптимизация заменяет if (ptr == NULL) на if (false) из-за того, что выше есть разыменование, которое гарантирует, что либо точно ptr != NULL, либо будет исключение до конструкции if. А затем применяется DCE, который удаляет первопричину применения оптимизации RNCE. Какого? Если будет удалена первопричина применения RNCE, то она не может быть применена, а следовательно у DCE нет предпосылок применяться в таком виде.


    1. Stolyarrr Автор
      28.06.2023 12:41
      +1

      Вообще, разыменование нулевого указателя - вполне корректная операция. Если говорить об "исключении", то его уж точно не будет, может сработать защита операционной системы, если она предусмотрена. Оптимизатор - это такая штука, которая зачастую не знает о том, что на входе получает компилятор и даже что он сам получает на предыдущем проходе. Такова модульная структура компиляторов.

      Вы забываете, что речь идёт о языке, в котором встречаются вещи и страннее. Такой кейс действительно возможен, более того, похожая ошибка когда-то была в ядре Linux для Red Hat (подробнее можно почитать тут: https://lwn.net/Articles/342330/).


      1. Nick_Shl
        28.06.2023 12:41
        +3

        Не корректная. Разыменование нулевого указателя - это undefined behavior. Простота большинстве компиляторов NULL это 0, а вот уже разыменованние 0 уже вполне определенное поведение. Но гарантии что NULL определен как 0 - нет.


        1. Stolyarrr Автор
          28.06.2023 12:41

          Спасибо за уточнение. Нулевой адрес, NULL и nullptr - это разные сущности. В комментарии выше я имел в виду нулевой адрес. Однако, справедливости ради, вы можете привести пример стандартной библиотеки, в которой макрос NULL определён не нулём?


    1. paluke
      28.06.2023 12:41
      +3

      Ну так там оптимизация многопроходная. И когда доходит дело до DCE, на изначальный код оптимизатор уже не смотрит. А разыменование гарантирует, что точно ptr != NULL, никаких "либо". ptr == NULL это UB, и его можно не рассматривать.


  1. kekoz
    28.06.2023 12:41
    +5

    Авторы стандартов используют предельно лаконичные и строгие формулировки, иноязычные читатели стандартов нагружают прочтённое адекватными их знаниям чужого языка собственными смыслами — и в результате мы получаем настоящие “перлы” типа “Никто не знает, как будет вести себя код, содержащий UB.”

    Текст вроде и осмысленный, и корректный, и кому-то на что-то откроет глаза, но в его основе лежит семантическое заблуждение, и потому весь текст получается вовсе не о том, о чём заявляется заголовком, а местами и вовсе заставляет задуматься — а не ставит ли автор телегу впереди лошади? Не попутаны ли причинно-следственные связи?

    О каком семантическом заблуждении я? Излагать буду максимально подробно. Автор (масса авторов и комментаторов по всему интернету, к сожалению) в обсуждаемых терминах Стандарта “undefinded behaviour”, ”unspecified behaviour”, “implementation-defined behaviour” привязывает первое слово к последнему как характеристику последнего. В результате мы получаем чушь в форме “код, содержащий UB”. Извините, но B (поведение) скомпилированного кода очень даже определено — архитектурой целевой платформы и средой исполнения.Код не может содержать какого-то там неопределённого поведения. Или неуточнённого. Или реализацией-определямого. Он всегда будет исполняться так, как определено, повторюсь, архитектурой целевой платформы и средой исполнения.

    Пример:

    #include <inttypes.h>
    #include <stdio.h>
    
    static uint8_t a[3] = { 1, 0, 1 };
    
    int main(void)
    {
            printf("%hd/%hd\n", *(uint16_t *)a, *(uint16_t *)(a + 1));
    
            return 0;
    }

    Эта смешная программка (“содержащая UB”, выражаясь в стиле автора) на AMD64 всегда, будучи хоть миллиард раз запущена, выведет 1/256. Очень даже определённое поведение.

    Эта же смешная программка на ARM точно так же всегда выведет — ахтунг! начинается интересное — либо 1/256, либо 256/1, и зависит это от среды исполнения (для тех, кто не знает — порядок байтов в многобайтовых словах, так называемый endian, на ARM настраивается при старте, и может быть как little, так и big), Но в одной и той же среде результат при миллиардах запусков будет один, то есть поведение вполне себе определённое.

    И эта же смешная программка на PDP-11/VAX всегда, сколько ни запускай, фолтнется с bus error. И это тоже вполне определено, нельзя там выдёргивать слово по нечётному адресу.

    То есть, поведение программки всегда детерминировано, полностью определено. Но позвольте, она же “содержит UB”, как же так?

    Возвращаемся к семантической ошибке. Первое слово в термине undefined behaviour (и других упомянутых терминах поведения) — это не характеристика поведения (которое — смотри пример выше — всегда очень даже определено), это — позиция Стандарта.

    Попробуем теперь переосмыслить и выразить простым русским языком значения этих терминов:

    unspecified behaviourСтандарт определяет, что параметры функции конечно же будут вычислены, и это точно произойдёт до вызова функции, но Стандарт не уточняет порядок вычисления этих параметров в случае, когда их более одного.

    implementation-defined behaviourСтандарт не определяет, что будет со старшим битом знакового целочисленного типа при сдвиге вправо, но предписывает авторам реализации чётко определить в документации на их компилятор, что при этом будет происходить в этой конкретной реализации.

    undefined behaviourСтандарт не определяет, каким будет результат использования той или иной языковой конструкции в исполняемом коде на целевой платформе в целевой среде исполнения.

    Чувствуете же разницу, друзья-коллеги? Не “Поведение неопределённое”, а “Стандарт никоим образом не определяет, каким должно быть поведение” при целочисленном делении на 0 — на PDP-11/VAX всегда и определённо трапнется, на MIPS молча и так же всегда и c чётко определённым результатом поедет дальше.

    Ничего феноменального в UB нет, надо лишь вспомнить чуть-чуть историю языка C. Его не зря всегда сравнивали с высокоуровневым ассемблером — это язык очень низкого уровня, почти абстрактный ассемблер и есть. Множество архитектур, на которые он был в то или иное время перенесён, имеют существенные различия. И эти различия не особо напрягали как отцов, так и многочисленных “портировщиков” UNIX (напоминаю — C родился для создания переносимой UNIX) на другие платформы, принципиальные отличия в архитектуре решались условной компиляцией и чистым ассемблером в совсем уж железо-зависимых частях.

    Но потом появился Стандарт. И члены рабочей группы прекрасно понимали, что решение проблем разности архитектур при таком многообразии архитектур — mission impossible. Для упрощения проблем реализаций сначала определили кучу implemetation-defined behaviour, а потом, абстрагируясь всё выше, и undefined behaviour.

    А потом “в тусовку” пришли дети Фортрана и Бейсика, решившие, что они тоже хотят писать на C всё те же свои математические, физические, экономические и прочие очень далёкие от “железа” программы. Им многого не хватало из привычных им сред, и они стали прессовать Комитет. И Комитет повёлся. В язык стали вводить конструкции, которые стали превращать очень низкоуровневый C во всё более высокоуровневый язык (взять хотя бы те же VLA). И ситуация дошла “до ручки”. Одно из самых свежих нововведений — сравнение указателей, не относящихся к одной и той же области storage— UB. У кучи разработчиков системного (очень низкого) уровня подорвались пердаки. Мегасрачи на reddit. Но всё же понятно при всей своей непонятности: тому, кто не знает, чем стэнфордская архитектура отличается от гарвардской архитектуры, тяжело понять, почему нельзя сравнивать указатели на код с указателями на данные. Но как быть в системах, где нет защиты памяти, и можно сгенерить код в области данных, и передать ему управление (привет вирусописателям)? А как быть на гарвардской архитектуре, где код лежит в 16-разрядной памяти, а данные — в 32-разрядной?

    Комитет пошёл на поводу у людей, которые о low-level программировании — ни ухом, ни рылом, какими бы доками они ни было в квантовой физике и макроэкономике. Комитет смирился с идеей превратить низкоуровневый C в высокоуровневый C-хрен_знает_что. Язык, на котором можно было (и неоднократно реализовано) делать операционные системы, превращается в сверхвысокоуровневое, не имеющее никакого отображения на железо, нечто, на котором можно писать очередное 1С, но ОС или хотя бы драйвер — нет, ребята, пишите на ассемблере.

    А потом C++ посредством механизма наследования (какая издёвка над столпами ООП) получил всё это дерьмо на свой обеденный стол. Держитесь, ребята. Или, осознавая существенные отличия архитектур, переносимо операционки писать, или разргребать, подобно дерьму, существенные отличия архитектур в угоду Васе, который не может понять, почему нельзя просто поделить икс на игрек...


    1. yeputons
      28.06.2023 12:41
      +3

      Да, "поведение не определено" именно стандартом, скомпилированные программы обычно ведут себя предсказуемо. Если не вмешивать ASLR, многопоточность и хотя бы гонки по данным, иначе там могут возникать неожиданные спецэффекты из-за отсутствия расставленных барьеров памяти процессора и барьеров оптимизации компилятора.

      Извините, но B (поведение) скомпилированного кода очень даже определено — архитектурой целевой платформы и средой исполнения.

      А сейчас фактически также необходимо знать внутренности компилятора и его настройки. Поэтому полезность после события "И Комитет повёлся" несколько сомнительна, если мы хотим хоть когда-нибудь просто обновить версию компилятора. Недостаточно знать целевую архитектуру и название компилятора, версия тоже важна, даже минорная. И флаги компиляции, конечно же.

      Например, следующий код ведёт себя по-разному в gcc 6.3 и gcc 6.4 (2017 год), но только если включить оптимизации -O2: в первом случае считает указатели p и q и их числовые представления всегда разными, а во втором — указатели разными, а их числовые представления одинаковыми.

      #include <stdio.h>
      #include <inttypes.h>
      int main() {
          int foo[3];
          int bar[3];
          int *p = foo + 3;
          int *q = bar;
          printf("%p %p are equal? %d\n",
                   p, q,
                   p == q);
          printf("0x%" PRIxPTR " 0x%" PRIxPTR " are equal? %d\n",
                  (intptr_t)p,  (intptr_t)q,
                  (intptr_t)p == (intptr_t)q);
      }

      А совсем классический пример — это, конечно, удаление проверки на NULL после неиспользуемого разыменования, ещё в 2007 поехало. Так что с современными компиляторами договор "вы просто генерируете понятно какой код в зависимости от платформы" уже не работает, фраза "это по стандарту UB, делаем что хотим" используется как универсальный повод делать довольно суровые оптимизации.


    1. Tsimur_S
      28.06.2023 12:41
      +6

      Мне кажется проблема семантики кроется немного в другом. И вы и автор используете понятие "(компьютерная) программа" который(тут ссылка на самый достоверный источник в мире) является двузначным понятием. Какая ирония, в топике про UB.

      Имея в виду "программу" как исполняемый файл вы безусловно правы в том что "программа" не может содержать UB.

      Имея же в виду "программу" как исходный код автор вполне себе прав утверждая что "программа" содержит UB.

      В качестве примера: утверждая что

      Эта смешная программка (“содержащая UB”, выражаясь в стиле автора) на AMD64 всегда, будучи хоть миллиард раз запущена, выведет 1/256.

      вы уже ошибаетесь. Данную "программку" невозможно запустить ни на каком существующем процессоре. Сначала вам придется ее скомпилировать, превратить в "программку".


    1. eao197
      28.06.2023 12:41
      +1

      То есть, поведение программки всегда детерминировано, полностью определено.

      Интересно, а чем определяется поведение вот такой программы: https://godbolt.org/g/o4HxtU ?


    1. geher
      28.06.2023 12:41

      но B (поведение) скомпилированного кода очень даже определено

      А как быть с использованием неинициалированной переменной, или, не к ночи будь помянуто, с разыменованием неинициализированного указатепя? Это вроде тоже UB.


  1. yeputons
    28.06.2023 12:41
    +2

    Некорректно написанная программа (Ill-formed program) — программа, которая нарушает либо синтаксические, либо семантические правила (либо и те, и другие). Она не должна компилироваться.

    Также бывает "Ill-formed, no diagnostic required" — программа некорректна, но имеет полное право компилироваться без предупреждений. Что произойдёт при попытке запустить — не определено. Может выдать верный ответ, может выдать неверный ответ, может не запуститься, может вести себя по-разному в зависимости от настроек ОС. Типичный пример — static initialization order fiasco.

    А бывает ещё смешнее: некоторые авторы компиляторов считают, что предупреждение — это вполне себе "diagnostic" с точки зрения стандарта. Поэтому могут выдать предупреждение и скомпилировать программу, использующую неподдерживаемые синтаксические конструкции, в немедленно падающий при запуске код. И сказать, что так и надо:

    This diagnostics is produced. I'd like to point out that clang behaves similarly (albeit this is not really relevant when we talk about the correctness).


  1. toxicdream
    28.06.2023 12:41
    +4

    UB - стандарт говорит компилятору делай как хочешь.

    Тем больше UB, тем больше свободы разработчикам компиляторов.

    Тем больше свободы, тем больше кто в лес, кто по дрова.

    В итоге ты смотришь на код и НЕ ПОНИМАЕШЬ что же в итоге выполнится.

    В общем, стандарт плохой и из-за этого язык стал "плохим".

    И комитет ещё не спит, сыпет новыми версиями стандартов с новыми UB.

    Можно всю жизнь учить c++ и все равно не знать что же происходит. Особенно в чужом коде :'(


    1. Nick_Shl
      28.06.2023 12:41
      +1

      В общем, стандарт плохой и из-за этого язык стал "плохим".

      Это не стандарт плохой, а программист. UB для того и существует, что бы так не делали.


      1. toxicdream
        28.06.2023 12:41
        +2

        А как делать тогда? Посмотрите на самый первый пример.

        Любой кто читает этот код вполне логично подумает что аргументы будут вычисляться слева направо. Но стандарт говорит - компилятор может делать как хочет. И таки находятся компиляторы и платформы на которых вычисление будет справо налево.

        А если там будет три параметра порядок вычисления точно будет линейным? А кто его знает что там в оптимизаторе закодено!

        Стандарт говорит что все такое должно быть описано в документации к компилятору.

        И вот ты читаешь код и НЕ ПОНИМАЕШЬ как он работает. Надо лезть в документацию и искать там описание этого UB. И хорошо если не придется еще и документацию к платформе искать и вычитывать там поведение которое может отличаться от значении в регистрах.

        Во всех остальных "нормальных" языках ты читаешь код и ПОНИМАЕШЬ как он будет выполняться.


        1. evtomax
          28.06.2023 12:41

          Любой кто читает этот код вполне логично подумает что аргументы будут вычисляться слева направо

          То, что аргументы будут вычисляться в том порядке, в котором используются внутри функции, не менее логично, например.

          А как делать тогда?

          Очевидно, не писать код так, чтобы он зависил от порядка вычисления аргументов. Лично для меня почти все примеры с UB - это какой-то реально сомнительный код.


          1. morijndael
            28.06.2023 12:41
            +1

            Лично для меня почти все примеры с UB - это какой-то реально сомнительный код.

            Потому что примеры сильно упрощены, для демонстрации. В реальном коде тот же UB будет намного сложнее разглядеть за другой логикой


        1. Gumanoid
          28.06.2023 12:41

          Порядок вычисления аргументов — это unspecified behaviour, а не implementation-defined behaviour, т.е. он не должен и не может быть описан в документации к компилятору.


        1. Nick_Shl
          28.06.2023 12:41
          +2

          Любой кто читает этот код вполне логично подумает что аргументы будут вычисляться слева направо.

          Тут знать надо, не знаешь - плохой программист. А если уж очень надо, то вычисления зависящие от порядка нужно делать до вызова функции, а не во время. Примерно так:

          int main()
          {
              int f_result = f();
              int h_result = h();
              return foo(f_result, h_result);
          }

          В этом примере f() и h() будут вызваны последовательно.

          Кстати, а как на счёт такого:

          bool main()
          {
            if(f() && h()) return true;
            return false;
          }

          Тоже считаете логичным, что все параметры должны быть проверены и функции должны быть вызваны, даже если f() вернёт false?

          Как вам сказали ниже "то, что аргументы будут вычисляться в том порядке, в котором используются внутри функции, не менее логично", например немного модифицируем код первого примера:

          int foo(int i, int j)
          {
              return (i && j);
          }
          
          int main()
          {
              return foo(f(), h());
          }

          Если функция foo вызывается всегда только с аргументами f() и h(), то компилятор может оптимизировать код так:

          int foo()
          {
              return (f() && h());
          }
          
          int main()
          {
              return foo();
          }

          А теперь видно, что если f() возвращает false, то h() вообще никогда не будет вызываться. И это - хорошо, незачем вызывать код, от выполнения которого результат проверки условия не зависит.

          И вот ты читаешь код и НЕ ПОНИМАЕШЬ как он работает. Надо лезть в документацию и искать там описание этого UB

          Нет, не надо. Надо писать код без UB. Писать код под конкретный компилятор без гарантии что после обновления(или даже простого изменения уровня оптимизации) поведение этого UB не изменится - очень плохая идея.

          В реальном коде тот же UB будет намного сложнее разглядеть за другой логикой

          Жду пример(хоть реального, хоть гипотетического проекта) где без UB не обойтись никак.


      1. yeputons
        28.06.2023 12:41

        В C++ столько интересного и непредсказуемого UB, что я не верю, что сколько угодно квалифицированный программист (или команда программистов) может написать хоть сколько-нибудь полезный проект хотя бы на десяток-сотню тысяч строк кода без UB и прочих нарушений стандарта. Даже если разрешить использовать фаззинг и все имеющиеся статические и динамические анализаторы.

        Если, конечно, полностью запретить чистые указатели, арифметику указателей, любые ссылки, итераторы, то шансы серьёзно возрастут.

        UPD: например, рассмотрим такой код:

        #include <iostream>
        #include <string_view>
        std::string_view foo() {
            return "hello world\n";
        }
        int main() {
            std::string_view f = foo().substr(1);
            std::cout << f << "\n";
        }

        В нём UB отсутствует. foo и main могут быть вообще в разных компонентах программы. Дальше оказалось, что foo иногда надо возвращать не статическую строку, а что-то динамическое. Поменяли возвращаемый тип на std::string . Казалось бы, сделали безопаснее: раньше возвращали указатель непонятно куда, теперь возвращаем обычный RAII-шный тип. Но на самом деле получили UB в main, который находится в другом углу программы. Причём предупреждений от компилятора GCC ноль, да и на коротких строках никаких последствий не будет.


        1. Nick_Shl
          28.06.2023 12:41

          Казалось бы, сделали безопаснее: раньше возвращали указатель непонятно куда, теперь возвращаем обычный RAII-шный тип. Но на самом деле получили UB в main

          А что такое std::string_view? Если я правильно понял, это объект который не хранит данные, а хранит указатель на область памяти где расположена строка. Получается при возврате из f() создаётся объект std::string на стеке, затем указатель на данные(строку) этого объекта копируется в std::string_view, затем объект std::string деструктится и указатель хранящийся в std::string_view становится не валидным.

          Очевидно, что тот кто менял возвращаемый тип должен был проверить где оно используется и подумать о последствиях такой замены.


  1. ss-nopol
    28.06.2023 12:41

    Но если первой отработает RNCE, то код станет таким (оптимизатор видит, что ptr проверяется на NULL уже после разыменования, соответственно, проверка бессмысленна):

    Если компилятор выкидывает "бессмысленный" с его точки зрения оператор if, он вполне мог бы выдать предупреждение "код не имеет эффекта". Я не могу себе представить ситуацию, где такое могло бы помешать. Печально что этого не происходит, надеюсь будет исправлено.

    И ещё, есть всё же существенная разница для безопасности, читается ли разыменованный указатель или записывается, поэтому компилятор мог бы сделать чуть больше и увидеть что указатель перед проверкой не записывается, а после проверки записывается.


    1. yeputons
      28.06.2023 12:41
      +1

      Оптимизации обычно выполняются не с исходным кодом, а с неким промежуточным представлением, в котором от исходного кода осталось мало. А скорее много добавилось, чего в исходниках нет. Макросы подставили, что-то заинлайнили (в том числе из стандартной библиотеки), какие-то отладочные переходы и вызовы __asan_load4 нагенерировали. В процессе вполне могут получаться абсолютно бесполезные проверки. На каждую выдавать предупреждение странно.

      Гипотетический пример:

      #include <iostream>
      int f(int x) {
        if (x < 0 || x >= 10) {
          return -1;
        }
        return x;
      }
      int main() {
        for (int i = 0; i < 10; ++i) {
          std::cout << f(i) << "\n";
        }
      }

      Здесь в общем случае проверка в f не лишняя. Но если заинлайнить f в цикл, то становится очевидно лишней. Кажется, что предупреждение выдавать всё-таки не следует.