При работе с C или C++ необходимо в какой-то степени разбираться в неопределённом поведении (UB): что это такое, каковы его эффекты, и как о него не споткнуться. Для простоты картины я буду в этой статье рассказывать только о C, но всё изложенное здесь также применимо и к C++, если явно не указано иное.

Что такое неопределённое поведение?

Общеизвестно, что на С программировать сложнее, чем на таких языках, как Python.

В определённых отношениях дело в том, что язык C низкоуровневый, близкий к ассемблеру. Он просто выдаёт вам практически то же самое, что выдаёт машина, на которой выполняется код.

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

Целые числа в C должны умещаться в регистр ЦП. Оператор + выполняет в процессоре одиночную инструкцию add. Если при этом переполнится машинное слово, то вы получите не верный ответ, а что-то другое.

int successor(int a) {
    // Может быть верно или неверно
    return a + 1;
}

(В языке C целые числа ассоциируются с регистрами ЦП, что неизбежно маскирует сложность ради достижения краткости. Например, компиляторы, нацеленные на работу с 32-разрядными платформами, могут поддерживать и 64-разрядные операции, если они реализованы с поддержкой библиотеки среды выполнения. Напротив, на 64-разрядных платформах размер int обычно остаётся равен 32 разрядам. Это делается по причинам исторического характера: слишком долго мы проработали на 32-разрядных платформах. К тому времени, как подоспело обновление, априорные допущения о размере int уже были повсеместно заложены в коде. Для ознакомления с этой темой можете почитать Википедию. Суть в самом устройстве C: язык спроектирован так, что любая из базовых операций на нём сводится к небольшому фиксированному количеству инструкций ЦП — обычно к одной.

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

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

void error(const char* msg);

int successor(int a) {
    if (a + 1 < a)
        error("Integer overflow!");
    return a + 1;

Эта логика проста. Если a и так является максимально возможным целочисленным значением, то прибавка к нему 1 (при этом мы держим в уме второе дополнение, которое действительно применяется на всех современных ЦП) «закольцует» значение и сбросит его до минимально возможного.

Вставив этот код в Compiler Explorer, для x86-64 gcc 13.2 с опцией -O3, в результате компиляции получим:

successor:
        lea     eax, [rdi+1]
        ret

Это просто безусловное сложение. Оказывается, тест незаметно отброшен. Что происходит?

В C переполнение знакового целого является неопределённым поведением. В стандарте C23 это явление описывается как поведение… к работе с которым не предъявляется никаких требований. Следовательно, компилятор может действовать по следующей логике:

  • По законам сложения математических целых чисел выражение a + 1 < a должно быть ложным.

  • Либо эти законы соблюдаются, либо происходит переполнение.

  • Если случилось переполнение, то перед нами неопределённое поведение, по поводу которого ничего не предписано — значит, любой результат годится. В частности, не требуется исправно выполнить тест. Допустимо просто проигнорировать его и продолжить выполнение по благополучному пути.

  • Конечно же, если переполнения не произошло — то нужно следовать по благополучному пути.

  • Следовательно, наиболее эффективный код, корректно реализующий данную функцию в соответствии с правилами стандарта ISO для языка C, пренебрежёт тестом и просто слепо приплюсует 1.

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

Итак, что же понимается под «неопределённым поведением».

  • Не просто «не делайте этого».

  • Не просто «это не поддерживается; делая так, вы действуете на свой страх и риск».

  • Не просто «это нарушает абстракцию, поэтому на выходе вы получите то, что именно в данном случае выдаст вам та аппаратная платформа, на которой выполняется код».

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

Какие поведения являются неопределёнными

В некотором смысле, неопределённое поведение возможно в любом языке программирования. Если вы напишете на Python subprocess.run('foo'), и при этом окажется, что foo отформатирует ваш жёсткий диск, то с Python, очевидно, снимается всякая ответственность за такой результат. Но обычно предполагается, что такое полное отсутствие ограничений возникает в результате вызова некого внешнего кода, либо спровоцировано операционной системой, либо, на худой конец, является каким-то малоизвестным следствием гонки, возникшей между потоками. Язык C необычен тем, что в нём неопределённое поведение возникает при неправильном использовании многих прозаических конструкций, входящих в ядро языка.

В черновике стандарта C23, приложение J.2, было перечислено 218 видов неопределённого поведения, и под номером 1 среди них — случай нарушения требования «будет» или «не будет», не охваченного ограничениями. Так что на практике вариантов неопределённого поведения даже больше. Но большинство из них более-менее экзотические. Например, под номером 218 упоминается случай, в котором функция towctrans вызывается с применением иной категории LC_CTYPE чем та, что использовалась при вызове функции wctrans, вернувшей описание. Это источник багов, но такие обстоятельства, мягко говоря, сильно ситуативны.

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

Разыменование плохого указателя

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

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

int a[10];
int* end = a + 10;

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

Неинициализированные данные

Интуитивно понятно, что следующий код должен быть корректен: n неинициализировано, поэтому на его значение нельзя полагаться, ведь оно может содержать любой мусор, который окажется в соответствующем регистре или по адресу в стеке. Но независимо от того, какое значение там окажется — будь C просто портируемой высокоуровневой разновидностью ассемблера, то побитовое И с 0 должно давать 0.

int zero(void) {
    int n;
    return n & 0;
}

Кстати, в соответствии с правилами языка, это неопределённое поведение. Код может вернуть 0, а может вызвать пресловутых носовых демонов. Причём вполне возможно, что актуальная версия компилятора даст в результате 0, а уже следующая наплодит демонов.

Переполнение знаковых целых чисел

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

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

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

Неудивительно, что деление любого числа на ноль (знакового или беззнакового) даёт неопределённое поведение.

Пограничный случай: при делении минимального знакового целочисленного значения на -1 приводит к переполнению.

Переполнение при работе с числами с плавающей точкой не обязательно приводит к неопределённому поведению. В принципе, арифметика чисел с плавающей точкой — отдельная тема, ей лучше посвятить самостоятельную статью.

Битовый сдвиг

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

Совмещение

Более коварный повод оступиться случается при работе с указателями. Если у вас есть объект типа A, и вы приводите его адрес к указателю типа B, то при разыменовании последнего возникает неопределённое поведение. Само приведение допустимо, но разыменование запрещено. Это правило иногда называется «строгим совмещением» и подробно обсуждается здесь. Эта тема глубокая, но некоторые её аспекты здесь важно подчеркнуть:

  • char* получает здесь персональное послабление

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

  • Каламбуры типов в пределах объединения допустимы в C, но недопустимы в C++.

Почему?

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

Дело не в необычных аппаратных платформах

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

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

Например, в PDP-11/20 (миникомпьютер, на котором исходно разрабатывалась ОС UNIX), Intel 8088 (использовалась в оригинальном IBM PC) и Motorola 68000 (использовалась в оригинальном Mac и на большинстве ранних рабочих станций) отсутствовали единицы управления памятью. Разыменование нулевого указателя и тогда не допускалось (то есть не поддерживалось и вряд ли могло принести какую-либо пользу), но при этом не могло перехватываться на аппаратном уровне в виде прерывания — следовательно, стандарт C не мог требовать такие прерывания как обязательные. При программной реализации такой функции потребовалось бы испещрить код условными переходами, что привело бы к неприемлемому снижению эффективности многих приложений.

DEC - PDP-11 - Кен Томпсон и Деннис Ритчи, около 1970. Хранится в коллекции книг и артефактов Гвен Белл, лот X7413.2015, каталог 102685442, Музей истории компьютеров
DEC - PDP-11 - Кен Томпсон и Деннис Ритчи, около 1970. Хранится в коллекции книг и артефактов Гвен Белл, лот X7413.2015, каталог 102685442, Музей истории компьютеров

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

Аналогичная ситуация — с переполнением знаковых целых. В архитектуре VAX предусмотрен бит статуса в регистре состояния процессора, при установке которого автоматически происходит прерывание, если в ходе арифметических операций над целыми числами происходит переполнение. В компиляторах, работающих с такой архитектурой, желательно хотя бы в качестве опции предусмотреть возможность активировать такой режим, поскольку в стандарте не может быть предусмотрено заворачивание. Но может быть прописано, что это зависит от реализации. В таком случае реализация VAX может выполнять прерывание при переполнении, и этот факт должен быть документирован. Например, заворачивание чисел допускается в X86, ARM, RISC-V, т.д., и там это документировано. Как вариант, возможно и прерывание. Как будет показано далее, отсутствие автоматических прерываний на аппаратном уровне не мешает компилятору всё равно предоставить документированное прерывание при переполнении.

Всё дело в оптимизации

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

int foo_or_bar(int which) {
    // предполагается, что вас не смутит, если будут вызваны обе функции 
    int x = foo();
    int y = bar();
    return *(&x + which);
}

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

Но, естественно, стоит вам включить оптимизацию — и он рассыплется, поскольку компилятор оставляет y в регистре, а не сливает его в стек. Должен ли он так делать? Если следовать букве правил, ответ прост. Разыменование указателя за пределами того объекта, для указания на который он создавался, ведёт к неопределённому поведению, поэтому компилятор вправе полагать, что вы так не сделаете. Исходя из этого допущения, он продолжит преобразование. Но с точки зрения действующей политики можно отступить на шаг и задаться вопросом — а оправдано ли, что правила сформулированы именно таким образом?  

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

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

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

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

Кроме случаев, когда это не так

Среди 218 разновидностей неопределённого поведения, перечисленных в приложении J.2 к стандарту C23, также присутствуют:

  • Пропуск перехода на новую строку в конце файла с исходным кодом.

  • Забывание закрыть блочный комментарий.

  • Забывание закрыть кавычки у символа или строкового литерала.

  • Нераспознаваемый символ в файле с исходным кодом.

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

Неопределённые поведения страшные

Тут кто-то написал движок для веб-форума на ассемблере. Впечатляющее достижение. Но такова наша природа, что иногда мы предпочитаем действовать по принципу «лучше, конечно, помучиться» — и не потому, что ожидаем лёгкой прогулки, а как раз потому, что предвосхищаем трудности. Как говорится, «враг был силён — тем выше наша слава».

В данном контексте AsmBB показательно, не столько само по себе, сколько как тема для одной дискуссии, развернувшейся на Hacker News, в которой встречаются, например, такие комментарии:

Берусь утверждать, что ассемблер + ABI ядра Linux безопаснее традиционного стека C/C++, поскольку и близко не замусорены «неопределённым поведением» настолько, как этот стек. Все переполнения и недозаполнения при арифметике над знаковыми числами происходят так, как это ожидается. При выделении памяти при помощи mmap + MAP_ANONYMOUS происходит инициализация в 0, как и ожидается. При попытке обратиться к неотображённой памяти в вашем адресном пространстве (в том числе, к адресу 0), вы спровоцируете SIGSEGV — как и ожидается. Ассемблер делает гораздо меньше допущений, чем компилятор C и даже вполовину столько не умничает, сколько последний. Поэтому гораздо вероятнее, что при ошибке ассемблерный код громко пыхнет и задымит, а не обманет втихомолку ваши ожидания

А также

Как отмечает 10000truths здесь: https://news.ycombinator.com/item?id=38985198, в ассемблере не приходится иметь дел с неопределённым поведением, и это очень кстати. Иногда попадаются такие поведения, которые варьируются от реализации к реализации, но никакие демоны из носа не вылетают. В частности, можно сложить два заранее не известных числа, совершенно не рискуя нарваться на поведение, зависящее от реализации. Теоретически, вы вообще не можете гарантировать, что в программе никогда не переполнится стек, и я сталкивался с таким переполнением на практике, работая с Arduino, когда наступала коллизия между стеком и кучей. То же касается багов со знаковостью (которые обычно являются дырами в безопасности; даже в qmail один такой нашёлся). Пожалуй, всё это легче избегается в ассемблере, чем в C, хотя, в новых версиях компилятора такие ситуации во многом исправлены.

Разумеется, это не означает буквально, что программировать на ассемблере безопаснее, чем на С, и это подмечено ещё в одном комментарии:

... Этот проект выполнялся в рамках одного соревнования в жанре «захват флага», в котором я участвовал — там нам просто выдали самую свежую версию кода. В процессе игры мы нашли не менее 8 уязвимостей.

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

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

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

Не существует систематического и надёжного способа ни предотвратить неопределённое поведение, ни отследить его постфактум.

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

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

Итак, имеем вечный спор:

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

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

Этот спор бесконечен, поскольку обе стороны в чём-то по-своему правы.

Что делать

Учитывая такое состояние дел, когда неопределённое поведение невозможно ни игнорировать, ни оправдывать — что же нам делать?

Предупреждения компилятора

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

GCC

-Wall -Wextra -Wpedantic -Wconversion -Wdeprecated

Visual C++

/Wall /external:anglebrackets /external:W0

(Строка от GCC может показаться странной: почему -Wall не делает то, что следует из названия? Поскольку возникали проблемы с теми проектами, которые использовали -Werror в своих собственных внутренних сборках (которые внутри себя были устроены совершенно разумно). Проблемы возникали при отправке исходного кода пользователям и предоставлении соответствующих внутренних сборочных скриптов в неизменном виде. После обновления в компилятор были добавлены новые предупреждения, из-за которых имевшиеся у пользователей сборки стали отказывать — а пользователи не в состоянии что-либо с этим поделать. Итак, если вы отправляете исходный код пользователям, то он должен поставляться с такими сборочными скриптами, в которых нет -Werror. Тем временем, в попытке обойти проблему, в –Wall стали добавлять не все новые предупреждения, поэтому, если вам  действительно требовались они все, то флаги приходится комбинировать.  

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

Если хотите от меня конкретный совет — вот он:

Проект всегда должен собираться чисто, без предупреждений. (Это можно принудительно обеспечить либо при помощи -Werror, либо применив правило, что перед отправкой пул-реквеста все предупреждения необходимо очистить. То же касается и всех деталей реализации. Действуйте по обстоятельствам, в зависимости от того, как удобнее поступать в рамках вашего конкретного потока задач).

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

Проверка границ

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

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

Хорошая новость: поэтому открывается возможность для проверки границ.

Плохая новость: в таком случае при работе с std::vector  мы попадаем в зависимость не от чего-то, что легко переключить во время компиляции, а от имени оператора. Границы у v.at(i) проверяются, а у v[i] — нет. Немного найдётся таких проектов на C++, в которых границы исправно проверяются и в продакшне. Во многом потому, что такая практика должна быть заложена сильно заранее, чтобы можно было измерить, как она скажется на производительности.  

Относительно хорошая новость: вообще далеко не все пользуются std::vector. Во многих проектах применяется его кустарный эквивалент, в котором действует иная стратегия выделения ресурсов — например, SmallVector из LLVM. Если вы так делаете, то рекомендую внедрить проверку границ. Условие ставьте на #ifdef DEBUG или #ifndef NDEBUG.

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

Санитайзеры

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

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

В некоторых компиляторах санитайзер предоставляется как фича:

GCC

-fsanitize=undefined

Clang

UndefinedBehaviorSanitizer

Visual C++

/RTC1

Этот список можно продолжать.

Статические анализаторы

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

  • Им нужна та же информация, что и компилятору — о путях включения, -D-макросах и т.д., поэтому их достаточно сложно настраивать, примерно так же, как это делается в рамках процедуры сборки.

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

  • По причине 1 кажется, что даже попытка попробовать статический анализатор может обойтись дорого. По причине 2 принято ожидать, что польза от статического анализатора вряд ли будет очень высока. Следовательно, он не очень высоко стоит в списке приоритетов.

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

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

Флаги безопасности

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

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

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

Флаг -fwrapv приказывает GCC придать чётко определённую семантику заворачивания. (В принципе, точно как и в Java.)

Как раз в этом случае важно, с каким именно компилятором вы работаете. Clang обычно стремится к совместимости с GCC, но что насчёт Visual C++? Насколько мне известно, он не предлагает никакого эквивалента. Следующий вопрос был задан за восемь лет до подготовки оригинала этой статьи, и я не вижу никаких намёков на то, что ответ на него мог измениться.

-ftrapv — это разновидность предыдущего флага. Вместо того, чтобы провоцировать неопределённое поведение или тихонько заворачивать число, он приводит к тому, что переполнение знакового целого без вариантов обрушивает вашу программу.

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

Кроме того, он лучше поддаётся портированию, чем -fwrapv. Задействовать его в GCC полезно, а не вредно, если вам также требуется работать с Visual C++. В первой версии вашей программы он её просто обрушивает, а также помогает искоренить баги, которые могли бы непредсказуемо повлиять на работу второй верии.

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

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

Флаг -fno-strict-aliasing приказывает GCC отключить строго совмещение.

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

Флаг -fno-delete-null-pointer-checks отключает оптимизации вида «разыменование нулевого указателя произойти не может, поэтому вполне можно продолжать работу, отключив для указателей такую проверку».

В Visual C++ этот флаг тоже не предлагается. Есть основания полагать, что код всегда работает так, как будто этот флаг установлен, но мне неизвестно, чтобы на это были какие-то явные гарантии в документации.

Можно отключить оптимизацию

Нужно понимать, что «юридически» это ничего не меняет. Результат неопределённого поведения может быть любым независимо от уровня оптимизации и, действительно, компиляторы иногда делают такие вещи, из-за которых можно споткнуться о неопределённое поведение. Например, «рефлекторно» распознают такие выражения, которые результируют в константы, даже при -O0.

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

Но.

Предположим, вам выдали целую простыню кода из какой-то встраиваемой системы, и до сих пор этот код компилировался на 68000 с использованием какой-то древней версии Aztec C, а ваша задача — заставить его работать на современном микроконтроллере под RISC-V с использованием компилятора GCC. Код запутанный и датируется временами, когда вполне можно было считать язык C высокоуровневым вариантом ассемблера. Вероятно, неопределённых поведений в нём, как семечек в арбузе. У вас нет ни средств, ни времени, чтобы полностью его перелопатить, а уж тем более — переписывать.

Но сейчас вы в избытке располагаете вычислительной мощностью. Гипотетически, этот код достаточно быстро работал на платформе 68000 с частотой 8 МГц и при использовании не самого сильного в оптимизациях компилятора. Современный микроконтроллер работает на порядки быстрее. Ладно, допустим, сейчас увеличились и объёмы данных, которые ему приходится обрабатывать, но, пожалуй, даже при таких оговорках этот код будет достаточно быстро выполняться и в неоптимизированном виде.

В такой ситуации отчасти наилучшим из доступных решений (обратите внимание: здесь я вкладываю смысл как в  «отчасти», так и в «наилучшим из доступных») будет компилировать код с опцией -O0.

Пишите на другом языке

Обычно такой вариант не рассматривается по той простой причине, что все проекты, в которых он был допустим, уже давно переписаны на других языках. Люди давно осознали, что, когда не ставятся очень жёсткие требования к производительности, либо низкоуровневому управлению, либо к плотному взаимодействию с уже существующей базой кода, лучше переключиться на более высокоуровневый язык. Молниеносный взлёт Java обусловлен во многом потому, что этот язык появился ровно тогда, когда рынок в нём нуждался. При этом, бизнес-приложения реализовывались на C++, но работать со столь низкоуровневым языком не хотелось. Переписывать существующий проект на другом языке — дорогостоящий и рискованный проект. В мире полно кода на C и C++, выполняющего массу полезной работы, и в обозримом будущем эта ситуация не изменится.

Но, всё-таки, автоматическое управление памятью зачастую гораздо более позволительно, чем кажется на первый взгляд. А на случаи, когда это непозволительно, есть другие языки, которые в нём не нуждаются. На момент написания оригинала этой статьи наиболее понятными и проверенными были Fortran для перемалывания чисел, Ada и Rust для кода общего назначения. Есть ещё Wuffs — предметно-ориентированный язык, на котором удобно обращаться с не вызывающими доверия форматами файлов. Всегда возможен случай, когда вам подойдёт именно один из этих языков, так что и их упомяну для полноты картины.    

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


  1. Yak52
    14.10.2025 16:04

    В регистре VAX предлагается бит статуса процессора,...

    Немного не так - "Архитектура VAX содержит отдельный бит статуса в регистре состояния процессора"...

    VAX это следующий этап развития архитектуры PDP-11


    1. Sivchenko_translate Автор
      14.10.2025 16:04

      Исправил, спасибо


  1. vadimr
    14.10.2025 16:04

    MMU - Блок управления памятью


    1. Sivchenko_translate Автор
      14.10.2025 16:04

      Согласен с вами, исправил


  1. sci_nov
    14.10.2025 16:04

    Получается, на ассемблере программировать всегда определённо (чётко)?


    1. randomsimplenumber
      14.10.2025 16:04

      Всегда можно посмотреть в справочнике, к чему приведет выполнение каждой команды.


      1. vanxant
        14.10.2025 16:04

        Нет, потому что процессор не в вакууме существует.

        См. например инструкцию HALT процессора Z80 в советских "гаражных" клонах Спектрума в режиме IM2.


        1. randomsimplenumber
          14.10.2025 16:04

          Ну, что будет в регистрах процессора мы же знаем? А в железе что угодно может происходить.


          1. vanxant
            14.10.2025 16:04

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

            И вот кстати да, содержимое R тоже не определено после HALT)


    1. vadimr
      14.10.2025 16:04

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


      1. sci_nov
        14.10.2025 16:04

        Неопределённые команды видимо недоступны просто так.


        1. vadimr
          14.10.2025 16:04

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

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

          Например, у процессоров Pentium была знаменитая неопределённая машинная команда F0 0F C7 C8, которая фактически приводила к их зависанию (Pentium bug).


          1. unreal_undead2
            14.10.2025 16:04

             в том числе и на такую, результат выполнения которой не определён (если таковые имеются в архитектуре).

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

            для этого придётся написать слегка необычно

            Писать код через db не так уж и странно (скажем, если надо использовать новые инструкции, которые ещё не завезли в тулчейн).

            которая фактически приводила к их зависанию (Pentium bug).

            Так это именно bug, а не feature )


            1. vadimr
              14.10.2025 16:04

              То, что процессор зависал - баг, а само наличие бессмысленного кода команды с разной шириной источника и приёмника - фича.


              1. unreal_undead2
                14.10.2025 16:04

                Фича - это на такую инструкцию сгенерировать #UD (06h).


      1. vanxant
        14.10.2025 16:04

        обычно результаты машинных команд предсказуемы

        Обычно - да, но SPECTRE и MELTDOWN передают всем привет)


        1. unreal_undead2
          14.10.2025 16:04

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


        1. Deosis
          14.10.2025 16:04

          Только эти уязвимости влияют на состояние кешей процессора, а не регистров и памяти.


    1. 00Kirill00
      14.10.2025 16:04

      В целом да. На ассемблере у вас нет неопределенного поведения, у вас есть поведение, определяемое архитектурой. ADD двух чисел даст переполнение ровно так, как это описано в мануале на процессор. Проблема в том, что это определенное поведение может отличаться на x86 и ARM, поэтому код становится непереносимым


      1. sci_nov
        14.10.2025 16:04

        В пору вводить стандарт арифметики для процессоров)


        1. unreal_undead2
          14.10.2025 16:04

          IEEE 754 же )

          А с целыми есть два более-менее распространённых варианта представления отрицателньых чисел - хотя найти сейчас живую машинку c ones' complement непросто.


          1. sci_nov
            14.10.2025 16:04

            Я про то, чтобы не было разницы в результате, например, сложения с переполнением. Хоть на arm, хоть на x86_64. Это были бы процессоры общего назначения, а специализированные - там можно делать все что угодно: свои компиляторы и прочее.


            1. unreal_undead2
              14.10.2025 16:04

              На arm и x86_64 (и прочих более-менее распространённых) в этом плане отличий нет )


  1. LinkToOS
    14.10.2025 16:04

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

    "на величину большую или равную тому числу, которым оперируете" - сомнительная формулировка. 8-битное число может быть равно 256, а максимальный сдвиг 8.
    Почему возникает неопределенное поведение после сдвига? После сдвига на максимальное количество бит, число должно стать равным нулю. На ассемблере сдвиг может быть арифметическим и логическим. На Си он всегда логический.


    1. kotan-11
      14.10.2025 16:04

      В большинстве процессоров операции сдвига используют счетчик не полностью - берут из него младшие 5 бит (6 в 64-битном режиме) в результате сдвиг на 64даст не ноль, как ожидается, а исходное число.


      1. LinkToOS
        14.10.2025 16:04

        Верно. Сдвиг будет равен значению младших 5 или 6 бит.
        The destination operand can be a register or a memory location. The count operand can be an immediate value or the CL register. The count is masked to 5 bits (or 6 bits if in 64-bit mode and REX.W is used). The count range is limited to 0 to 31 (or 63 if 64-bit mode and REX.W is used). A special opcode encoding is provided for a count of 1.

        In 64-bit mode, the instruction’s default operation size is 32 bits and the mask width for CL is 5 bits. Using a REX prefix in the form of REX.R permits access to additional registers (R8-R15). Using a REX prefix in the form of REX.W promotes operation to 64-bits and sets the mask width for CL to 6 bits. See the summary chart at the beginning of this section for encoding data and limits.
        IA-32 Architecture Compatibility
        The 8086 does not mask the shift count. However, all other IA-32 processors (starting with the Intel 286 processor) do mask the shift count to 5 bits, resulting in a maximum count of 31. This masking is done in all operating modes (including the virtual-8086 mode) to reduce the maximum execution time of the instructions.



    1. vanxant
      14.10.2025 16:04

      8-битное число может быть равно 256

      это как?)


      1. slonopotamus
        14.10.2025 16:04

        В военное время значение числа Пи достигает четырёх!


        1. LinkToOS
          14.10.2025 16:04

          Именно так )


    1. sci_nov
      14.10.2025 16:04

      Я к своему удивлению не так давно узнал, что x86_64 процессор может из коробки делить 128-битное (целое) число на 64-битное, и умножать 64-битные с сохранением 128-битного произведения. В С/С++ такого без вспомогательных библиотек, как я понимаю, нет. И ещё. Процессор может выполнять циклический сдвиг, а в С/С++ (до С++20) такого нет.

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


      1. Panzerschrek
        14.10.2025 16:04

        Это на самом деле проблема системы типов C++ и многих других языков - там целочисленные арифметические операции проводятся над операндами одинакового размера и результат имеет тот же размер, что и операнды. Хотя чисто с математической точки зрения это абсурд, сложение двух 32-битных чисел даёт 33-битное число, а произведение двух 32-битных даёт 64-битное.

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


        1. sci_nov
          14.10.2025 16:04

          Он должен сохранять результат в два регистра, что в сумме и даст 128 бит. Но я не игрался, поэтому 100% гарантии не дам)


        1. sci_nov
          14.10.2025 16:04

          Да, более 64-х бит частное не дозволяется.


        1. sergio_nsk
          14.10.2025 16:04

          арифметические операции проводятся над операндами одинакового размера и результат имеет тот же размер

          Это не так. Результат всегда int/unsigned/long/unsinged long/long long/unsigned long long в зависимости от операндов. Искать про integer promotion. А с плавающей точкой - результат всегда double.


          1. Panzerschrek
            14.10.2025 16:04

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

            Вот здесь можно это видеть. https://godbolt.org/z/ee5z1TKqh. В промежуточном коде операнды арифметических операций имеют однородные типы и результат имеет тот же тип. Там же видны операции, которые расширяют/преобразуют типы операндов по необходимости.


      1. panteleymonov
        14.10.2025 16:04

        В интринсиках все давно доступно.


        1. sci_nov
          14.10.2025 16:04

          Которые simd или что-то от gcc?


        1. sci_nov
          14.10.2025 16:04

          Нашёл от Microsoft интринсики. Фактически, тот же ассемблер.


          1. panteleymonov
            14.10.2025 16:04

            Думаю с вики лучше начать https://en.wikipedia.org/wiki/Intrinsic_function там ссылки на их вариации под разные компиляторы есть. Но это все же не ассемблер, скорее перенос логики его инструкций в обычные функции без вызова (например memcpy). В лучшем случае, с оптимизацией, они могут повторить этот же код и на асме, но это не точно.


    1. 00Kirill00
      14.10.2025 16:04

      Имеется в виду сдвиг на величину, большую или равную количеству бит в типе операнда. Для 8-битного unsigned char сдвиг на 8 или больше - это UB. Вы правы, формулировка в статье корявая


  1. Kotofay
    14.10.2025 16:04

    Разыменование нулевого указателя и тогда не допускалось (то есть, не поддерживалось и вряд ли могло принести какую-либо пользу) <...>


    Уж где где, а на PDP-11 читать значение, ой разыменовывать указатель, по адресу 0 вообще не возбранялось. И никакого UB. Получили бы значение, обычно это КОП JMP на начало программы.
    На этой машине вообще можно было читать откуда угодно из памяти/РВВ.

    Да и на х86 в реальном режиме тоже. Получили бы первую запись таблицы прерываний.


    1. vanxant
      14.10.2025 16:04

      Да и на х86 в реальном режиме

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


      1. cdriper
        14.10.2025 16:04

        ну как любые... 8086 по нулевому адресу хранит вектор нулевого прерывания


    1. wander
      14.10.2025 16:04

      Указатель с адресом ноль и нулевой указатель - это разные вещи (в общем случае). https://c-faq.com/null/varieties.html


      1. vanxant
        14.10.2025 16:04

        Нет, уже давно NULL это ((void*)0) по стандарту. Мамонты, где это не так, уж очень давно вымерли.


        1. wander
          14.10.2025 16:04

          То, что " NULL это ((void*)0) по стандарту" никак не отменяет того, о чем я сказал. Этот ноль в исходнике не означает нули в битовом представлении. Даже если технически оказывается так, что репрезентация этого нуля в битах тоже нули, семантически - это разные нули. Литерал "ноль", как и nullptr, как и NULL - это лишь удобные записи для обозначения нулевого указателя, а конкретные биты компилятор сгенерит сам, вы напрямую не можете никак не это повлиять.

          Еще одна ссылочка для понимания: https://c-faq.com/null/machnon0.html


          1. vanxant
            14.10.2025 16:04

            А пример можно машины + компилятора, где у NULL ненулевое значение? :)

            Я бы ещё понял, если бы вы сказали, что нулевым считается не только собственно адрес 0, но и некоторые малые околонулевые адреса (скажем, первые 64к в большинстве случаев и даже 4Gi в наркоманских ОС типа 64-битной солярки). Это позволяет а) отлавливать ситуации типа NULL[2]->x, которые всё равно останутся нулевыми и б) сравнивать с 0 только старшие биты указателей, что может быть несколько быстрее / энергоэффективнее.


            1. wander
              14.10.2025 16:04

              Там по ссылке есть ссылки на примеры.

              И да, я тут своего ничего не говорю. То, что я сказал, и то, что написано в FAQ - это просто пересказ человеческим языком того, что написано в стандарте.


              1. vanxant
                14.10.2025 16:04

                По ссылке CDC, Cray и прочие примеры из 60-ых, которых давно на золото переплавили.

                А я спрашиваю про современные, где хотя бы С90 поддерживается.


                1. wander
                  14.10.2025 16:04

                  У нас как-то контекст съехал. Человек в начале ветки сказал, что нулевой адрес в PDP-11 (уже переплавленной на золото, кстати) мог использоваться для адресации. Я на это ответил, что концепции нулевого указателя и нулевого адреса - различны с точки зрения стандарта (т.е. на практике это означает, что если в какой-то реализации нулевой адрес использовать для адресации можно, то для представленяи нулевого указателя будет выбрано какое-то другое значение - не ноль, и такие примеры были). Далее вы попытались меня поправить, приведя в пример описание нулевого указателя из стандарта. На что я опять же вам ответил, что приведенная вами в пример запись относится к форме записи такого указателя в исходном коде, а не к его битовому представлению. А потом контекст зачем-то поехал в сторону "а покажи-как мне реализацию где сейчас не так". Но это уже out of topic. Во-первых потому, что речь изначально шла про древнюю технику, а во-вторых потому, что отсутствие подобных современных машин не опровергает ничего из вышесказанного.


                  1. kipar
                    14.10.2025 16:04

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


                    1. wander
                      14.10.2025 16:04

                      Естественно. Нулевой указатель - это абстракция компилятора, я об этом еще вчера сказал.


                      1. kipar
                        14.10.2025 16:04

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


      1. vadimr
        14.10.2025 16:04

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


  1. vanxant
    14.10.2025 16:04

    Это тот случай, когда гугл переводит лучше. "Единица управления памятью", "семантика заворачивания"... нда.

    Но и сама статья очень слабая. В статье даже не упоминается поведение, определяемое реализацией (а разница в спецэффектах может быть огромной). Приведение float* к int* как пример. Алиасинг упомянут, но в таких терминах, что мидл-разработчик на С даже не поймёт.

    "Пишите на других языках" - ну, эээ. Тут даже не скажешь "спасибо, сова, за совет мышкам стать ёжиками". А какие мейнстримные языки, кроме Python, умеют из коробки корректно обрабатывать целочисленное переполнение?


    1. DrMefistO
      14.10.2025 16:04

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

      Касательно темы: как по мне это глупость - говорить про UB при разыменовании, например. Какое языку программирования C должно быть дело, что там разыменовывают с помощью mov dword ptr [rax]?

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


      1. WASD1
        14.10.2025 16:04

        Касательно темы: как по мне это глупость - говорить про UB при разыменовании, например. Какое языку программирования C должно быть дело, что там разыменовывают с помощью mov dword ptr [rax]?


        Касательно темы - UB в стандарте сформулировано таким образом (поведение не определено), что компилятор имеет право вести себя так, будто UB в коде нет, т.к. если UB произошло - то программа просто как-бы отменила всё ранее сделанное.

        Последние лет 10 (1) - компиляторы стали этим злоупотреблять (2), - и просто помечать весь код, содержащий UB как недостижимый, что привело не только к выкидыванию самого кода с UB, но и к compile-time наложению условий на значение переменных, которые "таковы, что UB не произойдёт".

        1) после того, как компиляторы стали сверхагрессивно инлайнить ф-ии
        2) а уже не всегда возможно отделить "злоупотребление" от "разумных оптимизаций"


  1. nickolaym
    14.10.2025 16:04

    Не понимаю, зачем переводить весьма куцую статью, которая апеллирует ещё к дремучим пидипи и ваксам, если тема раскрыта почти никак, - тогда как существует, например, здоровенная "книга неопределённых поведений"?

    Видов неопределённого поведения много. Элементарно: любое нарушение предусловия любой библиотеки, - и вуаля, поведение не определено.

    Если сказано "memcpy не умеет работать с перехлёстом массивов, не делайте так", а вы сделали так, - вас ждут необычные сюрпризы. Скорее всего, до аппаратных защит вы не доиграетесь, но кучу мусора в данных получить рискуете.

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

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

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

    Вот тут - ужасы нашего городка https://kodt-rsdn.livejournal.com/228358.html

    А вы говорите - древний вакс...


    1. tenzink
      14.10.2025 16:04

      Offtop:очень рад встретить вас тут. Помню, вот были времена на rsdn


      1. nickolaym
        14.10.2025 16:04

        Ниже в комментах и другой олд с рсдн проявился. Все мы так или иначе мигрируем по вселенным с единомышленниками.


  1. altaastro
    14.10.2025 16:04

    (язык) может гарантировать, что вы получите либо верный ответ, либо отказ. А неправильный ответ — ни в коем случае

    Нет, таких гарантий дать нельзя, так как проблема останова алгоритмически неразрешима в общем виде.


    1. vanxant
      14.10.2025 16:04

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


      1. altaastro
        14.10.2025 16:04

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


        1. vadimr
          14.10.2025 16:04

          Коллега прав, проблема останова существует только для бесконечной ленты. Я подробно рассматривал этот момент здесь: https://habr.com/ru/articles/926394/


          1. altaastro
            14.10.2025 16:04

            Штош, был неправ. Спасибо что ткнули носом, отличная статья


            1. vadimr
              14.10.2025 16:04

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

              Хотя в семантике C++, наверное, объём памяти всё же подразумевается конечным в связи с фиксированной разрядностью указателей. Так что, немного неожиданным образом, наличие операции sizeof(void*) гарантирует решение проблемы останова.

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


              1. unreal_undead2
                14.10.2025 16:04

                В семантике C++ (стандартной библиотеки) есть файлы, в которых тоже может храниться состояние программы.


                1. vadimr
                  14.10.2025 16:04

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


                  1. unreal_undead2
                    14.10.2025 16:04

                    Позиция необязательна - для stdin/stdout она вообще смысла не имеет, не вижу препятствий к реализации потока с сохранением информации, с которым работают read/write, но не seek/tell.


                    1. vadimr
                      14.10.2025 16:04

                      Позиция необязательна - для stdin/stdout она вообще смысла не имеет

                      Но при помощи stdin/stdout нельзя сделать сохранение информации для дальнейшего чтения (не мутя перенаправление средствами ОС, которые выходят за рамки языка С++).

                      не вижу препятствий к реализации потока с сохранением информации, для которого опеределены read/write, но не seek/tell.

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

                      Я не настолько глубоко знаю C++, чтобы утверждать, что лазейки точно нет, но пока я её не вижу.


                      1. unreal_undead2
                        14.10.2025 16:04

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

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


                      1. vadimr
                        14.10.2025 16:04

                        В любом случае это уже детали реализации за пределами семантики C++

                        О чём и речь.


          1. WASD1
            14.10.2025 16:04

            что там "подробно рассматривать" то?
            MT (как и любая процессорная архитектура) с конечной памятью - просто ОЧЕНЬ большой конечный автомат.


            1. vadimr
              14.10.2025 16:04

              Справедливое утверждение, но на удивление мало людей это ясно понимают.


              1. WASD1
                14.10.2025 16:04

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

                Забавно, что данный случай (UB в компиляторе) это вариант практической инженерии и к сводимости \ несводимости к State Machine вообще отношения не имеет.

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


      1. unreal_undead2
        14.10.2025 16:04

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


      1. WASD1
        14.10.2025 16:04

        Блин вот комментатор выше "поумничал".
        Но вы то повелись зачем?

        Конечность (сводимость к state machine) / бесконечность потенциальных данных вообще тут не при чём.
        Компилятор просто делает эквивалентные преобразования одного языка к другому (пока не перейдёт от условного C++ к условному IA32 / x86-64).

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


        1. vanxant
          14.10.2025 16:04

          Конечность (сводимость к state machine) / бесконечность потенциальных данных вообще тут не при чём.

          Да, это вы верно подметили. Компилятору (кроме С++) достаточно конечности входных данных, т.е. сырцов.

          Про плюсы оговорка, потому что шаблоны полны по Тьюрингу


  1. Panzerschrek
    14.10.2025 16:04

    Я согласен, что есть основания для наличия неопределённого поведения. Но в некоторых случаях оно существует чисто по историческим причинам и его можно было бы не делать таковым. Знаковое переполнение можно было бы явно определить - сейчас везде числа в одинаковом формате хранятся и переполнение даёт вполне ожидаемые результаты. Битовый сдвиг тоже не столь проблемный - его можно сделать поведением, зависящим от реализации. Сюда же идут преобразования между целыми и вещественными типами - в железе они работают почти везде одинаково, за исключением случаев с экстремальными значениями. С union та же история - стандарт запрещает читать значения не того типа, что записаны, но по факту много кто так делает и посему компиляторы тут даже отклоняются от стандарта.

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


    1. Siemargl
      14.10.2025 16:04

      Сейчас будут квантовые компьютеры со своими ньюансами. И на них опять будет С с УБ =)

      это я про "везде одинаковые"


    1. bear11
      14.10.2025 16:04

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


      1. Siemargl
        14.10.2025 16:04

        уже есть.

        "не используйте -О3"


  1. unreal_undead2
    14.10.2025 16:04

    Так как в нынешнем C++ правильно работать с отдельными битами double/float?


    1. haqreu
      14.10.2025 16:04

      constexpr double f64v = 19880124.0; 
      constexpr auto u64v = std::bit_cast<std::uint64_t>(f64v);
      static_assert(std::bit_cast<double>(u64v) == f64v); // round-trip


      1. unreal_undead2
        14.10.2025 16:04

        Спасибо, как то пропустил.


  1. LinkToOS
    14.10.2025 16:04

    Перевод просто ужасен. Оригинал достоин (такого) перевода. Оригинал не достоин перевода. (лингвистическое UB)


    1. Siemargl
      14.10.2025 16:04

      Русский язык включает и наборку УБ


      1. nickolaym
        14.10.2025 16:04

        Потому что русский язык полон по Тьюрингу, Ершову, и даже Эйлеру и Канту, великим русским мыслителям! )))


  1. haqreu
    14.10.2025 16:04

    Общеизвестно, что на С программировать сложнее, чем на таких языках, как Python.

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


  1. netch80
    14.10.2025 16:04

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

    Бывают ещё недоопределённые команды. Например, BSF, BSR в x86 при нуле на входе могут формально вернуть что угодно. Но в терминах C/C++ это не undefined, а unspecified behavior.

    Хуже то, что автор оправдывает наплевательство авторов компиляторов на проблемы программистов, когда сам факт нарушения невозможно отследить из-за сложности кода и интерференции эффектов. Например, переполнение знакового при умножении на константу, когда эта константа определена за тридевять модулей. Изменение размера типа целого. Ещё можно много вариантов придумать. Как сказал один деятель на RSDN, перефразируя Маркса, "нет такой подлости и низости, на которую бы не пошли авторы GCC и Clang ради очередных 2% в никому не нужном синтетическом тесте". Вместо этого и, например, рассказов типа "а вы выключите оптимизацию", надо было дать возможность контекстно управлять наиболее критичными случаями UdB, как переполнения и алиасинг. Синтаксические механизмы для этого давно есть.


  1. 00Kirill00
    14.10.2025 16:04

    Раздел Что делать самая полезная часть статьи

    Санитайзеры (-fsanitize=undefined), флаги (-fwrapv, -ftrapv) и статические анализаторы - это тот необходимый инструментарий, без которого писать на C/C++ в 2025 году - это просто безрассудство


    1. Siemargl
      14.10.2025 16:04

      и отвага!


  1. sergio_nsk
    14.10.2025 16:04

    Намного подробнее Путеводитель C++ программиста по неопределённому поведению. Лучшего не встречал.

    Заголовок: "Подробно о неопределённом поведении в С и C++". Потом вся статья про C и C23 с мимолётным упоминанием std::vector<T>::at(size_t). А у этих языков разные неопределённые поведения, пересекающиеся, но ни подмножества одно другого.

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

    Неверный перевод

    shifting by an amount greater than or equal to the size of the number, is UB.

    "большую или равную тому числу" != "greater than or equal to the size of the number".


    1. Jijiki
      14.10.2025 16:04

      std::vector<T>::at(size_t) тоесть уб в том случае если обращение в вектор по at в несуществующую ячейку вектора или в чем прикол, даже интересно

      для меня пока магия вот какая, есть вектор 118 елементов, есть time считается по формуле с dt и опр множителем в сухом остатке она приводит к количеству фреймов.

      меня прикольнула такая ситуация

      делаю 118 кадров от 1 до 117 они в векторе )

      я просто беру glm::clamp(time,1,600); и работает как-будто все 3 тыщи кадров в векторе, но в векторе ровно 118 кадров )


      1. sergio_nsk
        14.10.2025 16:04

        С at нет никакого ub. Есть исключение.