C言語のライブラリにたまにある、最初にグローバル状態を~init()
で確保して、それを最後に~release()
で解放するというインターフェースについて、C++から使うときはRAIIで自動化したい衝動に駆られます。そのとき問題となるのは、プログラムの最初で初期化したらあとはプログラム終了時まで誰にも触ってほしくない、という気持ちをどうやって実現するのか、という事です。
グローバルRAIIラッパー
単純にRAIIに従った、次のようなクラスを考えることができます。
#include <iostream> // グローバル状態初期化/解放関数とする void init() { std::cout << "init()\n"; } void release() { std::cout << "release()\n"; } // グローバルRAIIラッパ struct global_raii_t { global_raii_t() { init(); } ~global_raii_t() { release(); } }; // RAII実態 inline const global_raii_t raii_obj{}; int main() { std::cout << "main()\n"; }
これの問題点は、別の人がいくらでも新しいオブジェクトを作って勝手に確保/解放処理を走らせることができる点です。後コピーも普通にできてしまいます。
型を隠そう!
デフォルトコンストラクタを隠したりコピーを防ぐこともできますが、簡単には型名を知られなければいいので型を隠す方向で行ってみます。よく知られた型隠蔽手段としてローカルクラスがあります。
// RAII実態 inline const auto raii_obj = [] { // グローバルRAIIラッパ struct global_raii_t { global_raii_t() { init(); } ~global_raii_t() { release(); } }; return global_raii_t{}; }();
これで一見型名に触れられなくなりました。ただこれもまたよく知られているように、C++にはdecltype()
があります。
int main() { using C = std::decay_t<decltype(::raii_obj)>; C c1{}; // ok C c2 = ::raii_obj; // ok }
初期化処理を分ける+コピー禁止
RAII型を関数内部に移したので初期化処理はそこでやることにしましょう。すると、構築された時でも初期化処理が走らなくなります。同時にコピー/ムーブを禁止してしまいます。
// RAII実態 inline const auto raii_obj = [] { init(); // グローバルRAIIラッパ struct global_raii_t { global_raii_t() = default; global_raii_t(const global_raii_t&) = delete; global_raii_t& operator=(const global_raii_t&) = delete; ~global_raii_t() { release(); } }; return global_raii_t{}; }();
こうすれば、型名を取られて初期化されてもinit()
は走らないしコピーとかもできません(ムーブコンストラクタ/代入演算子は、宣言がなく対応するコピーコンストラクタ/代入演算子が宣言されている場合に暗黙delete
されています)。
int main() { using C = std::decay_t<decltype(::raii_obj)>; C c = ::raii_obj; // ng }
しかしお忘れではないですか?デストラクタのことを・・・
int main() { using C = std::decay_t<decltype(::raii_obj)>; C c{}; // ok }
後から構築を禁止する
もはやグローバルraii_obj
は動かないので、デストラクタが勝手に走るのを抑止するには新しく構築されるのを防止すればいいわけです。じゃあデフォルトコンストラクタを削除しよう!となりますが、しかし最初の一度だけは構築可能である必要があります。そこで、コンストラクタになんか引数を与えて構築するようにします。
// RAII実態 inline const auto raii_obj = [] { init(); // グローバルRAIIラッパ struct global_raii_t { global_raii_t(int){} global_raii_t(const global_raii_t&) = delete; global_raii_t& operator=(const global_raii_t&) = delete; ~global_raii_t() { release(); } }; return global_raii_t{1}; }();
こうすれば単純には構築できなくなりますが、必要な引数がint
であることはコードを見ればわかります。ここでも型名が隠蔽された型が必要です。
// RAII実態 inline const auto raii_obj = [] { init(); struct tag {}; // グローバルRAIIラッパ struct global_raii_t { global_raii_t(tag){} global_raii_t(const global_raii_t&) = delete; global_raii_t& operator=(const global_raii_t&) = delete; ~global_raii_t() { release(); } }; return global_raii_t{tag{}}; }();
こうするとtag
型を外から取得して構築する手段がなくなるので、これでglobal_raii_t
型を外から構築する手段はなくなりました。やった!
int main() { std::cout << "main()\n"; using C = std::decay_t<decltype(::raii_obj)>; C c{{}}; // ok! }
これは、tag
型が集成体であるため{}
による集成体初期化が可能であることによって起こっています。これを防ぐにはtag
型が非集成体であればいいわけです。その方法はいくつかありますが、一番簡単なのはexplicit
コンストラクタを追加することです。
// RAII実態 inline const auto raii_obj = [] { init(); struct tag { explicit tag() = default; }; // グローバルRAIIラッパ struct global_raii_t { global_raii_t(tag){} global_raii_t(const global_raii_t&) = delete; global_raii_t& operator=(const global_raii_t&) = delete; ~global_raii_t() { release(); } }; return global_raii_t{tag{}}; }();
こうすれば先ほどのように{}
を用いた構築はできなくなります。そして、もはやこのglobal_raii_t
型を外から構築する手段はありません。
int main() { std::cout << "main()\n"; using C = std::decay_t<decltype(::raii_obj)>; C c{{}}; // ng }
なお、これはC++17以降から有効です。C++14以前ではexplicit
デフォルトコンストラクタがあってもクラスは集成体となり得ます。C++14以前で集成体でなくするには、仮想関数を何か定義しておくか継承しておくのが楽かと思います。
いやまあ、正直ここまでやる必要性はあまり感じられません。ローカルクラスで包むだけでもいいと思いますし、何なら最初の素のRAIIラッパで十分な気がします・・・
さらなる懸念
まあ何とここからデストラクタを呼び出すことができてしまうんですね・・・
int main() { std::cout << "main()\n"; std::destroy_at(&raii_obj); // oK }
この場合std::destroy_at
の実行以降は未定義動作の世界です。しかしこれはちょっと防止する方法が思いつきません。