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'ить всё что угодно. В Csigned 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
.