Некоторое время назад в Интернете ходила статья о неопределённом поведении, просто бесившая коренную аудиторию Rust. Завсегдатаи С и C++ в ответ только бурчали, что кто-то просто не понимает Всех Тонкостей и Нюансов Их Светлейшего Языка. Как обычно, пришло время и мне постараться изо всех сил и вставить мои пять копеек в эту застарелую дискуссию.

Готовьтесь поговорить об Основной Проблеме языков C и C++, а также о Принципе Лома.

Неопределённое поведение

Эта статья была на виду в конце ноября 2022 года. Читатели обсуждали небольшой изъян языка, связанный с тем, что GCC может взять неаккуратное знаковое целое и проэксплуатировать неопределённое поведение, заложенное в стандартной библиотеке C — и далее зарядит дробовик и начнёт крошить из него ваш код. Полностью пример выглядит так:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

uint8_t tab[0x1ff + 1];

uint8_t f(int32_t x)
{
    if (x < 0)
        return 0;
    int32_t i = x * 0x1ff / 0xffff;
    if (i >= 0 && i < sizeof(tab)) {
        printf("tab[%d] looks safe because %d is between [0;%d[\n", i, i, (int)sizeof(tab));
        return tab[i];
    }

    return 0;
}

int main(int argc, char **argv)
{
    (void)argc;
    return f(atoi(argv[1]));
}

«Плохой» код, который превращается в проблему, будучи оптимизирован GCC, содержится в f, а именно — в операции умножения с последующей проверкой:

    // …
    int32_t i = x * 0x1ff / 0xffff;
    if (i >= 0 && i < sizeof(tab)) {
        printf("tab[%d] looks safe because %d is between [0;%d[\n", i, i, (int)sizeof(tab));
        return tab[i];
    }
    // …

Эта программа может быть скомпилирована при помощи 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 уже сложился де-факто официальный порядок интерпретации кода. Этот порядок определяет всё, от того, как обрабатывать неопределённое поведение, то того, какие оптимизации должны срабатывать, а какие нет, и вплоть до того, как записываются неуказанные поведения и поведения, зависящие от реализации. Всё, на что падает свет на что падают ваши пальцы, когда вы набиваете код, зависит от вашей реализации. Порядок следования факторов при интерпретации поведения — от максимально значимых до минимально значимых — получается таким:

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

  2. Точка зрения вендора/того, кто реализовал компилятор

  3. Стандарт языка C и другие связанные с ним стандарты (напр., POSIX, MISRA, ISO-26262, ISO-12207)

  4. Пользователь (⬅ это вы)

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

Низы не хотят

Это верно. Это выражение очень смешно звучит, если вворачивать его в разговоры об осознанном согласии, но в контексте общения с вендорами такой довод вообще не помогает! Ведь им достаточно предъявить нам каменную скрижаль и заявить: «Извините, но Так Сказано В Стандарте», дав нам отповедь как бандитам, независимо от того, нравится нам или нет заниматься таким мазохизмом. Это очень больно. «Но позвольте, — скажете вы, отчаянно пытаясь выбраться из-под той гребёнки, которая нас всех накрыла, — а что же делать с -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++. «Нативный» код, «машинный» код, инлайновый «ассемблер», «близко к металлу» — всё это элементы того «белого костюма», который нравится носить элитарным программистам, якобы способным распахнуть компьютер и всё и отовсюду сделать через командную строку.

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

Наш отпор

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

Усваиваем «принцип лома»

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

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>

uint8_t tab[0x1ff + 1];

uint8_t f(int32_t x)
{
    if (x < 0)
        return 0;
    // проверка переполнения
    if ((INT32_MAX / 0x1ff) <= x) {
        printf("overflow prevented!\n");
        return 0;
    }
    // здесь мы помахали ломом и убедились,
    // что ничего страшного не происходит! ????
    int32_t i = x * 0x1ff / 0xffff;
    if (i < sizeof(tab)) {
        printf("tab[%d] looks safe because %d is between [0,%d) ????\n", i, i, (int)sizeof(tab));
        return tab[i];
    }
    else {
        printf("tab[%d] is NOT safe; not executing ????!\n", i);
    }
    return 0;
}

int main(int argc, char* argv[])
{
    (void)argc;
    memset(tab, INT_MAX, sizeof(tab));
    return f(atoi(argv[1]));
}

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

Почему перемножать целые числа — всё равно, что размахивать ломом?

Естественно, в этом вся суть. Зачем настолько усложнять нечто столь простое, особенно, если это отлично вписывается в формулировку «могу проверить это постфактум на аппаратном уровне»? Ответ, естественно, лежит на поверхности: в данном случае музыку заказывают те, кто пишет компиляторы. Мы, пользователи, были и остаёмся в самом низу этой иерархии. Никто из нас не Райден, готовый сойтись с Сабзиро в эпичной битве; мы слабые и жалкие парии на этом празднике жизни, и нам положены тычки, пощёчины и избиение на потеху публике.

