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

Примечание от переводчика: в русском языке нет четкого соответствия в употребляемом контексте слова «wrap»/«wrapping». Существует математический термин "перенос", который близок к описываемому явлению, а термин "флаг переноса" (carry flag) — механизм выставления флага в процессорах при целочисленном переполнении. Другим вариантом перевода может быть фраза «вращение/переворот/оборот вокруг нуля». Она лучше отображает смысл «wrap» по сравнению с «перенос», т.к. показывает переход чисел при переполнении из положительного в отрицательный диапазон. Однако, как оказалось, эти слова смотрятся в тексте непривычно для тестовых читателей. Для упрощения в дальнейшем примем в качестве перевода термина «wrap» слово «перенос».

Компиляторы языка C (и C++) в своей работе всё чаще руководствуются понятием неопределённого поведения — представлением о том, что поведение программы при некоторых операциях не регламентировано стандартом и что, генерируя объектный код, компилятор вправе исходить из предположения, что программа таких операций не производит. Немало программистов возражало против такого подхода, поскольку сгенерированный код в этом случае может вести себя не так, как задумывал автор программы. Эта проблема становится всё острее, так как компиляторы применяют всё более хитроумные методы оптимизации, которые наверняка будут опираться на понятие неопределённого поведения.

В этом контексте показателен пример со знаковым целочисленным переполнением. Большинство разработчиков на C пишут код для машин, в которых для представления целых чисел используется дополнительный код, а сложение и вычитание в таком представлении реализованы точно так же, в беззнаковой арифметике. Если сумма двух положительных целых чисел со знаком переполнится — то есть станет больше, чем вмещает тип, — процессор выдаст значение, которое, будучи интерпретировано как двоичное дополнение знакового числа, будет считаться отрицательным. Это явление называется «переносом», поскольку результат, дойдя до верхней границы диапазона значений, «переносится» и начинается с нижней границы.

По этой причине можно иногда увидеть вот такой код на C:

int b = a + 1000;
if (b < a) { // переполнение
    puts("input too large!"); return;
}

Задача оператора if — обнаружить состояние переполнения (в данном случае оно возникает после прибавления 1000 к значению переменной a) и сообщить об ошибке. Проблема в том, что в C знаковое целочисленное переполнение является одним из случаев неопределённого поведения. Компиляторы с некоторых пор считают такие условия всегда ложными: если прибавить 1000 (или любое другое положительное число) к другому числу, результат не может быть меньше начального значения. Если же происходит переполнение, значит, возникает неопределённое поведение, и не допускать этого — уже (по-видимому) забота самого программиста. Поэтому компилятор может решить, что условный оператор можно целиком удалить в целях оптимизации (ведь условие всегда ложно, оно ни на что не влияет, значит, можно обойтись без него).

Проблема в том, что этой оптимизацией компилятор убрал проверку, которую программист добавил специально, чтобы выявить неопределённое поведение и обработать его. Вот тут можно посмотреть, как это происходит на практике. (Примечание: сайт godbolt.org, на котором размещён пример, очень классный! Можно редактировать код и сразу же смотреть, как его обрабатывают разные компиляторы, а их там представлено множество. Поэкспериментируйте!). Обратите внимание, что компилятор не убирает проверку на переполнение, если изменить тип на беззнаковый, поскольку поведение при беззнаковом переполнении в языке С определено (точнее, при беззнаковой арифметике результат переносится, поэтому переполнения на самом деле не происходит).

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

  • Перенос при переполнении — полезное поведение.
  • Перенос — это поведение, которого ожидают программисты.
  • Семантика неопределённого поведения при переполнении не даёт заметного преимущества.
  • Стандарт языка C в отношении неопределённого поведения разрешает реализации «полностью игнорировать ситуацию, при этом результат будет непредсказуем», но это не даёт компилятору права оптимизировать код, исходя из предположения, будто ситуация с неопределённым поведением не случится вовсе.

Разберём каждый пункт по очереди:

