Pull to refresh

Comments 24

Сейчас меня порвут в клочья, но C, а за ним и C++ проектировались исходя из огромного количества разных древних архитектур, на которых главной задачей было хоть как-то запуститься и решать задачи системного уровня. Никто изначально не планировал, что C(++) начнут использовать для вычислений, где все эти нюансы будут важны. Хотите — создавайте отдельные классы данных, операторы, fixed-point арифметику регулируемой разрядности. Дальше учите компиляторы со всем этим эффективно работать: там где платформа позволяет собирать в native-код (да хоть логику в FPGA синтезируйте), где не позволяет — добавлять всякие обертки и т.п. с потерей производительности на порядки.
Непонятно из вашего комментария, поддерживаете ли вы отсутствие wrapping при знаковом переполнении, или нет.
Какая разница, поддерживаю его лично я или нет, если передо мной, к примеру, лежат три процессора:
1) Врапинг происходит, переставляется флаг;
2) Врапинг происходит, но никакого флага нет;
3) Врапинга нету, есть насыщение, т.е. результат упирается в 0xffffffff (или в 0x80000000 если вниз).
И что будем в спецификацию на язык писать?

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

А что нету? Сейчас пойду на гитхаб, форкну какой-нибудь процессор и через час будет ;) Вообще арифметика с насыщением полезна в ЦОС, у каких-то DSP-процессоров было, сейчас уж не помню.
арифметика с насыщением, например, есть в analog devices blackfin. Но чтобы её задействовать, нужно вместо операторов "+", "-" применять специальные функции
Со специальными операторами и на x86 есть, в SIMD-инструкциях.
Есть. Многие DSP (сигнальные процессоры) кроме «обычного» имеют режим насыщения.
Даже х87 может отдать ±INF (насыщение).
На самом деле наверное было бы неплохо либо задавать signed wrapping поведение флагом компиляции
Это плохая идея, потому что часть исходника по сути оказывается где-то в файлах сборки, и не портируется между компиляторами.

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

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

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

Примечание. Перевод этой статьи — это попутный процесс изучения вопросов переполнения знаковых типов и разработки диагностики V1026, о которой я писал в посте "Релиз PVS-Studio 6.26". Уверен, многие пропустили эту заметку, так как ей сопоставлен только «Блог компании PVS-Studio». Пользуясь случаем, приглашаю посмотреть эту публикацию, где рассматривается интересный практический пример неопределённого поведения при переполнении переменной типа int.
Перенос при переполнении — это полезное поведение. Да, оно ухудшает переносимость, и заставляет перепроверять что это работает каждый раз при переходе на новый компилятор или уровень оптимизации. Но C — это низкоуровневый язык, он используется в первую очередь там где нужна низкоуровневая оптимизация. Использование переполнения в микроконтроллерах в некоторых случаях позволяет уменьшить время обработки прерываний в разы (за счёт уменьшения количества сохраняемых в стеке регистров). Бывают случаи, когда альтернатива этому — написание обработчика на ассемблере или замена микроконтроллера.
По мне, пихать UB при целочисленном (знаковом или беззнаковом) переполнении — плохая идея. Теряется обратная совместимость с просто кучей вещей, включая, вероятно, часть криптографии, где операции идут с беззнаковыми величинами, но вполне нормально прибавить 0x8C81 к 0xDBE7 и получить 0x6868 как обычное число, без всяких там UB, исключений и подобного геморроя.
Есть же и другие варианты решения проблемы, например, элемент языка, включающий перенос и элемент языка выключающий перенос в пределах выражения. Или даже выбрасывающий исключение, при желании.

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

int a = b ^+ 1000;

или

int a = b ↑+ 1000;

или функция

int a = add_wrap(b, 1000)

или ещё какая-нибудь специальная конструкция, включающая заданное поведение для данного выражения.
Я фигею с этих гуманитариев. Развели сопли на ровном месте. О модульной арифметике видимо им ничего не рассказывали.
Для модульной арифметики есть unsigned. Для int это в общем-то… Неверно?
Вы хотите сказать что c1 и c2 будут отличаться?
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);


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

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

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


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


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, выкидывающей на помойку костыли из прошлого.


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

А всего-то нужно было добавить в стандарт возможность проверки переполнения (чтение флага процессора). Хотя я не настолько стар, чтобы рассуждать, насколько этот флаг был поддерживаем архитектурами того времени. Как хорошо, что в наше время можно использовать интринсики и спать спокойно.
В разных архитектурах могут быть разные наборы флагов и разная их семантика. Сравните, например, выставление Carry-флага при вычитании в x86 и в ARM. А в MISP и RISC-V флагов в принципе нет. Но софт на языках C и C++ должны компилироваться под все эти платформы. Так что, не получится флаги в стандарт затащить.
Sign up to leave a comment.