Очень известный и распространенный источник проблем и не только в C/C++.
Новые современные языки программирования обычно запрещают использование неинициализированных переменных. Переменные либо всегда инициализируются значением по умолчанию (например, в Go). Либо попытка чтения из неинициализированной переменной дает ошибку компиляции (в Kotlin или в Rust).
C и C++ — старые языки. В них можно легко и просто объявить переменную, а инициализировать ее как-нибудь потом. Или забыть иницаилизировать вовсе. Но в отличие от совсем низкоуровневого ассемблера, в котором читать из неинициализированной переменной никто не запрещает — ну получите вы свои мусорные байтики и ладно — в C/C++ (а также в Rust, см MaybeUninit) это влечет за собой неопределенное поведение.
Неожиданный вариант такого UB можно наблюдать на следующем примере (взято тут):
struct FStruct {
bool uninitializedBool;
// Конструктор, не инициализирующий поля.
// Чтобы проблема воспроизвелась, конструктор должен быть определен в другой единице трансляции
// Можно сымитировать с помощью атрибута noinline
__attribute__ ((noinline))
FStruct() {};
};
char destBuffer[16];
void Serialize(bool boolValue) {
const char* whichString = boolValue ? "true" : "false";
size_t len = strlen(whichString);
memcpy(destBuffer, whichString, len);
}
int main()
{
// Конструируем объект с неинициализированным полем
FStruct structInstance;
// UB!
Serialize(structInstance.uninitializedBool);
//printf("%s", destBuffer);
return 0;
}
Программа падает. Поскольку неинициализированных переменных в корректной программе не бывает, компилятор полагает boolValue
всегда валидным и выполняет следующую занятную оптимизацию:
// size_t len = strlen(whichString); // 4 или 5!
size_t len = 5 - boolValue;
Так если отсутствие неинициализированных переменных способствует оптимизациям, почему бы их не запретить совсем, с жесткой ошибкой компиляции?
Во-первых, они позволяют экономить на спичках:
int answer;
if (value == 5) {
answer = 42;
} else {
answer = value * 10;
}
Если бы нам было запрещено объявлять переменную без инициализации, мы бы либо вынуждены были написать
int answer = 0;
И потратить в отладочной сборке целую одну лишнюю инструкцию xor
на зануление!
Либо завернуть вычисление answer
в отдельную функцию (или лямбда-функцию) и получить целый call
вместо jmp
, если компилятор не отоптимизирует!
Либо использовать тернарный оператор и получить что-то совершенно нечитаемое, если веток условий будет больше.
Во-вторых, иногда спички большие и дорогие. И экономия оправдана:
constexpr int data_size = 4096;
char buffer[data_size];
read(fd, buffer, data_size);
Инициализировать целый массив чтобы тут же его перетереть — не разумно. И маловероятно что компилятор эту инициализацию отоптимизирует: для этого ему нужны гарантии что условная функция read
не читает ничего из буфера. Такие гарантии могут быть зашиты для функций стандартной библиотеки, но не для пользовательских.
Прежде всего: какие конструкции порождают неинициализированные переменные?
Специальные функции, например, std::make_unique_for_overwrite
мы не рассматриваем. Функции выделения сырой памяти: *alloc
тоже. Хотя напомнить, что писать (T*)malloc(N)
в ожидании инициализированной памяти нельзя.
В боле общем случае, если верно, что is_trivially_constructible<T> == true
, то
T x;
T x[N];
T* p = new T;
T* p = new T[N]
;
Порождают неинициализированные переменные/массивы (или указатели на неинициализированные переменные/массивы)
Если тип нетривиально конструируемый, не спешите радоваться. Его конструктор по умолчанию мог забыть что-то проинициализировать. Или кто-то предоставил деструктор чтобы всех запутать. Или виртуальный метод.
Распространенный совет по повсеместному использованию {} при объявлении переменных работает и гарантирует инициализацию нулями только с тривиальными типами. Для нетривиальных — все на совести конструктора.
Но иногда вам может «повезти» и инициализация пройдет (гарантированно стандартом!) в два этапа: сначала нулями, потом вызовется конструктор по умолчанию. Подробнее тут.
Мне удалось воспроизвести этот эффект только при использовании std::make_unique
;
Как бороться с неинициализированными переменными и связанным с ним неопределенным поведением?
- Не разрывать объявление и инициализацию. Вместо этого использовать конструкции:
auto x = T{...};
auto x = [&] { ... return value }();
-
Проверять свои конструкторы, что в них инициализированы все поля.
-
Пользоваться инициализаторами по умолчанию при объявлении полей структур
-
Использовать свежие версии компиляторов: последний (на момент написания заметки) gcc 11.2 способен предупреждать об обращении к неинициализированным значениям. Но не всегда способен.
-
Не использовать
new T
, если вы не уверены в том, что делаете. Всегдаnew T{}
илиnew T()
. -
Не забывать про динамический и статический анализ внешними утилитами. Valgrind умеет ловить обращения к неинициализированной памяти.
Если к вам когда-нибудь придет светлая мысль использовать неинициализированную память в качестве источника случайности, гоните её как можно быстрее! Некоторые пробовали — не получилось.