Files
ubbook/runtime/uninitialized.md
T
2024-08-24 15:55:55 +01:00

10 KiB

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

Очень известный и распространенный источник проблем и не только в C/C++.

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

C и C++ — старые языки. В них можно легко и просто объявить переменную, а инициализировать ее как-нибудь потом. Или забыть иницаилизировать вовсе. Но в отличие от совсем низкоуровневого ассемблера, в котором читать из неинициализированной переменной никто не запрещает — ну получите вы свои мусорные байтики и ладно — в C/C++ (а также в Rust, см MaybeUninit) это влечет за собой неопределенное поведение.

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

Неожиданный вариант такого 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, то

  1. T x;
  2. T x[N];
  3. T* p = new T;
  4. T* p = new T[N];

Порождают неинициализированные переменные/массивы (или указатели на неинициализированные переменные/массивы)

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

Распространенный совет по повсеместному использованию {} при объявлении переменных работает и гарантирует инициализацию нулями только с тривиальными типами. Для нетривиальных — все на совести конструктора.

Но иногда вам может «повезти» и инициализация пройдет (гарантированно стандартом!) в два этапа: сначала нулями, потом вызовется конструктор по умолчанию. Подробнее тут. Мне удалось воспроизвести этот эффект только при использовании std::make_unique;

Как бороться с неинициализированными переменными и связанным с ним неопределенным поведением?

  1. Не разрывать объявление и инициализацию. Вместо этого использовать конструкции:
auto x = T{...};
auto x = [&] { ... return value }();
  1. Проверять свои конструкторы, что в них инициализированы все поля.

  2. Пользоваться инициализаторами по умолчанию при объявлении полей структур

  3. Использовать свежие версии компиляторов: последний (на момент написания заметки) gcc 11.2 способен предупреждать об обращении к неинициализированным значениям. Но не всегда способен.

  4. Не использовать new T, если вы не уверены в том, что делаете. Всегда new T{} или new T().

  5. Не забывать про динамический и статический анализ внешними утилитами. Valgrind умеет ловить обращения к неинициализированной памяти.

И последнее

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