На прошлой неделе в Софии, столице Болгарии, закончилась работа над стандартом C++26, который теперь включает все наши предложения по рефлексии:

  1. P2996R13: Reflection for C++26

  2. P3394R4: Annotations for Reflection

  3. P3293R3: Splicing a Base Class Subobject

  4. P3491R3: define_static_{string,object,array}

  5. P1306R5: Expansion Statements

  6. P3096R12: Function Parameter Reflection in Reflection for C++26

  7. P3560R2: Error Handling in Reflection

Эти предложения перечислены в порядке их принятия, а не по степени влияния (иначе я бы поставил Splicing a Base Class Subobject последним). Это невероятное достижение, которое стало возможным благодаря работе множества людей, но главная заслуга в принятии рефлексии в C++26 принадлежит Дэну Кацу (LinkedIn, GitHub).

Сегодня я хочу рассказать о замечательном примере, который Дэн создал во время перелёта из Софии, пока я спал в нескольких креслах от него: возможность на этапе компиляции обработать 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;
};

Так что следующие проверки выполнятся успешно:

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

И это просто невероятно круто (godbolt).

Далее в этой статье мы разберём, как добиться такого результата. Я попытался переписать пример, используя Boost.JSON, поскольку парсинг JSON — не самая интересная его часть. Однако Boost.JSON не поддерживает парсинг на этапе компиляции. То же самое касается и nlohmann::json. Поэтому для наглядности я буду делать вид, что Boost.JSON работает, но вы можете посмотреть реальный код по ссылке на Compiler Explorer.

Начнём с простого

Попробуем спарсить JSON-объект, содержащий только одну пару ключ-значение, где значение это int.

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

То есть, для JSON вроде {"x": 1} нам нужно получить следующий тип:

struct S {
    int x;
};

И создать объект:

S{1}

Начнем с получения желаемого поля структуры (int x для {"x": 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. Это потому, что вскоре нам нужно будет развернуть один слой рефлексии.

У нас есть поле, но теперь нам нужно объявить сам класс — для этого используется функция define_aggregate. Поскольку наша функция parse не шаблонная, нам нужен уникальный тип для каждого поля (ведь {"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, чтобы мы могли напрямую инстанцировать Cls с рефлексиями data_member_spec.

Теперь type — это рефлексия, представляющая уникальный тип для каждого набора нестатических полей. Важно, что этот тип автоматически «дедуплицируется» (для одинаковых полей будет получен одинаковый тип) и имеет внешнее связывание.

У нас есть тип (type) и инициализатор (init), осталось их объединить. Это ещё один вызов substitute. Как можно заметить, substitute — одна из самых полезных функций в API библиотеки:

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);

Может показаться, что это мелочь, но мы создаем два разных типа с разными полями. Это довольно круто.

От одного ко многим

Теперь, когда мы разобрались с одной парой ключ-значение, обобщить это на произвольное количество пар не так уж сложно. Для этого нам лишь нужно обобщить функцию parse.

Ранее у нас было один member и один init с типом std::meta::info. Теперь они должны стать std::vector<std::meta_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);
}

Пока у нас всё ещё одна пара ключ-значение, но всё уже готово для обработки множества пар.

Полный JSON

Как я упомянул ранее, Boost.JSON не работает в constexpr контексте. Но цель этой статьи — не показать, как парсить JSON, а как преобразовать JSON-объект в структуру C++. Поэтому я буду делать вид, что Boost.JSON работает.

Вместо string_view и int возьмём 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) объектом.

Итак, случай с числом нам уже знаком, здесь нет ничего нового. Единственный сложный момент — в какой тип C++ отображать JSON-овое число? Согласно спецификации, они могут быть сколь-угодно большими. Но не будем усложнять себе жизнь и предположим, что int хватит всем.

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);
}

Заодно реализуем функцию add_member, чтобы в дальнейшем меньше повторяться:

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);
}

Теперь строки. Для их представления в итоговом типе вполне сгодится тип const char*, а инициализатор для них мы можем получить с помощью функции std::meta::reflect_contant_string, преобразующей строку в рефлексию static constexpr char[] с нуль-терминированным содержанием, соответствующим содержанию переданной строки.

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 умел в constexpr.

