Языки C и C++ печально известны большими областями на картах, которые отмечены предупреждением “тут обитают драконы”, а если говорить более формально, речь идет о неопределенном поведении (undefined behavior).

Когда мы сталкиваемся с неопределенным поведением, может произойти все что угодно. Например, переменная может быть одновременно и true, и false. В блоге Джона Регера (John Regehr) есть парочка интересных примеров неопределенного поведения, а также определены несколько победителей объявленного в комментариях конкурса на самый сумасшедший объектный код, генерируемый компилятором в результате неопределенного поведения.

Рассмотрим следующую функцию:

int table[4];
bool exists_in_table(int v)
{
    for (int i = 0; i <= 4; i++) {
        if (table[i] == v) return true;
    }
    return false;
}

Какое это имеет отношение к путешествию во времени, спросите вы? Погодите, немного терпения.

Прежде всего, в схеме управления циклом вы могли заметить ошибку смещения на единицу. В результате чего, функция будет считывать еще один элемент, следующий за конечным элементом массива данных в table, прежде чем цикл завершится. Классический компилятор не особо это заботит. Он просто сгенерирует код для чтения элемента за границей массива (несмотря на то, что это нарушение правил синтаксиса языка), и вернет true, если в памяти за пределами массива, оказалось бы подходящее по типу значение.

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

  • Первые четыре раза в цикле функция может вернуть true.

  • Когда i равно 4,  в коде происходит неопределенное поведение. Поскольку неопределенное поведение позволяет мне делать все, что угодно, я могу полностью проигнорировать этот случай и предположить, что i никогда не будет равно четырем. (Если предположение окажется ошибочным, произойдет что-то непредвиденное, но это нормально, потому что неопределенное поведение позволяет мне действовать непредсказуемо.)

  • Случай, когда i равно 5, невозможен, потому что для того, чтобы это произошло, нужно сначала пройти через i = 4, что, как нам позволено считать, может не произойти.

  • Следовательно, все допустимые пути выполнения кода возвращают true.

Таким образом, постклассический компилятор может оптимизировать функцию следующим образом:

bool exists_in_table(int v)
{
    return true;
}

Так, вот это уже выглядит странно. Функция была оптимизирована практически до ничего из-за неопределенного поведения. Обратите внимание, что даже если значение отсутствует в table (включая даже недопустимый пятый элемент), функция все равно вернет true.

Теперь мы можем продвинуть постклассическое поведение еще на один шаг: поскольку компилятор может допустить, что неопределенное поведение никогда не возникнет (ведь, если это происходит, компилятор может делать все, что угодно), например, он может использовать неопределенное поведение в целях оптимизации.

int value_or_fallback(int *p)
{
 return p ? *p : 42;
}

Вышеупомянутая функция получает указатель на целое число и либо возвращает указанное значение, либо (если указатель равен null) возвращает резервное значение 42. Здесь пока все нормально.

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

int value_or_fallback(int *p)
{
 printf("The value of *p is %d\n", *p);
 return p ? *p : 42;
}

Добавленная строчка содержит баг: она разыменовывает указатель p, не проверив предварительно, равен ли он null. Однако этот маленький баг влечет масштабные последствия. Постклассический компилятор оптимизирует функцию, потому что он видит, что проверка нулевого указателя больше не требуется, следующим образом:

int value_or_fallback(int *p)
{
 printf("The value of *p is %d\n", *p);
 return *p;
}

Если бы указатель был равен null, тогда printf уже вызвал бы неопределенное поведение, поэтому компилятору разрешено делать что угодно в случае, если указатель имеет значение null (даже например, представить, что указатель не равен null).

Ладно, тут нет ничего удивительного. Возможно, что это и есть та оптимизация, которую вы ожидаете от компилятора. (Например, если тернарный оператор был спрятан в макрос, можно ожидать, что компилятор не проведет проверку, которая гарантированно будет false.)

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

