Некоторое время назад в Интернете ходила статья о неопределённом поведении, просто бесившая коренную аудиторию Rust. Завсегдатаи С и C++ в ответ только бурчали, что кто-то просто не понимает Всех Тонкостей и Нюансов Их Светлейшего Языка. Как обычно, пришло время и мне постараться изо всех сил и вставить мои пять копеек в эту застарелую дискуссию.  Готовьтесь поговорить об Основной Проблеме языков C и C++, а также о Принципе Лома. Эта статья была на виду в конце ноября 2022 года. Читатели обсуждали небольшой изъян языка, связанный с тем, что GCC может взять неаккуратное знаковое целое и проэксплуатировать неопределённое поведение, заложенное в стандартной библиотеке C — и далее зарядит дробовик и начнёт крошить из него ваш код. Полностью пример выглядит так: «Плохой» код, который превращается в проблему, будучи оптимизирован GCC, содержится в f, а именно — в операции умножения с последующей проверкой: Эта программа может быть скомпилирована при помощи GCC, например, вот так: gcc -02 -Wall -o f_me_up_gnu_daddy. После этого её можно запустить как ./f_me_up_gnu_daddy 50000000 , и здравствуй, дорогая ошибка сегментации (разумеется, это приведёт к дампу ядра, всё как принято). Как указано в той статье, программа даже выведет (printf) «безумную ложь, далее быстренько разыменует tab и бесславно помрёт». Если вы понимаете, в чём дело: умножив 50 000 000 на 0x1ff (511 в десятичной системе), получаем 25 550 000 000; иными словами, число СЛИШКОМ большое для 32-разрядного целого (допустимый максимум составляет жалких 2 147 483 647). Так провоцируется переполнение знаковых целых. Но оптимизатор предполагает, что переполнения знаковых целых произойти не может, поскольку число уже положительное (что в данном случае гарантирует проверка x < 0, плюс умножение на константу). В конце концов, GCC берёт этот код, выдаёт его вам на-гора и фактически удаляет проверку i >= 0, а заодно и всё, что она подразумевает. Естественно, автору статьи это не нравится. Оказывается, далеко не только ему. Для начала должен отметить: это не первый случай, когда язык C, его реализации и даже сам стандарт попадают под град критики за подобные оптимизации. Ранее в 2022 году кто-то опубликовал код ровно в таком же стиле: с участием индекса знаковых чисел, который автор затем попытался бомбардировать проверками безопасности после нескольких арифметических операций. На тот момент (до обвала Twitter и, соответственно, блокировки аккаунта) он успел пометить этот код хештегом #vulnerability и заявил, что из-за вмешательства GCC код получается более опасным. Ещё примерно полутора годами ранее Виктор Йодайкен как следует прошёлся в своей статье по «комитетчикам и реализаторам, выжившим из ума» , доведя эту идею до кульминации в своей статье о том, почему именно ISO C не подходит для разработки операционных систем (он даже вывесил видео в защиту своей позиции на Чтениях 11-го Воркшопа по языкам программирования и операционным системам). И это только свежие примеры, а вообще у аналогичных проблем долгая история. Учитывая, сколько известно серьёзных выпадов в адрес компиляторов, которые своей оптимизацией доводят код до неопределённого поведения, можно было бы подумать, что WG14 — комитет C — или WG21 — комитет C++ — обратят на это внимание и попробуют найти решение. Ведь проблема то и дело всплывает в сообществах C и C++ на протяжении десятилетий. Но, прежде чем перейти к обсуждению того, что уже сделано и должно быть сделано, давайте поговорим, почему всех уже настолько достало неопределённое поведение, и почему, в частности, оно случается всё чаще. В конце концов, есть же системные программисты™ и вендоры/разработчики  компиляторов®, которым всё это очень не нравится, и которые уже берутся соревноваться «кто первый моргнёт». Глаза сохнут, начинает накатывать скука, держать пальцы на клавиатуре становится всё сложнее, а уж тем более — сосредоточенно воспринимать происходящее… И вот. К сожалению, Как Виктор Йодайкен пытается подчеркнуть в своей статье и презентации, на его взгляд, неопределённое поведение не предполагалось применять так, как его используют сегодня (в особенности это касается тех, кто пишет компиляторы). Кроме того, автор поста, на который я ссылаюсь выше, также этим шокирован и ссылается на принцип наименьшего удивления, рассуждая, почему GCC, Clang и другие компиляторы продолжают по-свински оптимизировать код именно в такой манере. Причём, максимально адекватная в таком случае реакция (не слишком весёлая, а более вдумчивая: «а ведь люди от этого действительно зависят, уф»), поступила от felix-gcc, в гораздо более старом багрепорте, касающемся GCC: Согласно стандарту C, переполнение целочисленного типа является неопределённым, поэтому при сложении используйте беззнаковое целое или -fwrapv. Да вы ЧТО, издеваетесь? … ПОЖАЛУЙСТА, ОТКАТИТЕ ЭТО ИЗМЕНЕНИЕ. Оно спровоцирует СЕРЬЁЗНЫЕ ПРОБЛЕМЫ С БЕЗОПАСНОСТЬЮ во ВСЕВОЗМОЖНОМ КОДЕ. Меня не волнует, что защитники вашего языка утверждают, будто gcc виднее. ЛЮДЕЙ НА ЭТОМ БУДУТ ВЗЛАМЫВАТЬ. — felix-gcc, January 15, 2007 В игре в гляделки пользователи моргнули первыми, и в этот самый миг GCC принялся оптимизировать неопределённое поведение, ни в чём себя не ограничивая. Его примеру последовал Clang, и теперь жизнь многих разработчиков начинает напоминать рулетку, тогда как сами они полагают, что пишут надёжный и безопасный код. Теперь уже — нет, поскольку они пользуются конструкциями, которые трактуются в стандарте C как неопределённое поведение. По-видимому, победили те, кто защищает язык и регламентирует работу компиляторов, а такие как Виктор Йодайкен, felix-gcc и bug/ubitux (автор поста, из-за которого крайний раз вспыхнули протесты против оптимизаций такого рода) остались ни с чем. … И это, разумеется, правда, но не вся. Правда в том, что, сколько бы Йодайкен не настаивал в своём посте, что неопределённое поведение, используемое в качестве средства оптимизации – это просто «ошибка чтения», проблема началась не с ошибки чтения. Всё началось гораздо раньше, с предшественника ISO C — это было ещё до рождения некоторых из вас и до того, как вы впервые увидели компьютер. У WG14 возникла проблема ещё до того, как его назвали ISO/IEC SC22 JTC1 WG14 и даже до того, как он приобрёл официальный статус комитета по ANSI. У них был парк компьютеров, и поведение этих компьютеров сильно отличалось. Сверх того появилось множество вещей, которые плохо поддавались проверке и не всегда вписывались в вычислительные мощности, доступные в то время. На тот момент казалось, что перечислить все возможные варианты поведения — титаническая задача, если не сказать невыполнимая. Серьёзно, если сейчас кто-то не хочет писать документацию, то сложно даже представить, насколько этого не хотели делать в эпоху мейнфреймов, работавших на перфокартах. Кроме того, они совершенно не хотели препятствовать тому, что их новенький язык с иголочки стремились использовать на самом разном железе, либо различным вариантам использования, сложившимся на разных компьютерах. Так что эта схема разрабатывалась во времена, когда существовал фактически единственный разработчик компиляторов, рассчитанных на глубоко неудобную/проклятую архитектуру.  Так и пришлось познакомиться с неопределённым поведением (и похожими аспектами). Комитет просто позволил себе послабление по отдельным вопросам. Тем, которые:  Оказались слишком сложны (например, проверить Неукоснительное Следование Правилу Единственного Определения для всех версий втягиваемой в строку функции со всеми вариантами заголовков); или Не внушали уверенности (а что, если в будущем кто-то изобретёт компьютер, в котором используется ещё более экзотический CHAR_BIT или ещё более причудливые адресные пространства); или Были слишком сложны для документирования (соглашения об использовании целых чисел, варианты поведения при переполнении (по модулю, с насыщением, при прерывании, т.д.)). Они сочли, что это будет некоторая разновидность неопределённого/неуказанного/зависящего от реализации поведения. Пользуетесь обратным кодом вместо дополнительного? Получите неопределённое поведение на кончиках целочисленных диапазонов. Пользуетесь необычным сдвиговым регистром при работе с 16-разрядными целыми и перемещаете верхние биты? Неопределённое поведение. Передаёте слишком большой аргумент в функцию, задача которой — что‑либо обращать в минус? Неуказанное/неопределённое поведение! Перемножаете два целых числа, и произведение больше не умещается в диапазоне? Вы правы, это  Неопределённое поведение. WG14 умыл руки по поводу этой проблемы. И в течение следующих 30-40 лет всё оставалось как есть. Конечно, программисты не могли просто так писать код, основанный на неопределённом поведении. Поэтому таких товарищей, как felix-gcc, Виктора Йодайкена и, пожалуй, сотни тысяч других программистов коробило, что в реализациях различных компиляторов допускаются такие договорняки. Компиляторы должны были просто «генерировать код», после чего пользователи должны были иметь дело именно с тем, что приказали сделать машине. В конечном счёте, именно эту интерпретацию пытается донести до нас Йодайкен, формулируя в вышеупомянутом посте самый выстраданный и растянутый тезис о том, что неопределённое поведение является «ошибкой чтения» в C. Независимо от того, хочет ли кто‑то — и станет ли — вдаваться в такие же грамматические упражнения, как Йодайкен, всё это не имеет значения. В языке C уже сложился де-факто официальный порядок интерпретации кода. Этот порядок определяет всё, от того, как обрабатывать неопределённое поведение, то того, какие оптимизации должны срабатывать, а какие нет, и вплоть до того, как записываются неуказанные поведения и поведения, зависящие от реализации. Всё, на что падает свет  на что падают ваши пальцы, когда вы набиваете код, зависит от вашей реализации. Порядок следования факторов при интерпретации поведения — от максимально значимых до минимально значимых — получается таким: Сумма общепринятых знаний о том, как генерируется и интерпретируется код  Точка зрения вендора/того, кто реализовал компилятор  Стандарт языка C и другие связанные с ним стандарты (напр., POSIX, MISRA, ISO-26262, ISO-12207) Пользователь (⬅ это вы) Притом, насколько мне не нравится такое положение дел, пользователи — то есть, вы, я и все прочие люди, кто не занимается перетасовкой битов и байтов в Вашем Любимом Компиляторе — в этой ситуации попадают ровно под одну гребёнку. Это верно. Это выражение очень смешно звучит, если вворачивать его в разговоры об осознанном согласии, но в контексте общения с вендорами такой довод вообще не помогает! Ведь им достаточно предъявить нам каменную скрижаль и заявить: «Извините, но Так Сказано В Стандарте», дав нам отповедь как бандитам, независимо от того, нравится нам или нет заниматься таким мазохизмом. Это очень больно. «Но позвольте, — скажете вы, отчаянно пытаясь выбраться из-под той гребёнки, которая нас всех накрыла, — а что же делать с -fwrapv или -fno-delete-null-pointer-checks? Это я, пользователь, их контролирую!» К сожалению, это не подлинный контроль. Это моменты, которые вы получаете от вашей реализации. А реализация полностью контролируется теми, кто стоит выше вас, и, если вам доводится мигрировать на другой компилятор, не предлагающий таких плюшек, как GCC или Clang, вас могут провести ровно таким же образом. Кроме того, вас могут этого лишить. В политике Clang это сказано совершенно недвусмысленно, и именно так разработчики компилятора приобретают свободу действий, позволяющую реализовать такие флаги как -enable-trivial-auto-var-init-zero-knowing-it-will-be-removed-from-clang. Даже встраиваемые компиляторы, например SDCC, можно подкосить такими поведениями, зависящими от реализации. В результате может измениться размер структуры для этой последовательности битовых полей, описанной в данном багрепорте. Причём, хочу максимально ясно здесь обозначить, что это не вина SDCC; стандарт C позволяет разработчикам компиляторов поступать именно так, и они так и делают — вероятно, ради обеспечения работы на разных машинах и для соблюдения совместимости. В этом и суть. Вендорам компиляторов и авторам реализаций всегда позволялось поступать по собственному усмотрению, зачастую они действовали вразрез со стандартами, действуя так, а не иначе по соображениям, связанным с совместимостью. Но пусть даже «стандарт» по рангу уступает интересам вендоров и программистов, реализующих компиляторы, он остаётся мощным орудием. Вы же, пользователь, бессильны перед лицом вендора, а Стандарт — эффективное средство, умело обращаясь с которым, можно добиваться желаемого поведения. В этом не преуспели не только felix-gcc и ubitux, но и целые сообщества программистов C, работавшие в течение 30 лет. Они слишком серьёзно полагались на авторов реализаций и их закулисье, кулуарные сделки, при этом моля бездушное и своенравное божество, чтобы их расчёты не нарушались. Но у авторов реализаций свои приоритеты и свои контрольные показатели, свои  вехи, которых нужно достичь. Каждый день, смиряясь с любой дичью, которая вручалась нам как часть реализации — будь то высококачественный элемент управления, действующий на уровне Clang, действующий через #pragma, или что‑то другое, или компилятор‑написанный‑неким‑гуру‑по‑пьяни‑за‑выходные — мы обрекали себя именно на такое будущее. Притом, насколько C-программисты любят рассуждать, как «плотно к железу» они работают, на самом деле они к нему и не прикасаются. Вся их работа основана на негласной договорённости, что разработчик компилятора «сделает всё как надо», а эта формулировка, как оказалось, всегда понимается по-своему и для каждого человека, и в каждом сообществе. Притом, что оптимизации, приводящие к неопределённому поведению при переполнении знаковых целых, позволяют Поднять Планку Качества, они заметно мешают предсказуемо справляться с переполнениями на аппаратном уровне, так как вендор компилятора в принципе перекрывает вам возможность даже пытаться оптимизировать эти аспекты.  Вот почему программистов, работающих с C и C++, настолько бесит GCC, или Clang, или любая другая реализация, в которой компилятор ведёт себя не так, как они хотят. Он сокрушает иллюзии, будто именно вы рулите вашим кодом, и полностью противоречит Принципу Наименьшего Удивления. Не потому, что концепция неопределённого поведения не была досконально объяснена, или не потому, что её кто-то не понимает, а потому, что здесь приходится усомниться в самой истинности устоявшегося убеждения, будто «C — это просто сборщик макросов». А мы из года в год продолжаем твердить: баг за багом встречается в GCC, за очередным заплюсованным постом следует другой с простынёй комментариев, но устоявшиеся убеждения не меняются, так как они превратились в догмы в сообществе С и (в меньшей степени) в сообществе C++. «Нативный» код, «машинный» код, инлайновый «ассемблер», «близко к металлу» — всё это элементы того «белого костюма», который нравится носить элитарным программистам, якобы способным распахнуть компьютер и всё и отовсюду сделать через командную строку. А если вендор компиляторов решится этому противоречить, то покусится на священный договор, который просто обязан существовать между программистом и машиной. Согласитесь, мы и наши коллеги занимаемся не просто решением задач. Мы инженеры. Вендоры и разработчики компиляторов не зло, но они чётко провели свою красную линию: они намерены заниматься оптимизациями, основанными на неопределённом поведении. Чем больше всего прописано в стандарте, тем сильнее мы рискуем, указывая этим ребятам, которые «главные по железу», что им делать. Если вендоры компиляторов и дальше собираются нас прогибать и продолжать оптимизировать с риском неопределённого поведения, то одно из немногого, что мы в силах сделать — это забрать своё. Лично я выработал для себя очень простое правило, которое называю «принцип лома». Всякий раз, когда вы коммитите неопределённое поведение — считайте, что вы взяли лом и грохнули им по очень большой и дорогой вазе вашей мамы (или папы, бабушки, любого человека, который вам очень близок). Таким образом, если вам нужна была эта ваза (в данном случае — произведение знаковых целых), то вообразите, что теперь она совершенно невосстановима. От неё остались осколки, и теперь, чтобы собрать их в подобие вазы, требуется недюжинное мастерство. Поэтому прежде, чем начнёте размахивать ломом, всё проверьте до того, как закоммитить неопределённое поведение, а не после. Например, возьмём программу, рассмотренную в том посте, и внесём вот такие изменения, позволяющие проверить, не разносите ли вы её до основания, пока ситуация не покатилась к чертям: Это безопасный способ проверки на переполнение, которым рекомендую пользоваться прежде, чем согрешить. При данной конкретной проверке переполнения не приходится беспокоиться об отрицательных значениях или других подобных случаях, так как мы проверяли на «меньше нуля» ранее, и сейчас нам это очень пригодилось. Стилистически вышеприведённый листинг, конечно же, не очень хорош: мы пользуемся магическими числами и не всё указываем, но в общих чертах «принцип лома» уже понятен: если хотите им помахать, то проверяйте сначала, а не потом, когда ваза уже разбита. Но, честно говоря, в сравнении с масштабом проблемы это лишь небольшое облегчение. Естественно, в этом вся суть. Зачем настолько усложнять нечто столь простое, особенно, если это отлично вписывается в формулировку «могу проверить это постфактум на аппаратном уровне»? Ответ, естественно, лежит на поверхности: в данном случае музыку заказывают те, кто пишет компиляторы. Мы, пользователи, были и остаёмся в самом низу этой иерархии. Никто из нас не Райден, готовый сойтись с Сабзиро в эпичной битве; мы слабые и жалкие парии на этом празднике жизни, и нам положены тычки, пощёчины и избиение на потеху публике. Как же нам отучиться ворочать ломом? Что нужно сделать, чтобы не беспокоиться, что любую вазу, которая нам попадается, можно разбить на мелкие черепки?  Обратите внимание: все багрепорты до одного, на которые я ссылаюсь в этом посте, заканчиваются одинаково: «так сказано в стандарте; нет, я не шучу, отвалите» (необязательно в таком тоне, но именно в таком смысле). Если все эти вендоры собираются так ревностно придерживаться стандарта C, то нам стоит прикладывать усилия, чтобы этот стандарт менялся или дополнялся, а затем это отражалось на уровне поведений. В самом деле, беда, что Керниган и Ричи вообще оставили в языке так много неопределённого поведения, и что на первом же заседании комитета ANSI C всё было оставлено в таком виде. Далее проблемы росли, как снежный ком, и в сотнях контекстов проклюнулись неуказанные или неопределённые поведения. Теперь эксплуатировать их могут не только члены Комитета, но и ребята из красных команд, использующие во вред тот код, что мы ежедневно пишем. Но ситуация небезнадёжна. В конце концов, в C удалось стандартизировать заголовок <stdckdint.h>, всё благодаря неустанной работе Давида Свободы, который постарался сделать в C более безопасные и качественные целые числа. Об этих разработках и их использовании я писал здесь, но, возможно, потребуется ещё очень много времени, прежде, чем эти реализации удастся выкатить в рамках стандартной библиотеки. Если вам невтерпёж, то можете взять выложенную в открытый доступ версию кода, которая в очень высоком качестве доступна здесь (C++-шники могут сами взять библиотеку простых безопасных целых чисел, написанную Питером Соммерладом, так как сам язык C++ нисколько не продвинулся в этой области). Не везде этот код идеален, но в этом и есть прелесть опенсорса: каждый может сделать его немного лучше так, что нам не придётся по пятьсот раз переизобретать базовые вещи. Если по-настоящему доводить их до ума, то становится проще обеспечивать высокое качество реализаций, попадающих в стандартную библиотеку, и при работе с ними уже можно рассчитывать на производительность. Кроме того, так свободные разработчики получают стимул наконец-то поделиться своим кодом хотя бы между собой, так что не возникает ситуация, когда люди пытаются сделать одно и то же на 80 разных платформах. Свобода смог изменить стандарт C к лучшему и, пусть он и не исправил всех проблем, он хотя бы своим примером показал, как перевести проблему в разряд решаемых (в срок, который большинству из нас остаётся до пенсии). Разумеется, предстоит сделать ещё многое: ckd_div в предложении Дэвида не рассмотрено. Дело в том, что известно всего два случая отказов при делении: N / 0 и {}_MIN / -1, так как результаты этих операций невозможно представить в дополнительном коде как целые числа ({}_MAX от любого заданного целочисленного типа сюда не относится). Для ckd_modulus характерны ровно те же проблемы, что и для ckd_div, поэтому все решения, помогающие с ckd_div, можно применить и с ckd_modulus. ckd_right_shift и ckd_left_shift здесь не рассмотрены. Неопределённое поведение возникает при сдвиге, затрагивающем верхний разряд; было бы очень хорошо предоставить определения для двух этих случаев, чтобы поведение при сдвиге стало чётко определённым: нужно точно знать, что происходит, когда при работе со знаковым целым мы сдвигаем разряды в сторону самого верхнего, в особенности потому, что теперь в C предусмотрена работа с дополнительным кодом. Разумеется, здесь рассматриваются только математические проблемы, характерные для целых чисел в стиле C и C++. Есть ещё МАССА других неопределённых или неуказанных вариантов поведения, которые могут быть чреваты проблемами для пользователей. Едва ли пользователь подозревает, что такие вещи, как NULL + 0, приводят к неопределённому поведению, или что при передаче NULL с длиной 0 библиотечным функциям (представляющим, например, пустой массив) также возникает неопределённое поведение.  Многие баги обычно проистекают и из целочисленного продвижения (напр., когда мы сдвигаем на 15 вправо 16-разрядное unsigned short со значением 0xFFFF, это приводит к целочисленному продвижению до int ещё до того, как верхний бит перейдёт в высший, и это обернётся неопределённым поведением). Эта проблема решается при помощи недавно стандартизированного типа _BitInt(N), разработанного Эрихом Кином, Аароном  Боллманом, Томми Хоффнером и Мелани Блоуэр — о нём я также писал здесь. В C++ подобных возможностей пока почти нет, разве что в форме заказных библиотек; посмотрим, будет ли язык развиваться в эту сторону, но пока приходится пользоваться конструктом C в C++. Например, на уровне Clang было бы удобно реализовать непродвигаемые целые числа, которые понадобятся вам при необходимости вернуть контроль над кодом, если он начнёт доставлять проблемы. (Небольшое предостережение: мы не решили, как применять _BitInt(N) с обобщёнными функциями, поэтому с обобщёнными функциями из <stdckdint.h> он работать не будет, ведь в С нет обобщённой параметричности). Ведётся большая работа, и мы активно в ней участвуем. Ничто из вышеперечисленного не получится сделать за ночь на утро. Но таков мир, который мы унаследовали от предков. Здесь мы наименее сильная часть экосистемы, а в руках вендоров и разработчиков компиляторов по-прежнему остаются мощнейшие рычаги контроля над всеми языковыми аспектами. Но, если и далее относиться к стандарту С как к священному писанию, которым они оправдывают любые свои действия, то нам — чтобы выжить — нужно его переписать. Очень многие не желают вносить в С ни изменений, ни дополнений. Им нравится стабильность, желательно замороженная. Они считают фичей, что на внедрение в язык С такой простой штуки как #embed может уйти пять лет, поскольку так, на их взгляд, язык C не превращается в «мешанину» (иногда доводится слышать: «мешанина как в C++»). Но для меня и многих других, кто пытается писать код, держа в уме железо, как можно ближе к металлу, код, в точности выражающий наши мысли, языки C и C++ уже сломаны. Можно либо оставить всё как есть и далее позволять вендорам сгонять нас с нашей территории. Или дать отпор. Мы заслуживаем работать с такими целыми числами, которые не провоцируют неопределённого поведения при сдвиге разрядов влево. Заслуживаем таких операций умножения и вычитания, от которых нам не прилетит. Заслуживаем код, который действительно выражает то, что мы хотели написать, поэтому мы можем встроить необходимые гарантии безопасности в софт, которым ежедневно пользуются программисты во всём мире. Мы заслуживаем лучшего — так что же делать, если отцы-основатели не уладили этот вопрос и оставили его решать нам? Что ж, придётся справляться самим. Есть секретный вариант, позволяющий со всем этим справиться. Можно забыть о приёмах против лома, абстрагироваться от тонкостей неопределённых, неуказанных или зависящих от реализации поведений — достаточно всего лишь вооружиться инструментами, которые не выставляют вас дураком и просто позволяют писать именно то, что вы имеете в виду.