Как же нам отучиться ворочать ломом? Что нужно сделать, чтобы не беспокоиться, что любую вазу, которая нам попадается, можно разбить на мелкие черепки?

Наше супероружие

Обратите внимание: все багрепорты до одного, на которые я ссылаюсь в этом посте, заканчиваются одинаково: «так сказано в стандарте; нет, я не шучу, отвалите» (необязательно в таком тоне, но именно в таком смысле). Если все эти вендоры собираются так ревностно придерживаться стандарта 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++ уже сломаны.

Можно либо оставить всё как есть и далее позволять вендорам сгонять нас с нашей территории. Или дать отпор. Мы заслуживаем работать с такими целыми числами, которые не провоцируют неопределённого поведения при сдвиге разрядов влево. Заслуживаем таких операций умножения и вычитания, от которых нам не прилетит. Заслуживаем код, который действительно выражает то, что мы хотели написать, поэтому мы можем встроить необходимые гарантии безопасности в софт, которым ежедневно пользуются программисты во всём мире. Мы заслуживаем лучшего — так что же делать, если отцы-основатели не уладили этот вопрос и оставили его решать нам? Что ж, придётся справляться самим.

...И напоследок

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

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


  1. proletariy
    21.08.2023 19:02
    +1

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

    Но, нет худа без добра! Все это рождает философию, как подход к жизни. Кто-то сказал, однажды: «Если про какой-то баг знают все, и знают, как его обходить, то это уже не баг, это фича!» :-). Видимо, эта часть философии прижилась и въелась…


  1. KanuTaH
    21.08.2023 19:02
    +5

    Естественно, в этом вся суть. Зачем настолько усложнять нечто столь простое, особенно, если это отлично вписывается в формулировку «могу проверить это постфактум на аппаратном уровне»?

    Не такое уж это и "простое" дело, особенно если имеешь дело с DSP, где есть разные режимы арифметики, и соответственно разные флаги для случаев "переполнения" и "насыщения", и какой (или, скорее, какие) из них проверять - зависит от конкретного случая, да и в любом случае подобные проверки - штука не переносимая, логика проверок может отличаться в зависимости от конкретной модели DSP. В общем, статья производит впечатление какой-то попытки кавалерийского наскока с шашкой в стиле "да на самом деле все просто", хотя, если чуть отойти от x86 и вообще от процессоров общего назначения, то все не так уж и просто. В частности, те же "наивные" проверки на знаковое переполнение в стиле "сначала сложим/умножим, а потом проверим на смену знака", право на существование которых почему-то отстаивают некоторые люди из числа "недовольных непонятными усложнениями элементарной арифметики", в том же saturation mode просто не будут работать.


    1. jpegqs
      21.08.2023 19:02
      +1

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

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


      1. Deosis
        21.08.2023 19:02
        +2

        Компилятор оптимизирует код исходя из предположения, что UB в коде не происходит.

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

        Ответственность за недопущение UB компилятор перекладывает на программиста.


        1. jpegqs
          21.08.2023 19:02

          Это у вас такая логика, это неправильная "буквальная" интерпретация UB, не учитывая того для чего UB в стандарте. Если программист поставил проверку, значит она для чего-то есть? Но компилятор пользуясь неопределённостями в стандарте интерпретирует так, чтобы её удалить. Из-за этого многие крупные и серьёзные проекты собираются с -fwrapv, что отключает эти спекуляции.


          1. mpa4b
            21.08.2023 19:02
            +2

            "Формально правильно, а по сути издевательство" (c) самизнаетекто


          1. isadora-6th
            21.08.2023 19:02
            +1

            -O0

            Буквально то, что вы написали выйдет. А -fwrapv на весь проект это какой-то бардак в головах. Я честно говоря редко сталкиваюсь с проблемой переполнения если сам её себе не придумал (используя всякие uint8_t). А мазать проверки везде это больно по перфу, но с таким отношением, вам в Java всякие (там кстати тоже РУКАМИ оверфлоу проверяют)

            Да и лишняя ветка там, сям, вот уже кешик хорошо работает, спекулятивное выполнение ускоряется, пушка же. Процессоры с Pentium 4 стали ощутимо быстрей при том, что гоняли на 3.2 ГГц в 2003. Это же не просто про скорость тактов.


          1. Deosis
            21.08.2023 19:02

            А вы при сборке отключаете оптимизацию dead-code elimination?


  1. aamonster
    21.08.2023 19:02
    +4

    Подумалось вдруг: а ведь обычно всерьёз оптимизировать имеет смысл хорошо если 1% кода. Так почему бы места, где это можно делать (используя предположение, что программист не допускает UB), не помечать явно? Ну как уже сделано для проваливания в следующий кейс внутри switch.


    1. Kelbon
      21.08.2023 19:02

      Оптимизировать имеет смысл 100% кода. Всегда.

      Ну а сама по себе идея "помечать" такие места ломает все возможные оптимизации, объяснять долго и бессмысленно


  1. jpegqs
    21.08.2023 19:02

    На тот момент (до обвала Twitter и, соответственно, блокировки аккаунта)
    он успел пометить этот код хештегом #vulnerability и заявил, что из-за
    вмешательства GCC код получается более опасным.

    Забавно, я в 2020-м писал примерно то же, и даже заводил баг на GCC. Где меня просто послали, сославшись на UB. UB ведь это такое хорошее оправдание чтобы писать небезопасный компилятор. При том что Clang такое не делал (и до сих пор компилирует мой пример правильно), Clang не нужно указание "-fwrapv" чтобы не генерировать небезопасный код.


    1. cher-nov
      21.08.2023 19:02

      UB ведь это такое хорошее оправдание чтобы писать небезопасный компилятор.

      Это неправда.

      Разработчики GCC и Clang по сути делают то, что должен был с самого начала делать исключительно комитет по стандартизации, а именно — справочный образец (reference implementation) компилятора Си, который одновременно с этим являлся бы непосредственной формализацией семантики языка программирования. Потому что изложить её обычным человеческим языком на практике невозможно — документ такого объёма нельзя ни нормально (то есть однозначно) написать, ни нормально прочитать.

      Давно показано, что из любого текста, в общем‑то, можно извлекать фактически бесконечное число разных смыслов, причём многие из них будут друг с другом вполне сопоставимы по своей логической состоятельности. Привлечение же специальной терминологии ситуацию не только не улучшает, но даже наоборот — любой сложный термин является лишь некоторым обобщением (абстракцией) над более простыми, чему неизбежно сопутствуют и невязки в его понимании. Собственно, именно эта проблема в своё время уже привела в философии к так называемому лингвистическому повороту — Витгенштейн, Коржибский и прочие ребята (как же я люблю аналитическую философию, вот они слева‑направо). К слову, здесь на Хабре вот совсем недавно были весьма занятные статьи на эту же тему: раз, два.

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

      А вот C++ уже едва ли что-то поможет, тут да, всё так.


      1. KanuTaH
        21.08.2023 19:02
        +4

        должен был с самого начала делать исключительно комитет по стандартизации, а именно — справочный образец (reference implementation) компилятора Си, который одновременно с этим являлся бы непосредственной формализацией семантики языка программирования

        Глупости. Так называемый справочный образец не будет существовать в вакууме, он будет разработан под конкретную архитектуру. Предположим (в контексте статьи) что такой образец был изначально разработан комитетом под архитектуру, где signed overflow приводит к wrap around. Должно ли это поведение всеми силами эмулироваться и на архитектурах, где вместо wrap around происходит saturation, даже если это будет неэффективно и в конечном счёте вредно? Очевидно, нет. То есть по-прежнему где-то должно быть описано, какая часть поведения "справочного образца" значима с точки зрения её соблюдения в остальных реализациях, а на какую часть поведения рассчитывать нельзя. То же самое UB, только в профиль.


        1. vanxant
          21.08.2023 19:02
          +1

          Референсный компилятор вполне может компилить в байт-код по типу "ассемблера MIX" Кнута или p-code Вирта, на момент создания С оба уже были, а синтаксис Си в отличие от тех же плюсов разбирается любым стандартным (т.е. многократно проверенным) лексером/парсером. Дальше к этому можно прикрутить несложный неопитимизирующий бэкенд под конкретную архитектуру [процессора] и ОС. И уже этого франкентшейна можно использовать для тестирования корректности оптимизирующего компилятора.


          1. KanuTaH
            21.08.2023 19:02
            +2

            Ну даже если он будет компилировать не в реальный, а в некий "виртуальный ассемблер" для "виртуальной машины", что это принципиально меняет в плане моего возражения? Должно ли поведение этой виртуальной машины любой ценой эмулироваться везде, даже там, где это неэффективно/вредно? А если нет, то UB никуда не девается, и нет никакой причины, по которой оптимизатор не мог бы выполнять свои оптимизации, рассчитывая на то, что UB никогда не произойдет.


            1. cher-nov
              21.08.2023 19:02
              +1

              Референсный компилятор вполне может компилить в байт-код по типу "ассемблера MIX" Кнута или p-code Вирта, на момент создания С оба уже были, а синтаксис Си в отличие от тех же плюсов разбирается любым стандартным (т.е. многократно проверенным) лексером/парсером.

              О, спасибо большое — Вы у меня этот комментарий с языка сняли. :)

              Должно ли поведение этой виртуальной машины любой ценой эмулироваться везде, даже там, где это неэффективно/вредно?

              Если речь идёт о стандарте — то да, должно. Но тут надо определиться. Либо мы хотим стандартизировать язык программирования, и тогда документ надо писать по образцу A Commentary on the UNIX Operating System (то есть в виде неформальных поясняющих записок к формальному строгому описанию). Либо же мы идём по стопам условного Pascal, где за всю историю накопилось множество едва совместимых друг с другом диалектов (и да, я знаю, что для него тоже стандарт сделали, но на него всем было наплевать ещё тогда). Тоже вполне себе вариант, со своими плюсами. К слову — это весьма вероятное будущее Rust в свете появления gcc‑rs.

              А если нет, то UB никуда не девается, и нет никакой причины, по которой оптимизатор не мог бы выполнять свои оптимизации, рассчитывая на то, что UB никогда не произойдет.

              UB в таком случае начинает возникать естественно, в ходе дела, и перестаёт описываться произвольным образом. А не как сейчас в стандартах Си и C++: хачю штобы харошые праграмы на харошых мафынах роботале, а нихарошые на нихарошых нироботале, вотЪ! (и ладонью по столу).


              1. KanuTaH
                21.08.2023 19:02
                +4

                То есть вы предлагаете эмулировать референсное поведение даже там, где это не нужно/не эффективно, я правильно понял? Ну есть уже такие языки, та же Java, с соответствующими плюсами и минусами и соответствующими областями применения.

                Либо мы хотим стандартизировать язык программирования, и тогда документ надо писать по образцу A Commentary on the UNIX Operating System (то есть в виде неформальных поясняющих записок к формальному строгому описанию). Либо же мы идём по стопам условного Pascal, у которого за всю историю накопилось множество несовместимых друг с другом реализаций (и да, я знаю, что для него тоже стандарт сделали, но на него всем было наплевать ещё тогда).

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

                UB в таком случае начинает возникать естественно, сам по себе

                Что значит "естественно, сам по себе"? UB - это когда никакое конкретное поведение не гарантируется стандартом, всего-навсего.

                А не как сейчас в стандартах Си и C++: хачю штобы харошые праграмы на харошых мафынах лаботали, а нихарошые на нихарошых нилаботали, вотЪ! (и ладонью по столу).

                Это тоже глупость, ничего подобного в стандартах C/C++ нет. Нет никаких "хороших" и "нехороших" машин, есть определенное/гарантированное поведение, и есть не определенное. То, что некорректно написанная программа, эксплуатирующая неопределенное поведение, иногда работает - не более чем случайность. Например, одна архитектура может "простить" обращение по невыровненному адресу, и все-таки загрузить значение в регистр, хоть и за бОльшее количество тактов, а другая этого не прощает. Это не значит, что первая мафына "харошая", а вторая "нихарошая". Это в любом случае UB, потому что по стандарту лайфтайм объекта начинается только с момента когда "storage with the proper alignment and size for type T is obtained" (emphasis mine). Что произойдет, если ты обратился к объекту, у которого лайфтайм по определению не начался потому, что требования к выравниванию не соблюдены, является undefined behavior. Может, сработает, а может, и нет, никаких гарантий на тему того, что именно произойдет, не дается. Потому что давать гарантии следует осторожно - как только ты их даешь, то ты отнимаешь свободу у разработчика компилятора под конкретную архитектуру, и он вынужден предоставлять эти гарантии даже если это означает дикую неэффективность. Как другой пример на данную тему в рамках данной статьи - заставлять компилятор на архитектурах с "насыщением" в случае знакового переполнения эмулировать "закольцовку" значения в стиле INT_MAX + 1 == INT_MIN, на них это будет дико неэффективно и по большому счету бессмысленно.


                1. jpegqs
                  21.08.2023 19:02
                  +2

                  То, что некорректно написанная программа, эксплуатирующая неопределенное поведение, иногда работает - не более чем случайность. Например, одна архитектура может "простить" обращение по невыровненному адресу

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

                  Чтение невыровненных данных тоже предсказуемо, зависит от того, поддерживает ли это процессор или нет. x86 всегда поддерживал, старые ARM < v6 не поддерживают. Более того, есть архитектуры где невыровненные данные работают быстро, и для таких архитектур намеренно (под "#if defined") написан код что читает/записывает зная что указатель может быть невыровненным.

                  Некоторые UB - типа чтения неинициализированных данных - действительно могут быть непредсказуемные (но не являются заметно случайными, их нельзя использовать в качестве генератора случайных чисел).

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


                1. cher-nov
                  21.08.2023 19:02

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

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

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

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

                  Что значит "естественно, сам по себе"? UB - это когда никакое конкретное поведение не гарантируется стандартом, всего-навсего.

                  Это означает, что в reference implementation места, требующие явной поддержки со стороны исполнительного устройства, становятся видны сразу, и по ним уже можно ориентироваться, что стандартизировать, а что оставить на волю implementation-defined / unspecified / undefined поведения. А не как сейчас, когда, скажем, в C23 realloc(ptr, 0) вдруг превратился в самый настоящий UB, а malloc(0) как был, так и остался implementation-defined, и всё это произошло по щучьему велению.


    1. isadora-6th
      21.08.2023 19:02
      +1

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

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

      Что-бы что? И да, не на всех платформах где собирается С, INT_MAX() + 1 это INT_MIN(), стандартизовать это = привести эмбедеров в очередной зоопарк своих компиляторов и своих ассемблерных вставок.

      Когда-то видел крутой пример, сильно более обоснованный, где удалило проверку на overflow в функции hash и она стала отдавать негативные значения потому-что чек:

      if(value < 0) val *= -1;, не имел смысл для ситуации когда число только увеличивается.

      Фикс был кстати val &= 0xEF'FF'FF'FF.

      Что вообще другое и делает по другому)


      1. jpegqs
        21.08.2023 19:02

        А еще поломает работу многим людям, кто завязывался на это поведение

        Кому поломает, тем кто ищет уязвимости?


        1. isadora-6th
          21.08.2023 19:02

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

          Вообще модно это называется Dead-code-elimination, а всякие истории про удаление проверок на отрицательное это просто расширение этого понятия.

          Вроде удаления повторных проверок на NULL если выше вы разыменовывали указатель или делали такую проверку выше, много примеров.

          В случае, если ты не ССЗБ, то на жизнь никак не сказывается.


  1. Maksim-K
    21.08.2023 19:02

    какая разница между "высоком качестве доступна здесь (C++-шники могут сами взять библиотеку простых безопасных целых чисел, написанную Питером Соммерладом"

    что имели ввиду в "Про напоследок"


  1. Zara6502
    21.08.2023 19:02
    -1

    int32_t i = x * 0x1ff / 0xffff

    Ну для меня очевидно, что в результате этой операции i станет меньше x, но больше 0. Поэтому вопрос скорее в том, умножать ли сначала или делить константы. Учитывая что i всё равно потом будет приводиться к int32_t, то потери в приведении неизбежны и вопрос только в точности. Будь я бы компилятором, то переделал бы код в

    int32_t i = x * 0.0077973601892119

    и выкинул бы проверку

    i >= 0

    PS: я, кстати, часто не отдаю на откуп компиляторов подсчёт констант и делаю всё сам заранее, но у меня и не программы, а так, развлечение.


    1. kipar
      21.08.2023 19:02
      +2

      ну на double умножать - сильно просадит производительность, особенно на каком-нибудь cortex-m3, где в процессоре плавающей точки нет и она эмулируется.

      Я в таких случаях делаю

      int32_t i = muldiv(x, 0x1ff, 0xffff)

      где muldiv внутри приводит к int64_t, потом умножает, потом делит.


      1. aamonster
        21.08.2023 19:02

        Минутка занудства: muldiv не "вначале приводит к int64_t", а просто умножает, получая результат двойной длины (на x86, собственно, инструкция умножения так и устроена).


        1. kipar
          21.08.2023 19:02

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


          1. aamonster
            21.08.2023 19:02

            А, простите, не пришло в голову, что он самописный, а не библиотечный.

            ЗЫ: емнип компилятор и ваш код с приведением типа должен соптимизировать в то же самое.


        1. SpiderEkb
          21.08.2023 19:02
          +1

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

          Я сейчас больше на другом языке пишу, там есть типы с фиксированной точкой. Так вот если результат, например, типа 15,0 (15 знаков, 0 после запятой), то все потенциально опасные вычисления сначала присваиваются в промежуточную переменную максимального для данного типа размера (63,0, там, кстати, явно декларируется, что все промежуточные результаты вне зависимости от размера операндов, всегда имеют тип максимального размера). Потом размер результат проверяется на не выход за границы и только потом уже присваивается выходной переменной нужного типа. Т.е. примерно так:

          dcl-s minVal packed(15: 0) inz(*loval); // минимальная граница
          dcl-s maxVal packed(15: 0) inz(*hival); // максимальная граница
          dcl-s resVal packed(15: 0);             // результат
          dcl-s intVal packed(63: 0);             // промежуточный результат
          
          intVal = ... // вычисления
          
          select;
            when intVal in %range(minVal: maxVal); // безопасно
              resVal = intVal; // переполнения не будет
          
            when intVal < minVal; // переполнение "вниз"
              resVal = minVal;    // тут же еще выствить ошибку
          
            wnen intVal > maxVal; // переполнение "вверх"
              resVal = maxVal;    // тут же еще выставить ошибку
          
          endsl;

          *loval/*hival очень удобные штуки - для компилятора это значит "минимальное/максимальное для данного типа значение. В данном случае это -9999...999 (15 9-к) и +9999...999 (15 9-к) соответственно.

          Но в общем и целом тут UB практически нет.


          1. aamonster
            21.08.2023 19:02
            +1

            Что-то банковское? (десятичные типы данных не часто встретишь)


            1. SpiderEkb
              21.08.2023 19:02

              Да. Основная логика на центральных серверах.

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

              Суть в том, что если вы объявили packed(3:0) (три цифры) а потом пытаетесь в него запихать 99999 - получите системное исключение по переполнению. И нормальным является проверять прежде чем что-то куда-то присваивать. Об этом уже даже не думаешь специально, оно само так пишется.


              1. vanxant
                21.08.2023 19:02
                +2

                У вас прямо скажем совсем не мейнстрим, а нечто совсем противоположное.

                Сначала присказка. Когда архитектура х86 переходила на 64 бита, из набора инструкций было выкинуто буквально пару штук совсем уж редких и никому ненужных. Про LAHF/SAHF вы, возможно, даже слышали — эти 8-битные инструкции сохраняли/загружали младший байт регистра флагов в аккумулятор (мнемоника от Load AH, F), вот только в х86 регистр флагов уже был 16-битным. Соответственно, эти инструкции смысла не имели, а остались только для совместимости с intel 8080/85 (не двоичной, а текстовой). Так вот, эти две инструкции потом пришлось воскрешать из-за вмвари, которая их таки зачем-то использовала.

                Теперь сказка: ещё одной выброшенной инструкцией оказалась INTO. Эта однобайтовая инструкция проверяла флаг целочисленного переполнения и, в случае оного, выбрасывала, ну, исключение переполнения (INT 3). Т.е. делала ровно то, что вы описываете, и всё этой одной однобайтовой инструкцией.

                Но оказалось, что это совершенно, абсолютно никому не нужно.


                1. SpiderEkb
                  21.08.2023 19:02
                  +2

                  У вас прямо скажем совсем не мейнстрим, а нечто совсем противоположное.

                  Естественно. Причем, очень даже специфическое противоположное.

                  Но оказалось, что это совершенно, абсолютно никому не нужно.

                  Вопрос спорный. Типичная для нас ситуация. Обрабатываем 50 000 000 блоков данных. В фоновом режиме (т.е. оно там где-то само что-то делает тихонько в batch job). И в одном из блоков возникло переполнение.

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

                  Но. Мы также не можем остановить обработку 50 000 000 блоков просто потому что в одном из них "что-то пошло не так".

                  Посему нормальным является всегда возвращать результат и статус операции. у 49 999 999 статусы будут "ок, результат корректный". У одного - "не ок, было переполнение результат некорректный". Но обработаются все 50 000 000. Тот, где "не ок" будет занесен в лог с максимальной детализацией и затем пойдет на ручной разбор - где, что и почему случилось. Делается это через ручной контроль, или делается это через перехват исключений путем monitor ... on-error ... endmon (аналог try ... catch), но любое нештатное поведение должно быть зафиксировано и разобрано потом руками.

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

                  Так и живем.


                  1. 0xd34df00d
                    21.08.2023 19:02

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


                    1. SpiderEkb
                      21.08.2023 19:02

                      О каких библиотеках идет речь? У нас есть язык. Им и пользуемся.

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

                      Речь о том, что тут не должно быть UB. Должна быть явная ошибка, которая будет зафиксирована так или иначе. Причем ошибка такая, которая однозначно диагностируется - где и почему. Но никак не UB когда внешне все ок, но результат недостоверный. Который может приводить к инцидентам типа "Луна-25" (не говорю что там было это, но такое возможно теоретически).


      1. Zara6502
        21.08.2023 19:02
        +1

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

        PS: я вообще в работе в основном с 8-битными системами общаюсь, у меня там свои проблемы.


        1. kipar
          21.08.2023 19:02

          вы же написали "на месте компилятора". Вот компилятор и не может заменить целочисленное выражение на плавающую точку, это будет совсем не эквивалентная замена - и воткнет в код библиотеки софтовой эмуляции на некоторых платформах и думаю даже можно подобрать пример когда результат будет отличаться от muldiv (т.к. не все int64 можно точно представить в виде double).


          1. aamonster
            21.08.2023 19:02

            Помнится, много лет назад у Wacom C был ключ, разрешающий подобные оптимизации.


            1. Zara6502
              21.08.2023 19:02

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


          1. Zara6502
            21.08.2023 19:02

            вы же написали "на месте компилятора"

            и в контексте чего я это написал? в коде где уже есть деление для 32 бит? Я работаю с железом где вообще нет деления и умножения. Вы сочинили какой-то кейс который "кладет на лопатки" мои слова, тогда и я могу начать сочинять до бесконечности. И я не утверждал что моё решение закрывает все возможные кейсы на планете Земля, я только написал мой вариант решения проблемы для сферического коня в вакууме (для меня вся эта статья - точно такой же конь сферический).


  1. SpiderEkb
    21.08.2023 19:02
    +1

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

    Вообще, UB давно уже стало "визитной карточкой" С/С++. И это печально.


    1. Kyushu
      21.08.2023 19:02

      Включаешь оптимизацию - работа становится нестабильной

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


    1. kipar
      21.08.2023 19:02

      Несколько лет назад оптимизация lto в gcc на arm была очень нестабильной - то странные ошибки при компиляции выдывала, то программа компилировалась и не работала. Но в последних версиях вроде бы починили.


    1. isadora-6th
      21.08.2023 19:02

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

      Вот от того, в каком порядке в итоговый бинарь соберется из объектников зависило вообще взлетит ли он. Чаще всего проблема вообще не возникала, иногда ломало строки в логах. И происходила прочая несуразица.

      За всю мою практику... каждый раз... ошибка сидела перед монитором)


    1. adeshere
      21.08.2023 19:02

      стоило включить оптимизацию, как она начинала падать в самых неожиданных местах

      А причину в итоге удалось выяснить? У меня ровно

      то же самое (в другом языке) наблюдалось

      Падение оптимизированного кода тоже происходило

      в непредсказуемом месте

      Причем, как потом выяснилось, совсем не там, где была ошибка - что и мешало отловить глюк

      Три года бились всем миром, пока наконец один мудрый человек не показал на стек FPU, после чего внезапно все заработало. Как оказалось, оптимизатор "забывал" вынуть из FPU-стека результат вычисления, потому что он нигде не использовался. Замена локальной переменной на глобальную вынудила его этот результат вынимать - после чего глюк исчез.

      Как шахматы - трагедия одного темпа, так и программирование - это

      трагедия одного символа

      В моем случае оказалось достаточно заменить

      Local_tmpVar=...

      на

      Global_tmpVar=...,

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


      1. SpiderEkb
        21.08.2023 19:02

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


        1. vanxant
          21.08.2023 19:02
          +3

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

          Потом, конечно, проблему нашли - была запись в элемент массива сразу за последним. Отладчик перехватывал malloc и добавлял в конец каждого блока какие-то свои данные, которые мы в итоге и портили вместо данных собственно программы.


          1. SpiderEkb
            21.08.2023 19:02

            На нашей платформе такое, к счастью, невозможно - сразу получишь исключение "выход за границу аллоцированного объекта"


    1. Kelbon
      21.08.2023 19:02

      Ну значит не вылизано там было ничего, статические/рантайм анализаторы включайте


  1. KarmaCraft
    21.08.2023 19:02

    Статья хорошая, а пример не показательный!


    1. Urub
      21.08.2023 19:02
      +2

      статья хорошая, а перевод ужасен


  1. Urub
    21.08.2023 19:02

    разумеется, это приведёт к дампу ядра, всё как принято

    разумеется, нет


  1. isadora-6th
    21.08.2023 19:02

    C++-шники могут сами взять библиотеку простых безопасных целых чисел, написанную Питером Соммерладом, так как сам язык C++ нисколько не продвинулся в этой области

    А по ссылке:

    It turned out that signed integer division and sign extension is harder to get right than I naïvely thought. However, more test cases helped to figure out the corner cases either not covered, or causing UB. If you are already using this library, please update!

    Или, кажется деление интов это не тривиальная таска как в JS. А когда ты думаешь на уровне процессоров и совместимости все немного сложнее, кто бы мог подумать!?

    А в репо кстати куча компайл-тайм (не рантайм) эзотерики на темплейтах где занимаются промоутом размерчиков до следующей размерности. А такие штуки:

    if (val > 0xffff'ffff'fffffffful) {
    throw "integral constant too large";
    }

    https://github.com/PeterSommerlad/PSsimplesafeint/blob/main/include/psssafeint.h#L74

    Вообще пугают своей идеей. Почему он должен ему что-то там гарантировать на комайл тайме (если выражение по сути всегда false) и бросок const char* это сильно. Учитывая обрезание до u64 в месте вызова из-за семантики функции, вообще не понятно зачем оно такое. А на рантайме проблема остаётся. Автор малограмотен в терминах С++?

    >Давида Свободы

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

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

    -5 % 2 = ?

    Ответ: Зависит от платформы и компилятора и процессора и погоды на луне. Был приятно удивлен увидев в ответе 0xfeadafaf на esp32, и всегда позитивные числа на esp8266


    1. isadora-6th
      21.08.2023 19:02

      А дело раскрылось просто, автор фанат Rust, и он как и множество "нетоксичных" адептов этого замечательного языка, набегает на C++ сообщество со своим "Апасна UB" и приводит свои слабенькие примеры убеждая что в расте то, все как надо.

      Бтв, там тоже никакой магии, просто у них стандартизован чек на переполнение. https://doc.rust-lang.org/std/primitive.i32.html#method.checked_add

      В мире крестовиков вообще все не слава богу, надеюсь "стандартизуют" методы для арифметики с риском переполнения, а не __gnu/clang конструкции.

      Может и не было вокруг языка этой неприятной смузи ауры, если бы новички на качелях Даннинга — Крюгера не набегали. Надеюсь -- с годами исправятся.


    1. 0xd34df00d
      21.08.2023 19:02
      +5

      Почему он должен ему что-то там гарантировать на комайл тайме (если выражение по сути всегда false) и бросок const char* это сильно.
      А на рантайме проблема остаётся. Автор малограмотен в терминах С++?
      А дело раскрылось просто, автор фанат Rust

      Дело раскрывается ещё проще: в терминах C++ оказывается малограмотен не автор библиотеки, а воннаби-разоблачитель малограмотных фанатов раста с хабра.


      Почему он должен ему что-то там гарантировать на комайл тайме

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


      Собственно, consteval перед функцией мог бы вам на что-то намекнуть, но нет времени читать, надо разоблачать.


      (если выражение по сути всегда false)

      Кто сказал? Кто в стандарте гарантирует, что диапазон std::uint64_t достаточен unsigned long long?


      Собственно, проверка sizeof до этого могла бы вам на что-то намекнуть, но нет времени читать, надо разоблачать.


      и бросок const char* это сильно

      Бросить можно хоть голый int, главное — что это будет ошибкой компиляции, потому что в consteval-функциях (и в constexpr-функциях, вызываемых в constexpr-контекстах) бросать так нельзя. Бросают тут const char* потому, что это самый простой способ заодно в лог компиляции отправить какое-то человекочитаемое сообщение об ошибке, чтобы пользователь быстрее понял, в чём проблема.


      Собственно, комментарий // trigger compile-time error мог бы вам на что-то намекнуть, но нет времени читать, надо разоблачать.


      А на рантайме проблема остаётся.

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


      если бы новички на качелях Даннинга — Крюгера не набегали

      Oh, the irony.


      1. isadora-6th
        21.08.2023 19:02

        Иронично)

        Хорошо, что есть более опытные комментаторы, действительно баран и чему-то научился, что в consteval throw будет делать компайл тайм ошибку. Спасибо.

        Кто сказал? Кто в стандарте гарантирует, что диапазон std::uint64_t достаточен unsigned long long?

        Получается библиотека работает только в случае если unsigned long long > 64bit. Или у ull разные размеры во время сборки и непосредственно на runtime?

        нет времени читать, надо разоблачать

        Ну а про разоблачение, это про автора "JeanHeyd Meneide" который исходный автор статьи. Он приводит пример runtime переполнения int, приносит библиотеку, которая делает проверки на compile-time. "Автор малограмотен в терминах С++?" имелось ввиду про исходного автора, не библиотеки.

        В общем, буду учится выражаться корректней) Спасибо)


    1. Kelbon
      21.08.2023 19:02
      +1

      Это consteval функция, бросок char* это отличный способ выдать ошибку на компиляции, никаких проблем здесь нет. На рантайме этой функции не существует


  1. vbogach
    21.08.2023 19:02

    На самом деле дело не только в проверке i >= 0. Например, если заменить тип i на uint32_t, то программа будет писать, что "tab[4294963943] looks safe because 4294963943 is between [0;512]", то есть результат сравнения i < sizeof(tab) не соответствует действительности.


    1. KanuTaH
      21.08.2023 19:02

      Так UB никуда не девалось же из-за того, что i стало беззнаковым.


      1. vbogach
        21.08.2023 19:02
        +1

        Это правда, но автор в статье пишет:

        «Но оптимизатор предполагает, что переполнения знаковых целых произойти не может, поскольку число уже положительное (что в данном случае гарантирует проверка x < 0, плюс умножение на константу). В конце концов, GCC берёт этот код, выдаёт его вам на‑гора и фактически удаляет проверку i >= 0, а заодно и всё, что она подразумевает.»

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


      1. haqreu
        21.08.2023 19:02

        Переполнение беззнаковых ведь не UB?


        1. KanuTaH
          21.08.2023 19:02
          +1

          Беззнаковым оно станет позже, при присвоении результата выражения в i. x же все еще знаковый, и операция x * 0x1ff - это все еще операция со знаком.


          1. haqreu
            21.08.2023 19:02

            Я было предположил, что икс тоже беззнаковым будет, но ведь даже это не работает, т.к. integer promotion, да.


  1. Algrinn
    21.08.2023 19:02
    -2

    Языкам C/C++ давным давно пора на свалку истории. От ошибок в С коде происходит смещение пространства/времени и настолько дикая и аномальная хрень, чтобы объяснить которую нужно отправлять десяток гуру С экстрасенсов и они будут пол года разбираться, что вообще происходит, особенно если приложение толстый монолит, который работает по нагрузкой. Говорят, Rust пришёл всех спасти, но посмотрим.


    1. SpiderEkb
      21.08.2023 19:02

      Я бы не стал так категорично, но на мой взгляд будущее за специализацией. Отказ от одного "микроскопа", которым можно и микробов рассматривать и гвозди забивать в пользу специализированных инструментов для каждой области с возможностью их интеграции как это сделано в LLVM или IBM'овской ILE.

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


  1. buldo
    21.08.2023 19:02

    А есть опция компилятора "покажи все UB, которые нашёл"?


    1. lieff
      21.08.2023 19:02

      У gcc\clang есть рантаймовые asan, tsan, ubsan, которые замедляют выполнение. У msvc только asan.