構造体のフィールド名を取り出す field-reflection: C++20 の黒魔術から C++26 の静的リフレクションへ
はじめに
「field-reflection」は、1 つのヘッダファイルで構成された C++ ライブラリです。構造体のデータメンバー (以下、フィールド) の名前と型や、列挙子の名前を、コンパイル時に取り出します。これを使うと、構造体を JSON に書き出すような処理を、型ごとに書かずに 1 つの関数で書けます。2024 年 2 月に C++20 向けに公開し、このたび C++26 の静的リフレクションに対応して v1.0.0 としてリリースしました。
このライブラリは、私が個人で開発している JSON ライブラリ「cpp-yyjson」のために作りました。cpp-yyjson は、C の JSON ライブラリ yyjson を C++20 から扱えるようにしたものです。JSON と構造体を DOM (JSON の木構造) を経由せずに直接変換するには、フィールドの名前と型をコンパイル時に知る仕組みが必要です。この仕組みを独立させて、field-reflection として公開しました。私たち株式会社 Fixstars Amplify の組合せ最適化ソルバー「Fixstars Amplify Annealing Engine (以下 Amplify AE)」も、リクエストの JSON の解析に cpp-yyjson を使っています。
本記事では、静的リフレクションで何ができるかを確認し、field-reflection の使い方を紹介したあと、その実装を 2 通り解説します。先に、標準の静的リフレクションを使った素直な C++26 版を見ます。次に、言語機能を組み合わせて同じことを実現する C++20 版の、いわゆる黒魔術の仕組みを見ます。最後に、2 つの実装の違いと、cpp-yyjson と Amplify AE での利用を紹介します。
静的リフレクションとは
構造体を JSON に書き出す関数を考えます。C++23 までの標準には構造体のメンバーを列挙する手段がないので、型ごとに手で書くことになります。
enum class role { member, admin };
struct account
{
std::string name;
role access;
};
// 型ごとに手で書く
void write_json(std::ostream& os, const account& a)
{
os << "{\"name\":\"" << a.name << "\",\"access\":\""
<< (a.access == role::admin ? "admin" : "member") << "\"}";
}
この書き方では、メンバーを追加するたびに関数も直す必要があります。メンバーの名前と列挙子の名前も、文字列として書き直すことになります。マクロで型を登録して、このような関数を生成させるライブラリもありますが、登録の手間と更新漏れの危険は残ります。
プログラムが自身の型の情報を得る仕組みをリフレクションと呼びます。そのうち、型の情報をコンパイル時に得るものを静的リフレクションと呼びます。構造体のフィールドの数、型、名前と、列挙子の名前をコンパイル時に得られれば、上の関数を、任意の構造体を受け取る 1 つのテンプレートとして書けます。field-reflection を使うと、次のようになります。
#include <field_reflection.hpp>
using namespace field_reflection;
template <typename T>
void write_json(std::ostream& os, const T& value)
{
if constexpr (std::is_enum_v<T>)
os << '"' << enum_name(value) << '"';
else if constexpr (std::is_arithmetic_v<T>)
os << value;
else if constexpr (std::is_convertible_v<const T&, std::string_view>)
os << '"' << std::string_view(value) << '"';
else if constexpr (field_namable<T>)
{
os << '{';
const char* separator = "";
for_each_field(value, [&](std::string_view name, const auto& field) {
os << std::exchange(separator, ",") << '"' << name << "\":";
write_json(os, field);
});
os << '}';
}
}
struct team
{
std::string name;
account leader;
int size;
};
// {"name":"core","leader":{"name":"Alice","access":"admin"},"size":3}
write_json(std::cout, team{"core", {"Alice", role::admin}, 3});
for_each_field は、構造体の各フィールドの名前と値を引数にしてラムダ式を呼び出します。enum_name は列挙子の名前を返します。数値、文字列、列挙型、構造体のメンバーを追加しても、write_json を直す必要はありません。入れ子の構造体も再帰で書き出せます (真偽値、文字型、コンテナの扱いと、文字列のエスケープは省略しています)。フィールドの名前と並びはコンパイル時に決まるので、for_each_field を -O2 で最適化すると、メンバーに直接アクセスした場合と同じ命令になります。x86-64 Linux の GCC 15、Clang 22、GCC 16 で実際に比べたところ、独立した命令の順序を除いて一致しました (README)。
field-reflection の特徴
C++26 では静的リフレクションが標準に入りましたが、C++20 と C++26 で同じ API を使えることと、構造体と列挙型に対する操作を短く簡単に書けることが、field-reflection の利点です。
field-reflection には C++20 版と C++26 版の 2 つの実装があり、どちらも同じ API で呼び出せます。そのため、使う C++ の規格に合わせてコードを書き換える必要はありません。C++20 版は言語機能を組み合わせた黒魔術で、C++26 版は標準の静的リフレクションで実装しています。黒魔術と呼ぶのは、規格が書式を定めていない関数名の文字列から名前を取り出すからです。コンパイラが静的リフレクションに対応すれば、コードを変えずに扱える型が増えます。
C++26 の静的リフレクションの仕組みは汎用なので、フィールドを列挙するだけでも、後の節で示すようにいくつもの機能を組み合わせることになります。field-reflection では、構造体と列挙型でよく使う操作を、簡単な API にまとめました。C++26 で入ったアノテーションも、アノテーションの型を指定するだけで読み出せます。
構造体のフィールドを扱うライブラリには Boost.PFR が、列挙子の名前を扱うライブラリには magic_enum や enchantum があります。field-reflection は、構造体と列挙型の両方を 1 つのヘッダで扱えるように作りました。冒頭の write_json のように、構造体のフィールドをたどる関数の中で、列挙型のフィールドも列挙子の名前で書き出せます。
field-reflection の使い方
field-reflection は依存のない単一のヘッダファイルです。include/field_reflection.hpp をコピーするか、CMake のターゲット field_reflection::field_reflection をリンクして使います。
#include <field_reflection.hpp>
#include <iostream>
#include <string>
#include <string_view>
using namespace field_reflection;
struct user
{
int id = 42;
std::string name = "Alice";
double score = 3.14;
};
enum class color { red, green, blue };
static_assert(field_count<user> == 3);
static_assert(field_name<user, 1> == "name");
static_assert(enum_name(color::green) == "green");
static_assert(enum_cast<color>("blue") == color::blue);
int main()
{
std::cout << "C++26 backend: " << FIELD_RFL_STD_REFLECTION << '\n';
auto u = user{};
get_field<0>(u) = 7; // u.id = 7
// id: 7
// name: Alice
// score: 3.14
for_each_field(u, [](std::string_view name, const auto& value) {
std::cout << name << ": " << value << '\n';
});
}
このコードは Compiler Explorer で試せます。GCC 16 の -std=c++20 と -std=c++26 -freflection で実行し、どちらの実装が選ばれたかを FIELD_RFL_STD_REFLECTION で表示します。Compiler Explorer では、ヘッダを GitHub の URL で #include しています。
主な API は次のとおりです。どれも定数式の中で使えます。
field_count<T>:フィールドの数
field_name<T, N>:N 番目のフィールドの名前
field_type<T, N>:N 番目のフィールドの型
get_field<N>(t):N 番目のフィールドへの参照
for_each_field(t, f):各フィールドに f を適用する。2 つのオブジェクトの同じフィールドどうしに適用する版もある
enum_name(e):列挙子の名前
enum_cast<E>(s):名前から列挙子への変換
対象にする型は、既定では集成体 (aggregate) です。集成体とは、ユーザ宣言のコンストラクタ、仮想関数、private や protected の非静的データメンバーを持たない構造体やクラスのことです。型がこれらの操作に対応しているかは、field_namable<T> などのコンセプトで判定できます。冒頭の write_json のように、if constexpr で処理を分けられます。
コンパイラが静的リフレクションに対応していれば、C++26 版を自動で選ぶようにしています。
コンパイラ
C++20 版
C++26 版
GCC
11 以降
16 以降 (-std=c++26 -freflection)
Clang
15 以降
-
MSVC
19.37 (Visual Studio 2022 17.7) 以降
-
MSVC では /Zc:preprocessor が必要です (CMake のターゲットを使えば自動で付きます)。Clang で libc++ を使うときは、libc++ 16 以降が必要です (README)。
標準の静的リフレクションによる C++26 版の実装
C++26 では、静的リフレクション (P2996) が標準に入りました。field-reflection の C++26 版の中心部分は、次のように 30 行ほどで書けます。
#include <meta>
#include <string_view>
#include <type_traits>
// 型 T の非静的データメンバーのリフレクションを、静的な配列に格納する
template <typename T>
constexpr auto members = std::define_static_array(
std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::current()));
template <typename T>
constexpr std::size_t field_count = members<T>.size();
template <typename T, std::size_t N>
constexpr std::string_view field_name = std::meta::identifier_of(members<T>[N]);
template <std::size_t N, typename T>
constexpr auto& get_field(T& t)
{
return t.[:members<std::remove_cv_t<T>>[N]:];
}
template <typename T, typename F>
constexpr void for_each_field(T& t, F f)
{
template for (constexpr auto m : members<std::remove_cv_t<T>>)
{
f(std::meta::identifier_of(m), t.[:m:]);
}
}
template <typename E>
constexpr std::string_view enum_name(E value)
{
template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E)))
{
if (value == [:e:]) return std::meta::identifier_of(e);
}
return {};
}
このコードも Compiler Explorer で試せます。
^^T はリフレクション演算子で、型 T を表す std::meta::info の値を返します。std::meta::info は、型やメンバーのようなプログラムの要素を値として表す型です。その中身は、<meta> ヘッダの consteval 関数 (メタ関数) で調べます。
nonstatic_data_members_of は、非静的データメンバーのリフレクションを std::vector<std::meta::info> で返します。コンパイル時に確保したメモリはコンパイル時に解放しなければならないので、std::vector のままでは constexpr 変数に保存できません。そこで、std::define_static_array (P3491) で静的な配列に格納し、std::span として保存します。nonstatic_data_members_of の 2 つ目の引数 access_context::current() は、呼び出した場所から見たアクセス権を表し、ここでは public のメンバーだけが返ります。
メンバーの名前は identifier_of が返します。[:m:] はスプライスと呼ばれ、リフレクションをコードに戻します。m がメンバー id を表すなら、t.[:m:] は t.id と同じ意味です。template for は展開文 (P1306) で、ループをコンパイル時に展開します。各回の m は別々の定数なので、型の違うフィールドを同じループで扱えます。
余談: ^^ 演算子とスプライス
リフレクション演算子とスプライスを組み合わせると、ソースコードに顔文字のようなものが現れます。
#include <meta>
#include <iostream>
int ω = 42;
consteval bool ヽ(std::meta::info r) { return std::meta::is_variable(r); }
consteval bool Σ(std::meta::info r) { return std::meta::is_type(r); }
int main()
{
static_assert(ヽ(^^ω));
static_assert(!Σ(^^ω));
std::cout << [:^^ω:]; // ω と同じ
}
ω、ヽ、Σ は、どれも識別子に使える文字です。残念ながら (^^;) は書けません。
field-reflection の C++26 版では、上のコードに次の処理を加えました。
基底クラスのメンバーを再帰的に集め、基底クラスのフィールドを先に並べる
オプションを指定すると、参照を取れないビットフィールドを値として読み書きする
アノテーション (P3394) を読む
アノテーションの読み出し
アノテーションは、宣言に定数を付けておき、リフレクションで読み出す仕組みです。静的リフレクションと同じく、C++26 で標準に入りました。Rust の serde の #[serde(rename = "...")] や Go の構造体タグ (json:"id") と同じように、宣言に添えた情報で処理を変えられます。field-reflection では、フィールド、型、列挙子に付いたアノテーションを、アノテーションの型を指定して読み出せます。
struct alias_name
{
const char* name;
consteval alias_name(std::string_view n) : name(std::define_static_string(n)) {}
};
struct [[= alias_name("User")]] user
{
[[= alias_name("ID")]] int id;
std::string name;
};
static_assert(std::string_view(field_annotation<user, 0, alias_name>->name) == "ID");
static_assert(!field_annotation<user, 1, alias_name>.has_value());
static_assert(std::string_view(type_annotation<user, alias_name>->name) == "User");
アノテーションの値は構造的型 (structural type) でなければなりません。そのため、上の例では文字列を std::define_static_string で静的な文字列にし、const char* で持たせています。
黒魔術による C++20 版の実装
C++20 には <meta> がありません。それでも、言語の機能をいくつか組み合わせると、フィールドの数、型、名前をコンパイル時に得て、値にもアクセスできます。名前の取り出しは規格が書式を定めていない文字列に頼るので、黒魔術と呼ばれることがあります。ここでは、名前の取り出しを中心に仕組みを説明します。
なお、ここで説明する技法は、C++ のコミュニティで見いだされ、改良されてきたものです。field-reflection を作るにあたっても、visit_struct と reflect-cpp から強く影響を受けました。visit_struct をマクロなしで置き換えるものとして、また reflect-cpp のリフレクションの一部を再実装したものとして作りました。フィールドの数の数え方は、Boost.PFR の実装を一部参考にしました。
フィールドの数と値
名前を取り出す準備として、フィールドの数と、各フィールドへのポインタが必要です。
フィールドの数は、集成体初期化で数えます。集成体は T{a, b, c} のように各フィールドを先頭から順に初期化できるので、任意の型に変換できる値を並べて T{any{}, any{}, ...} がコンパイルできる最大の個数を探せば、それがフィールドの数です。requires 式は式がコンパイルできるかを判定するだけなので、変換演算子に定義は要りません。
// 任意の型に変換できる型
struct any
{
template <typename T>
operator T() const;
};
// T{any, any, ...} が通る最大の個数を数える
template <typename T, typename... Args>
consteval std::size_t field_count()
{
if constexpr (requires { T{Args{}..., any{}}; })
return field_count<T, Args..., any>();
else
return sizeof...(Args);
}
実際のライブラリでは、フィールドが別の集成体や配列のときに中括弧が省略される場合など、多くの特殊な場合に対応しています。
各フィールドへのポインタは、構造化束縛で取り出します。構造化束縛は識別子の数をフィールドの数に一致させる必要があるので、フィールドの数ごとに書き分けます。field-reflection ではマクロで 100 個まで生成しており、この数が C++20 版のフィールド数の上限です。
template <typename T>
constexpr auto to_ptr_tuple(T& t)
{
constexpr auto n = field_count<T>();
if constexpr (n == 1)
{
auto& [a] = t;
return std::tuple(&a);
}
else if constexpr (n == 2)
{
auto& [a, b] = t;
return std::tuple(&a, &b);
}
else if constexpr (n == 3)
{
auto& [a, b, c] = t;
return std::tuple(&a, &b, &c);
}
// ... ライブラリは 100 個まで用意している
}
値と型はこのポインタから得られますが、名前は得られません。構造化束縛の識別子 a、b、c は、こちらで付けた名前だからです。
static オブジェクトのメンバーを指すポインタ
フィールドの名前は、関数テンプレートの名前の文字列から取り出します。std::source_location::current().function_name() は、それを呼んだ関数の名前を返します。GCC、Clang、MSVC は、関数テンプレートの名前にテンプレート引数を含めます。
template <auto V>
consteval std::string_view function_name()
{
return std::source_location::current().function_name();
}
この V に、あるオブジェクトのフィールドを指すポインタを渡すと、関数名にフィールドの名前が現れます。ただし、テンプレート引数にはアドレスがコンパイル時に決まるポインタしか渡せず、型 T のオブジェクトをコンパイル時に作れるとも限りません。そこで、static メンバーとして宣言だけしたオブジェクトを使います。
template <typename T>
struct wrapper
{
T value;
static wrapper<T> fake; // 宣言だけで定義しない
};
// user の 2 番目のフィールド name を指すポインタを渡す
function_name<std::get<1>(to_ptr_tuple(wrapper<user>::fake.value))>();
fake は定義していませんが、アドレスを取るだけで値を読まないので、コンパイル時の計算は通ります。実行時のコードからも参照されないので、リンクも通ります (Clang は -Wundefined-var-template の警告を出します)。ただし規格の上では、アドレスを取ることも変数の odr-use にあたるので、定義の無いままのプログラムは ill-formed (診断不要) です。
ここで渡しているポインタは、オブジェクト fake そのものではなく、そのメンバー name (部分オブジェクト) を指しています。C++17 までは、部分オブジェクトへのポインタをテンプレート引数に渡せませんでした。GCC 15 で -std=c++17 を指定すると、次のエラーになります。
error: '& wrapper<user>::fake.wrapper<user>::value.user::name' is not a valid template argument of type 'std::__cxx11::basic_string<char>*' because 'wrapper<user>::fake.wrapper<user>::value.user::name' is not a variable or function
C++20 では P1907R1 がこの制限を外したので、上のコードがコンパイルできます。field-reflection が C++20 以降を必要とする理由の 1 つがこれです。
関数名からのフィールド名の切り出し
上の function_name が返す文字列は、コンパイラごとに次のように違います。
GCC 15: consteval std::string_view function_name() [with auto V = (& wrapper<user>::fake.wrapper<user>::value.user::name); std::string_view = std::basic_string_view<char>]
Clang 22: std::string_view function_name() [V = &fake.value.name]
MSVC: class std::basic_string_view<char,struct std::char_traits<char> > __cdecl function_name<&fake->value->name>(void)
どのコンパイラでも、文字列の中にフィールド名 name が現れています。この文字列の書式は処理系定義です。実際、名前の後ろに続く文字列 (GCC では ); std::string_view = std::basic_string_view<char>]、Clang では ]、MSVC では >(void)) も、名前の直前の文字 (GCC では :、Clang では .、MSVC では >) も、コンパイラごとに違います。
field-reflection では、名前が分かっているフィールドへのポインタを先に function_name に渡す方法を選びました。その関数名から、名前の後ろに続く文字列と名前の直前の文字を調べておきます。こうすれば、コンパイラごとに書き分ける必要がありません。
struct detector
{
void* dummy;
};
template <typename T, std::size_t N>
consteval std::string_view field_name()
{
// 名前が既知のフィールド dummy で、名前の後ろに続く文字列と、名前の直前の文字を調べる
constexpr auto probe = function_name<std::get<0>(to_ptr_tuple(wrapper<detector>::fake.value))>();
constexpr auto pos = probe.rfind("dummy");
constexpr auto suffix = probe.substr(pos + std::string_view("dummy").size());
constexpr auto separator = probe[pos - 1];
// N 番目のフィールドを指すポインタをテンプレート引数に渡し、関数名から名前を切り出す
constexpr auto s = function_name<std::get<N>(to_ptr_tuple(wrapper<T>::fake.value))>();
const auto last = s.rfind(suffix);
auto first = last;
while (s[first - 1] != separator) --first;
return s.substr(first, last - first);
}
static_assert(field_name<user, 0>() == "id");
static_assert(field_name<user, 1>() == "name");
static_assert(field_name<user, 2>() == "score");
user のフィールドを渡したときも同じ関数テンプレートなので、名前の後ろには同じ文字列 suffix が続き、名前の直前には同じ文字 separator があります。Clang の場合は次のとおりです。
std::string_view function_name() [V = &fake.value.name]
|^^^^|
| +-- suffix
+------- separator
suffix の手前から separator の次の文字までを切り出せば、フィールド名になります。この方法なら、コンパイラが関数名にテンプレート引数を含める限り、書式が違っても同じコードで名前を取り出せます1。
ここまでのコードをまとめました。
C++20 版の名前の取り出し (全体)#include <cstddef>
#include <iostream>
#include <source_location>
#include <string>
#include <string_view>
#include <tuple>
// 任意の型に変換できる型
struct any
{
template <typename T>
operator T() const;
};
// T{any, any, ...} が通る最大の個数を数える
template <typename T, typename... Args>
consteval std::size_t field_count()
{
if constexpr (requires { T{Args{}..., any{}}; })
return field_count<T, Args..., any>();
else
return sizeof...(Args);
}
// 構造化束縛で各フィールドへのポインタを取り出す
template <typename T>
constexpr auto to_ptr_tuple(T& t)
{
constexpr auto n = field_count<T>();
if constexpr (n == 1)
{
auto& [a] = t;
return std::tuple(&a);
}
else if constexpr (n == 2)
{
auto& [a, b] = t;
return std::tuple(&a, &b);
}
else if constexpr (n == 3)
{
auto& [a, b, c] = t;
return std::tuple(&a, &b, &c);
}
// ... ライブラリは 100 個まで用意している
}
template <typename T>
struct wrapper
{
T value;
static wrapper<T> fake; // 宣言だけで定義しない
};
template <auto V>
consteval std::string_view function_name()
{
return std::source_location::current().function_name();
}
struct detector
{
void* dummy;
};
template <typename T, std::size_t N>
consteval std::string_view field_name()
{
// 名前が既知のフィールド dummy で、名前の後ろに続く文字列と、名前の直前の文字を調べる
constexpr auto probe = function_name<std::get<0>(to_ptr_tuple(wrapper<detector>::fake.value))>();
constexpr auto pos = probe.rfind("dummy");
constexpr auto suffix = probe.substr(pos + std::string_view("dummy").size());
constexpr auto separator = probe[pos - 1];
// N 番目のフィールドを指すポインタをテンプレート引数に渡し、関数名から名前を切り出す
constexpr auto s = function_name<std::get<N>(to_ptr_tuple(wrapper<T>::fake.value))>();
const auto last = s.rfind(suffix);
auto first = last;
while (s[first - 1] != separator) --first;
return s.substr(first, last - first);
}
struct user
{
int id;
std::string name;
double score;
};
static_assert(field_count<user>() == 3);
static_assert(field_name<user, 0>() == "id");
static_assert(field_name<user, 1>() == "name");
static_assert(field_name<user, 2>() == "score");
enum class color { red, green, blue };
int main()
{
// 関数名の文字列を表示する
std::cout << function_name<std::get<1>(to_ptr_tuple(wrapper<user>::fake.value))>() << '\n';
std::cout << function_name<color::green>() << '\n';
std::cout << function_name<static_cast<color>(5)>() << '\n';
}
Compiler Explorer で試す (GCC 16、Clang 22、MSVC)
列挙子の名前
列挙子の名前も同じ仕組みで取り出せます。function_name に列挙型の値を渡すと、列挙子のある値なら列挙子の名前が、列挙子のない値なら数値が関数名に現れます。
GCC 15: consteval std::string_view function_name() [with auto V = color::green; std::string_view = std::basic_string_view<char>]
consteval std::string_view function_name() [with auto V = (color)5; std::string_view = std::basic_string_view<char>]
Clang 22: std::string_view function_name() [V = color::green]
std::string_view function_name() [V = (color)5]
MSVC: class std::basic_string_view<char,struct std::char_traits<char> > __cdecl function_name<color::green>(void)
class std::basic_string_view<char,struct std::char_traits<char> > __cdecl function_name<(enum color)0x5>(void)
C++20 には、列挙型がどの値の列挙子を持つかを知る手段がありません。そのため、field-reflection の C++20 版では、既定で [-128, 128] のすべての値を試し、名前が現れた値を列挙子として集めています。範囲は enum_range を特殊化して変えられますが、広げるほどコンパイル時間が延びます。実際の実装では、多数の値を 1 つの関数テンプレートのテンプレート引数にまとめて渡し、その関数名を一度に解析して、コンパイル時間を短くしています。
C++20 版と C++26 版の比較
C++20 版で扱える型は C++26 版でも扱え、フィールドの数、型、名前も同じ結果になります (後で述べる基底クラスの例と、フレキシブル配列メンバーのようなコンパイラの拡張を除きます)。違いは、C++26 版のほうが扱える型と列挙子が多いことです。そのため、if constexpr (field_namable<T>) のように分岐するコードでは、実装によって選ばれる分岐が変わることがあります。また、1 つのプログラムのすべての翻訳単位を、同じフラグでコンパイルしてください。違うフラグが混ざると ODR 違反になります。主な違いを次の表にまとめます。
C++20 版
C++26 版
フィールド数の上限
100
なし
基底クラスを持つ集成体
対象外
基底クラスのフィールドを先に並べて扱う
参照型のフィールドの名前
取れない
取れる
範囲外の値を持つ列挙子
名前を取れない
名前を取れる
同じ値を持つ別名の列挙子での enum_cast
std::nullopt
値を返す
ビットフィールド
数だけ
オプションで値と名前も
非集成体
対象外
オプションで対象にできる
アノテーション
なし
あり
結果の正確さにも違いがあります。C++20 版では、型の情報を集成体初期化と関数名の文字列から推測しています。例えば、先頭の基底クラスが任意の型の引数から構築できる場合、C++20 版はそのクラスを基底クラスのないクラスと区別できません。そのため、フィールドの数、型、名前が C++26 版と一致しないことがあります。C++26 版では、コンパイラが持つ型の情報をメタ関数で直接読むので、このような推測が要りません。
コンパイル時間も比べました。x86-64 Linux で、型を 1 つ増やしたときに増えるコンパイル時間 (-c -O0) を測ったところ、大きな差はありませんでした (README)。8 個のフィールドを持つ構造体 1 つあたりでは、C++20 版が 19–23 ms (GCC 15、Clang 22)、C++26 版が 20 ms (GCC 16) でした。10 個の列挙子を持つ列挙型 1 つあたりでは、C++20 版の 14 ms に対して、C++26 版は 9 ms でした。
非集成体の扱い
C++26 版では、reflect_options を渡すと非集成体も対象にできます。
class point
{
int x_ = 0, y_ = 0;
public:
point(int x, int y) : x_(x), y_(y) {}
};
constexpr auto opts = reflect_options{.non_aggregate = true, .non_public = true};
static_assert(!field_namable<point>);
static_assert(field_namable<point, opts>);
static_assert(field_name<point, 0, opts> == "x_");
auto p = point{1, 2};
for_each_field<opts>(p, [](std::string_view name, auto& value) { /* ... */ });
non_aggregate は非集成体を、non_public は private と protected のメンバーを対象に加えます。non_public は private や protected のメンバーにも外からアクセスできるようにするので、メンバーを書き換えるとクラスの不変条件を破るおそれがあります。オプションは C++26 版でだけ有効にしました。C++20 版はオプションを無視するので、どちらの実装でもオプションを渡すコードはコンパイルできます。ただし、オプションで加えた型は C++20 版では対象になりません。
既定で集成体だけを対象にしたのは、集成体ならフィールドの並びがそのまま値を表すからです。集成体はフィールドの並びから直接初期化できます。一方、非集成体では、値の意味をコンストラクタが決めます。例えば struct meters { double value; meters(double v); }; は数値から構築するための型で、{"value": ...} という JSON から構築するための型ではありません。また、ラムダ式や std::allocator<T> のように、データを表さない非集成体も多くあります。これらを空の構造体として扱わないように、非集成体はオプションで明示したときだけ対象にしました。
応用: cpp-yyjson
冒頭で紹介した cpp-yyjson では、field-reflection を使って JSON と C++ の型を直接変換しています。
// JSON から冒頭の team 型へ
auto t = yyjson::read<team>(R"({"name":"core","leader":{"name":"Alice","access":"admin"},"size":3})");
// 構造体から JSON へ: {"name":"core","leader":{"name":"Alice","access":"admin"},"size":3}
auto json = yyjson::write(t);
上の例では、JSON のキーと構造体のフィールド名を field-reflection で対応させ、DOM を経由せずに JSON のテキストから構造体を組み立てます。列挙型は列挙子の名前で読み書きするので、冒頭で書いた write_json は yyjson::write の 1 行で済みます。
Amplify AE は C++ と CUDA で実装されており、リクエストの JSON を cpp-yyjson で C++ の型に変換しています。リクエストを受け取ってからレスポンスを返すまでのオーバーヘッドを、できるだけ減らす必要があるからです。cpp-yyjson の設計とベンチマークの詳細は、別の記事で紹介する予定です。
おわりに
本記事では、field-reflection の使い方と、その 2 つの実装を紹介しました。C++20 版では、集成体初期化、構造化束縛、部分オブジェクトへのポインタのテンプレート引数、関数名の文字列を組み合わせて、フィールドの名前を取り出しています。C++26 版では、標準の静的リフレクションで同じ API を実装しました。
シリアライザやロガー、テスト用のユーティリティのように、さまざまな構造体や列挙型を受け取る汎用のコードに、ぜひ field-reflection をお試しください。使い方の例は Compiler Explorer ですぐに動かせます。
C++26 では、静的リフレクションとアノテーションが標準に入りました。field-reflection は、C++20 と同じ API で使えるだけでなく、これらの新しい機能を手軽に使うためのライブラリでもあります。C++26 に対応したコンパイラを使い始めるときにも、field-reflection をぜひ役立ててください。
脚注
実際のライブラリでは、切り出した名前を別の静的な文字列にコピーしています。関数名の文字列全体がプログラムのバイナリに残らないようにするためです。 ↩︎
10 likes
Mentions
構造体のフィールド名を取り出す field-reflection: C++20 の黒魔術から C++26 の静的リフレクションへ はじめに 「field-reflection」は、1 つのヘッダファイルで構成された C++ ライブラリです。構造体のデータメンバー (以下、フィールド) の名前と型や、列挙子の名前を、コンパイル時に取り出します。これを使うと、構造体を JSON に書き出すような処理を、型ごとに書かずに 1 つの関数で書けます。2024 年 2 月に C++20 向けに公開し、このたび C++26 の静的リフレクションに対応して v1.0.0 としてリリースしました。 このライブラリは、私が個人で開発している JSON ライブラリ「cpp-yyjson」のために作りました。cpp-yyjson は、C の JSON ライブラリ yyjson を C++20 から扱えるようにしたものです。JSON と構造体を DOM (JSON の木構造) を経由せずに直接変換するには、フィールドの名前と型をコンパイル時に知る仕組みが必要です。この仕組みを独立させて、field-reflection として公開しました。私たち株式会社 Fixstars Amplify の組合せ最適化ソルバー「Fixstars Amplify Annealing Engine (以下 Amplify AE)」も、リクエストの JSON の解析に cpp-yyjson を使っています。 本記事では、静的リフレクションで何ができるかを確認し、field-reflection の使い方を紹介したあと、その実装を 2 通り解説します。先に、標準の静的リフレクションを使った素直な C++26 版を見ます。次に、言語機能を組み合わせて同じことを実現する C++20 版の、いわゆる黒魔術の仕組みを見ます。最後に、2 つの実装の違いと、cpp-yyjson と Amplify AE での利用を紹介します。 静的リフレクションとは 構造体を JSON に書き出す関数を考えます。C++23 までの標準には構造体のメンバーを列挙する手段がないので、型ごとに手で書くことになります。 enum class role { member, admin }; struct account { std::string name; role access; }; // 型ごとに手で書く void write_json(std::ostream& os, const account& a) { os << "{\"name\":\"" << a.name << "\",\"access\":\"" << (a.access == role::admin ? "admin" : "member") << "\"}"; } この書き方では、メンバーを追加するたびに関数も直す必要があります。メンバーの名前と列挙子の名前も、文字列として書き直すことになります。マクロで型を登録して、このような関数を生成させるライブラリもありますが、登録の手間と更新漏れの危険は残ります。 プログラムが自身の型の情報を得る仕組みをリフレクションと呼びます。そのうち、型の情報をコンパイル時に得るものを静的リフレクションと呼びます。構造体のフィールドの数、型、名前と、列挙子の名前をコンパイル時に得られれば、上の関数を、任意の構造体を受け取る 1 つのテンプレートとして書けます。field-reflection を使うと、次のようになります。 #include <field_reflection.hpp> using namespace field_reflection; template <typename T> void write_json(std::ostream& os, const T& value) { if constexpr (std::is_enum_v<T>) os << '"' << enum_name(value) << '"'; else if constexpr (std::is_arithmetic_v<T>) os << value; else if constexpr (std::is_convertible_v<const T&, std::string_view>) os << '"' << std::string_view(value) << '"'; else if constexpr (field_namable<T>) { os << '{'; const char* separator = ""; for_each_field(value, [&](std::string_view name, const auto& field) { os << std::exchange(separator, ",") << '"' << name << "\":"; write_json(os, field); }); os << '}'; } } struct team { std::string name; account leader; int size; }; // {"name":"core","leader":{"name":"Alice","access":"admin"},"size":3} write_json(std::cout, team{"core", {"Alice", role::admin}, 3}); for_each_field は、構造体の各フィールドの名前と値を引数にしてラムダ式を呼び出します。enum_name は列挙子の名前を返します。数値、文字列、列挙型、構造体のメンバーを追加しても、write_json を直す必要はありません。入れ子の構造体も再帰で書き出せます (真偽値、文字型、コンテナの扱いと、文字列のエスケープは省略しています)。フィールドの名前と並びはコンパイル時に決まるので、for_each_field を -O2 で最適化すると、メンバーに直接アクセスした場合と同じ命令になります。x86-64 Linux の GCC 15、Clang 22、GCC 16 で実際に比べたところ、独立した命令の順序を除いて一致しました (README)。 field-reflection の特徴 C++26 では静的リフレクションが標準に入りましたが、C++20 と C++26 で同じ API を使えることと、構造体と列挙型に対する操作を短く簡単に書けることが、field-reflection の利点です。 field-reflection には C++20 版と C++26 版の 2 つの実装があり、どちらも同じ API で呼び出せます。そのため、使う C++ の規格に合わせてコードを書き換える必要はありません。C++20 版は言語機能を組み合わせた黒魔術で、C++26 版は標準の静的リフレクションで実装しています。黒魔術と呼ぶのは、規格が書式を定めていない関数名の文字列から名前を取り出すからです。コンパイラが静的リフレクションに対応すれば、コードを変えずに扱える型が増えます。 C++26 の静的リフレクションの仕組みは汎用なので、フィールドを列挙するだけでも、後の節で示すようにいくつもの機能を組み合わせることになります。field-reflection では、構造体と列挙型でよく使う操作を、簡単な API にまとめました。C++26 で入ったアノテーションも、アノテーションの型を指定するだけで読み出せます。 構造体のフィールドを扱うライブラリには Boost.PFR が、列挙子の名前を扱うライブラリには magic_enum や enchantum があります。field-reflection は、構造体と列挙型の両方を 1 つのヘッダで扱えるように作りました。冒頭の write_json のように、構造体のフィールドをたどる関数の中で、列挙型のフィールドも列挙子の名前で書き出せます。 field-reflection の使い方 field-reflection は依存のない単一のヘッダファイルです。include/field_reflection.hpp をコピーするか、CMake のターゲット field_reflection::field_reflection をリンクして使います。 #include <field_reflection.hpp> #include <iostream> #include <string> #include <string_view> using namespace field_reflection; struct user { int id = 42; std::string name = "Alice"; double score = 3.14; }; enum class color { red, green, blue }; static_assert(field_count<user> == 3); static_assert(field_name<user, 1> == "name"); static_assert(enum_name(color::green) == "green"); static_assert(enum_cast<color>("blue") == color::blue); int main() { std::cout << "C++26 backend: " << FIELD_RFL_STD_REFLECTION << '\n'; auto u = user{}; get_field<0>(u) = 7; // u.id = 7 // id: 7 // name: Alice // score: 3.14 for_each_field(u, [](std::string_view name, const auto& value) { std::cout << name << ": " << value << '\n'; }); } このコードは Compiler Explorer で試せます。GCC 16 の -std=c++20 と -std=c++26 -freflection で実行し、どちらの実装が選ばれたかを FIELD_RFL_STD_REFLECTION で表示します。Compiler Explorer では、ヘッダを GitHub の URL で #include しています。 主な API は次のとおりです。どれも定数式の中で使えます。 field_count<T>:フィールドの数 field_name<T, N>:N 番目のフィールドの名前 field_type<T, N>:N 番目のフィールドの型 get_field<N>(t):N 番目のフィールドへの参照 for_each_field(t, f):各フィールドに f を適用する。2 つのオブジェクトの同じフィールドどうしに適用する版もある enum_name(e):列挙子の名前 enum_cast<E>(s):名前から列挙子への変換 対象にする型は、既定では集成体 (aggregate) です。集成体とは、ユーザ宣言のコンストラクタ、仮想関数、private や protected の非静的データメンバーを持たない構造体やクラスのことです。型がこれらの操作に対応しているかは、field_namable<T> などのコンセプトで判定できます。冒頭の write_json のように、if constexpr で処理を分けられます。 コンパイラが静的リフレクションに対応していれば、C++26 版を自動で選ぶようにしています。 コンパイラ C++20 版 C++26 版 GCC 11 以降 16 以降 (-std=c++26 -freflection) Clang 15 以降 - MSVC 19.37 (Visual Studio 2022 17.7) 以降 - MSVC では /Zc:preprocessor が必要です (CMake のターゲットを使えば自動で付きます)。Clang で libc++ を使うときは、libc++ 16 以降が必要です (README)。 標準の静的リフレクションによる C++26 版の実装 C++26 では、静的リフレクション (P2996) が標準に入りました。field-reflection の C++26 版の中心部分は、次のように 30 行ほどで書けます。 #include <meta> #include <string_view> #include <type_traits> // 型 T の非静的データメンバーのリフレクションを、静的な配列に格納する template <typename T> constexpr auto members = std::define_static_array( std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::current())); template <typename T> constexpr std::size_t field_count = members<T>.size(); template <typename T, std::size_t N> constexpr std::string_view field_name = std::meta::identifier_of(members<T>[N]); template <std::size_t N, typename T> constexpr auto& get_field(T& t) { return t.[:members<std::remove_cv_t<T>>[N]:]; } template <typename T, typename F> constexpr void for_each_field(T& t, F f) { template for (constexpr auto m : members<std::remove_cv_t<T>>) { f(std::meta::identifier_of(m), t.[:m:]); } } template <typename E> constexpr std::string_view enum_name(E value) { template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E))) { if (value == [:e:]) return std::meta::identifier_of(e); } return {}; } このコードも Compiler Explorer で試せます。 ^^T はリフレクション演算子で、型 T を表す std::meta::info の値を返します。std::meta::info は、型やメンバーのようなプログラムの要素を値として表す型です。その中身は、<meta> ヘッダの consteval 関数 (メタ関数) で調べます。 nonstatic_data_members_of は、非静的データメンバーのリフレクションを std::vector<std::meta::info> で返します。コンパイル時に確保したメモリはコンパイル時に解放しなければならないので、std::vector のままでは constexpr 変数に保存できません。そこで、std::define_static_array (P3491) で静的な配列に格納し、std::span として保存します。nonstatic_data_members_of の 2 つ目の引数 access_context::current() は、呼び出した場所から見たアクセス権を表し、ここでは public のメンバーだけが返ります。 メンバーの名前は identifier_of が返します。[:m:] はスプライスと呼ばれ、リフレクションをコードに戻します。m がメンバー id を表すなら、t.[:m:] は t.id と同じ意味です。template for は展開文 (P1306) で、ループをコンパイル時に展開します。各回の m は別々の定数なので、型の違うフィールドを同じループで扱えます。 余談: ^^ 演算子とスプライス リフレクション演算子とスプライスを組み合わせると、ソースコードに顔文字のようなものが現れます。 #include <meta> #include <iostream> int ω = 42; consteval bool ヽ(std::meta::info r) { return std::meta::is_variable(r); } consteval bool Σ(std::meta::info r) { return std::meta::is_type(r); } int main() { static_assert(ヽ(^^ω)); static_assert(!Σ(^^ω)); std::cout << [:^^ω:]; // ω と同じ } ω、ヽ、Σ は、どれも識別子に使える文字です。残念ながら (^^;) は書けません。 field-reflection の C++26 版では、上のコードに次の処理を加えました。 基底クラスのメンバーを再帰的に集め、基底クラスのフィールドを先に並べる オプションを指定すると、参照を取れないビットフィールドを値として読み書きする アノテーション (P3394) を読む アノテーションの読み出し アノテーションは、宣言に定数を付けておき、リフレクションで読み出す仕組みです。静的リフレクションと同じく、C++26 で標準に入りました。Rust の serde の #[serde(rename = "...")] や Go の構造体タグ (json:"id") と同じように、宣言に添えた情報で処理を変えられます。field-reflection では、フィールド、型、列挙子に付いたアノテーションを、アノテーションの型を指定して読み出せます。 struct alias_name { const char* name; consteval alias_name(std::string_view n) : name(std::define_static_string(n)) {} }; struct [[= alias_name("User")]] user { [[= alias_name("ID")]] int id; std::string name; }; static_assert(std::string_view(field_annotation<user, 0, alias_name>->name) == "ID"); static_assert(!field_annotation<user, 1, alias_name>.has_value()); static_assert(std::string_view(type_annotation<user, alias_name>->name) == "User"); アノテーションの値は構造的型 (structural type) でなければなりません。そのため、上の例では文字列を std::define_static_string で静的な文字列にし、const char* で持たせています。 黒魔術による C++20 版の実装 C++20 には <meta> がありません。それでも、言語の機能をいくつか組み合わせると、フィールドの数、型、名前をコンパイル時に得て、値にもアクセスできます。名前の取り出しは規格が書式を定めていない文字列に頼るので、黒魔術と呼ばれることがあります。ここでは、名前の取り出しを中心に仕組みを説明します。 なお、ここで説明する技法は、C++ のコミュニティで見いだされ、改良されてきたものです。field-reflection を作るにあたっても、visit_struct と reflect-cpp から強く影響を受けました。visit_struct をマクロなしで置き換えるものとして、また reflect-cpp のリフレクションの一部を再実装したものとして作りました。フィールドの数の数え方は、Boost.PFR の実装を一部参考にしました。 フィールドの数と値 名前を取り出す準備として、フィールドの数と、各フィールドへのポインタが必要です。 フィールドの数は、集成体初期化で数えます。集成体は T{a, b, c} のように各フィールドを先頭から順に初期化できるので、任意の型に変換できる値を並べて T{any{}, any{}, ...} がコンパイルできる最大の個数を探せば、それがフィールドの数です。requires 式は式がコンパイルできるかを判定するだけなので、変換演算子に定義は要りません。 // 任意の型に変換できる型 struct any { template <typename T> operator T() const; }; // T{any, any, ...} が通る最大の個数を数える template <typename T, typename... Args> consteval std::size_t field_count() { if constexpr (requires { T{Args{}..., any{}}; }) return field_count<T, Args..., any>(); else return sizeof...(Args); } 実際のライブラリでは、フィールドが別の集成体や配列のときに中括弧が省略される場合など、多くの特殊な場合に対応しています。 各フィールドへのポインタは、構造化束縛で取り出します。構造化束縛は識別子の数をフィールドの数に一致させる必要があるので、フィールドの数ごとに書き分けます。field-reflection ではマクロで 100 個まで生成しており、この数が C++20 版のフィールド数の上限です。 template <typename T> constexpr auto to_ptr_tuple(T& t) { constexpr auto n = field_count<T>(); if constexpr (n == 1) { auto& [a] = t; return std::tuple(&a); } else if constexpr (n == 2) { auto& [a, b] = t; return std::tuple(&a, &b); } else if constexpr (n == 3) { auto& [a, b, c] = t; return std::tuple(&a, &b, &c); } // ... ライブラリは 100 個まで用意している } 値と型はこのポインタから得られますが、名前は得られません。構造化束縛の識別子 a、b、c は、こちらで付けた名前だからです。 static オブジェクトのメンバーを指すポインタ フィールドの名前は、関数テンプレートの名前の文字列から取り出します。std::source_location::current().function_name() は、それを呼んだ関数の名前を返します。GCC、Clang、MSVC は、関数テンプレートの名前にテンプレート引数を含めます。 template <auto V> consteval std::string_view function_name() { return std::source_location::current().function_name(); } この V に、あるオブジェクトのフィールドを指すポインタを渡すと、関数名にフィールドの名前が現れます。ただし、テンプレート引数にはアドレスがコンパイル時に決まるポインタしか渡せず、型 T のオブジェクトをコンパイル時に作れるとも限りません。そこで、static メンバーとして宣言だけしたオブジェクトを使います。 template <typename T> struct wrapper { T value; static wrapper<T> fake; // 宣言だけで定義しない }; // user の 2 番目のフィールド name を指すポインタを渡す function_name<std::get<1>(to_ptr_tuple(wrapper<user>::fake.value))>(); fake は定義していませんが、アドレスを取るだけで値を読まないので、コンパイル時の計算は通ります。実行時のコードからも参照されないので、リンクも通ります (Clang は -Wundefined-var-template の警告を出します)。ただし規格の上では、アドレスを取ることも変数の odr-use にあたるので、定義の無いままのプログラムは ill-formed (診断不要) です。 ここで渡しているポインタは、オブジェクト fake そのものではなく、そのメンバー name (部分オブジェクト) を指しています。C++17 までは、部分オブジェクトへのポインタをテンプレート引数に渡せませんでした。GCC 15 で -std=c++17 を指定すると、次のエラーになります。 error: '& wrapper<user>::fake.wrapper<user>::value.user::name' is not a valid template argument of type 'std::__cxx11::basic_string<char>*' because 'wrapper<user>::fake.wrapper<user>::value.user::name' is not a variable or function C++20 では P1907R1 がこの制限を外したので、上のコードがコンパイルできます。field-reflection が C++20 以降を必要とする理由の 1 つがこれです。 関数名からのフィールド名の切り出し 上の function_name が返す文字列は、コンパイラごとに次のように違います。 GCC 15: consteval std::string_view function_name() [with auto V = (& wrapper<user>::fake.wrapper<user>::value.user::name); std::string_view = std::basic_string_view<char>] Clang 22: std::string_view function_name() [V = &fake.value.name] MSVC: class std::basic_string_view<char,struct std::char_traits<char> > __cdecl function_name<&fake->value->name>(void) どのコンパイラでも、文字列の中にフィールド名 name が現れています。この文字列の書式は処理系定義です。実際、名前の後ろに続く文字列 (GCC では ); std::string_view = std::basic_string_view<char>]、Clang では ]、MSVC では >(void)) も、名前の直前の文字 (GCC では :、Clang では .、MSVC では >) も、コンパイラごとに違います。 field-reflection では、名前が分かっているフィールドへのポインタを先に function_name に渡す方法を選びました。その関数名から、名前の後ろに続く文字列と名前の直前の文字を調べておきます。こうすれば、コンパイラごとに書き分ける必要がありません。 struct detector { void* dummy; }; template <typename T, std::size_t N> consteval std::string_view field_name() { // 名前が既知のフィールド dummy で、名前の後ろに続く文字列と、名前の直前の文字を調べる constexpr auto probe = function_name<std::get<0>(to_ptr_tuple(wrapper<detector>::fake.value))>(); constexpr auto pos = probe.rfind("dummy"); constexpr auto suffix = probe.substr(pos + std::string_view("dummy").size()); constexpr auto separator = probe[pos - 1]; // N 番目のフィールドを指すポインタをテンプレート引数に渡し、関数名から名前を切り出す constexpr auto s = function_name<std::get<N>(to_ptr_tuple(wrapper<T>::fake.value))>(); const auto last = s.rfind(suffix); auto first = last; while (s[first - 1] != separator) --first; return s.substr(first, last - first); } static_assert(field_name<user, 0>() == "id"); static_assert(field_name<user, 1>() == "name"); static_assert(field_name<user, 2>() == "score"); user のフィールドを渡したときも同じ関数テンプレートなので、名前の後ろには同じ文字列 suffix が続き、名前の直前には同じ文字 separator があります。Clang の場合は次のとおりです。 std::string_view function_name() [V = &fake.value.name] |^^^^| | +-- suffix +------- separator suffix の手前から separator の次の文字までを切り出せば、フィールド名になります。この方法なら、コンパイラが関数名にテンプレート引数を含める限り、書式が違っても同じコードで名前を取り出せます1。 ここまでのコードをまとめました。 C++20 版の名前の取り出し (全体)#include <cstddef> #include <iostream> #include <source_location> #include <string> #include <string_view> #include <tuple> // 任意の型に変換できる型 struct any { template <typename T> operator T() const; }; // T{any, any, ...} が通る最大の個数を数える template <typename T, typename... Args> consteval std::size_t field_count() { if constexpr (requires { T{Args{}..., any{}}; }) return field_count<T, Args..., any>(); else return sizeof...(Args); } // 構造化束縛で各フィールドへのポインタを取り出す template <typename T> constexpr auto to_ptr_tuple(T& t) { constexpr auto n = field_count<T>(); if constexpr (n == 1) { auto& [a] = t; return std::tuple(&a); } else if constexpr (n == 2) { auto& [a, b] = t; return std::tuple(&a, &b); } else if constexpr (n == 3) { auto& [a, b, c] = t; return std::tuple(&a, &b, &c); } // ... ライブラリは 100 個まで用意している } template <typename T> struct wrapper { T value; static wrapper<T> fake; // 宣言だけで定義しない }; template <auto V> consteval std::string_view function_name() { return std::source_location::current().function_name(); } struct detector { void* dummy; }; template <typename T, std::size_t N> consteval std::string_view field_name() { // 名前が既知のフィールド dummy で、名前の後ろに続く文字列と、名前の直前の文字を調べる constexpr auto probe = function_name<std::get<0>(to_ptr_tuple(wrapper<detector>::fake.value))>(); constexpr auto pos = probe.rfind("dummy"); constexpr auto suffix = probe.substr(pos + std::string_view("dummy").size()); constexpr auto separator = probe[pos - 1]; // N 番目のフィールドを指すポインタをテンプレート引数に渡し、関数名から名前を切り出す constexpr auto s = function_name<std::get<N>(to_ptr_tuple(wrapper<T>::fake.value))>(); const auto last = s.rfind(suffix); auto first = last; while (s[first - 1] != separator) --first; return s.substr(first, last - first); } struct user { int id; std::string name; double score; }; static_assert(field_count<user>() == 3); static_assert(field_name<user, 0>() == "id"); static_assert(field_name<user, 1>() == "name"); static_assert(field_name<user, 2>() == "score"); enum class color { red, green, blue }; int main() { // 関数名の文字列を表示する std::cout << function_name<std::get<1>(to_ptr_tuple(wrapper<user>::fake.value))>() << '\n'; std::cout << function_name<color::green>() << '\n'; std::cout << function_name<static_cast<color>(5)>() << '\n'; } Compiler Explorer で試す (GCC 16、Clang 22、MSVC) 列挙子の名前 列挙子の名前も同じ仕組みで取り出せます。function_name に列挙型の値を渡すと、列挙子のある値なら列挙子の名前が、列挙子のない値なら数値が関数名に現れます。 GCC 15: consteval std::string_view function_name() [with auto V = color::green; std::string_view = std::basic_string_view<char>] consteval std::string_view function_name() [with auto V = (color)5; std::string_view = std::basic_string_view<char>] Clang 22: std::string_view function_name() [V = color::green] std::string_view function_name() [V = (color)5] MSVC: class std::basic_string_view<char,struct std::char_traits<char> > __cdecl function_name<color::green>(void) class std::basic_string_view<char,struct std::char_traits<char> > __cdecl function_name<(enum color)0x5>(void) C++20 には、列挙型がどの値の列挙子を持つかを知る手段がありません。そのため、field-reflection の C++20 版では、既定で [-128, 128] のすべての値を試し、名前が現れた値を列挙子として集めています。範囲は enum_range を特殊化して変えられますが、広げるほどコンパイル時間が延びます。実際の実装では、多数の値を 1 つの関数テンプレートのテンプレート引数にまとめて渡し、その関数名を一度に解析して、コンパイル時間を短くしています。 C++20 版と C++26 版の比較 C++20 版で扱える型は C++26 版でも扱え、フィールドの数、型、名前も同じ結果になります (後で述べる基底クラスの例と、フレキシブル配列メンバーのようなコンパイラの拡張を除きます)。違いは、C++26 版のほうが扱える型と列挙子が多いことです。そのため、if constexpr (field_namable<T>) のように分岐するコードでは、実装によって選ばれる分岐が変わることがあります。また、1 つのプログラムのすべての翻訳単位を、同じフラグでコンパイルしてください。違うフラグが混ざると ODR 違反になります。主な違いを次の表にまとめます。 C++20 版 C++26 版 フィールド数の上限 100 なし 基底クラスを持つ集成体 対象外 基底クラスのフィールドを先に並べて扱う 参照型のフィールドの名前 取れない 取れる 範囲外の値を持つ列挙子 名前を取れない 名前を取れる 同じ値を持つ別名の列挙子での enum_cast std::nullopt 値を返す ビットフィールド 数だけ オプションで値と名前も 非集成体 対象外 オプションで対象にできる アノテーション なし あり 結果の正確さにも違いがあります。C++20 版では、型の情報を集成体初期化と関数名の文字列から推測しています。例えば、先頭の基底クラスが任意の型の引数から構築できる場合、C++20 版はそのクラスを基底クラスのないクラスと区別できません。そのため、フィールドの数、型、名前が C++26 版と一致しないことがあります。C++26 版では、コンパイラが持つ型の情報をメタ関数で直接読むので、このような推測が要りません。 コンパイル時間も比べました。x86-64 Linux で、型を 1 つ増やしたときに増えるコンパイル時間 (-c -O0) を測ったところ、大きな差はありませんでした (README)。8 個のフィールドを持つ構造体 1 つあたりでは、C++20 版が 19–23 ms (GCC 15、Clang 22)、C++26 版が 20 ms (GCC 16) でした。10 個の列挙子を持つ列挙型 1 つあたりでは、C++20 版の 14 ms に対して、C++26 版は 9 ms でした。 非集成体の扱い C++26 版では、reflect_options を渡すと非集成体も対象にできます。 class point { int x_ = 0, y_ = 0; public: point(int x, int y) : x_(x), y_(y) {} }; constexpr auto opts = reflect_options{.non_aggregate = true, .non_public = true}; static_assert(!field_namable<point>); static_assert(field_namable<point, opts>); static_assert(field_name<point, 0, opts> == "x_"); auto p = point{1, 2}; for_each_field<opts>(p, [](std::string_view name, auto& value) { /* ... */ }); non_aggregate は非集成体を、non_public は private と protected のメンバーを対象に加えます。non_public は private や protected のメンバーにも外からアクセスできるようにするので、メンバーを書き換えるとクラスの不変条件を破るおそれがあります。オプションは C++26 版でだけ有効にしました。C++20 版はオプションを無視するので、どちらの実装でもオプションを渡すコードはコンパイルできます。ただし、オプションで加えた型は C++20 版では対象になりません。 既定で集成体だけを対象にしたのは、集成体ならフィールドの並びがそのまま値を表すからです。集成体はフィールドの並びから直接初期化できます。一方、非集成体では、値の意味をコンストラクタが決めます。例えば struct meters { double value; meters(double v); }; は数値から構築するための型で、{"value": ...} という JSON から構築するための型ではありません。また、ラムダ式や std::allocator<T> のように、データを表さない非集成体も多くあります。これらを空の構造体として扱わないように、非集成体はオプションで明示したときだけ対象にしました。 応用: cpp-yyjson 冒頭で紹介した cpp-yyjson では、field-reflection を使って JSON と C++ の型を直接変換しています。 // JSON から冒頭の team 型へ auto t = yyjson::read<team>(R"({"name":"core","leader":{"name":"Alice","access":"admin"},"size":3})"); // 構造体から JSON へ: {"name":"core","leader":{"name":"Alice","access":"admin"},"size":3} auto json = yyjson::write(t); 上の例では、JSON のキーと構造体のフィールド名を field-reflection で対応させ、DOM を経由せずに JSON のテキストから構造体を組み立てます。列挙型は列挙子の名前で読み書きするので、冒頭で書いた write_json は yyjson::write の 1 行で済みます。 Amplify AE は C++ と CUDA で実装されており、リクエストの JSON を cpp-yyjson で C++ の型に変換しています。リクエストを受け取ってからレスポンスを返すまでのオーバーヘッドを、できるだけ減らす必要があるからです。cpp-yyjson の設計とベンチマークの詳細は、別の記事で紹介する予定です。 おわりに 本記事では、field-reflection の使い方と、その 2 つの実装を紹介しました。C++20 版では、集成体初期化、構造化束縛、部分オブジェクトへのポインタのテンプレート引数、関数名の文字列を組み合わせて、フィールドの名前を取り出しています。C++26 版では、標準の静的リフレクションで同じ API を実装しました。 シリアライザやロガー、テスト用のユーティリティのように、さまざまな構造体や列挙型を受け取る汎用のコードに、ぜひ field-reflection をお試しください。使い方の例は Compiler Explorer ですぐに動かせます。 C++26 では、静的リフレクションとアノテーションが標準に入りました。field-reflection は、C++20 と同じ API で使えるだけでなく、これらの新しい機能を手軽に使うためのライブラリでもあります。C++26 に対応したコンパイラを使い始めるときにも、field-reflection をぜひ役立ててください。 脚注 実際のライブラリでは、切り出した名前を別の静的な文字列にコピーしています。関数名の文字列全体がプログラムのバイナリに残らないようにするためです。 ↩︎