これはC++アドベントカレンダーが埋まっていなかったので急遽書こうと思い立って急いで寝る前にバーっと書いてたら気づいたときには2時を回っていた記事です。何かあったら教えるか見逃してください。
そもそも何の話?
Static Initialization Order Fiascoとは、異なる翻訳単位で初期化される static storage duration を持つ変数の初期化順序が不定であるために起きる問題の名称です。ちなみに"fiasco"は、weblioによると「(野心的な企てがこっけいな結果で終わるような)大失敗」のことで、イタリア語の「瓶」が語源だそうです。ガラス製品作りに失敗した材料で瓶を作ったことからだとか。おしゃれな言い回しをしますね。問題自体は深刻なんですが。
どういう問題なのか順に説明していきましょう。まずstatic storage durationを持つ変数とは何ぞやというのがありますね。これは、大まかに言うと、グローバル変数と静的メンバ変数、あと静的ローカル変数のことです(thread_local
には今回言及しません)。静的メンバ変数というのは以下のようなもののことです。
// X.hpp class X { static int x; // これ }; // X.cpp int X::x = 0;
まあ今(C++17以降)だとinline
を使ってX.cpp
には定義を書かずに済ませる、ということも多くなってきているでしょう。
// X.hpp class X { static inline int x; // これ };
静的メンバ変数は個々のインスタンスが持つことはなく、クラスXの全てのインスタンスで共通のものになります。要するに型に紐付けられたグローバル変数です。template
のときは各実体化で共通のものになります。template
のときは静的メンバ変数の定義もヘッダに書きます。あるいはinline
にしましょう。
// Y.hpp template<typename T> class Y { static int y; }; template<typename T> int Y<T>::y = 0;
静的ローカル変数は、関数内のstatic
変数のことです。関数内のstatic
変数は、見た目は違いますが実際には関数の内部からのみアクセス可能なグローバル変数です。
void f() { static int called = 0; called += 1; return ; }
静的ローカル変数は他のものと異なり、はじめてその宣言の上に制御が渡った際に初期化されます。これは特殊なので少し置いておくことにして、非ローカルな変数、グローバル変数と静的メンバ変数に注目します。
非ローカルなstatic storage durationを持つ変数は、(一部の例外を除いて)main
の実行より前に初期化され、main
の実行後、破棄されます。つまり、ユーザーが何かする際には常に存在しているオブジェクトになるというわけですね。
ですが、C++のクラスにはコンストラクタがあり、コンストラクタの中で何だって出来ます。なので、main
の実行開始よりも前に実行したい処理がある場合、それを実行するコンストラクタを持つクラスをグローバル変数として定義することで、実際にmain
よりも前に実行することが可能になります。これは例えば、グローバルに使いたいログ取り用のオブジェクトの初期化や、シリアライズなどの際に使用する派生クラスの読み込みテーブルの生成などがありえます。
さて、コンストラクタで何でもできるということは、他のグローバル変数や何かのクラスの静的メンバ変数を参照することもできるということですよね。では、以下のような状況を考えてみましょう。
struct X { static inline std::vector<int> xs; }; struct Y { Y() {X::xs.push_back(42);} }; inline Y y;
このy
が初期化される時、X::xs
はどうなっているでしょう? 「初期化」にはコンストラクタ呼び出しが含まれます。つまり、Y::Y()
が呼び出されている瞬間はこの「非ローカルなstatic storage durationを持つ変数の初期化」の真っ最中ということです。X::xs
の初期化(コンストラクタ呼び出し)はY
よりも前に終わっているのでしょうか? もしX::xs
のコンストラクタ呼び出しが終わっていないのにpush_back
を読んだら、何が起きるでしょう?
「(野心的な企てがこっけいな結果で終わるような)大失敗」とはまさにこの初期化の順序に起因するものです。最初にネタバレしてしまっていますが、この順序は大抵不定になってしまいます。そして不定になった結果、あるときは正しく実行され、ある時は謎のSegmentation Faultになる、しかもmain
に入る前なのでログの類も出ない、ということになってしまいます。
初期化の順序はどこまで決まっているのか?
static storage durationを持つ変数の初期化には、静的初期化と動的初期化の二段階があります。どちらも、main
の前に起きます。
静的初期化では、コンパイル時に値が決まる場合はその値が、そうでない場合は0が、変数の占める領域に書き込まれます。そして動的初期化は全ての静的初期化が済んだ後に行われます。つまり、コンストラクタが呼ばれていない場合は変数の占める領域はゼロ埋めされているということですね。
続いて動的初期化の段階でコンストラクタが順次呼ばれ、コンパイル時には決まらない変数の値が設定されていきます。大抵の場合、重要なのはここでのコンストラクタの実行順序でしょう。
簡潔さのため、以下で「普通の変数」を「inline
でもtemplate
でもない変数、もしくは明示的に特殊化されたtemplate変数またはtemplateクラスの静的メンバ変数」とします。
まず、特殊化されていないtemplate
の場合、基本的に全てが不定です。何に対しても、それより先に初期化されるか後に初期化されるかはわかりません。
- templateクラスの静的メンバ変数(明示的に特殊化されている場合を除く)は、他の何に対しても、先に初期化されるかは不定です。
- template変数(明示的に特殊化されている場合を除く)は、他の何に対しても、先に初期化されるかは不定です。
また、普通の変数同士の場合、定義されている翻訳単位が別れているかどうかで決まります。
- 異なる翻訳単位で定義されている普通の変数同士は、どちらが先に初期化されるか不定です。
- 同じ翻訳単位で定義されている普通の変数同士は、定義の順序で初期化されます。
inline
変数同士、またはinline
変数と普通の変数の場合は、条件付きでどちらが先に呼ばれるかが保証されます。
- inline変数同士が、全ての翻訳単位で同じ順序で宣言されている場合、その順序で初期化されます。
- inline変数が、全ての翻訳単位で別の普通の変数よりも前に宣言されている場合、その順序で初期化されます。
inline変数から参照する別のinline変数が同じヘッダに書かれているなら、全ての翻訳単位で同じ順序にできそうですね。
どういう問題が起きる?
不定になりがちであることがわかったので、では静的初期化は完了したものの、コンストラクタはまだ呼ばれていないうちにその変数を触ってしまったときに起きる問題について少し考えてみましょう。あまり考えなくても、響きだけで破滅的なことが起きるだろうことは想像できますが。
コンストラクタが呼ばれていない場合、オブジェクトは正しく初期化されておらず、静的初期化時にゼロ埋めされています。通常、クラスはコンストラクタによって初期化されている前提でメソッドを実装するでしょうから、そのような場合にメンバメソッドを呼び出すと動かないことが予想されます。例えば、GNU実装のstd::map
の中にある赤黒木はコンストラクタで少し根ノードに関連した処理をします。これが行われずゼロ埋めされているままになるわけなので、例えばstd::map
に要素を足そうとするとうまくいかず、Segmentation faultになったりするでしょう。
あるいは、たまたま全てのメンバをゼロ埋めするようなコンストラクタ実装だったとしても、メンバメソッドを呼び出した後に再度コンストラクタが走ってしまうので、データが虚空に消える可能性があります。こちらの方がより悪いかもしれません。入れたはずのデータが無くなるだけで、動きはするわけですからね。
どうやって回避すればいい?
さて、回避方法ですが。
そもそもstatic storage durationを持つような変数をみだりに作らない、できる限り変更しない、というのは前提の話なので置いておきます。重々承知の上で、でも必要だから仕方ない、となるケースはあるわけです。具体例は次節で紹介します。
「普通の変数」の場合の回避策として、inline
変数を一つのヘッダにまとめて置くことで順序を固定する、というのはありえるでしょう。定義が別の翻訳単位にある、ということがあり得ないようにすればいいわけなので、そもそもソースファイルで定義をせず、#include
の順序にも依存しないようにできればいいはずです。
それが保証できそうにない場面もあります。特に、template
変数やtemplate
クラスの静的メンバ変数の場合はもうどうしようもありません。明示的に特殊化するという手もあるかもしれませんが、まあ面倒です。
他の方法は、最初に少し触れた静的ローカル変数を利用することです。まず、静的メンバ変数を静的メンバ関数にしてしまいます。そしてその関数の中で初期化することにします。
template<typename T> struct X { static T& x(); }; template<typename T> T& X::x() { static T x_; return x_; }
静的ローカル変数は最初にそこに制御が渡ったときに初期化されるので、静的非ローカル変数の初期化中だろうがmain
の前だろうが、最初にその関数を呼び出したときに確実に初期化されます。なので、X::x()
によって返ってくる参照の先には、常に正しく初期化された変数があります。
とはいえこれでもまだ少し問題があります。main
の後(std::exit
内)でこれらのstatic storage durationを持つ変数が破棄されます。つまり、デストラクタが呼ばれます。このとき、デストラクタが呼ばれる順序はコンストラクタが呼ばれた順序と逆順になります。つまり、コンストラクタが呼ばれる順序が不定なら、デストラクタが呼ばれる順序もまた実行前にはわからないということです。
上のコードを見てみましょう。このx_
はmain
終了後破棄されます。そして、もし他の変数の(あとで呼ばれる)デストラクタがこのx_
を参照していたら、どうなるでしょう……? デストラクト済みの変数を見ることになりますね。これは当然、問題を引き起こすでしょう。main
の実行は終わってるんだから後は野となれ山となれだ、という人もいるかもしれませんが、未定義動作が起きた場合は真に何が起きるかわからないので、まああまり安心はできませんよね。
そもそもデストラクタの中であまりややこしいことをするべきではないという話はありますが、一応、簡単な解決策としてよく紹介されるのはポインタを使う方法です。
template<typename T> T& X::x() { static T* x_ = new T(); return *x_; }
こうしておけば、変数として保持されているのはあくまでポインタなので、T
のデストラクタは呼ばれません。よってこのオブジェクトは破棄されません。……。おっしゃりたいことはわかりますが、これは本当によく紹介される方法の一つです。まあ普通はOSが使用したメモリを破棄してくれるので、リークはそこまで気にするようなことではないのでしょう。初期化時に動的メモリ確保が発生しますが、最初の一度だけなので実行速度への影響もほぼありませんし。
あるいは、多少のオーバーヘッドがかかってもいいなら、Nifty Counter(あるいはシュワルツカウンタ)と呼ばれる参照カウント式のイディオムによって、初期化と破棄の両方をなんとかすることができます。std::cout
はグローバル変数なので、これを用いていると書かれていますね。私は確認していませんが。
More C++ Idioms/小粋なカウンタ(Nifty Counter) - Wikibooks
あとは、まさにこの問題のために作られたライブラリを使用することでしょうか。以下のライブラリはStatic Initialization Order Fiascoを回避するためのライブラリです。
作者の人はStatic Initialization Order Fiascoの解説もしています。長かったのでまだ見てないので見た人は要約して教えてください。多分この記事より詳しいんじゃないでしょうか。
いつそんなことが問題になる?
そもそもこんなことが問題になるケースは存在するのか? とお思いでしょうから、少し例を考えてみましょう。
真っ先に思いつくのは、ログ取りです。大抵の場合、ログを取るときにいちいち関数にLogger& logger
みたいな引数を足してLogger
を持ち回りたくはないわけで、できればぱぱっとlog::info("x = ", x)
とか書きたいですよね。とはいえ、ログはファイルに書き出したいとか、ログの重要度ごとにフィルタしたいという欲求もあるので、Logger
は状態を持つことが多いでしょう。また、Logger
は常に真っ先に初期化されていないといけないので、あらゆる理由からグローバル変数にしたくなるわけです。
ちょっと実際のコードを見てみましょう。ググったら上の方に出てきたのでspdlogにします。
spdlog::log()
のような関数ですが、実装はこのあたりにあって、なにやら怪しい関数default_logger_raw()
の返り値のメンバメソッドを呼び出していますね。
spdlog/spdlog.h at v1.8.1 · gabime/spdlog · GitHub
template<typename FormatString, typename... Args> inline void log(level::level_enum lvl, const FormatString &fmt, const Args &...args) { default_logger_raw()->log(source_loc{}, lvl, fmt, args...); }
default_logger_raw
はここにあります。どうもregistry::instance()
という関数がlogger
を返しているようです。
spdlog/spdlog-inl.h at v1.8.1 · gabime/spdlog · GitHub
SPDLOG_INLINE spdlog::logger *default_logger_raw()
{
return details::registry::instance().get_default_raw();
}
registry::instance
を見に行ってみると、ありました。静的ローカル変数への参照を返すことによって呼び出したときには確実に初期化されていることを担保するテクニックです。
spdlog/registry-inl.h at v1.8.1 · gabime/spdlog · GitHub
SPDLOG_INLINE registry ®istry::instance() { static registry s_instance; return s_instance; }
というわけで、Logger
はグローバルに持ちたい以上、ライブラリとして任意の状況で使われることを想定するとなると、今回の問題が発生するわけです。
他に思いつくのは、シリアライズをするライブラリでしょうか。デシリアライズする際のAPIは普通、「この型でデシリアライズしてくれ」と指定する形になっていると思います。Foo foo = load<Foo>(archive)
のような形で。
ですが、実際の型がわからないケースもあります。ユーザーから要求されている型は基底クラスへの(スマート)ポインタで、実際には派生クラスを読み込んで返さないといけない、という場合です。このとき、派生クラスの型情報はシリアライズされている文字列などのIDなので、そこからコンストラクタを取得できる表を作っておかなければなりません。std::map<std::string, std::function<Base*(SerializedData&)>
みたいな感じでしょうか。
しかも、ユーザーはmain
の一番最初にチェックポイントファイルからデシリアライズする可能性があるので、main
の前にこの表の構築は済ませておきたいところです。となると、型名からコンストラクタへの表の設定はそもそも、シリアライズ関数を定義する時点でこっそりとグローバル変数の初期化に紛れ込ませるかたちで済ませておかなければならないでしょう。
では少し見てみましょう。ぱっと思い出したのでcerealにします。
cerealでは派生クラスを読み込ませるためには CEREAL_REGISTER_POLYMORPHIC_RELATION(Base, Derived)
というマクロを使わなければなりません。その定義を見ると、RegisterPolymorphicCaster
というものがあります。ちなみにこのbind
自体は別のマクロによって呼び出されるようになっています。
cereal/polymorphic.hpp at v1.3.0 · USCiLab/cereal · GitHub
#define CEREAL_REGISTER_POLYMORPHIC_RELATION(Base, Derived) \ namespace cereal { \ namespace detail { \ template <> \ struct PolymorphicRelation<Base, Derived> \ { static void bind() { RegisterPolymorphicCaster<Base, Derived>::bind(); } }; \ } } /* end namespaces */
RegisterPolymorphicCaster
は実際にテンプレート引数が継承関係にあるかどうかをチェックし、継承関係があればStaticObject
というまんまな名前のオブジェクトのgetInstance()
を呼んでいますね。
cereal/polymorphic_impl.hpp at v1.3.0 · USCiLab/cereal · GitHub
template <class Base, class Derived> struct RegisterPolymorphicCaster { static PolymorphicCaster const * bind( std::true_type /* is_polymorphic<Base> */) { return &StaticObject<PolymorphicVirtualCaster<Base, Derived>>::getInstance(); } // ... };
プログラムの実行前にこのPolymorphicVirtualCaster<Base, Derived>
をユーザーが定義している派生クラスの数だけ登録しておいて、必要なDerived
をそこからルックアップして派生クラスのデシリアライズに使っているわけです。
で、StaticObject
は(少し入り組んでいますが)他と概ね同じことをしています。
cereal/static_object.hpp at v1.3.0 · USCiLab/cereal · GitHub
static T & create() { static T t; //! Forces instantiation at pre-execution time (void)instance; return t; }
長いから読まなかった。三行で?
グローバル変数や静的メンバ変数の初期化・破棄の順序は不定になりがちなので、コンストラクタやデストラクタでお互いに参照してると確率で死んだり死ななかったりします。回避方法はいくつかあるので本文を参照してください。