Comments 24
1) Врапинг происходит, переставляется флаг;
2) Врапинг происходит, но никакого флага нет;
3) Врапинга нету, есть насыщение, т.е. результат упирается в 0xffffffff (или в 0x80000000 если вниз).
И что будем в спецификацию на язык писать?
А реальные примеры таких процессоров нет? Насколько мне известно, врапинг есть и на армах, и на х86, и на микроконтроллерах разнообразных.
Причина проста: С писали как самый простой транслятор в асм, поэтому там есть неопределённое поведение.
Почему-то есть компилируемые языки под множество платформ, где неопределённого поведения нет, либо оно минимизировано.
В том же D — врапинг это норма.
Даже х87 может отдать ±INF (насыщение).
Лучшим решением была бы стандартизированная #pragma, а ещё лучше блоки типа C#
unchecked { ... }
, где внутри блока гарантированно происходит wrap, не задевая этим производительность остальных частей.
Но, с другой стороны, если нужен wrap, можно пользоваться unsigned, проблема-то не в настройке поведения при переполнении, а в том, что для программистов не очевидно и допускаются ошибки.
Думаю, любой разработчик, пишущий критичный с точки зрения безопасности код, в идеале должен хорошо владеть семантикой языка, на котором он пишет, а также знать о его подводных камнях. Применительно к C это означает, что необходимо знать семантику переполнения и тонкости неопределённого поведения.
Спорить очень трудно, но я попробую. Вместо того, чтобы требовать знания тонкостей реализации переполнения, нужно требовать использования функций, аналогичных os_*_overflow из macOS, которые для GCC и CLang отображаются на встроенные, а для остальных выполены макросами.
В итоге получается, что при осторожном программировании на С любой арифметический оператор — потенциальный источник проблем, и должен быть либо заменен на вышеупомянутую функцию даже если автор кода мамой клянется, что он все до этого 3 раза проверил.
В общем, не надо надеяться только на профессионализм людей (потому что не очень хороший день бывает даже у очень матерых волков), лучше надеяться на процессы и автоматику, именно поэтому стоит посмотреть на более безопасный Rust в качестве замены крайне опасного С, и научиться пользоваться valgrind, asan, ubsan и статическими анализаторами.
Это может быть, например, специальный оператор, типа
int a = b ^+ 1000;
или
int a = b ↑+ 1000;
или функция
int a = add_wrap(b, 1000)
или ещё какая-нибудь специальная конструкция, включающая заданное поведение для данного выражения.
signed char a1=83, b1=-37, c1=a1*b1;
unsigned char a2=83, b2=256-37, c2=a2*b2;
К тому же, на самом деле знаковая арифметика платформозависимая. Хотя 99.9%, тем патче современных, реализуют именно двоичное дополнение, что по сути уравнивает логику (делает её бинарно-совместимой), тем не менее существуют экземпляры. Я молчу про экзотику, которая есть, но которую никто не видел (насыщение, вместо переполнения, например в DSP/GPU при обработки сигналов это может быть очень полезно, продвинутые телефоны раньше точно так умели, сейчас не знаю).
Но вообще дело не даже не в том, что могло быть. А в том, что есть. С точки зрения Си у нас undefined behaviour. Всё. Смиритесь. Разные языки по разному определяют поведение. Алсо, вопрос на засыпку:
char a = -128;
char b = 0;
char c = -1;
b -= a;
c *= a;
printf("%d %", b, c);
Ответ простой и линейный, но тем не менее.
С точки зрения Си у нас undefined behaviourВот это и не нравиться что все кому не лень пытаются добавть побольше UB и сделать так чтоб компилятор еще в подобных случаях нарушая логику программы оптимизировал с неверными предпосылками. При этом все кричат об офигенной оптимизации. И потом героически начинают преодолевать эти грабли.
Посмотрите что получилось с webassembly когда он выкидывает исключения при переполнении целых типов, при этом компилятор об этом не догадывается и строит свои оптимизации не учитывая данный факт.
Я бы тут копнул немного глубже, чтобы понять, что тут происходит и почему мы имеем то, что имеем.
Возьмем изначальный пример:
int b = a + 1000;
if (b < a) { // переполнение
puts("input too large!"); return;
}
Теперь вспомним ассемблер и подумаем, как бы этот код мог быть написан на нем?
- Складываем
a + 1000
. - Проверяем флаг переполнения. Если выставлен, то пишем "input too large!".
- Если нет переполнения, то все ок и продолжаем работу.
Т.е. по сути, ассемблер как раз нам дает абсолютно корректное и железобетонное поведение: пытаемся выполнить операцию и проверяем итоговый результат. Нет необходимости предсказывать, является ли значение переменной меньше, чем мы ожидаем при прибавлении. Ведь в случае константы 1000 все очевидно, а в случае прибавление динамической переменной — уже не очень. Она может оказаться любой, и отрицательной в том числе. Т.е. надо будет сначала проверить с чем складываем, а потом уже выставить правильное условие? Выглядит крайне непривлекательно.
Вместо этого, по сути, ассемблер позволяет сделать аналог try/catch: выполняем операцию, и ловим исключение (выставленный флаг) в случае нарушения условий.
Когда же переходим к С, то оказывается, что флагов нет и быть не может. Приходится изворачиваться. Получаются франкенштейны типа b < a
и прочих. На самом деле умный ход, но он является костылем из-за недостаточной низкоуровневости С.
Итого мы имеем, что низкоуровневый язык, каким и изначально задумывался язык С, не может передать достаточно важные и эффективные конструкции из машинного языка. Этот костыль носит фундаментальный характер, и проявился он в спецификации UB, выкидывающей на помойку костыли из прошлого.
Т.е. компиляторы на сегодняшний день не могут по другому, а программисты уже не хотят мириться с таким поведением. Хочется большего контроля со стороны компиляторов, причем без потери эффективности. Налицо революционная ситуация...
Почему перенос при целочисленном переполнении — не очень хорошая идея