[C++] std::anyが動的メモリ確保を行うとき、行わないとき(msvc)。

std::anyとは

std::anyはどのような型のオブジェクトでも格納することが出来、デストラクタも勝手に呼んでくれるなかなか便利な機能である。C++17で導入されたType Erasureの一つだ。
この機能は一体どうやって実現しているのだろうか。規格に沿っているかどうかは知らないが、パフォーマンスを気にしないのであれば、オブジェクトのホルダーを基底クラスと派生クラスに分離することで非常にシンプルに作成できる。コード例はちょっとググれば簡単に見つかるので省略する。もし貴方がC++初級者で、クラスや仮想関数、テンプレートの仕組みをそれなりに知っていて、ちょっと不思議なテクニックに興味を持ち始めているのなら、とても良い教材なので是非勉強してみてほしい。

では実際のstd::anyはこれを使っているのか?否である。少なくともmsvcにおいては否である。C++規格なりcppreferenceなりを参照すれば記載されているが、std::anyは格納されるインスタンスの大きさが十分に小さい場合、動的メモリ確保を避けることが推奨されている。これを実現するために、もう少し込み入った設計になっているのだ。

本記事にはこの動作の分岐が何を基準に行われるのかを書き留めておく。ただし、ここで述べるのは64bitかつmsvcの場合である。gccやclangの場合はいちいちコード探して読むのが面倒くさいので今の所書く気はない。

解説

まず最初に、std::anyについて重要なことを話しておこう。
sizeof(std::any)の値は何だろう?もし上述のHolder基底クラスと仮想関数を使ったイディオムなら、64bit環境の場合、最小で8バイトあればよい。
しかし実は、64bitのmsvcの場合、sizeof(std::any)==64である。このうち8バイトは、格納されるオブジェクトの型情報(typeidとか)を保存するために使われる。残り56バイトがオブジェクト格納庫である。

msvcのstd::anyは、格納するオブジェクトを3種類に分類している。

  1. Trivial Object
  2. Small Object
  3. Big Object

このうち、Trivial ObjectとSmall Objectがstd::anyの内部スペースに構築されるオブジェクトで、Big Objectが動的メモリ確保を伴うオブジェクトである。これらはどのような基準で分類されるのだろうか。

1. Trivial Object

Trivial Objectとは、大きさが56バイト以下で、trivially copyableなオブジェクトのことである1。trivially copyableとは、ざっくりいうと、ユーザー定義のコピー、ムーブコンストラクタや代入演算子、デストラクタがない、そして非静的メンバや基底クラスもすべてtrivially copyableであるような、C言語の構造体のようにシンプルなオブジェクトだ2。trivially copyableの意味は規格によってやや異なっているし、詳しいことは別の解説に任せることにする。

std::anyの実装についてtrivially copyableであることの重要な意味は、デストラクタを呼ぶ必要がないかつ無造作にstd::memcpyしても問題ないという点である。
もし格納するオブジェクトがTrivial Objectなら、std::anyの動作はシンプルだ。56バイト以下ということはstd::anyそれ自体が持つスペースにすっぽりと収まってしまうから動的メモリ確保は不要だし、特殊メンバ関数が必要になるのは最初にコンストラクタを呼ぶときだけで、コピー、ムーブしたいときはmemcpy一つで済ませられるしデストラクタを呼ぶ必要もないから、実行時型情報なんて失ってしまっても問題ない。楽なものである。

2. Small Object

Small Objectであるためには、56-sizeof(void*)==48バイト以下で、nothrow move constructibleで、かつTrivial Objectでないことが条件だ。Small Objectはユーザー定義のコピー、ムーブコンストラクタ、代入演算子、デストラクタなどを持つから、std::anyは適宜それらを呼び出さなければならない。そのためにstd::anyは56バイトのうちポインタ1個分のスペースに、コピー、ムーブコンストラクタ、デストラクタを呼ぶための関数情報を格納している。とはいえ48バイト以下であるからstd::anyの余剰スペースに収まる大きさで、動的メモリ確保は起きない。

3. Big Object

Trivial、Smallいずれの条件も満たさなければ、いよいよBig Objectと扱われる。Big Objectはコピーするときはコピーコンストラクタを呼ばなければならないし3、デストラクタも必要だし、48バイトを超えてしまうので4動的メモリ確保が必要になる。そして48バイトのうち動的に確保したメモリへのポインタを除く40バイト分は決して使われないデッドスペースとなる。

まとめ

さて、ざっくりと動作を整理しよう。

  1. 56バイト以下でtrivially copyableなら、オブジェクトはstd::anyに動的メモリ確保を伴わず格納され、コピーやムーブはmemcpyのみ、デストラクタもいちいち呼ばれない。
  2. 48バイト以下で例外を投げずムーブできるのなら、オブジェクトはstd::anyに動的メモリ確保を伴わず格納され、コピーやムーブ、デストラクタは関数ポインタ経由で適宜呼ばれる。
  3. どちらの条件も満たさないのなら、オブジェクトはstd::anyに動的メモリ確保を伴って格納される。コピー、デストラクタは関数ポインタ経由で呼ばれる。

このように動作が分岐している。

最後に

どうして64バイトなんて無駄遣いするんだよ!と私は叫んだ。私にとってstd::anyは到底使い物にならない機能と化した。ちなみにgccは16バイト、clangは32バイトだった。動的メモリ確保を避けるように実装するのなら最小設計はgccの16バイト(オブジェクト格納庫として8バイト、各種関数ポインタ保存用に8バイト)だと思われる。
std::anyがテンプレート引数で大きさを指定できるようになっていたら良かったのに。いいよもう、自分で作るから。どうせ今までだって自作してたんだから。


  1. 厳密なことを言うと、TrivialまたはSmallに分類されるためには格納するオブジェクトのアライメントがalignof(max_align_t)以下でなければならないのだが、max_align_tよりも大きなアライメントを与える状況が私の貧弱な知識では想像できなかったので省略した。

  2. C言語の構造体、というのは厳密に言えば異なる。trivially copyableであるとしても、ユーザー定義のコンストラクタを持っていたり、仮想関数が定義されていたりする可能性はあるのだ。C言語の構造体と同等のものについて知りたければtrivial classやPODについて調べてみよう。

  3. なおムーブコンストラクタは呼ばれない。動的確保したポインタを移し替えれば済むからである。

  4. 正確には、ムーブ時に例外が投げられるようなクラスの場合はSmall Objectとして扱えないので、48バイト以下であってもBig Objectになる。