Перенос при переполнении — полезное поведение?

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

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

if (a > INT_MAX - 1000) { // будет ли переполнение
    puts("input too large!");
    return;
}
int b = a + 1000;

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

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

Перенос — это поведение, которого ожидают программисты?

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

Очевидное решение проблемы (ожидание программистами именно этого поведения) — сделать так, чтобы компилятор выдавал предупреждение, когда он оптимизирует код, предполагая отсутствие неопределённого поведения. К сожалению, как мы видели в примере на сайте godbolt.org по ссылке выше, компиляторы не всегда поступают таким образом (Gcc версии 7.3 — да, а версии 8.1 — нет, так что налицо шаг назад).

Семантика неопределённого поведения при переполнении не даёт заметного преимущества?

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

Я допускаю, что конкретно этой оптимизацией (удаление проверок математически противоречивых условий) в обычных программах на C зачастую можно пренебречь, так как их авторы стремятся к наилучшей производительности и всё равно оптимизируют код вручную: то есть, если очевидно, что данный оператор if содержит условие, которое никогда не будет истинным, программист, скорее всего, уберёт его сам. В самом деле, я выяснил, что в нескольких исследованиях эффективность такой оптимизации была поставлена под вопрос, протестирована и признана практически несущественной в рамках контрольных тестов. Однако, хотя эта оптимизация почти никогда не даёт преимущества в языке C, генераторы кода и оптимизации компиляторов в большинстве своём универсальны и могут использоваться в других языках — и для них данный вывод может быть неверен. Возьмём язык C++ с его, скажем так, традицией полагаться на оптимизатор, чтобы тот убирал избыточные конструкции в коде шаблонов, а не делать это вручную. А ведь существуют и языки, которые преобразуются транспайлером в C, и при этом избыточный код в них также оптимизируется C-компиляторами.

Кроме того, даже если сохранить проверки на переполнение, вовсе не факт, что прямая стоимость переноса целочисленных переменных будет минимальной даже на машинах, использующих дополнительный код. Архитектура Mips, например, может выполнять арифметические операции только в регистрах фиксированного размера (32 бита). Тип short int, как правило, имеет размер 16 бит, а char — 8 бит; при хранении в регистре переменной одного из этих типов её размер расширится, и, чтобы корректно перенести её, потребуется выполнить по крайней мере одну дополнительную операцию и, возможно, задействовать дополнительный регистр (чтобы вместить соответствующую битовую маску). Должен признать, что я уже давно не имел дела с кодом для Mips, так что я не уверен насчёт точной стоимости этих операций, но я уверен, что она ненулевая и что на других архитектурах RISC могут возникнуть такие же проблемы.

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

Если разобраться, этот аргумент особенно слаб. Его суть в том, что стандарт якобы позволяет реализации (компилятору) трактовать «неопределённое поведение» лишь в ограниченных пределах. В самом же тексте стандарта — в том его фрагменте, к которому апеллируют сторонники переноса, — сказано следующее (это часть определения понятия «неопределённое поведение»):

ПРИМЕЧАНИЕ: Неопределённое поведение может принимать вид от полного игнорирования ситуации, при этом результат будет непредсказуем, ...

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

Прежде всего, следует заметить, что этот текст дан как «примечание», а потому не является нормативным (т.е. не может что-то предписывать), согласно директиве ISO, упомянутой в предисловии к стандарту:

В соответствии с Частью 3 Директив ISO/IEC, данное предисловие, введение к тексту, примечания, сноски и примеры также носят исключительно информационный характер.

Поскольку этот фрагмент о «неопределённом поведении» является примечанием, он ничего не предписывает. Обратите внимание, что настоящее определение понятия «неопределённое поведение» звучит так:

поведение, возникающее при использовании непереносимой или некорректной программной конструкции или некорректных данных, к которому настоящий Международный Стандарт не предъявляет никаких требований.

