将 JSON 映射到 C++ 对象

上周,C++26 在保加利亚索非亚定稿,它将包含我们一直在推动的所有反射相关内容:

  1. P2996R13:C++26 的反射
  2. P3394R4:反射的注解
  3. P3293R3:拼接基类子对象
  4. P3491R3define_static_{string,object,array}
  5. P1306R5:扩展语句
  6. P3096R12:C++26 反射中的函数参数反射
  7. P3560R2:反射中的错误处理

这些是以被采纳的顺序列出,而非按其影响的顺序(否则拼接基类会排在最后)。这是一项相当了不起的成就,若没有很多人的努力是不可能实现的,但在 C++26 的反射方面,没有哪一个人比 Dan Katz 更应负责。

今天要谈论 Dan 在从索非亚回家的航班上(作者在几个座位之外昏迷着)构思的一个非常酷的示例:在编译时摄入一个 JSON 文件并将其转换为 C++ 对象。即给定一个如下的文件test.json

{
    "outer": "text",
    "inner": { "field": "yes", "number": 2996 }
}

可以编写如下代码:

constexpr const char data[] = {
    #embed "test.json"
   , 0
};

constexpr auto v = json_to_object<data>;

这段代码的结果是现在有一个对象v,其类型类似:

struct {
    char const* outer;
    struct {
        char const* field;
        int number;
    } inner;
};

并且其值相应地从 JSON 文件中填充:

static_assert(v.outer == "text"sv);
static_assert(v.inner.number == 2996);
static_assert(v.inner.field == "yes"sv);

这非常酷。

本文其余部分将逐步介绍如何实现这一功能。

让我们从简单开始[](#lets-start-simple)

不是从完整示例开始,而是从一个非常简略的形式开始——这足以演示所有有趣的内容。将解析一个 JSON 对象版本,其中只有一个键和一个值,且该值是一个int

consteval auto parse(std::string_view key, int value) -> std::meta::info;

给定一个值如{"x": 1},需要采取哪些步骤?最终结果是要生成一个类似的类型:

struct S {
    int x;
};

并生成值S{1}。根据简化的签名,首先获取正确的数据成员和初始化器:

consteval auto parse(std::string_view key, int value)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    auto member = reflect_constant(data_member_spec(^^int, {.name=key}));
    auto init = reflect_constant(value);

    //...
}

将两个值都包装在reflect_constant中,尽管data_member_spec已经是一个meta::info,这是因为稍后需要解开一层反射。

接下来,data_member_spec的唯一真正用途是将其传递给define_aggregate。这里的parse不是模板,对于每个member需要一个不同的类型(因为{"x": 1}{"y": 1}需要导致不同的类型)。巧妙的解决方案是:

template <std::meta::info...Ms>
struct Outer {
    struct Inner;
    consteval {
        define_aggregate(^^Inner, {Ms...});
    }
};

template <std::meta::info...Ms>
using Cls = Outer<Ms...>::Inner;

consteval auto parse(std::string_view key, int value)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    auto member = reflect_constant(data_member_spec(^^int, {.name=key}));
    auto init = reflect_constant(value);

    auto type = substitute(^^Cls, {member});
    //...
}

substitute(^^Z, {^^Args...})生成^^Z<Args...>。即,给定一个模板的反射和模板参数的反射,将返回特化的反射。这就是之前所说的解开一层反射,需要member是一个表示data_member_spec值的反射,以便可以直接用表示data_member_spec的反射实例化Cls

type现在是一个表示每个非静态数据成员集的唯一类型的反射。重要的是,它表示的类型会自动去重并具有外部链接。这可能被证明是从这样的算法内部创建类型的常见习惯用法。

现在有了类型(type)和初始化器(init),只需将它们组合在一起,这又是一个substitute调用:

template <std::meta::info...Ms>
struct Outer {
    struct Inner;
    consteval {
        define_aggregate(^^Inner, {Ms...});
    }
};

template <std::meta::info...Ms>
using Cls = Outer<Ms...>::Inner;

template <class T, auto... Vs>
inline constexpr auto construct_from = T{Vs...};

consteval auto parse(std::string_view key, int value)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    auto member = reflect_constant(data_member_spec(^^int, {.name=key}));
    auto init = reflect_constant(value);

    auto type = substitute(^^Cls, {member});
    return substitute(^^construct_from, {type, init});
}

这样就足以使这些断言通过:

static_assert([: parse("x", 1) :].x == 1);
static_assert([: parse("y", 2) :].y == 2);

虽然看起来不多,但为了实现这一点合成了两个具有不同成员的类类型,这很酷。

从一到多[](#from-one-to-many)

现在已经有一个单个的键/值对可以工作,将其推广到任意多个键/值对并不困难。正在代入的两个模板(Clsconstruct_from)都是可变参数的,所以无需重复。只需推广实现即可。

到目前为止,有一个member和一个init,类型都是info。两者都需要变为vector<info>

consteval auto parse(std::string_view key, int value)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits;

    members.push_back(reflect_constant(
        data_member_spec(^^int, {.name=key})));
    inits.push_back(reflect_constant(value));

    auto type = substitute(^^Cls, members);
    inits.insert(inits.begin(), type);
    return substitute(^^construct_from, inits);
}

需要将type插入到inits的开头,因为construct_from需要先使用类型实例化,然后再使用所有初始化器。但这种方法有点笨拙,可以通过先在inits中添加一个占位符,然后再替换它来清理:

consteval auto parse(std::string_view key, int value)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits = {^^void};

    members.push_back(reflect_constant(
        data_member_spec(^^int, {.name=key})));
    inits.push_back(reflect_constant(value));

    inits[0] = substitute(^^Cls, members);
    return substitute(^^construct_from, inits);
}