void unwitting(bool door_is_open)
{
 if (door_is_open) {
  walk_on_in();
 } else {
  ring_bell();
  // ждем, пока дверь откроется, со значением по умолчанию
  fallback = value_or_fallback(nullptr);
  wait_for_door_to_open(fallback);
 }
}

Постклассический компилятор может оптимизировать всю функцию следующим образом:

void unwitting(bool door_is_open)
{
 walk_on_in();
}

Что произошло?

Компилятор заметил, что вызов value_or_fallback(nullptr) приводит к неопределенному поведению по всем путям кода. Распространяя этот анализ назад, компилятор замечает, что если door_is_open имеет значение false, то в ветке else вызывается неопределенное поведение по всем путям кода. Следовательно, всю else ветвь можно считать невозможной.²

А теперь мы наконец-то переходим к путешествию во времени:

void keep_checking_door()
{
 for (;;) {
  printf("Is the door open? ");
  fflush(stdout);
  char response;
  if (scanf("%c", &response) != 1) return;
  bool door_is_open = response == 'Y';
  unwitting(door_is_open);
 }
}

Постклассический компилятор может запомнить вывод, что “если door_is_open равно false, то происходит неопределенное поведение”, и переписать эту функцию следующим образом:

void keep_checking_door()
{
 for (;;) {
  printf("Is the door open? ");
  fflush(stdout);
  char response;
  if (scanf("%c", &response) != 1) return;
  bool door_is_open = response == 'Y';
  if (!door_is_open) abort();
  walk_on_in();
 }
}

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

“Путешествие в прошлое” возможно даже для объектов с внешней видимостью, например файлов, потому что стандарт допускает, чтобы все происходило в момент, когда обнаруживается неопределенное поведение. И это позволяет прыгнуть в машину времени и притвориться, что вы никогда не звонили fwrite.

Даже если вы возьметесь утверждать, что компилятору не разрешено путешествовать во времени, все равно можно увидеть, что предыдущие операции отменены. Например, может так случиться, что неопределенная операция приведет к повреждению буферов файлов, поэтому данные на самом деле никогда не записывались. Даже если буферы были очищены, эта операция могла привести к вызову функции ftruncate, созданную для логического удаления только что записанных данных. Или же это могло привести к тому, что DeleteFile удалил файл, которые вы предполагали, что создали.

Все эти виды поведения имеют один общий заметный эффект, а именно отмена предыдущего действия. Было ли действие выполнено, было изменено или никогда не происходило, вопрос спорный с точки зрения теории компиляторов.

Компилятор также мог в прошлом размножить эффект неопределенной операции.

¹ Примечание автора: согласно стандарту - путешествие во времени допустимо перед возникновением неопределенного поведения:

Однако, если любое такое выполнение содержит неопределенную операцию, этот международный стандарт не налагает никаких требований на реализацию, выполняющую эту программу с этим входом (также это касается операций, предшествующих первой неопределенной операции).

² Другой способ взглянуть на это преобразование состоит в том, что компилятор увидел, что else ветвь вызывает неопределенное поведение для всех путей кода, после чего он переписал код, воспользовавшись правилом, согласно которому неопределенное поведение допускает что угодно:

void unwitting(bool door_is_open)
{
 if (door_is_open) {
  walk_on_in();
 } else {
  walk_on_in();
 }
}

В данном случае,  “walk_on_in” по ошибке было принято за “anything”.

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

int p = nullptr;
int& i = p;
foo(&i); // undefined

Вы можете подумать, что & и взаимоуничтожаются, и результат остается такой же, как если бы вы написали foo(p), но тот факт, что вы создали ссылку на несуществующий объект, даже если вы по сути не делали этого, вызывает неопределенное поведение (§8.5.3 (1)).

Похожие статьи: Что должен знать каждый программист на C о неопределенном поведении, часть 1, часть 2, часть 3.

Обновление: разделил & на две строки, потому что  одиночная является проблемой.


Материал подготовлен в рамках курса «Программист С».