Я выделил главную мысль: к неопределённому поведению не предъявляется никаких требований; список «возможных видов неопределённого поведения» в примечании содержит лишь примеры и не может быть окончательным предписанием. Фразу «не предъявляет никаких требований» невозможно истолковать как-то иначе.

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

Размышления напоследок

Доводы в защиту переноса по большей части несостоятельны. Пожалуй, самый сильный аргумент получается, если их объединить: на перенос иногда рассчитывают менее опытные программисты (которые не знают тонкостей языка C и неопределённого поведения в нем), и оно не снижает производительность — хотя последнее верно не во всех случаях, а первая часть неубедительна, если рассматривать её отдельно.

Лично я предпочёл бы, чтобы переполнения блокировались (trapping), а не переносились. То есть, чтобы программа падала, а не продолжала работать — с неопределённым ли поведением или потенциально некорректным результатом, ведь и в том, и в другом случае появляется уязвимость. Такое решение, конечно, немного снизит производительность на большинстве (?) архитектур, особенно на x86, но, с другой стороны, ошибки, связанные с переполнением, будут сразу выявлены и ими не получится воспользоваться или получить с их помощью некорректные результаты дальше по ходу выполнения программы. Кроме того, в теории компиляторы при таком подходе могли бы безопасно удалять избыточные проверки на переполнение, поскольку оно точно не случится, хотя, как я вижу, ни Clang, ни GСС этой возможностью не пользуются.

К счастью, и прерывание, и перенос реализованы в компиляторе, которым я пользуюсь чаще всего, — GCC. Для переключения между режимами используются аргументы командной строки -ftrapv и -fwrapv соответственно.

Действий, приводящих к неопределённому поведению, конечно же, много — целочисленное переполнение лишь одно из них. Я вовсе не считаю, что все эти случаи полезно трактовать как неопределённое поведение, и я уверен, что существует немало специфических ситуаций, когда семантика должна определяться языком или, по крайней мере, оставаться на усмотрение реализаций. И я опасаюсь излишне вольных трактовок этого понятия производителями компиляторов: если поведение компилятора не отвечает интуитивным представлениям разработчиков, особенно тех, кто лично читал текст стандарта, это может привести к реальным ошибкам; если прирост производительности в этом случае пренебрежимо мал, то лучше от таких трактовок отказаться. В одном из следующих постов я, возможно, разберу некоторые из этих проблем.

Дополнение (от 24 августа 2018 года)

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

  • Я не утверждал, что неопределённое поведение предпочтительнее переноса при переполнении — скорее, что на практике перенос ненамного лучше неопределённого поведения. В частности, проблемы с безопасностью можно получить что в первом случае, что во втором — и я готов поспорить, что многие из уязвимостей, спровоцированных неотловленными вовремя переполнениями (кроме тех, за которые ответственен компилятор, удаливший ошибочные проверки), на самом деле появились из-за переноса результата, а не из-за неопределённого поведения, связанного с переполнением.
  • Единственное реальное преимущество переноса состоит в том, что проверки на переполнение не удаляются. Хотя так можно обезопасить код от некоторых сценариев атак, остаётся вероятность, что часть переполнений не будет проверена совсем (т.е. программист забудет добавить такую проверку) и останется незамеченной.
  • Если вопрос безопасности не столь важен, и на первый план выходит высокая скорость работы программы, то неопределённое поведение даст более выгодную оптимизацию и больший прирост производительности, по крайней мере в некоторых случаях. С другой стороны, если безопасность превыше всего, перенос чреват уязвимостями.
  • Это значит, что, если выбирать между прерыванием, переносом и неопределённым поведением, то задач, в которых перенос может быть полезен, очень мало.
  • Что же касается проверок на возникшее переполнение, я считаю, что оставлять их вредно, потому что создаётся ложное впечатление, будто они работают и будут работать всегда. Прерывание переполнений позволяет избежать этой проблемы; адекватные предупреждения — смягчить её.
  • Думаю, любой разработчик, пишущий критичный с точки зрения безопасности код, в идеале должен хорошо владеть семантикой языка, на котором он пишет, а также знать о его подводных камнях. Применительно к C это означает, что необходимо знать семантику переполнения и тонкости неопределённого поведения. Печально, что некоторые программисты так и не доросли до этого уровня.
  • Мне встречалось утверждение, будто бы «большинство программистов на C ожидают переноса в качестве поведения по умолчанию», но свидетельств этого мне неизвестно. (В статье я написал «некоторые программисты», потому что знаю несколько примеров из реальной жизни, и вообще сомневаюсь, что кто-то будет с этим спорить).
  • Есть две разные проблемы: что требует стандарт языка C и что должны реализовывать компиляторы. Меня (в целом) устраивает то, как стандарт определяет неопределённое поведение при переполнении. В этом посте я говорю о том, что должны делать компиляторы.
  • При прерывании переполнения нет нужды проверять на него каждую операцию. В идеале программа при таком подходе либо ведёт себя непротиворечиво с точки зрения математических правил, либо прекращает работу. При этом становится возможным существование «временного переполнения», которое не приводит к появлению некорректного результата. Тогда и выражение a + b — b, и выражение (a * b) / b можно оптимизировать до a (первое возможно и при переносе, а вот второе — уже нет).

