Undefined behaviour.


Если просто дать определение этой штуки, возникнет вопрос, на кой она нужна. Поэтому начнём мы с истории из жизни компиляторов.

Aliasing. restrict.

Напишем вот что:

void sum(float* res, float const* in, size_t n) {
    for (size_t i = 0; i < n; i++)
        *res += in[i];
}

Компилятор же умеет оптимизировать код, почему бы ему не завести регистр, где он будет накапливать чиселки, после чего запишет их в *res. И он действительно заведёт регистр XMM0. Но почему-то он будет записывать результат в *res на каждой итерации цикла, а не после него. Почему? Потому что может так оказаться, что указатели in и res ссылаются в одно и то же место. И тогда нам бы надо прибавить корректное значение. У компилятора нигде не написано, что такого быть не может, хотя ни один адекватный человек так делать не будет.

Научным языком ситуация с двумя ссылками на одно место называется aliasing.
Ещё один пример на ту же тему:

void memcopy(char* dst, char const* src, size_t count) {
    for (size_t i = 0; i < n; i++)
        dst[i] = src[i];
}

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

Мы говорили о том, что нельзя в общем случае предполагать размеры int'а и short. Поэтому разработчики компиляторов нагло этим воспользовались:

int foo(int* a, int* b) {
    *a = 1;
    *b = 2;
    return *a;
}

Тут нельзя оптимизировать return *a в return 1. Зато тут:

int foo(int* a, short* b) {
    *a = 1;
    *b = 2;
    return *a;
}

Вполне можно, ведь некорректно одним указателем ссылаться на int и short. Это strict aliasing rule — вы не можете ссылаться на одни и те же данные разными типами. У него есть два исключения.

  • int и unsigned (или любые другие типы, отличающиеся только знаковостью).
  • char, unsigned char и std::byte, могут alias'ить всё что угодно. В C signed char тоже может.

Это может привести к интересным эффектам. Если мы попытаемся заполнить std::vector<char> нулями, то компилятор будет перечитывать data и size, а то вдруг данные вашего вектора alias'ятся с его полями. Такую же проблему, но в меньших масштабах, имеем с std::vector<size_t>, поскольку у нас size — это size_t.

Такая проблема приводила к тому, что всякие численные методы не писались на C, а писались на Fortran'е, в силу специфики которого компилятор имеет право предполагать, что его аргументы на alias'ятся. Чтобы как-то жить с alias'ингом в C в стандарте C99 даже добавили ключевое слово restrict, которое показывает, что данный указатель не alias'ится ни с одним другим. В C++ его нет, но многими компиляторами поддерживается как расширение и пишется как __restrict. И если написать

void sum(float* __restrict res, float const* in, size_t n) {
    for (size_t i = 0; i < n; i++)
        *res += in[i];
}

То компилятор не только вытащит запись в *res вне цикла, но начнёт ещё и векторизацию использовать, что ускорит код в 4 раза.

Хорошо, мы написали функцию, на которую мы повесили __restrict. Вопрос — что произойдёт, если мы-таки передадим туда что-то, что alias'ится? Да никто не знает, честно говоря. В зависимости от компилятора код может быть оптимизирован, а может быть не. Вы не можете заставить компилятор оптимизировать код по стандарту, а значит не можете заставить сделать что-то конкретное — произойдёт то, что произойдёт. Тут вы по сути вступаете в контракт с компилятором — Вы не передаёте какую-то фигню, а компилятор включает оптимизации. Если вы нарушите свои условия, ваш код может сделать всё что угодно. Только тут пример показателен, потому что вы сами кровью подписали контракт, написав __restrict.

Сдвиги.

А вот если вы сделаете

int shift(int a, int b) {
    return a << b;
}

То непонятно, что произойдёт, если вы передадите b или отрицательное, или большее 32. На разных платформах сдвиги работают по-разному. На x86 вот, вообще 32- и 64-битные числа сдвигаются по модулю битности, а 16-битные — не по модулю 16 бит, а тоже 32. Поэтому конкретно прописать, что произойдёт, нельзя, на каких-то платформах нужно будет if'ать. Поэтому это тоже UB. Но в данном примере ничего кровью не подписывали. Впрочем, данный пример имеет ещё одну особенность — можно определить, что под каждую конкретную платформу имеем конкретное поведение. А есть случай, когда мы и так делать не можем.

Разыменование нулевого указателя и обращение вне массива.

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

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

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

Переполнение типов.

bool is_maximal_int(int a) {
    return a + 1 < a;
}

Тут понятно, почему UB (потому же, почему и сдвиги). Но у него тоже есть нечто показательное: вы можете предсказать, что будет на большинстве компиляторов. И мы можем даже посмотреть на это и объяснить.
Этот код будет оптимизирован компилятором в return false. Почему? Ну, смотрите, если a — это не максимальный int, то возвращаем false, иначе по стандарту UB, значит можно вернуть что угодно, например, тоже false. По стандарту в знаковых числах (и только в них) если результат операции +, - или * не представим в вашем типе, то это UB.

