[C++]Boost.PFRの実装の解説のような。

動機

Boost 1.75.0からPFRが追加された。通常の構造体のメンバ変数に対してあたかもstd::tupleであるかのようにget<N>でアクセスできる、という謎機能ライブラリである。
このようなライブラリの存在は以前から知ってはいたのだが、白状すると、その機能を知った時は疑問符が大量に噴出した。実装方法が全く想像できなかったのである。私はC++のプロでもなんでもない学生なので分からなくたって別に何も問題ないのだが、気になって仕方がなかったのでソースコードを読んでみることにした。諸事情あってBoostを使えない私でも、根本的構造を理解すれば自力で実装することもできるはずだ。

以下、C++17を想定している1。また以下で用いる"メンバ変数"は非staticなものを意味する。

解説

思いの外シンプルで、思いの外力技だった。よくもまあこんな実装方法を思いついたものだと感心するような、呆れるような。

前提として、PFRで扱うことのできるクラスは集成体初期化が可能なものに限る。privateなメンバを持っていたり、デフォルトでないユーザー定義のコンストラクタを持っていたりすると扱えない。当然である。
今、Xというクラスのメンバ変数へget<N>でアクセスしたいとしよう。非常に簡潔な動作説明としては、

  1. Xのメンバ変数の数を数える。
  2. 変数の数ごとに特殊化(みたいなことを)した関数の中で、構造化束縛を使って全メンバへの参照を取得する。
  3. tupleに纏める。
  4. get<N>でtupleのN番目の変数を取得する。

という流れである。

複雑なのは1.のメンバ変数の数を調べる部分だ。これをどう実装しているのかというと、PFR内部ではこれが配列か(a)、デフォルト集成体初期化2が可能か(b)、不可能か(c)で分岐するのだが、(a)の場合はそもそも配列の要素数を調べるだけなので一瞬で終わるし、(b)と(c)は本質的にはテンプレート再帰回数の削減を意図した必ずしも必要でない呼び分けであるため、ここでは(b)と(c)の場合に絞り、特に分岐を考えずに解説する。

  1. とりあえずメンバ変数の数は最大でも(ビットフィールドを想定しメンバ変数1個が最小で1ビットの大きさを持つと考え)max_fields_count = sizeof(X) * CHAR_BITと考えよう。今、ある整数Nについて、N=max_fields_countとする。
  2. a[i]をテンプレート付きキャスト演算子を持つクラスのオブジェクトとする3X{ a[1], a[2], ..., a[N] }とN個の引数を与える集成体初期化を呼び出せない場合、メンバの数はN個未満である。その場合、--Nとして再度集成体初期化を試みる(PFR本来の実装では、デフォルト集成体初期化が可能な場合には再帰回数を減らすために二分探索するなど、もう一工夫している)。
  3. これを繰り返していけば、いずれ集成体初期化が可能なNの最大値が分かるので、それがメンバ変数の数である。

以上の振る舞いを、正確さや効率よりも単純さを優先して実装すると、次のようになる。

#include <string>
#include <iostream>

template <size_t, bool>
struct Castable
{
    template <class Type>
    operator Type& () const&& noexcept;
};
template <size_t S>
struct Castable<S, false>
{
    template <class Type>
    operator Type&& () const&& noexcept;
};

template <class Type, size_t ...Indices>
constexpr auto IsAggregateInitializable(std::index_sequence<Indices...>)
    -> decltype(Type{ Castable<Indices, std::is_copy_constructible<Type>::value>()... }, std::true_type())
{
    return std::true_type();
}
template <class Type>
constexpr std::false_type IsAggregateInitializable(...) { return std::false_type(); }

template <class Type, size_t N>
constexpr std::enable_if_t<IsAggregateInitializable<Type>(std::make_index_sequence<N>()), size_t>
GetNumOfMemberVariables_rec(int)
{
    return N;
}
template <class Type, size_t N>
constexpr auto GetNumOfMemberVariables_rec(long)
{
    return GetNumOfMemberVariables_rec<Type, N - 1>(1);
}
template <class Type>
constexpr size_t GetNumOfMemberVariables()
{
    return GetNumOfMemberVariables_rec<Type, sizeof(Type) * CHAR_BIT>(1);
}

template <class Type>
auto BindToTuple(Type& t, std::integral_constant<size_t, 1>)
{
    auto& [a] = t;
    return std::tie(a);
}
template <class Type>
auto BindToTuple(Type& t, std::integral_constant<size_t, 2>)
{
    auto& [a, b] = t;
    return std::tie(a, b);
}
template <class Type>
auto BindToTuple(Type& t, std::integral_constant<size_t, 3>)
{
    auto& [a, b, c] = t;
    return std::tie(a, b, c);
}
template <class Type>
auto BindToTuple(Type& t, std::integral_constant<size_t, 4>)
{
    auto& [a, b, c, d] = t;
    return std::tie(a, b, c, d);
}

template <size_t N, class Type>
decltype(auto) Get(Type& t)
{
    return std::get<N>(BindToTuple(t, std::integral_constant<size_t, GetNumOfMemberVariables<Type>()>()));
}

struct Test
{
    int x;
    std::string y;
};
int main()
{
    Test test{ 1, "2.34" };
    constexpr size_t Size = GetNumOfMemberVariables<Test>();
    std::cout << "The number of Member variables is " << Size << std::endl;//2
    std::cout << Get<0>(test) << std::endl;//1
    std::cout << Get<1>(test) << std::endl;//2.34

    return 0;
}

上ではBindToTupleはメンバ変数が1~4個のものまでしか定義していないが、Boost.PFRでは100個のものまでが全部書き下されていた。これを力技と言わずなんと言うのか。

補足だが、上ではデフォルト集成体初期化の可否に関係なく動作するようN=max_field_countから減らしていく方向で実装したけれども、この方法は構造体の大きさがある程度大きくなるとコンパイルエラーになる可能性がある。テンプレートの再帰回数上限は典型的には1024回程度だが、例えば構造体のメンバの大きさが全て8バイトだとすると、メンバ変数が19個以上になるあたりでこの上限を超えてしまう。デフォルト集成体初期化可否によって少し処理を調整する必要があるけれども、N=0から増やしていくか、PFRのように二分探索するのが良いだろう。
こんなマニアックすぎる記事を読みに来るような玄人な方々にそのあたりのコード例の提示は流石に必要あるまいし、省略させてもらう。

メンバ変数名を使わずとも静的にメンバへアクセスできる機能は画期的ではある。ただ今ひとつ活用する機会を思いつかない。コンパイル時にメンバの名前を変更したりできれば、なんて何年も前に考えていたことはあるし、その部分的な代替機能にはなるのだろうが、テンプレートとマクロに慣れきってしまってメンバ名を介したポリモーフィズムが体に染み付いており、今となってはこれを必要とする状況がまず生じない。
とはいえ、知識があればいつか役に立つ可能性はあるので、とりあえず備忘録として記事にしておく。


  1. C++14の実装もあるが、読むのが面倒なので止めた。

  2. X{}のように、引数を一切与えない集成体初期化。Xの全てのメンバ変数がデフォルト初期化可能である場合、デフォルト集成体初期化も可能になる。逆に言えば、デフォルトコンストラクタを持たないメンバ変数、参照型メンバ変数などを持ってしまうとこれが不可能になる。

  3. template <class Type> operator Type&() const && noexcept、またはtemplate <class Type> operator Type&&() const && noexceptという演算子オーバーロードを持つようなクラス。任意の型にキャストすることができるので、集成体初期化の判定のための引数として利用できる。