Примечание. Перевод статьи публикуется в блоге с разрешения автора. Оригинальный текст: Davin McCall "Wrap on integer overflow is not a good idea".

Дополнительные ссылки по теме от команды PVS-Studio:

  1. Андрей Карпов. Undefined behavior ближе, чем вы думаете.
  2. Will Dietz, Peng Li, John Regehr, and Vikram Adve. Understanding Integer Overflow in C/C++.
  3. V1026. The variable is incremented in the loop. Undefined behavior will occur in case of signed integer overflow.
  4. StackOverflow. Is signed integer overflow still undefined behavior in C++?

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


  1. nerudo
    25.10.2018 11:58
    +1

    Сейчас меня порвут в клочья, но C, а за ним и C++ проектировались исходя из огромного количества разных древних архитектур, на которых главной задачей было хоть как-то запуститься и решать задачи системного уровня. Никто изначально не планировал, что C(++) начнут использовать для вычислений, где все эти нюансы будут важны. Хотите — создавайте отдельные классы данных, операторы, fixed-point арифметику регулируемой разрядности. Дальше учите компиляторы со всем этим эффективно работать: там где платформа позволяет собирать в native-код (да хоть логику в FPGA синтезируйте), где не позволяет — добавлять всякие обертки и т.п. с потерей производительности на порядки.


    1. qw1
      25.10.2018 12:09

      Непонятно из вашего комментария, поддерживаете ли вы отсутствие wrapping при знаковом переполнении, или нет.


      1. nerudo
        25.10.2018 12:14
        +8

        Какая разница, поддерживаю его лично я или нет, если передо мной, к примеру, лежат три процессора:
        1) Врапинг происходит, переставляется флаг;
        2) Врапинг происходит, но никакого флага нет;
        3) Врапинга нету, есть насыщение, т.е. результат упирается в 0xffffffff (или в 0x80000000 если вниз).
        И что будем в спецификацию на язык писать?


        1. Temtaime
          25.10.2018 19:51

          А реальные примеры таких процессоров нет? Насколько мне известно, врапинг есть и на армах, и на х86, и на микроконтроллерах разнообразных.
          Причина проста: С писали как самый простой транслятор в асм, поэтому там есть неопределённое поведение.
          Почему-то есть компилируемые языки под множество платформ, где неопределённого поведения нет, либо оно минимизировано.
          В том же D — врапинг это норма.


          1. nerudo
            25.10.2018 20:38

            А что нету? Сейчас пойду на гитхаб, форкну какой-нибудь процессор и через час будет ;) Вообще арифметика с насыщением полезна в ЦОС, у каких-то DSP-процессоров было, сейчас уж не помню.


            1. Disco_Cat
              26.10.2018 09:54

              арифметика с насыщением, например, есть в analog devices blackfin. Но чтобы её задействовать, нужно вместо операторов "+", "-" применять специальные функции


              1. qw1
                26.10.2018 13:13

                Со специальными операторами и на x86 есть, в SIMD-инструкциях.


          1. opaopa
            26.10.2018 00:17
            +1

            Есть. Многие DSP (сигнальные процессоры) кроме «обычного» имеют режим насыщения.
            Даже х87 может отдать ±INF (насыщение).


      1. domix32
        25.10.2018 12:34

        На самом деле наверное было бы неплохо либо задавать signed wrapping поведение флагом компиляции


        1. qw1
          25.10.2018 13:13
          +1

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

          Лучшим решением была бы стандартизированная #pragma, а ещё лучше блоки типа C#
          unchecked { ... },
          где внутри блока гарантированно происходит wrap, не задевая этим производительность остальных частей.

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


  1. CodeRush
    25.10.2018 12:05

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

    Спорить очень трудно, но я попробую. Вместо того, чтобы требовать знания тонкостей реализации переполнения, нужно требовать использования функций, аналогичных os_*_overflow из macOS, которые для GCC и CLang отображаются на встроенные, а для остальных выполены макросами.
    В итоге получается, что при осторожном программировании на С любой арифметический оператор — потенциальный источник проблем, и должен быть либо заменен на вышеупомянутую функцию даже если автор кода мамой клянется, что он все до этого 3 раза проверил.
    В общем, не надо надеяться только на профессионализм людей (потому что не очень хороший день бывает даже у очень матерых волков), лучше надеяться на процессы и автоматику, именно поэтому стоит посмотреть на более безопасный Rust в качестве замены крайне опасного С, и научиться пользоваться valgrind, asan, ubsan и статическими анализаторами.


  1. Andrey2008 Автор
    25.10.2018 12:19
    +9

    Примечание. Перевод этой статьи — это попутный процесс изучения вопросов переполнения знаковых типов и разработки диагностики V1026, о которой я писал в посте "Релиз PVS-Studio 6.26". Уверен, многие пропустили эту заметку, так как ей сопоставлен только «Блог компании PVS-Studio». Пользуясь случаем, приглашаю посмотреть эту публикацию, где рассматривается интересный практический пример неопределённого поведения при переполнении переменной типа int.


  1. Sun-ami
    25.10.2018 14:10
    +2

    Перенос при переполнении — это полезное поведение. Да, оно ухудшает переносимость, и заставляет перепроверять что это работает каждый раз при переходе на новый компилятор или уровень оптимизации. Но C — это низкоуровневый язык, он используется в первую очередь там где нужна низкоуровневая оптимизация. Использование переполнения в микроконтроллерах в некоторых случаях позволяет уменьшить время обработки прерываний в разы (за счёт уменьшения количества сохраняемых в стеке регистров). Бывают случаи, когда альтернатива этому — написание обработчика на ассемблере или замена микроконтроллера.


  1. vesper-bot
    25.10.2018 14:50

    По мне, пихать UB при целочисленном (знаковом или беззнаковом) переполнении — плохая идея. Теряется обратная совместимость с просто кучей вещей, включая, вероятно, часть криптографии, где операции идут с беззнаковыми величинами, но вполне нормально прибавить 0x8C81 к 0xDBE7 и получить 0x6868 как обычное число, без всяких там UB, исключений и подобного геморроя.


  1. sena
    25.10.2018 16:02
    +1

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

    Это может быть, например, специальный оператор, типа

    int a = b ^+ 1000;

    или

    int a = b ^+ 1000;

    или функция

    int a = add_wrap(b, 1000)

    или ещё какая-нибудь специальная конструкция, включающая заданное поведение для данного выражения.


  1. kovserg
    25.10.2018 20:11

    Я фигею с этих гуманитариев. Развели сопли на ровном месте. О модульной арифметике видимо им ничего не рассказывали.


    1. Deerenaros
      25.10.2018 21:19

      Для модульной арифметики есть unsigned. Для int это в общем-то… Неверно?


      1. kovserg
        26.10.2018 23:26

        Вы хотите сказать что c1 и c2 будут отличаться?

        signed char a1=83, b1=-37, c1=a1*b1;
        unsigned char a2=83, b2=256-37, c2=a2*b2;
        


        1. Deerenaros
          27.10.2018 03:53

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

          К тому же, на самом деле знаковая арифметика платформозависимая. Хотя 99.9%, тем патче современных, реализуют именно двоичное дополнение, что по сути уравнивает логику (делает её бинарно-совместимой), тем не менее существуют экземпляры. Я молчу про экзотику, которая есть, но которую никто не видел (насыщение, вместо переполнения, например в DSP/GPU при обработки сигналов это может быть очень полезно, продвинутые телефоны раньше точно так умели, сейчас не знаю).

          Но вообще дело не даже не в том, что могло быть. А в том, что есть. С точки зрения Си у нас undefined behaviour. Всё. Смиритесь. Разные языки по разному определяют поведение. Алсо, вопрос на засыпку:

          char a = -128;
          char b = 0;
          char c = -1;
          b -= a;
          c *= a;

          printf("%d %", b, c);


          Ответ простой и линейный, но тем не менее.


          1. kovserg
            27.10.2018 10:43

            Вы раздуваете из мухи слона на ровном месте. Сточки зрения арифметики по модулю 256 никаких чудес будет -128. Те кому такое поведение не нравиться использовать более вместительные типы. И потом даже в том же DSP и GPU есть операции без насыщения. В некоторых DSP есть операции с обратным распространением переноса и что под это тоже надо UB придумать?

            С точки зрения Си у нас undefined behaviour
            Вот это и не нравиться что все кому не лень пытаются добавть побольше UB и сделать так чтоб компилятор еще в подобных случаях нарушая логику программы оптимизировал с неверными предпосылками. При этом все кричат об офигенной оптимизации. И потом героически начинают преодолевать эти грабли.

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


  1. gridem
    26.10.2018 06:20

    Я бы тут копнул немного глубже, чтобы понять, что тут происходит и почему мы имеем то, что имеем.


    Возьмем изначальный пример:


    int b = a + 1000;
    if (b < a) { // переполнение
        puts("input too large!"); return;
    }

    Теперь вспомним ассемблер и подумаем, как бы этот код мог быть написан на нем?


    1. Складываем a + 1000.
    2. Проверяем флаг переполнения. Если выставлен, то пишем "input too large!".
    3. Если нет переполнения, то все ок и продолжаем работу.

    Т.е. по сути, ассемблер как раз нам дает абсолютно корректное и железобетонное поведение: пытаемся выполнить операцию и проверяем итоговый результат. Нет необходимости предсказывать, является ли значение переменной меньше, чем мы ожидаем при прибавлении. Ведь в случае константы 1000 все очевидно, а в случае прибавление динамической переменной — уже не очень. Она может оказаться любой, и отрицательной в том числе. Т.е. надо будет сначала проверить с чем складываем, а потом уже выставить правильное условие? Выглядит крайне непривлекательно.


    Вместо этого, по сути, ассемблер позволяет сделать аналог try/catch: выполняем операцию, и ловим исключение (выставленный флаг) в случае нарушения условий.


    Когда же переходим к С, то оказывается, что флагов нет и быть не может. Приходится изворачиваться. Получаются франкенштейны типа b < a и прочих. На самом деле умный ход, но он является костылем из-за недостаточной низкоуровневости С.


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


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


  1. mbait
    26.10.2018 09:42
    +1

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


    1. marsianin
      26.10.2018 15:20

      В разных архитектурах могут быть разные наборы флагов и разная их семантика. Сравните, например, выставление Carry-флага при вычитании в x86 и в ARM. А в MISP и RISC-V флагов в принципе нет. Но софт на языках C и C++ должны компилироваться под все эти платформы. Так что, не получится флаги в стандарт затащить.