Инициализация статических переменных в С++. Неоднозначности и возможные решения
Занимаясь разработкой на языке С++, мы рано или поздно приходим к вопросу об инициализации статических переменных. Более сложный случай, когда проект большой и над ним трудится много людей. Статические переменные, как правило, объявляются в глобальной области имён, то есть используются многими участниками проекта, что часто вызывает споры и раздоры.
Есть мнение о полном избегании объявления глобальных переменных в угоду обеспечения низкой связанности и манипуляции взаимодействия классов. Давайте последовательно посмотрим, что же происходит.
1. Статическая инициализация
При создании статической переменной, возникает вопрос, когда эта переменная будет инициализирована, сколько времени она проживёт и когда будет уничтожена? Статическая инициализация позволяет нам создать переменную, которая будет инициализирована до запуска программы со временем жизни в течение всей программы и уничтожении после завершения. Такие константные переменные не зависят от исполнения — они всегда существуют, создаются во время компиляции и располагаются в исполнимом файле (в бинарнике). Как результат: нулевые накладные расходы, ранняя диагностика проблем и безопасность. Вопрос об использовании статической инициализации напрямую зависит от предметной области разрабатываемого проекта — чем ниже уровень, тем соблазн использования выше, а иногда и критичен, тут скорость решает всё.
Как пример:
const std :: size_t tabsize = 64 ; int tab [ tabsize ] ;
2. Нулевая инициализация
Она возникает тогда, когда начальное значение не может быть оценено во время компиляции, в этом случае все статические переменные либо инициализируются константами, либо нулём, что является большой проблемой, так как однозначно неизвестен вид инициализации. Такая переменная попадёт в динамическую память, но с константным выражением обычно инициализированной нулём, либо она будет в бинарнике с заданным константным выражением. Как следствие, это ведёт к появлению трудно выявляемых ошибок, так как исчезает возможность контроля корректности инициализированных значений.
Для того чтобы этого избежать и принудительно создать статическую переменную, используется спецификатор constexpr, который вычисляет выражение (или результат работы функции) на этапе компиляции при условии, что оно может быть вычислено. Например:
constexpr auto const getLog(std::size_t n){ std :: size_t k = 0; while(n>>= 1) k++; return k; } constexpr std :: size_t n = 64 ; constexpr std :: size_t sz = getLog(tabsize) ; int tab2 [ sz ] ;
Но здесь есть свои подводные камни, о которых необходимо упомянуть. Мы все любим выражения подобно
constexpr auto VER = std::string( "3.4.1" );
работать не будут, что и логично, так как класс std::string выделяет некоторый ресурс, который должен быть освобождён при уничтожении, в данном случае памяти. Следовательно, std::string( "3.4.1" ) не может быть константным выражением, вычисляемым во время компиляции. В замен мы вынуждены использовать const и за это платим перемещением из времени компиляции во время выполнения, т. е. переходим из статической в динамическую инициализацию.
const auto VER = std::string( "3.4.1" );
3. Static-проблема порядка инициализации
Так как порядок инициализации статических переменных чётко не определён, возникает серьёзная проблема правильной инициализации, если значения находятся в разных модулях. Короче говоря, предположим, что у нас есть два static-объекта x и y, которые существуют в отдельных исходных файлах, скажем, x.cpp и y.cpp. Предположим далее, что инициализация для y объекта (обычно y — конструктор объекта) вызывает некоторый метод x объекта. Вот и все. Мы получили 50%-ную вероятность испортить программу.
Как правило, подобная проблема возникает из-за плохого проектирования проекта. Лучший способ её решить — это рефакторинг кода, чтобы разорвать зависимость инициализации глобальных переменных от единиц компиляции. Необходимо сделать модули автономными и стремиться к постоянной инициализации.
Если рефакторинг кода не подходит, возможно использовать идиому Construct On First Use. Основная идея состоит в том, чтобы спроектировать статические переменные, которые не являются константными выражениями (то есть теми, которые должны быть инициализированы во время выполнения) таким образом, чтобы они создавались при первом обращении к ним. Подобный подход часто называется синглтоном Мейера. Как пример:
// a.cpp int duplicate ( int n ) { return n * 2 ; } auto & A () { static auto a = duplicate ( 7 ); return a; } // b.cpp #include <iostream> #include "a.h" auto B = A (); int main () { std :: cout << B << std :: endl ; return EXIT_SUCCESS ; }
В заключении хотелось бы отметить, что в общем смысле вопрос правильной инициализации достаточно сложный. Этой теме посвящено множество публикаций и выступлений, особенно на фоне неоднозначного изменения в стандартах языка С++. Так, C++11 принёс концепцию «универсальной инициализации», которая привнесла ещё более сложные правила, и, в свою очередь, их перекрыли в C++14, C++17 и снова поменяют в C++20.