この記事は RustでJSONから値をゆるりと取り出すマクロを書いた話 という記事に影響を受けています。参照先の記事では、Rustのserde_json
というライブラリを使い、JSONの内部の値をj.foo.bar.baz[1][2]
のような構文で取り出すマクロを書いています。C++でも似たものを作れそうだと思ったので、書いてみました。
この記事ではライブラリの実装を解説しつつ、コンパイル時計算などのメタプログラミングのテクニックを紹介します。
対象読者
- C++ 中級者以上
- メタプログラミングに興味がある人
インスパイア元のRustのマクロでは再帰を使っていますが、C++版ではテンプレートとコンパイル時計算を主に使います。
この記事ではBoostを使用します。BoostはC++の準公式ともいえる強力なライブラリ群で、C++開発者にとっては馴染みの深いものだと思います。Boostの導入方法はこの記事では説明しないので、ご了承ください。
Boost.JSON について
この記事ではJSONライブラリとして Boost.JSON を使います。これは2020年の Boost 1.75 で追加された、比較的新しいライブラリです。
このライブラリでは、JSONの構造をC++のデータとして表現するためのboost::json::value
という型があります。この型について、使い方を簡単に示します。
#include <cstdint>
#include <boost/json.hpp>
// boost::json::value を json::value と書けるようにするための
// 名前空間エイリアス
namespace json = boost::json;
int main() {
const char* json_text = R"(
{
"foo": {
"users": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
}
}
)";
// JSON 形式の文字列を boost::json::value 型に変換
json::value json = json::parse(json_text);
// 内部のデータへのアクセスは at() で行う。
// オブジェクトの場合はキーを示す文字列を、配列の場合はインデックスを渡す。
json::value& inner = json.at("foo")
.at("users")
.at(0) // at("0") ではない
.at("id");
// 値を取り出す場合は as_int64() などの関数を使う。
int64_t& id = json.at("foo")
.at("users")
.at(0)
.at("id")
.as_int64();
// 文字列は std::string 型ではなく boost::json::string 型
// として保持している。
json::string& name = json.at("foo")
.at("users")
.at(0)
.at("name")
.as_string();
assert(id == 1);
assert(name == "Alice");
// データの種類をチェックする場合は is_xxx を使う
assert(json.at("foo").is_object());
assert(inner.is_int64());
}
Boost.JSON の機能は他にもある (例. ユーザー定義型への変換) のですが、この記事で扱う範囲については上記の説明で足りるかと思います。
問題提起
boost::json::value
型ですが、ネストされた構造にアクセスするにはat
をチェインする必要があり、やや面倒です。
json::value json = ...;
int64_t& id = json.at("foo").at("users").at(0).at("id").as_int64();
より簡単にアクセスする方法として、JSON Pointer記法を使うAPIがあります。
int64_t& id = json.at_pointer("/foo/users/0/id").as_int64()
ですがこれは、コンパイル時でなく実行時にJSON Pointerをパースするため、構文の誤りを防ぐことができません。エラーが発生した際、JSONにその値が存在しないのか、JSON Pointerの構文に誤りがあるのかを判別しづらい問題があります。また、JSON Pointerを解析するコストがあるため、at
のチェインよりも少し時間がかかります。
上記の課題を解決するため、以下のように JavaScript と同様の構文で内部の値にアクセスできるようにしたい、というのが今回の目的です。ついでに、QUERY_JSON(..., int64)
のように型も指定できるようにすることで、利便性を上げます。
// boost::json::value& 型としてアクセス
auto& value = QUERY_JSON(json, foo.users[0].id);
// int64_t& や json::string& 型としてアクセス
auto& id = QUERY_JSON(json, foo.users[0].id, int64);
auto& name = QUERY_JSON(json, foo.users[0].name, string);
以降では、ユーティリティの実装の解説を行います。
実装の解説
必要バージョン
- C++20
方針
マクロは QUERY_JSON(json, foo.users[0].id)
のように、第二引数のところをJavaScriptの構文で書けることを目指します。インスパイア元記事ではこれをRustのマクロで解決していますが、C++のマクロでは難しいです。そこで、C++版ではこの部分を文字列として扱い、コンパイル時にパースすることにします。
文字列処理には std::string_view
を使います。これは動的確保を行わず、文字列への参照のみを保持する型であり、サイズや部分文字列の取得をコンパイル時に行うことができます。
constexpr auto text = std::string_view("abc.123"); // リテラル文字列 "abc.123" への参照を持つ
constexpr auto sub1 = text.substr(0, 3); // 先頭から3文字を取り出す
constexpr auto sub2 = text.substr(4); // 4文字目以降を取り出す
static_assert(sub1 == "abc");
static_assert(sub2 == "123");
static_assert(sub1.size() == 3);
static_assert(sub2.size() == 3);
実装のイメージとして、まずは
-
"foo.bar"
->tuple("foo", "bar")
-
"foo[0].bar"
->tuple("foo", 0, "bar")
-
"foo[0][1]"
->tuple("foo", 0, 1)
-
"foo..bar"
-> コンパイル失敗
のような変換をコンパイル時に行い、これを元に json::value::at
のチェインに相当する関数を生成することを考えます。
以降、実装の解説です。
コンパイル時 stoi
文字列を整数に変換する std::stoi
ですが、これは定数式では使えない (constexpr
ではない) ので、代わりのものを自作します。配列のインデックスとして使うため、戻り値はint
ではなくsize_t
にします。
constexpr size_t to_digit(char c) {
if (c < '0' || '9' < c) {
throw std::logic_error("Invalid integer literal");
}
return static_cast<size_t>(c - '0');
};
constexpr size_t str_to_index_impl(std::string_view str, size_t accum) {
return str.empty() ? accum
: str_to_index_impl(str.substr(1), accum * 10 + to_digit(str.front()));
}
constexpr size_t str_to_index(std::string_view str) {
return str_to_index_impl(str, 0);
}
例外を投げるコードパス (文字列に 0-9 意外の値が含まれる場合) については、これを定数式として評価した場合、コンパイルに失敗します。
コンパイル時に変換できていることを確認するため、static_assert
を使って検証します。
static_assert(str_to_index("0") == 0);
static_assert(str_to_index("9") == 9);
static_assert(str_to_index("123") == 123);
// コンパイルに失敗
// str_to_index("abc");
JSONのパスを解析する
"foo.bar[0].baz"
のような文字列を読み取り、"foo"
, "bar"
, 0
, "baz"
という要素の配列に変換することを考えます。これはコンパイル時に処理する必要があるため、std::vector
ではなく、静的にサイズが決まるstd::array
型に変換する必要があります。
オブジェクトのキーまたは配列のインデックスを意味する型として、以下のようなエイリアスを用意します。この型は、 std::string_view
もしくは size_t
のいずれかを値として保持します。
// オブジェクトのキー (string_view) または
// 配列のインデックス (size_t) を保持する型
using KeyOrIndex = std::variant<std::string_view, size_t>;
これを使い、以下の入出力を持つ parse_json_paths
関数を作ります。
- 入力:
std::string_view
- 出力:
std::array<KeyOrIndex, N>
ですが、これは課題があります。std::array
のサイズが入力の文字列に依存しますね。入力が "foo.bar"
であれば std::array<KeyOrIndex, 2>
を、"foo.bar.baz"
や "foo.bar[0]"
であれば std::array<KeyOrIndex, 3>
を返すようにしたいです。これを実現するには、ちょっとした工夫が要ります。
要素数の取得
まずは要素の数をコンパイル時に数える関数を定義します。 これは文字列中の .
と [
の数を数えれば良さそうです。
constexpr size_t count_json_paths(std::string_view text) {
return 1 + std::ranges::count_if(
text,
[](char c) { return c == '.' || c == '['; });
}
std::ranges::count_if
は<algorithm>
ヘッダに定義されています。constexpr
にも対応しており、最近のC++は便利になったなあと感じさせられます。
コンパイル時にカウントできていることをstatic_assert
で確認します。
static_assert(count_json_paths("foo.bar") == 2);
static_assert(count_json_paths("foo.bar.baz") == 3);
static_assert(count_json_paths("foo.bar[1].baz") == 4);
static_assert(count_json_paths("foo.bar.baz[2]") == 4);
std::arrayへの変換
要素数を得られるようになったので、これを使って std::array<KeyOrIndex, N>
を返すことを考えます。まずは以下のような書き出しを考えるかと思います。
constexpr auto parse_json_paths(std::string_view text) {
constexpr size_t size = count_json_paths(text);
auto result = std::array<KeyOrIndex, size>{};
...
}
ですが、これはコンパイルに失敗します。なぜなら、引数として受け取ったtext
が定数と見なされないためです。これは関数のシグニチャをconstexpr
ではなくconsteval
にしても同じです。
実はこれを解決する方法があります。それはconstexprラムダを使う方法です。
template<typename WrappedStringLiteral>
constexpr auto parse_json_paths(WrappedStringLiteral fn) {
constexpr std::string_view text = fn();
constexpr size_t size = count_json_paths(text);
auto result = std::array<KeyOrIndex, size>{};
...
}
// 利用側
constexpr fn = []() constexpr {
return std::string_view("foo.bar");
};
constexpr result = parse_json_paths(fn);
ちょっと黒魔術感がありますね。これは「コンパイル時に呼び出し可能であり、std::string_view
を返す」関数オブジェクトを作り、それをsplit_json_elements
に渡しています。この方法なら、入力文字列を関数内部で定数として扱うことができます。
これを使って、続きを書いていきます。戻り値の型は文字列の内容によって決まるので、関数宣言の先頭はauto
にする必要があります。
template <typename WrappedStringLiteral>
constexpr auto parse_json_paths(WrappedStringLiteral fn) {
constexpr std::string_view text = fn();
constexpr size_t size = count_json_paths(text);
auto result = std::array<KeyOrIndex, size>{};
size_t count = 0;
size_t start = 0;
bool is_array = false;
for (size_t i = 0; i < text.size(); ++i) {
switch (text[i]) {
case '.':
if (is_array) {
throw std::logic_error("Invalid syntax");
}
if (start < i) {
result[count++] = text.substr(start, i - start);
}
start = i + 1;
break;
case '[':
if (is_array) {
throw std::logic_error("Invalid syntax");
}
is_array = true;
if (start < i) {
result[count++] = text.substr(start, i - start);
}
start = i + 1;
break;
case ']':
if (!is_array) {
throw std::logic_error("Invalid syntax");
}
is_array = false;
result[count++] = str_to_index(text.substr(start, i - start));
start = i + 1;
break;
default:
break;
}
}
if (start < text.size()) {
result[count++] = text.substr(start);
}
if (count != result.size()) {
throw std::logic_error("Invalid syntax");
}
return result;
}
これもstatic_assert
でチェックします。constexprラムダを作る部分はマクロにします。
using std::array;
#define WRAP_STRING(text) \
[]() constexpr { return std::string_view(text); }
static_assert(
parse_json_paths(WRAP_STRING("foo.bar.baz")) ==
array<KeyOrIndex, 3>{"foo", "bar", "baz"});
static_assert(
parse_json_paths(WRAP_STRING("foo.bar[1].baz")) ==
array<KeyOrIndex, 4>{"foo", "bar", size_t{1}, "baz"});
static_assert(
parse_json_paths(WRAP_STRING("foo.bar.baz[2]")) ==
array<KeyOrIndex, 4>{"foo", "bar", "baz", size_t{2}});
static_assert(
parse_json_paths(WRAP_STRING("foo.1st.2[0]")) ==
array<KeyOrIndex, 4>{"foo", "1st", "2", size_t{0}});
// 以下のような不正ケースはコンパイルエラー
// parse_json_paths(WRAP_STRING("foo..bar"));
// parse_json_paths(WRAP_STRING("foo.[bar]"));
// parse_json_paths(WRAP_STRING("foo.[[0]]"));
最後のケースを見て分かる通り、先頭が数値で始まるキー ("1st"
や "2"
) も扱えるようにしています。[]
で囲われた部分だけを配列のインデックスとしてみなし、size_t
に変換しています。
タプルへの変換
std::array<KeyOrIndex, N>
のままだと、中身の値 (size_t
or std::string_view
) による呼び分けが実行時に必要になります。実行時のコストを無くすため、これをタプルに変換します。
以下のような変換ができれば良さそうです。
- 入力:
std::array<KeyOrIndex, N>
- 出力:
std::tuple<...>
これには std::index_sequence
を使ったテクニックを使います。
まずは以下のような関数を作り、KeyOrIndexがstring_view
とsize_t
のどちらを持つかを示す配列を作ります。
enum class PathType {
ObjetKey,
ArrayIndex
};
template <size_t N>
constexpr std::array<PathType, N> get_path_types(std::array<KeyOrIndex, N> paths) {
auto result = std::array<PathType, N>{};
for (size_t i = 0; i < N; ++i) {
auto path_type = std::holds_alternative<size_t>(paths[i]) ? PathType::ArrayIndex : PathType::ObjetKey;
result[i] = path_type;
}
return result;
}
get_path_types
の入出力例は以下の通りです。
- 入力:
std::array<KeyOrIndex, 3>{"foo", 1, "baz"}
- 出力:
std::array<PathType, 3>{ObjetKey, ArrayIndex, ObjetKey}
これを可変長引数テンプレートおよびstd::
index_sequenceと組み合わせます。PathType
は convert
が返す型の切り替えに使うため、関数の引数ではなく、テンプレートの引数として渡す必要があります。
// 戻り値の型はテンプレート引数に応じ、
// size_t または string_view のいずれかを返す
template <PathType path_type>
constexpr auto convert(KeyOrIndex path) {
if constexpr (path_type == PathType::ObjetKey) {
return std::get<std::string_view>(path);
} else {
return std::get<size_t>(path);
}
}
template <size_t... Is, typename WrappedStringLiteral>
constexpr auto parse_json_impl(WrappedStringLiteral fn, std::index_sequence<Is...>) {
constexpr auto paths = parse_json_paths(fn);
constexpr auto path_types = get_path_types(paths);
return std::make_tuple(convert<path_types[Is]>(paths[Is])...);
}
template <typename WrappedStringLiteral>
constexpr auto parse_json(WrappedStringLiteral fn) {
constexpr auto size = count_json_paths(fn());
return parse_json_impl(fn, std::make_index_sequence<size>{});
}
これは慣れていないと分かりづらいかもしれません。ここでは
// 配列A (paths)
array<KeyOrIndex, 3>{ "foo", 0, "baz" }
// 配列B (path_types)
array<PathType, 3>{ ObjetKey, ArrayIndex, ObjetKey }
の2つを用意し、関数 conert<PathType>(KeyOrIndex)
の引数に配列Aの中身を、テンプレートの引数に配列Bの中身を渡すことで、配列Aを {string_view, size_t, string_view}
に変換し、それを std::make_tuple
に渡しています。
static_assert
で動作を確認します。
using std::tuple;
#define WRAP_STRING(text) \
[]() constexpr { return std::string_view(text); }
static_assert(
parse_json(WRAP_STRING("foo.bar.baz")) ==
tuple{"foo", "bar", "baz"});
static_assert(
parse_json(WRAP_STRING("foo.bar[0].baz")) ==
tuple{"foo", "bar", 0, "baz"});
static_assert(
parse_json(WRAP_STRING("foo.bar.baz[2]")) ==
tuple{"foo", "bar", "baz", 2});
static_assert(
parse_json(WRAP_STRING("foo.bar.1st[1][02][30].values[1][2]")) ==
tuple{"foo", "bar", "1st", 1, 2, 30, "values", 1, 2});
良い感じですね!
タプルをイテレートする
Boost.Hana という、メタプログラミングのためのライブラリを使います。std::tuple
をイテレートするには、 <boost/hana/ext/std/tuple.hpp>
をインクルードした上で、boost::hana::for_each
を使います。
const参照版と非const参照版の2つを用意します。
#include <boost/json.hpp>
#include <boost/hana.hpp>
#include <boost/hana/ext/std/tuple.hpp> // std::tuple を扱う場合に必要
template <typename WrappedStringLiteral>
boost::json::value& query_json(boost::json::value& json, WrappedStringLiteral fn) {
constexpr auto keys = parse_json(fn);
auto* ref = &json;
boost::hana::for_each(keys, [&](auto x) {
ref = &(ref->at(x));
});
return *ref;
}
template <typename WrappedStringLiteral>
const boost::json::value& query_json(const boost::json::value& json, WrappedStringLiteral fn) {
constexpr auto keys = parse_json(fn);
const auto* ref = &json;
boost::hana::for_each(keys, [&](auto x) {
ref = &(ref->at(x));
});
return *ref;
}
boost::hana::for_each
の第二引数にはジェネリックラムダを渡しています。引数のx
は、タプルの各要素の型に応じて size_t
または std::string_view
型になります。内部では、 boost::json:::value::at()
の文字列版のオーバーロード (オブジェクトのキーとしてアクセス) と、size_t版のオーバーロード (配列のインデックスとしてアクセス) がそれぞれ呼ばれます。
転送参照 (ユニバーサル参照) を使うと、const参照と非const参照を1つの関数テンプレートで定義できます。ですが、こちらはテンプレートが実体化されるまで第一引数の型エラーを判定できない点が難点です。
// 転送参照版 (const参照と非const参照のどちらにも対応)
template <typename JsonValueT, typename WrappedStringLiteral>
auto query_json(JsonValueT&& json, WrappedStringLiteral fn) -> decltype(auto) {
constexpr auto keys = parse_json(fn);
auto* ref = &json;
boost::hana::for_each(keys, [&](auto x) {
ref = &(ref->at(x));
});
return *ref;
}
ここまでで説明した関数は、全てライブラリの名前空間に入れておきます。
namespace json_query_internal {
...
}
マクロ定義
必要な関数は全て揃いました。最後に、これを呼び出しやすい形のマクロにします。
QUERY_JSON
マクロは以下のように、2引数もしくは3引数を受け取れるようにし、3引数版ではアクセスするデータの型を受け取れるようにします。
// シンタックス
QUERY_JSON(json, path)
QUERY_JSON(json, path, type)
// 例
// json.at("foo").at("bar").at(0) と等価
QUERY_JSON(json, foo.bar[0])
// json.at("foo").at("bar").at(0).as_int64() と等価
QUERY_JSON(json, foo.bar[0], int64)
// json.at("foo").at("bar").at(0).as_string() と等価
QUERY_JSON(json, foo.bar[0], string)
// as_double() や as_bool(), as_array() なども同様
QUERY_JSON(json, foo.bar[0], double)
...
このように、引数の数によってマクロの動作を変えたい場合は、 Boost.Preprocessor にある BOOST_PP_OVERLOAD
が便利です。
#include <boost/preprocessor.hpp>
// 呼び出す関数
namespace json_query_internal {
template <typename WrappedStringLiteral>
boost::json::value& query_json(boost::json::value& json, WrappedStringLiteral fn);
template <typename WrappedStringLiteral>
boost::json::value& query_json(boost::json::value& json, WrappedStringLiteral fn);
}
// 2引数用のマクロ
#define QUERY_JSON_IMPL_2(json, json) \
json_query_internal::query_json( \
json, \
[]() constexpr { return std::string_view(#path); })
// 3引数用のマクロ
#define QUERY_JSON_IMPL_3(json, path, type) \
json_query_internal::query_json( \
json, \
[]() constexpr { return std::string_view(#path); } \
).as_##type()
// BOOST_PP_OVERLOAD を使って呼び分け
#define QUERY_JSON(...) BOOST_PP_OVERLOAD(QUERY_JSON_IMPL_, __VA_ARGS__)(__VA_ARGS__)
C++のマクロだと引数をトークンに埋め込むことができるので、3引数版ではこれを利用してboost::json::value::as_xxx()
を呼び出すようにします。
以上で完成です!
動作例
#include <cassert>
#include <boost/json.hpp>
#include <json_query/json_query.hpp>
namespace json = boost::json;
int main() {
const char* json_text = R"(
{
"foo": {
"users": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
}
}
)";
json::value json = json::parse(json_text);
// boost::json::value& としてアクセス
auto& id = QUERY_JSON(json, foo.users[0].id);
assert(id.is_int64());
assert(id.as_int64() == 1);
// 型を指定してアクセス
assert(QUERY_JSON(json, foo.users[0].id, int64) == 1);
assert(QUERY_JSON(json, foo.users[0].name, string) == "Alice");
// 値の書き換え
QUERY_JSON(json, foo.users[0].name) = "New name";
return 0;
}
補足
配列のインデックスについて
文字列としてパースする都合上、配列のインデックスとして変数を渡すことはできません。
QUERY_JSON(json, foo.bar[0]); // OK
QUERY_JSON(json, foo.bar[123]); // OK
size_t n = 0;
QUERY_JSON(json, foo.bar[n]); // コンパイルエラー
例外について
エラー発生時は、Boost.JSON で同等の呼び出しを行った時と同じ例外が送出されます。
// json.at("foo").at("users").at(0).at("id").as_string()
// がエラーになるのであれば、この呼び出しと同じ例外が投げられる
auto& name = QUERY_JSON(json, foo.users[0].id, string);
おわりに
やや長くなりましたが、C++のメタプログラミングの例を共有してみました。こうしたテクニックを使う機会はあまりないかもしれませんが、うまく使うことで、通常の関数やクラスではできないことができるようになります。
この記事を読んで、メタプログラミングに興味を持っていただけたら幸いです。