Skip to content

Latest commit

 

History

History
199 lines (141 loc) · 12.7 KB

static_initialization_order_fiasco.md

File metadata and controls

199 lines (141 loc) · 12.7 KB

Static initialization order fiasco

Проблемы с использованием объектов до окончания их полной инициализации наигрывается во многих языках программирования. Сомнительный дизайн с разрывом объявления, конструирования и инициализации можно воплотить в жизнь чуть ли ни где угодно. Но обычно для этого все-таки надо приложить некоторые усилия. А в C/C++ можно вляпаться незаметно, случайно и очень долго об этого не подозревать.

В C/C++ мы можем разделять код программы по разным, независимым единицам трансляции (в разные .c/.cpp файлы). Они могут компилироваться параллельно. Скорость сборки повышается. И все было бы хорошо.

Но только в одном «модуле» появляется глобальная переменная, используемая в другом модуле, начинаются проблемы. И проблемы не только от того, что глобальные переменные в принципе признак не самого удачного дизайна. Проблема в том, что связи между модулями нет (заголовочные файлы ничего не связывают). И после объединения модулей код с инициализацией глобальной переменной может оказаться ПОСЛЕ кода с использованием.

Стандарты C и С++ гарантируют, что глобальные переменные будут сконструированы в порядке их объявления внутри единицы трансляции. А между единицами трансляции — неопределен. И вместе с порядком неопределено и поведение программы.

// module.h
extern int global_value;

// module.cpp
#include "module.h"

int init_func() {
    return 5 * 5;
}
int global_value = init_func(); 

// main.cpp
#include "module.h"

#include <iostream>

static int use_global = global_value * 5;

int main() {
    std::cout << use_global;
}

Результат будет зависеть от того, в каком порядке будут обработаны main.cpp и module.cpp.

До C++11 в следующем простеньком примере было неопределенное поведение. Как раз из-за возможности неправильного порядка инициализации статических объектов.

#include <iostream>

struct Init {
    Init() {
        std::cout << "Init!\n"; 
    }
} init; // до C++11 не было гарантии,
        // что std::cout сконструирован к этому моменту

int main() {
    return 0;
}

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

// module.h
int global_variable();

// module.cpp
int global_variable() {
    static int glob_var = init_func();
    return glob_var;
}

В таком случае при первом же доступе инициализация гарантировано произойдет.


Помимо неопределенного поведения из-за неправильного порядка инициализации, наиграть можно проблемы и с порядком деинициализации!

Стандарт C++ гарантирует, что деструкторы объектов всегда вызываются в порядке, обратном порядку завершения работы конструкторов.

#include <iostream>
#include <string>

const std::string& static_name() {
    static const std::string name = "Hello! Hello! long long string!";        
    return name;
}

struct TestStatic {
    TestStatic() {
        std::cout << "ctor: " << "ok" << "\n";
    }
    ~TestStatic() {
        std::cout << "dctor: " << static_name() << "\n";
    }
} test;


int main() {
    std::cout << static_name() << "\n";
}

Сначала отрабатывает конструктор TestStatic. Затем main, вызвав static_name, конструирует строку. По завершении программы СНАЧАЛА уничтожается строка, а затем деструктор TestStatic обращается к уже уничтоженной строке.

Чтобы избежать подобного, можно либо в конструкторе TestStatic вызвать функцию static_name — тогда конструктор строки завершится до завершения конструктора TestStatic и порядок уничтожения объектов будет другим.

Либо (и так иногда делают) в принципе предотвратить уничтожение статической строки: создать ее в куче.

const std::string& static_name() {
    static const std::string* name 
        = new std::string("Hello! Hello! long long string!");        
    return *name;
}

Но тогда вы соглашаетесь на утечку памяти. Конечно, никакой утечки на самом деле не будет — статический объект умрет при завершении работы программы. И память все равно будет освобождена. Однако утилиты, используемые для обнаружения утечек, обязательно укажут на ваш статический объект в куче. И вам придется их отфильтровывать, чтобы не мешали искать настоящие утечки.

Initialization order fiasco и неиспользуемые заголовки

Для ускорения процесса сборки хорошей практикой в C++ является уменьшение количества подключаемых заголовков. Подключать стараются только то, что действительно используется. Если размер структур в конкретном файле не важен (например, используются только ссылки и указатели), то можно подключить отдельный маленький заголовок с предобъявлениями (например, iosfwd вместо iostream). Есть линтеры (cpplint, например), которые могут подсказывать, какие заголовочные файлы у вас совсем не используются. Все неиспользуемое — в мусор!

Если следовать подобным советам и подходам, исходники после препроцессинга получаются меньше. Меньше неиспользуемых символов. Повторяющихся символов тоже меньше — меньше работы для линкера. Красота. Все только выигрывают... Вроде бы.

На самом деле есть подводные камни, об которые легко разбиться. И они связаны с порядком инициализации статических объектов (спасибо Egor Suvorov за концепцию примера).

Допустим, вы пишете библиотеку логгирования. Ее интерфейс скромен

// logger.h

#include <string_view>
void log(std::string_view message);

В интерфейсе используется только минимально необходимый заголовок.

В первой реализации вы решили логгировать в stdout с помощью стандартной библиотеки потоков ввода/вывода.

// logger.cpp
#include "logger.h"

#include <iostream>

void log(std::string_view message) {
    std::cout << "INFO: " << message << std::endl;
}

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

// main.cpp
#include "logger.h"

struct StaticFactory {
    StaticFactory() {
        log("factory created");
    }
} factory;


int main() {
    log("start main");
    return 0;
}

Он, располагая компилятором gcc version 10.3.0 (Ubuntu 10.3.0-1ubuntu1), собрал приложение командой:

g++ -std=c++17 -o test main.cpp logger.cpp

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

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

g++ -std=c++17 -o test2 logger.cpp main.cpp 

Запускаете. И, о чудо!, ничего не падает. Закрываем баг-репорт?


В этом примере очень злобная ошибка с нарушением порядка инициализации статических объектов. C++11 гарантирует, что объекты std::cin, std::cout, std::cerr и их «широкие» аналоги будут инициализированы ДО любого статического объекта, объявленного в вашем файле, ТОЛЬКО ЕСЛИ заголовок <iostream> подключен ПЕРЕД объявлением ваших объектов. Достигается это в глубинах <iostream> созданием статического объекта std::ios_base::Init. До C++11 гарантий не было. Темные времена.

В своей заботе о минимизации зависимостей и размере обработанных препроцессором исходников (или просто последовав совету линтера), вы не включили iostream в интерфейсный заголовок библиотеки, но использовали его в реализации. Пользователь, не знающий об этом, получает проблемы. Не самое удачное решение.

Объекты стандартных потоков не единственная возможность для подобных ошибок. Любая библиотека, использующая глобальные статические объекты, не позаботившаяся об их инициализации ДО любых действий пользователя — потенциальный источник проблем. Если вы автор библиотеки, внимательнее относитесь к проектированию ее интерфейса. В C++ он не ограничивается только лишь сигнатурами функций и описанием классов.