Последние штрихи

В реальной реализации функция parse_json принимает string_view и действительно парсит весь JSON:

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

На этом мы могли бы и остановиться, но приправим нашу библиотеку красивым интерфейсом:

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 :];

Вот и всё. Теперь мы можем загрузить произвольный JSON-файл с помощью директивы #embed (также принятой в C++26) и сразу же превратить его в объект C++:

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

constexpr auto v = json_to_object<data>;

Или даже работать напрямую со строковыми литералами, используя определенный нами пользовательский литерал:

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

По сути, мы реализовали аналог JSON type providers из F# в виде довольно компактной библиотеки. Интерфейс, конечно, не совсем такой же, но напильничком по нашей реализации пройтись — и будет не отличить.

(Reflection is) a whole new language.

— Hana Dusíková at June 2025 ISO C++ standards meeting (Sofia, Bulgaria)

Комментарии (11)


  1. vadimr
    28.06.2025 14:43

    Чувак захардкодил данные в исполняемый файл и почему-то считает, что это круто.

    Хотя исходно смысл рефлексии в программировании прямо противоположный - менять код при выполнении программы.


    1. sabudilovskiy
      28.06.2025 14:43

      Как сказать, что ты программируешь только на какой-нибудь Java, не говоря об этом.

      Кто виноват, что у вас такой плохой язык, что он не в состоянии такое сделать на компиляции? что у вас ошибки рефлексии можно увидеть ТОЛЬКО на рантайме


      1. Kotofay
        28.06.2025 14:43

        На Java гнать волну не стоит.

        Смысл хардкодить из json во время компиляции? Проще тогда сразу средствами языка всё описать и в include подключить.
        А рефлексия во время выполнения это средство расширения функционала без переписывания кода.


        1. sabudilovskiy
          28.06.2025 14:43

          Кто тебе сказал, что это нельзя применить и в рантайме? Средство расширение функционала - какого? 99% кода рефлексии на Java делает тупо обход всех полей и сериализацию

          Вы просто реально достали, у вас ущербное мышление, что динамическая рефлексия - это вся рефлексия, которая возможна, хотя динамическую рефлексию при желании можно реализовать через статическую, но не наоборот.


          1. vadimr
            28.06.2025 14:43

            Представьте, что ваш json меняется пользователем в произвольные моменты времени. Попробуйте это реализовать через "статическую рефлексию".


        1. eao197
          28.06.2025 14:43

          Смысл хардкодить из json во время компиляции? Проще тогда сразу средствами языка всё описать и в include подключить.

          А если этот json производится какой-то внешней тулзиной, к исходникам которой у вас даже доступа нет?


          1. vadimr
            28.06.2025 14:43

            Откуда тогда уверенность, что он не изменится за время эксплуатации вашей программы?


            1. DungeonLords
              28.06.2025 14:43

              Вот у нас ровно так и делается. Есть программа на Java и программа на Qt. И нужно создать общий так сказать header file из которого обе программы будут брать актуальный для данного релиза поток данных. Динамически подгружать - только ошибки плодить. А синтаксис у языков разный, JSON бы спас


              1. vadimr
                28.06.2025 14:43

                Перекомпилировать программу из-за изменения данных – жуткий костыль. Так-то ничего в аргументации по поводу хардкодинга не изменилось за последние 70 лет. И именно для того, чтобы не нужно было зашивать структуру данных в исполняемый модуль, но в то же время необязательно было бы писать настоящий самомодифицирующийся код (что возможно далеко не во всех языках) и была придумана рефлексия.


      1. rutexd
        28.06.2025 14:43

        Даже без Java, делать подобные внешние определения - такое себе. Добавляет пространство для ошибок, уменьшает гибкость. Не видно во что компилируется макрос - надо лезть в внешний файл и убивает анализаторы кода.

        Идея хорошая, поиграться / протестировать - за, в прод - боже упаси.


  1. rPman
    28.06.2025 14:43

    Хм... на основе .proto файлов нужно создавать классы