プログラミング
C++26リフレクションによる美しい型消去
Beautiful Type Erasure with C++26 Reflection (ryanjk5.github.io)
要約
C++26のリフレクション機能を利用した新しい型消去ライブラリ「rjk::duck」を紹介します。このライブラリは、従来の型消去実装に伴う冗長なコードや複雑さを解消し、パフォーマンスを維持しながら柔軟な型消去を実現します。C++26のリフレクション、タグ生成、vtableコード生成などの仕組みを解説しています。
全文翻訳
C++26リフレクションによる美しい型消去
C++26リフレクションによる美しい型消去
std::anyやstd::functionよりも複雑な型消去を試みたことがあるなら、100行以上の間違いやすいコードを書いたか、Boost.TypeErasureやFolly.Polyのようなボイラープレートの多いライブラリに頼ったかのどちらかでしょう。rjk::duckは、C++26リフレクションの魔法を利用して、これらの問題点を解消しつつ、すべてのカスタマイズ性とパフォーマンスを維持します。
以下の基本的な例を考えてみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
#include <rjk/duck.hpp>
// ...
struct [[=rjk::trait]] Container {
auto size() const -> std::size_t;
auto empty() const -> bool;
auto clear() -> void;
};
rjk::duck<Container> c{std::vector<int>{1, 2, 3}};
c.size(); // 3
c = std::string{"hello"}; // 実行時に基盤となる型をスワップ
c.size(); // 5
c = std::map<int, int>{{1, 2}, {3, 4}};
c.empty(); // false
c.clear();
c.empty(); // true
インターフェースを一度宣言するだけで、すでに書かれた定義が残りの処理を行います。
ライブラリ自体は単一ヘッダーインクルードです。所有セマンティクスと非所有セマンティクス、演算子、インターフェース合成、既存インターフェースのアダプター、サードパーティ型のエクステンションメソッドなどを提供します。
これは最先端技術の話なので、現時点ではgccで-std=c++26 -freflectionを使用した場合のみサポートされています。duckは、単純なenumからstringへの変換やJSONシリアライゼーションの例よりも高度な方法でリフレクションを使用するため、この記事の残りの部分では、このライブラリを可能にするトリックを解明していきます。
特に、タグ生成、vtableコード生成、オーバーロード解決、そしてduckを小さく保つ相互変換トリックについて説明します。
C++26リフレクションの簡単な紹介
上記の例で奇妙な構文 [[=rjk::trait]] に気づいたかもしれません。これはC++26のアノテーションで、属性のように構造体に適用できます。traitの実際の定義は単純です。
1
constexpr inline struct{} trait{};
型がtraitアノテーションを持っているかどうかは、次のようにチェックできます。
1 2 3 4 5
return std::ranges::any_of(annotations_of(^^MyType), [](std::meta::info annotation) {
return type_of(annotation) == type_of(^^trait);
} );
^^演算子は何かをリフレクトします。この場合、型(MyType)と変数(trait)の両方をリフレクトしています。リフレクションはすべてstd::meta::info型を持ち、annotations_ofやtype_ofのような様々なメタ関数でクエリできます。
duckのプロセスの最初のステップの1つは、traitのメンバーを解釈し、duckが内部で使用するフォーマットであるタグに変換することです。このtraitの場合:
1 2 3 4
struct [[=rjk::trait]] MyTrait {
auto foo() -> void;
auto bar() const -> int;
};
目標は、has_fn<"foo", auto() -> void> と has_fn<"bar", auto() const -> int> というタグを生成することです。これは、MyTraitのメンバーを検査し、次のように変換することで pretty easily に実装できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
consteval auto members_to_tags(std::meta::info trait) -> std::vector<std::meta::info> {
const auto ctx = std::meta::access_context::unprivileged(); // 公開メンバーのみをチェック
return members_of(trait, ctx)
// traitのすべてのメンバーを取得
| std::views::filter(std::meta::is_user_declared) // コンストラクタなどを除外
| std::views::filter(std::meta::is_function) // データメンバーを除外
| std::views::filter(std::meta::has_identifier) // オペレータ関数などを除外
| std::views::transform([](std::meta::info member) {
// identifier_of は名前を文字列ビューとして返します。fixed_string は
// テンプレート引数として使用できるカスタム構造体型です。
const fixed_string name{identifier_of(member)};
const auto signature = type_of(member); // 関数型を返します
// has_fn<name, signature> を作成
const auto tag = substitute(^^has_fn, {reflect_constant(name), signature});
return tag;
})
| std::ranges::to<std::vector>();
}
実際のインプリメンテーションでは、traitの基底クラスの反復処理、const traitの処理、演算子など、他の多くの側面を処理する必要があります。しかし、コアの変換は見た目と同じくらい単純です。
vtableの生成
C++26で得られたコード生成メカニズムは限定的ですが、非常に強力です。vtableがどのように生成されるかの各コンポーネントを、構造体の作成から始めましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
template <is_trait... Traits>
struct vtable_generator {
struct vtable;
// ...
consteval {
std::vector<std::meta::info> members{ /* typeid, copy, move, destroy... */ };
constexpr static std::array<std::meta::info, sizeof...(Traits)> traits{^^Traits...};
template for (constexpr auto trait : traits) {
for (const auto tag : members_to_tags(trait)) {
const auto args = template_arguments_of(tag);
const auto name = extract<fixed_string>(args[0]); // 関数のcvref修飾子を削除
const auto func_type = remove_fn_qualifiers(args[1]);
const auto signature = add_pointer(prepend_arg(func_type, ^^void*));
const auto member = data_member_spec(signature, {.name = name});
members.push_back(member);
}
}
define_aggregate(^^vtable, members);
}
};
constevalブロックは新しい機能であり、現在コードを生成できる唯一のコンテキストであり、define_aggregateを使用します。template forは新しい展開ステートメントの構文であり、fold式に頼ることなくパラメータパックを反復処理できます。
それ以外は、コードは pretty straightforward です。すべてのtraitを収集し、それぞれのタグを収集し、traitの各メンバー関数に対して関数ポインタを生成します。
認識すべき簡略化の1つは、このアプローチではオーバーロードを処理できないことです。なぜなら、同じ名前のメンバーを2つ持つことはできないからです。実際のコードでは、slot_0、slot_1などの名前を割り当て、後で再導出します。また、ここではプレーンなvoid*を使用していますが、実際には関数の修飾子に基づいてconst void*も必要になります。
型に対する静的なvtableの作成も同様にそれほど複雑ではありません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// vtable_generator の内側
template <typename T>
consteval static auto make_vtable() -> vtable {
constexpr static auto ctx = std::meta::access_context::unprivileged();
constexpr static auto slots = define_static_array(
nonstatic_data_members_of(^^vtable, ctx)
| std::views::drop(/* copy, move, etc. */)
);
vtable result{};
template for (constexpr auto index : std::views::indices(slots.size())) {
constexpr auto T_member = *std::ranges::find_if(
members_of(^^T, ctx),
[](std::meta::info member) {
return is_function(member) && has_identifier(member) &&
identifier_of(member) == identifier_of(slots[index]) &&
type_of(member) == type_of(slots[index]);
}
);
result.[: slots[index] :] = convert_to_vtable_func<T>(T_member);
}
return result;
}
template <typename T>
constexpr static auto vtable_for = make_vtable<T>();
実際のオーバーロード解決(T_memberを見つけるため)は、はるかに複雑です(下記参照)。ここでは明確さのために正確なシグネチャで一致させています。
スプライス演算子 ([: expr :]) は別の新しい機能であり、リフレクションの世界から現実に戻ってコードをもたらします。ここでは、生成されたvtableに追加された各関数ポインタに実際に割り当てるために使用しています。std::views::indicesは、私たちが得たもう1つの便利なライブラリユーティリティであり、std::views::iota(0, upper_bound)に相当します。
残された唯一の未解決の糸はconvert_to_vtable_funcです。何らかの方法で、T_memberを、格納して呼び出すことができる実際のauto(*)(void*, Args...) -> Retに変換する必要があります。その変換がduckの型消去が実際に行われる場所なので、適切に分解する価値があります。
スロットから呼び出しへ
その核心において、これはどの型消去ライブラリでも使用されるのと同じトリックです。
1 2 3 4 5 6 7
template <typename T, typename Invoker, typename Ret, typename... Args>
struct vtable_fn_maker {
constexpr static auto eras