仍然只有一个键值对,但已经有了处理多个的结构。

之前提到 Boost.JSON 在constexpr中不工作,但本文的重点不是说明如何解析 JSON,而是如何将 JSON 对象转换为 C++ 结构体。所以将假装 Boost.JSON 可以工作。

不是使用string_viewint,而是接受一个boost::json::object并遍历所有的键/值对。函数的大致结构仍然相同:

consteval auto parse(boost::json::object object)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits = {^^void};

    for (auto const& [key, value] : object) {
        //...
    }

    inits[0] = substitute(^^Cls, members);
    return substitute(^^construct_from, inits);
}

value现在是一个boost::json::value,可以是 JSON 值的任何类型。为简单起见,假设只能是(1)一个数字,(2)一个字符串,或(3)一个对象。按顺序处理这些情况。

对于数字情况,看起来很熟悉,因为在之前的部分已经做过了,唯一的区别是数字的来源:

consteval auto parse(boost::json::object object)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits = {^^void};

    for (auto const& [key, value] : object) {
        if (value.is_number()) {
            members.push_back(reflect_constant(
                data_member_spec(^^int, {.name=key})));
            inits.push_back(reflect_constant(
                value.to_number<int>()))
        } else {
            //...
        }
    }

    inits[0] = substitute(^^Cls, members);
    return substitute(^^construct_from, inits);
}

由于成员部分在所有情况下都将是相同的——添加一个数据成员,其名称为key——所以预先进行重构:

consteval auto parse(boost::json::object object)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits = {^^void};

    for (auto const& [key, value] : object) {
        auto add_member = [&](std::meta::info type){
            members.push_back(reflect_constant(
                data_member_spec(type, {.name=key})));
        };

        if (value.is_number()) {
            add_member(^^int);
            inits.push_back(reflect_constant(
                value.to_number<int>()))
        } else {
            //...
        }
    }

    inits[0] = substitute(^^Cls, members);
    return substitute(^^construct_from, inits);
}

对于字符串,实际上提出了一个有趣的问题,因为需要能够将初始化器作为常量模板参数传递。这些不能是字符串字面量。此外,当创建嵌套对象时,也需要这些内部对象可用作常量模板参数。这意味着不能使用string_view——它还不是结构类型。所以将坚持使用char const*。幸运的是,有一个函数std::meta::reflect_constant_string可以用来获取一个字符串值并获得具有这些内容的空终止、静态存储持续时间的char的 constexpr 数组的反射。

consteval auto parse(boost::json::object object)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits = {^^void};

    for (auto const& [key, value] : object) {
        auto add_member = [&](std::meta::info type){
            members.push_back(reflect_constant(
                data_member_spec(type, {.name=key})));
        };

        if (value.is_number()) {
            add_member(^^int);
            inits.push_back(reflect_constant(
                value.to_number<int>()))
        } else if (auto s = value.if_string()) {
            add_member(^^char const*);
            inits.push_back(std::meta::reflect_constant_string(*s));
        } else {
            //...
        }
    }

    inits[0] = substitute(^^Cls, members);
    return substitute(^^construct_from, inits);
}

最后是对象情况。给定一个任意的 JSON 对象,如何获得一个到该对象的 C++ 值的反射?已经写过这个函数,就是parse!递归就是这么酷。在这种情况下,不能立即得到需要生成的对象的类型——但一旦获得了值的反射,之后就可以得到类型:

consteval auto parse(boost::json::object object)
    -> std::meta::info
{
    using std::meta::reflect_constant;

    std::vector<std::meta::info> members;
    std::vector<std::meta::info> inits = {^^void};

    for (auto const& [key, value] : object) {
        auto add_member = [&](std::meta::info type){
            members.push_back(reflect_constant(
                data_member_spec(type, {.name=key})));
        };

        if (value.is_number()) {
            add_member(^^int);
            inits.push_back(reflect_constant(
                value.to_number<int>()))
        } else if (auto s = value.if_string()) {
            add_member(^^char const*);
            inits.push_back(std::meta::reflect_constant_string(*s));
        } else {
            std::meta::info inner = parse(value.as_object());
            add_member(remove_const(type_of(inner)));
            inits.push_back(inner);
        }
    }

    inits[0] = substitute(^^Cls, members);
    return substitute(^^construct_from, inits);
}

这就是全部内容。如果可以使用 Boost.JSON,情况就是这样。

总结[](#wrapping-it-up)

在 Dan 的实际实现(经过一些编辑)中,parse_json接受一个string_view并实际必须解析所有的 JSON:

consteval auto parse_json(std::string_view json) -> std::meta::info {
    // stuff
}

它遵循了这里呈现的相同结构。还有最后一件事要做:提供一个稍微更好的接口:

struct JSONString {
    std::meta::info Rep;
    consteval JSONString(const char *Json) : Rep{parse_json(Json)} {}
};

template <JSONString json>
consteval auto operator""_json() {
    return [:json.Rep:];
}

template <JSONString json>
inline constexpr auto json_to_object = [: json.Rep :];

就是这样。可以使用新的#embed引入任意的 JSON 文件并立即将其转换为 C++ 对象:

constexpr const char data[] = {
    #embed "test.json"
   , 0
};

constexpr auto v = json_to_object<data>;

或者甚至可以直接使用 UDL 操作字符串字面量:

static_assert(
    R"({"field": "yes", "number": 2996})"_json
   .number == 2996);

基本上实现了一个F# JSON 类型提供程序作为一个相当短的库。当然,它与确切的接口并不完全相同——但 F# 设计实际上只是在此基础上的一个轻微重构。反射是一种全新的语言。

阅读 17
0 条评论