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