Всех желающих приглашаем на бесплатное demo-занятие «WinAPI: пишем GUI приложение на C и кросскомпилируем из-под Linux». На открытом уроке мы разберём несложное приложение с графическим интерфейсом, использующее WinAPI, а так же скомпилируем выполняемый .exe файл для Windows, находясь в Linux, с помощью инструмента сборки CMake.
>> РЕГИСТРАЦИЯ

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


  1. aamonster
    09.12.2021 19:34
    +1

    В последнем примере звёздочки не потерялись? (фраза "что & и взаимоуничтожаются" намекает)


  1. staticmain
    09.12.2021 19:36
    +2

    Материал подготовлен в рамках курса «Программист С».


    А код везде на С++. Такие себе курсы.


    1. Chuvi
      10.12.2021 07:35
      -1

      Я вам страшный секрет открою: единственное что здесь можно "притянуть" к C++ это использование ссылки в последнем примере. Остальное - чистейший C.

      Вы кода на C++ никогда не видели?


      1. staticmain
        10.12.2021 11:50
        +2

        Покажите мне nullptr в «чистейшем Си»


        1. Alexey_Alive
          11.12.2021 07:41

          #define nullptr ((void*)0)

          Такое часто можно встретить в новом C коде. Более того в C23 введут nullptr, так как обычный NULL по стандарту может иметь тип int.


          1. staticmain
            11.12.2021 09:45

            Ну вот когда введут — тогда и будете писать nullptr. А надефайнить можно всё что угодно, хоть боярский язык, С++ от этого С не станет.


            1. Alexey_Alive
              11.12.2021 17:17

              А с чего это C++? Кроме последнего примера с ссылкой ничего плюсового тут нет. Код стайл абсолютно сишный.

              "надефайнить" что угодно не получится, попробуйте "надефайнить" шаблоны или лямбды. А вот nullptr в C как раз хорошо дефайнится, так как от void * неявный каст всегда. В C23 nullptr тоже предложен через define.

              Так что ваше первоначальное "код везде на C++" - не верно. Код на C++ явно только в последнем примере. Всё остальное - вполне себе сишный код в типично сишном стиле.


  1. oleg-m1973
    09.12.2021 20:10
    -1

    Таким образом, постклассический компилятор может оптимизировать функцию следующим образом:

    Хотелось бы посмотреть на код, где-нибудь в godbolt, где компилятор подобным образом оптимизирует выход за границу массива



  1. fougasse
    09.12.2021 20:41
    +1

    Ключевое слово «может». А пока, насколько я понимаю, промышленные компиляторы оптимизируют не так прямолинейно.

    Ну и код плюсовый, но это мелочи.


    1. Alexey_Alive
      11.12.2021 07:47

      Плюсовый он только в последнем примере, где есть ссылки. Остальное выглядит как современный C код. Bool ещё в далёком C99 ввели, nullptr часто сами программисты через define вводят, так как NULL может иметь тип int.В C23 nullptr частью стандарта станет. А какие плюсовые моменты кроме ссылки в последнем примере есть?!


  1. ss-nopol
    09.12.2021 21:39
    +1

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

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


    1. staticmain
      10.12.2021 00:48
      +3

      Тогда при компиляции кода на 400к строк у вас будет 56 731 лист примечаний от компилятора, где он рассказывает как он удалил переменную, оптимизировал поиск, предположил, что значение никогда не будет меньше нуля и так далее.


      1. Arlekcangp
        10.12.2021 17:54

        Из примера непонятно, что мешает компилятору проверить условие выхода за границы статического массива? Как по мне в таких случаях должны использоваться как минимум варнинги вместо непонятных оптимизаций. И вот если программист укажет явно, что хочет именно этого, тогда уже оптимизация. Но в этом случае опять же она будет заведомо не верной. Раз программист считает, что в пятом элементе массива что то есть и код без ошибок, значит компилятор должен это учесть. Он не должен считать себя умнее программиста (пока... Вот подрастëт ещё, тогда посмотрим))


        1. light_and_ray
          10.12.2021 18:21

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


          1. CaptainFlint
            10.12.2021 23:20

            Если программист так задумал, тогда зачем ему выходить за пределы массива? Ворнинг в такой ситуации — совершенно нормальное поведение. А если программист хочет избавиться от ворнинга — пусть уменьшает верхнюю границу цикла до максимально допустимой. Ведь он точно знает, что туда код никогда не попадёт, и значит, разницы в поведении не будет.


            1. light_and_ray
              11.12.2021 01:04

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


              1. CaptainFlint
                11.12.2021 02:35

                Речь о том, что когда компилятор видит такой код, то с гораздо большей вероятностью это окажется ошибкой программиста, указавшего некорректную верхнюю границу, нежели то, что он обеспечил гарантию наличия элемента в массиве и на этом основании решил задать заведомо невозможное значение границы. Поэтому с точки зрения компилятора было бы очень логично выдать предупреждение. Для того предупреждения и придуманы: указывать программисту на места потенциальных ошибок, где результирующее поведение программы может отличаться от исходных намерений.

                Конечно, легко ссылаться на несколько сотен страниц стандарта, требовать изучить его и знать наизусть, говорить, что UB=ССЗБ. Но язык стал бы намного дружелюбнее, если компилятор не превращал UB-код в машину времени и не форматировал диск втихомолку, а отмечал бы странные места для более тщательного исследования.


  1. VladD-exrabbit
    10.12.2021 01:34

    Ждал, каким будет перевод «unrung the bell» на русский. «Отменил прозвеневший звонок» — ну тоже ничего.


  1. bfDeveloper
    10.12.2021 01:50
    +6

    Имхо, объяснение неверное, или я его не так понял. Компилятор всегда предполагает, что UB в коде нет. Он может так считать, потому что, если оно есть, то любое поведение корректно. Так как UB нет, то цикл не может дойти до индекса i == 4. Единственный вариант не дойти до i == 4 - выйти с true раньше. А дальше уже удаляется ненужный код, так как он не имеет побочных эффектов.


    1. libroten
      10.12.2021 08:25
      +1

      Спасибо за объяснение!

      Мне из текста статьи было абсолютно не понятно, исходя из чего компилятор делает вывод, что где-то на итерациях с 0 по 3 гарантированно нужно вернуть true.

      Автору статьи, кмк, стоит исправить свое объяснение на Ваше.


    1. Kelbon
      10.12.2021 19:42

      Я бы объяснил так, при индексе >= 4 - уб. Тогда i всегда < 4, тогда цикл бесконечный. В этом бесконечном цикле только один выход - return true, значит он когда то и случиться, можно оптимизировать до return true


      1. ruomserg
        10.12.2021 20:19

        Тут еще более тонко! Бесконечный цикл без побочных эффектов в C99 тоже UB! А поскольку компилятор знает что программист не допускает UB — то цикл является конечным, и так как все выходы из него возвращают true, то… — и далее по тексту. В C11, кстати, не являются UB бесконечные циклы с константным условным выражением. То есть while(1) — как бы снова запретили вырезать из кода. Но на приведенный пример это, увы, не распространяется. :-(


  1. Deosis
    10.12.2021 07:27

    Самый впечатляющий пример UB, в котором компилятор опровергает теорему Ферма

    https://habr.com/ru/post/229963/


    1. bfDeveloper
      10.12.2021 14:36
      +1

      Мой личный топ это https://habr.com/ru/company/infopulse/blog/338812/

      Форматирование диска одним вызовом по нулевому указателю гораздо эмоциональнее опровержения старой теоремы. Хотя, о вкусах UB не спорят.


  1. ruomserg
    10.12.2021 20:07

    Я могу только в очередной раз сказать, что текущее понимание «UB» производителями компиляторов — суть злоупотребление правом. Дух (но не буква) стандарта языка «C» в отношении «UB» заключался в том, что разработчики стандарта не видели (!) возможности навязать определенное универсальное поведение в таких случаях (например, доступ за границей массива) без неприемлимых потерь производительности. Поэтому в таких случаях они оставили поведение неопределенным. Мне кажется, что в то время имелось в виду — что создатели стандарта языка не дают никаких гарантий поведения в такой ситуации, и вы должны обратиться к руководству по своему компилятору и аппаратной платформе, чтобы понять — как будет вести себя скомпилированная данным компилятором на данной платформе программа. И да, если вы закладывались на определенное поведение компилятора/платформы в случае UB — это делало вашу программу не переносимой (впрочем, обойти это условной компиляцией это тоже никто не мешал...).

    С тех пор прошло много времени. Некоторые считают, что разработчики компиляторов оказались рабами синтетических тестов. Я считаю, что виноват C++ и мета-программирование на шаблонах приведшее к чрезвычайной популярности техник dead-code elimination. Отягчающим обстоятельством является также то, что почти все существующие компиляторы кросс-платформенные, а иметь разные стратегии кодогенерации и оптимизации под каждую платформу — довольно дорого…

    В общем, в какой-то момент разработчики компиляторов злоупотребили предоставленным им в рамках стандарта правом генерировать любой эффективный на данной платформе код в случае UB — и объявили что в случае UB они считают эффективным вообще ничего не генерировать. Нельзя сказать, что такое случается только в мире «C/C++». Так, например, в конституции одной знакомой нам страны был заложен запрет президенту занимать свой пост более двух сроков подряд. Однако, сначала один знакомый нам конституционный суд ослабил запрет до «двух сроков подряд, а если не подряд — то уже можно», а потом в результате неких юридических манипуляций запрет был признан как бы вообще не существующим… Так что у разработчиков компиляторов есть на кого равняться… Люди — они такие люди…

    Что с этим делать? Есть несколько способов. Можно собраться и определить в новой версии стандарта правильное поведение во всех упомянутых случаях — так поступает, например, Паскаль. Ценой будет падение производительности на многих (или всех) платформах. Непонятно, кому такой хоккей будет нужен?..

    Чтобы избежать этого — можно определить абстрактную среду исполнения, и раскрыть UB в терминах этой среды (оставив компилятору и виртуальной машине свободу в генерации кода для конкретной платформы). Это будет примерно Java. Со всеми ее достоинствами и недостатками.

    Можно не делать ничего, и стараться писать код без UB. Это будет примерно C++, потому что без dead code elimination — он не жилец. А специфичные для платформ оптимизации придется прятать в библиотеки или интринсики.

    Для «C», с моей точки зрения, следовало бы принять поправку к стандарту, обязывающую создателей компилятора специфицировать поведение в каждом случае UB на конкретной платформе, или (если они не могут этого сделать по объективным причинам — например, таковое определяется конфигурацией аппаратуры или операционной системой) — описать код, который они обязуются генерировать в таком случае. Мне довольно трудно представить себе вменяемого разработчика компилятора, который во всех случаях UB в «C» сможет написать «стереть файлы с диска» или «удалить всю функцию из кода». В любом случае, в интернете будет кому объяснить такому публично (и, возможно, нелицеприятно) всю неправильность его позиции… А один раз описав (и обосновав!) контракт компилятора по генерации кода в случае UB на конкретной платформе — можно ожидать что разработчики будут связаны этим контрактом (и обоснованиями) на достаточно долгий срок — что обеспечит стабильность (но не переносимость!!!) поведения кода на языке «C» и сохранит его как высокоуровневый, эффективный, и почти переносимый ассемблер. Что и отличает его до сих пор от Паскаля, Джавы и прочих конкурирующих языков программирования…


    1. Alexey_Alive
      11.12.2021 08:02
      +1

      Зачем гадать, что имели в виду создатели стандарта, если они сами разъяснили, что каждый термин означает в C Rationale Document.

      Unspecified behavior gives the implementor some latitude in translating programs. This latitude does not extend as far as failing to translate the program.

      Undefined behavior gives the implementor license not to catch certain program errors that are difficult to diagnose.

      Implementation-defined behavior gives an implementor the freedom to choose the appropriate approach, but requires that this choice be explained to the user.