const.

Ещё весёлая ситуация есть с const'ами:

int const PI = 3;

int length(int r) {
return 2 * PI * r;
}

Так вот, это оптимизируется в умножение на 6. Несмотря на то, что технически никто не мешает вам снять const при помощи const_cast и записать что-то другое в PI. Потому что когда вы снимаете const, а потом что-то записываете — UB. Рассмотрим еще примеры с const_cast:

void good()
{
    int a = 42;
    int const& ref1 = a;
    int& ref2 = const_cast<int&>(ref1);
    ref2 = 43;

}

Так делать можно. Гарантируется, что если что-то было не константным, мы сами навесили const, а дальше сняли и поменяли, то всё будет хорошо. Другой пример:

void bad()
{
    int const a = 42;
    int& ref = const_cast<int&>(ref1); // Это не UB. Это так, чтобы, например, можно было вызвать функцию, на которой ошибочно не поставили const
    ref2 = 43; // UB, менять const переменную нельзя
}

Что же всё-таки будет, если мы пытаемся менять константу?

  • Может быть, если компилятор вообще не упрощает код, переменная реально изменится.
  • Может быть, компилятор всё оптимизировал и изменений мы не заметим.
  • SIGSEGV: переменная могла лежать в куске памяти, в который просто запрещено писать.
  • ???. Возможно, ещё что-то. Это ведь UB.

Преднамеренное неопределённое поведение.

В GCC есть прекрасная функция, которая называется __builtin_unreachable. Всё что она делает — делает UB. Возникает вопрос: а зачем? Чтобы компилятор мог как ему вздумается оптимизировать то, что вы и ваши коллеги считаете недостижимым. Например, если у вас есть switch, где вы перебрали все случаи (вы знаете, что перебрали все случаи, а компилятор — нет), то в default: можно написать эту функцию, и тогда компилятор не будет проверять, что вы попадёте в один из case'ов.

Best practices.

Хорошо, а как делать надо?

  • Снимать const с неизменяемого объекта всегда плохо, просто так не делайте.
  • Если вам нужна операция с переполнением, в GCC есть __builtin_add_overflow, который точно и без UB делает то, что вам нужно.
  • Если вы хотите кастить указатели друг в друга, то вам нужен memcpy. Причём memcpy компилятор нормально оптимизирует, получается как каст, только по стандарту. В случае memcpy вы получите всё так, как на текущей архитектуре работает (т.е. LE либо BE), а если вы хотите конкретный порядок байт вне зависимости от системы, напишите что-то вида ptr[3] | ptr[2] << 8 | ptr[1] << 16 | ptr[0] << 24. Это даже медленно не будет, потому что компилятор просекает такие паттерны и оптимизирует их.

Способы неправильно воспринимать UB.

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

struct tun_struct *tun = ...;
struct sock *sk = tun->sk;
if (!tun)
    return POLLER;

Традиционно это код из ядра Linux, содержащий UB. Но sk не использовалось, поэтому к ошибкам не приводило. А когда GCC начал всё оптимизировать сильнее, он нафиг убрал переменную sk. Но поскольку вы обращаетесь к tun, можно нафиг убрать и проверку его на NULL. Тем не менее ваша программа останется корректной при любом не'NULL'овом значении tun.

Другое неправильное понимание UB — вот такая попытка проверить, является ли число максимальным int'ом, с помощью переполнения:

bool test(int a) {
    return a + 1 < a;
}

Такая функция сведётся к return false. Но человек смотрит на то, что в процессоре это совершенно чётко работает, и не понимает, почему в C++ не работает. А дело в том, что опасно при изучении того, как работает код, смотреть на инструкции процессора. Нельзя так, сложение в процессоре и сложение в языке могут различаться. Правильная функция, проверяющая, является ли число максимальным int'ом, может выглядеть так:

#include <limits>

bool test(int a) {
    return a == std::numeric_limits<int>::max();
}

Или можно сделать проверку в беззнаковых типах:

bool test(int a) {
    return static_cast<int>(static_cast<unsigned>(a) + 1) < a;
}

Похожая очень грустная ситуация с многопоточностью, где инструкции есть у процессора, есть команды в C++, и они совсем разные.

А ещё неправильно обращаться к разработчикам компиляторов, которые в новой версии якобы сломали ваш код. Раньше компиляторы оптимизировали всё очень слабо, и исполняли всё так, как написано (соответственно, UB не портилось). А потом начинают появляться новые оптимизации, которые всё сильнее и сильнее ломают ваш код, содержащий UB.

Неправильные решения UB

У компиляторов есть ключики, позволяющие отключить оптимизации, которые полагаются на undefined behaviour. Например, -fno-strict-aliasing. Но:

  • Если код требует этих ключиков, то он, скорее всего, сломанный.
  • Эти ключики подавляют оптимизации компилятора. Например, если использовать ключ -fno-strict-aliasing, то компилятор не сможет делать те оптимизации, которые делал, когда обсуждался strict-aliasing.