Всем привет! Меня зовут Тимур, я работаю в YADRO, и уже больше двух месяцев мой системный компилятор — gcc16. В новом стандарте добавили очень много, и сегодня я расскажу вам о некоторых обновлениях. В основном о рефлексии, но также будет немного и о контрактах.
Рефлексия в программировании — это способность программы работать со своим исходным кодом. Например, пройтись в цикле по всем полям класса и узнать их названия и типы. Работа с json — это очень хороший пример для изучения возможностей рефлексии. Я поигрался с ней, мне очень понравилось, и этой радостью я хочу поделиться с сообществом.

Как я раньше работал с json в C++? Шел по стандартному пути и брал Python. Не шучу, за много лет программирования на C++ я не использовал json в своей работе. Недавно мне потребовалось сделать один небольшой проект за очень сжатые сроки. Надо было читать несколько toml-файлов, обрабатывать данные из них и сохранять в yaml. В последний раз до этого на Python я писал пять лет назад, но выбора у меня все равно не было.
Взял я, значит, Python и начал разбираться с парсингом json. У меня есть небольшой опыт в js, и там работа с json достаточно удобна, что неудивительно. Поэтому использование ["key"] я даже не рассматривал и искал нормальные варианты. За пару дней перепробовал разные решения и в итоге нашел pydantic. Эта библиотека предоставляет удобный способ взаимодействия с данными. Пример кода:
class FrrConfig(BaseModel): enable: bool = True net: str daemons: list[FrrDaemon] ospfd: OspfdFrrConfig | None = None
В коде просто указывается название переменной, тип и значение по умолчанию. Все остальное библиотека берет на себя: сама заполнит все поля и проведет валидацию данных.
После выхода C++26 я понял, что должен сделать то же самое, но на «плюсах». На данный момент есть следующий функционал:
Поддержка базовых типов json.
Можно использовать optional в C++ для хранения опциональных данных.
Json-массив можно представить разными C++-контейнерами, включая хеш-таблицы.
Значения enum отображаются в текстовое представление прямо как в коде
Для типа можно сгенерировать json-схему.
Значения можно валидировать с помощью контрактов.
Для парсинга используется библиотека от nlohmann, а подключается она через Conan. Как мне подсказали мои коллеги, simdjson уже поддерживает рефлексию и в ней можно использовать все то, что я опишу.

Работа с enum
Новый стандарт позволяет автоматически переводить значение enum в его строковое представление из исходного кода. В контексте json это можно использовать для указания только определенного набора значений для строки. Для более плавного знакомства с синтаксисом рефлексии я покажу, как это можно сделать. Рассмотрим enum class:
enum class standart : std::uint8_t { cpp20, cpp23, cpp26 }
Этот тип будет сериализован так:
{“a”: ”cpp20”, “b”: “cpp23”, “c”: “cpp26”}
Его также можно десериализовать и получить значение типа standart.
Функция для получения строкового представления значения enum class:
/** \brief A function for getting a text description of the enum class value \tparam T enum class \param [in] enum_obj enum class value \return enum class value as string */ template<scoped_enum_json_type T> std::string_view get_scoped_enum_value_name(const T& enum_obj) { constexpr auto members = std::define_static_array(std::meta::enumerators_of(^^T)); template for (constexpr auto& member : auto(members)) { if constexpr (std::meta::has_identifier(member) && !std::meta::is_function(member)) { constexpr auto member_name = std::meta::identifier_of(member); if (std::remove_cvref_t<T>([:member:]) == std::remove_cvref_t<T>(enum_obj)) { return member_name; } } } // Inaccessible code return "no_enum_representation"; }
Функция enumerators_of() работает с рефлексией enum и позволяет получить все значения enum в строковом представлении. Define_static_array() вернет массив, доступный на этапе компиляции. В members будут находиться все значения типа T. С помощью нового синтаксиса template for можно проитерироваться по массиву в момент компиляции!
Внутри цикла используется синтаксис if constexpr. Такая проверка выполнится в момент компиляции. С функцией identifier_of можно получить строковое представление значения enum и записать его в переменную, которая будет доступна в коде как обычный string_view. Так как member — объект рефлексии, его нельзя просто сравнить с аргументом функции. Нужно использовать сплайс для превращения рефлексии в обычный C++-объект и сравнить с аргументом функции его.
Новый синтаксис может быть запутанным и не очень понятным, поэтому рассмотрим, что конкретно произойдет в момент компиляции и, соответственно, не будет происходить во время выполнения кода. А что скомпилируется как есть и будет работать только в рантайме.
Определим простое перечисление:
enum class gender : bool { male, female };
Для него цикл template for сделает две итерации — по male и female. Проверка с constexpr выполнится для обоих значений. Логику итоговой функции можно представить примерно так:
if (enum_obj == gender::male) { return "male"; } if (enum_obj == gender::female) { return "female"; }
Вся работа с полями будет происходить похожим образом.
Работа с сериализацией
Я реализовал сериализацию одним заголовочным файлом, не требующим библиотек. Для большей модульности печать в json вынесена в отдельный класс, благодаря чему для смены формата не придется менять сериализатор. Объект, который нужно сериализовать, передается функции to_json.
/** \brief A function for serializing c++ object to json Wrapper over object_to_json \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \tparam S a stream type that supports output operations via `operator<<` \param [in] data the data to print \param [out] stream stream to put json into it \param [in] settings json output stream settings */ template <model_type T, ostream_type S> void to_json(const T& data, S& stream, const json_print_settings& settings = json_print_settings()) { json_printer<S> printer(stream, settings); object_to_json(data, printer); if (settings.multiline) stream << "\n"; }
Это функция-обертка, вызывающая настоящую функцию сериализации object_to_json.
/** \brief A function for serializing c++ object to json \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \tparam S a stream type that supports output operations via `operator<<` \param [in] object the data to print \param [out] printer json printer object */ template <model_type T, ostream_type S> void object_to_json(const T& object, json_printer<S> printer) { constexpr auto ns_refl = ^^T; constexpr auto ctx = std::meta::access_context::current(); constexpr auto members = std::define_static_array(std::meta::members_of(ns_refl, ctx)); printer.open(); template for (constexpr auto& member : auto(members)) { if constexpr (std::meta::has_identifier(member) && !std::meta::is_function(member)) { constexpr auto member_name = std::meta::identifier_of(member); printer.print_name(member_name); class_member_to_json(object.[:member:], printer); } } printer.close(); }
Что делает эта функция:
Берет рефлексию типа с помощью ^^. Контекст позволяет ограничивать область видимости для рефлексии. Например, с помощью
currentуказывается текущая область видимости. То есть вmembersпопадут только те поля, что доступны из этого места в коде. Это сделано специально, чтобы скрыть непубличные члены.Проходит в цикле по всем мемберам класса и проверяет, что у мембера есть имя и он не является функцией.
Вызывает шаблонную функцию обработки члена класса и передает в нее ссылку на поле объекта через сплайс. Изначально вместо шаблонной функции я использовал
if constexpr, но это не подойдет при большом количестве возможных типов.
Определение шаблона функции сериализации объекта:
/** \brief Defining a function for printing data from c++ classes \tparam T interpretation of json type in c++ \tparam S a stream type that supports output operations via `operator<<` \param [in] member the data to print \param [out] printer json printer object */ template <class T, ostream_type S> void class_member_to_json(const T& member, json_printer<S> printer);
Для всех поддерживаемых типов есть своя реализация. Чтобы различать функции, между типами применяются концепты.
Функция сериализации обычного типа:
/** \brief Implementing a function for printing basic json types from c++ classes \tparam T one of the basic json types \tparam S a stream type that supports output operations via `operator<<` \param [in] member the data to print \param [out] printer json printer object */ template <basic_json_type T, ostream_type S> void class_member_to_json(const T& member, json_printer<S> printer) { if constexpr (std::is_same_v<T, std::string>) printer.print_string(member); else printer.print_value(member); }
Концепт для этой функции определяется так:
/** \brief A concept for defining basic json types \tparam T must be one of the built-in integer types, a floating-point number, a string, or a boolean variable */ template <class T> concept basic_json_type = std::integral<T> || std::floating_point<T> || std::is_same_v<T, std::string> || std::is_same_v<T, bool>;
За работу с этими типами отвечает json библиотека, поэтому никакой дополнительной работы для них с моей стороны не проводилось.
Вложенные объекты сериализуются с помощью рекурсии:
/** \brief Implementing a function for printing c++ models as json objects \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \tparam S a stream type that supports output operations via `operator<<` \param [in] member the data to print \param [out] printer json printer object */ template <object_json_type T, ostream_type S> void class_member_to_json(const T& member, json_printer<S> printer) { object_to_json(member, printer); }
Концепт для объекта:
/** \brief A concept for defining models Same as model_type \tparam T must be inherited from the model class */ template <class T> concept object_json_type = std::is_base_of_v<model, T>;
Я использую наследование от типа model как отличительный признак для сериализуемого типа данных. Изначально я сделал так для большего сходства с pydantic. Когда я закончил код, коллеги предложили мне несколько других вариантов: использовать аннотации и проверять их с помощью рефлексии или сделать отдельный трейт serializable.
Для контейнера все будет сложнее. Внутри контейнера могут находиться объекты или другие контейнеры. Сами контейнеры бывают разные, а для json неважно, map, list или queue. Поэтому эта функция немного сложнее:
/** \brief Implementing a function for printing c++ containers as json arrays \tparam T json array or, which is the same thing, c++ emplace_back or emplace container \tparam S a stream type that supports output operations via `operator<<` \param [in] member the data to print \param [out] printer json printer object */ template <container_json_type T, ostream_type S> void class_member_to_json(const T& member, json_printer<S> printer) { printer.open_container(); for (const auto& it : member) { printer.print_container_delimeter(); if constexpr (std::is_base_of_v<model, typename T::value_type>) object_to_json(it, printer); else class_member_to_json(it, printer); } printer.close_container(); }
Здесь в рантайм выполнится цикл по всем элементам контейнера. В момент компиляции для каждой версии шаблонной функции пройдет проверка, является ли тип внутри контейнера наследником model. Если да, то будет вызываться функция сериализации объекта, а если нет, то поля.
Концепт для определения контейнера:
/** \brief A concept for defining a container with adding elements via emplace_back \tparam T must be a container that supports begin(), end(), emplace_back() */ template <class T> concept emplace_back_container_type = requires(T t) { t.begin(); t.end(); t.emplace_back(); }; /** \brief A concept for defining a container with adding elements via emplace \tparam T must be a container that supports begin(), end(), emplace() */ template <class T> concept emplace_container_type = requires(T t) { t.begin(); t.end(); t.emplace(); }; /** \brief A concept for defining containers for json \tparam T must be one of the emplace or emplace_back containers and not optional */ template <class T> concept container_json_type = (emplace_back_container_type<T> || emplace_container_type<T>) && !is_optional<T>;
Этот концепт проверяет, что тип контейнера использует emplace или emplace_back для добавления нового элемента, имеет методы begin и end, а также не является std::optional.
Для обработки std::optional тоже нужна отдельная функция. Optional позволяет указывать опциональные поля, которых может не быть в объекте при сериализации и в исходных данных при десериализации.
/** \brief Implementing a function for printing c++ optionals as json data \tparam T optional with valid json types \tparam S a stream type that supports output operations via `operator<<` \param [in] member the data to print \param [out] printer json printer object */ template <optional_json_type T, ostream_type S> void class_member_to_json(const T& member, json_printer<S> printer) { if (member.has_value()) class_member_to_json(member.value(), printer); else printer.print_null(); }
Функция проверяет наличие значения в optional, и, если оно есть, вызывает для него функцию обработки поля. Если optional пуст, то будет выведен null.
Концепт для optional:
template<typename> constexpr bool is_optional_impl = false; template<typename T> constexpr bool is_optional_impl<std::optional<T>> = true; template<typename T> constexpr bool is_optional = is_optional_impl<std::remove_cvref_t<T>>; /** \brief A concept for defining optional members for json \tparam T must be std::optional with one of the supported json types */ template <class T> concept optional_json_type = is_optional<T>;
Но json может быть не только простым объектом, но и массивом.
// Json из объекта { object } // Json из массива [ array ]
Поэтому для сериализации нужна еще одна функция:
/** \brief A function for serializing c++ container to json \tparam T container with json object type or, which is the same thing, inheritance of the model class in c++ \tparam S a stream type that supports output operations via `operator<<` \param [in] data the data to print \param [out] stream stream to put json into it \param [in] settings json output stream settings */ template <container_json_type T, ostream_type S> void to_json(const T& data, S& stream, const json_print_settings& settings = json_print_settings()) { json_printer<S> printer(stream, settings); printer.open_container(); for (const auto& it : data) { printer.print_container_delimeter(); object_to_json(it, printer); } printer.close_container(); if (settings.multiline) stream << "\n"; }
Эта функция пройдет в цикле по контейнеру и напечатает каждый элемент.
Генерация схемы
Я люблю явную типизацию, из-за этого я когда-то давно перешел с Python на C++. Насколько мне не нравится ванильный js, настолько же мне нравится типизированный ts. А Python с явным указанием типов да с хорошим lsp-сервером — просто красота. Поэтому я был обязан сделать генерацию схемы для типа.
Дам краткое описание json schema, если кто не знает, что это такое. Json schema — документ в формате json, описывающий структуру другого json. В схеме указываются все возможные поля и их типы, могут накладываться ограничения для валидации, а также указываться опциональность полей.
Простой json:
{ "firstName": "John", "lastName": "Doe", "age": 21 }
Пример схемы для этих данных:
{ "$id": "https://example.com/person.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Person", "type": "object", "properties": { "firstName": { "type": "string", "description": "The person's first name." }, "lastName": { "type": "string", "description": "The person's last name." }, "age": { "description": "Age in years which must be equal to or greater than zero.", "type": "integer", "minimum": 0 } } }
В схеме есть стандартные поля со стандартным содержимым — $id, $schema — а title берется из названия класса. Эта схема говорит разработчику или валидатору, что в данных должны быть строковые параметры для имени и фамилии, а также целочисленный возраст больше 0.
Очевидно, что сам по себе json не требует схемы и работать с ним можно и без нее. Можно работать с данными как с контейнером, проверять наличие элементов через ["key"], сравнивать тип. Но такой подход приводит к огромному количеству лишнего кода, который может содержать неприятные ошибки. А схему можно представить как декларативное описание данных, что очень похоже на модель из pydantic или из моего проекта.
Для генерации схемы есть две функции. Функция для сохранения схемы для типа:
/** \brief A function for saving a json schema for a c++ type to a file \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \param [in] file_name the name of the schema file \param [in] settings json output stream settings \return true if the file was opened, otherwise false */ template <model_type T> bool save_schema(const std::string_view& file_name, const json_print_settings& settings = json_print_settings()) { std::ofstream schema_file(file_name.data()); if (!schema_file.is_open()) { return false; } json_printer<std::ofstream> printer(schema_file, settings); printer.open(); constexpr auto model_name = std::meta::identifier_of(^^T); printer.print_name("$id"sv); // "$id": "{type_name}" printer.print_string(model_name); printer.print_name("$schema"sv); // "$schema": "http://json-schema.org/draft-07/schema#" printer.print_string("http://json-schema.org/draft-07/schema#"sv); printer.endl(); type_to_schema<T>(printer); printer.close(); if (settings.multiline) schema_file << "\n"; return true; }
Функция для сохранения схемы для контейнера:
/** \brief A function for saving a json schema for a c++ container type to a file \tparam T json array type or, which is the same thing, c++ container type \param [in] file_name the name of the schema file \param [in] settings json output stream settings \return true if the file was opened, otherwise false */ template <container_json_type T> bool save_schema(const std::string_view& file_name, const json_print_settings& settings = json_print_settings()) { std::ofstream schema_file(file_name.data()); if (!schema_file.is_open()) { return false; } json_printer<std::ofstream> printer(schema_file, settings); printer.open(); printer.print_name("$schema"sv); // "$schema": "http://json-schema.org/draft-07/schema#" printer.print_string("http://json-schema.org/draft-07/schema#"sv); printer.print_name("type"sv); printer.print_string("array"sv); printer.print_name("items"sv); printer.open(); type_to_schema<std::remove_cvref_t<typename T::value_type>>(printer); printer.close(); printer.close(); if (settings.multiline) schema_file << "\n"; return true; }
Эти две функции не очень интересны, так как являются обертками над функцией type_to_schema. Эта функция самая интересная:
/** \brief A function for serializing c++ type to json schema format \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template<typename T, ostream_type S> requires model_type<T> || container_json_type<T> void type_to_schema(json_printer<S> printer) { constexpr auto ns_refl = ^^T; constexpr auto ctx = std::meta::access_context::current(); constexpr auto members = std::define_static_array(std::meta::members_of(ns_refl, ctx)); printer.print_name("type"sv); printer.print_string("object"sv); printer.print_name("properties"sv); printer.open(); template for (constexpr auto& member : auto(members)) { if constexpr (std::meta::has_identifier(member) && !std::meta::is_function(member)) { using member_type = typename [:std::meta::type_of(member):]; constexpr auto member_name = std::meta::identifier_of(member); printer.print_name(member_name); printer.open(); print_member_schema<T, member_type>(printer); printer.close(); } } printer.close(); printer.print_name("additionalProperties"sv); printer.print_value(false); printer.print_name("required"sv); printer.open_container(); template for (constexpr auto& member : auto(members)) { if constexpr (std::meta::has_identifier(member) && !std::meta::is_function(member)) { constexpr auto member_name = std::meta::identifier_of(member); if constexpr (!is_optional<typename [:std::meta::type_of(member):]>) { printer.print_container_delimeter(); printer.print_string(member_name); } } } printer.close_container(); }
В этом коде появилась новая функция — std::meta::type_of. Она получает рефлексию типа поля класса, а после он подставляется в member_type через сплайс. Это позволяет получить полную информацию о поле в рантайме: его название в виде строки, его тип и даже обратиться к нему через объект класса. После чего будет вызвана функция print_member_schema, которой будет передан тип поля. Помимо типа поля, будет передан тип текущего объекта: это нужно для решения проблем рекурсивных типов, когда тип имеет поле с таким же типом.
Еще в этой функции есть второй цикл, ищущий optional. Это нужно, чтобы указать обязательные поля, так как схема предполагает возможность опциональных полей, как и pydantic, и мой проект.
Опциональность поля определяется наличием его в required. Если оно там есть, то оно должно быть и в json.
"additionalProperties": false, "required": [ "a", "b" ]
Такая схема будет требовать наличия "a" и "b" в данных.
Печать типов мало чем отличается от сериализации, поэтому пройдусь по ней кратко. Определение функции:
/** \brief Defining function for printing json schema \tparam T interpretation of json type in c++ \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template<model_type P, class T, ostream_type S> void print_member_schema(json_printer<S> printer);
Функция для печати стандартных типов:
/** \brief Implementing a function for printing basic json types in schema \tparam T one of the basic json types \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template<model_type P, basic_json_type T, ostream_type S> void print_member_schema(json_printer<S> printer) { printer.print_name("type"sv); printer.print_string(type_to_json_schema_name<T>()); }
Функция type_to_json_schem_name возвращает строку с названием типа. Покажу сразу все функции, так как в них ничего интересного нет.
/** \brief Defining a function for getting a json schema type from a c++ type \tparam T interpretation of json type in c++ \return a string with the json schema type */ template<class T> constexpr std::string type_to_json_schema_name(); /** \brief Implementing a function for getting a json schema type from a string \tparam T string or string_view \return a string with the json schema type */ template<class T> requires std::is_same_v<T, std::string> || std::is_same_v<T, std::string_view> constexpr std::string_view type_to_json_schema_name() { return "string"sv; } /** \brief Implementing a function for getting a json schema type from a integer or bool \tparam T one of the integer types or bool \return a string with the json schema type */ template<std::integral T> constexpr std::string_view type_to_json_schema_name() { if (std::is_same_v<T, bool>) return "boolean"sv; return "integer"sv; } /** \brief Implementing a function for getting a json schema type from a floating type \tparam T one of the double or float \return a string with the json schema type */ template<std::floating_point T> constexpr std::string_view type_to_json_schema_name() { return "number"sv; } /** \brief Implementing a function for getting a json schema type from a object type \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \return a string with the json schema type */ template<model T> constexpr std::string_view type_to_json_schema_name() { return "object"sv; }
Для печати схемы объекта вызывается type_to_schema:
/** \brief Implementing a function for printing c++ models in json schema \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template<model_type P, object_json_type T, ostream_type S> void print_member_schema(json_printer<S> printer) { type_to_schema<T>(printer); }
Печать контейнера тоже отличается, так как в типе может быть контейнер с этим же самым типом. И печать типа в лоб в таком случае приведет к переполнению стека вызовов и падению программы. К счастью, создатели схемы добавили для такой ситуации специальный синтаксис.
"a": { "type": "array", "items": { "$ref": "#" } }
С помощью строчки "$ref": "#" тип элемента контейнера указывается как тип объекта, в котором находится контейнер. Функция для печати контейнера:
/** \brief Implementing a function for printing c++ containers in json schema \tparam T json array or, which is the same thing, c++ emplace_back or emplace container \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template<model_type P, container_json_type T, ostream_type S> void print_member_schema(json_printer<S> printer) { printer.print_name("type"sv); printer.print_string("array"sv); printer.print_name("items"sv); printer.open(); if constexpr (std::is_same_v<P, typename T::value_type>) { printer.print_name("$ref"sv); printer.print_string("#"sv); } else if constexpr (std::is_base_of_v<model, typename T::value_type> || emplace_container_type<typename T::value_type> || emplace_back_container_type<typename T::value_type> || scoped_enum_json_type<typename T::value_type>) { print_member_schema<P, typename T::value_type>(printer); } else { printer.print_name("type"sv); printer.print_string(type_to_json_schema_name<typename T::value_type>()); } printer.close(); }
В коде все условия помечены constexpr, и они будут выполнены в момент компиляции. Всего будет три версии функции:
При совпадении типа, из которого была вызвана функция, и типа в контейнере, будет печататься рекурсивный тип.
Если был передан контейнер с объектом, контейнер с контейнером или контейнер с enum, то будет рекурсивно вызвана эта же функция.
Для контейнера с обычным типом будет напечатан тип.
Optional обрабатывается просто:
/** \brief Implementing a function for printing c++ optionals in json schema \tparam T optional with valid json types \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template <model_type P, optional_json_type T, ostream_type S> void print_member_schema(json_printer<S> printer) { print_member_schema<P, typename T::value_type>(printer); }
Еще одной интересной возможностью схемы является поддержка enum:
"h": { "enum": [ "a", "b" ] }
Такой синтаксис ограничивает возможные значения строки двумя буквами — "a" и "b". В Python для этого можно использовать literal, а в C++ — enum class и функции из начала статьи.
/** \brief Implementing a function for printing c++ enum class in json schema \tparam T a string with the value of one of the enum class \tparam S a stream type that supports output operations via `operator<<` \param [out] printer json printer object */ template <model_type P, scoped_enum_json_type T, ostream_type S> void print_member_schema(json_printer<S> printer) { printer.print_name("enum"sv); printer.open_container(); for (const auto& it : get_scoped_enum_values_names<T>()) { printer.print_container_delimeter(); printer.print_string(it); } printer.close_container(); }
На этом генерация схемы закончена.
Десериализация
На самом деле десериализацию изначально я делал после всего остального. Но именно на ней я додумался до текущего способа — получается, что она была первой в текущем варианте. Но здесь я все равно решил оставить ее на конец.
Внешний интерфейс парсинга json чуть сложнее, так как требуется обработать три разных ситуации для двух источников данных.
Источник данных:
файлы,
строка.
Возможная обработка данных:
json-объект,
json-массив,
json-массив, каждый элемент которого нужно обработать определенным образом.
Функция парсинга данных из файла:
/** \brief A function for parsing json file and writing data to a c++ object \tparam T an object that inherits from the model class \param [out] obj the reference for saving data from json \param [in] file_name name of the json data file \return true if the file was opened, otherwise false */ template <model_type T> bool parse_file(T& obj, const std::string_view& file_name) { std::ifstream json_file(file_name.data()); if (!json_file.is_open()) return false; json data = json::parse(json_file); json_to_object(obj, data); return true; }
Функция парсинга из json-библиотеки умеет парсить файлы, поэтому все, что нам нужно, — открыть файл, распарсить его и передать в функцию обработки json с помощью рефлексии.
Для парсинга контейнера сделана отдельная функция:
/** \brief A function for parsing json file and writing data to a c++ object \tparam T a container with object that inherits from the model class type \param [out] obj the reference for saving data from json \param [in] file_name name of the json data file \return true if the file was opened, otherwise false */ template <container_json_type T> bool parse_file(T& obj, const std::string_view& file_name) { std::ifstream json_file(file_name.data()); if (!json_file.is_open()) return false; json data = json::parse(json_file); for (const auto& it : data) { using internal_type = std::remove_cvref_t<typename T::value_type>; internal_type internal_obj{}; json_to_object(internal_obj, it); if constexpr (emplace_back_container_type<T>) obj.emplace_back(std::move(internal_obj)); else if constexpr (emplace_container_type<T>) obj.emplace(std::move(internal_obj)); } return true; }
Парсинг json все так же делается через библиотеку. Цикл происходит по json-контейнеру. На каждой итерации создается временный объект, в который парсится json-объект. После чего на этапе компиляции определяется метод добавления элемента в контейнер и этот метод вызывается для исходного контейнера. Эта функция позволяет парсить json-массив в любой подходящий c++ контейнер.
И последняя функция для парсинга:
/** \brief A function for parsing a json array and passing its elements to a custom lambda function \tparam T an object that inherits from the model class \param [in] file_name name of the json data file \param [in] func the function to which the object of type T will be passed \return true if the file was opened, otherwise false */ template <model_type T> bool parse_file(const std::string_view& file_name, const std::function<void (T&&)>& func) { std::ifstream json_file(file_name.data()); if (!json_file.is_open()) return false; json data = json::parse(json_file); for (const auto& it : data) { T internal_obj{}; json_to_object(internal_obj, it); func(std::move(internal_obj)); } return true; }
Она позволяет передать кастомную функцию обработки данных. Например, можно сделать map, где в качестве ключа будет одно из значений json, а в качестве значения — сам спарcенный json-объект.
Функции для обработки строк ничем не отличаются, поэтому я не буду приводить их здесь. Главным элементом является функция json_to_object, она отвечает за непосредственный парсинг и в случае необходимости может быть заменена на парсинг yaml или toml. Новый стандарт позволяет сделать это с помощью if constexpr. Можно сделать выбор функции для парсинга на основании типа. Но я хотел только продемонстрировать рефлексию, поэтому не стал перегружать проект еще и этим.
Главной функцией для парсинга является json_to_object:
/** \brief A function for parsing json and writing data to a c++ object \tparam T an object that inherits from the model class \param [out] obj the reference for saving data from json \param [in] data json data */ template <model_type T> void json_to_object(T& obj, const json& data) { constexpr auto ns_refl = ^^T; constexpr auto ctx = std::meta::access_context::current(); constexpr auto members = std::define_static_array(std::meta::members_of(ns_refl, ctx)); template for (constexpr auto& member : auto(members)) { if constexpr (std::meta::has_identifier(member) && !std::meta::is_function(member)) { constexpr auto member_name = std::meta::identifier_of(member); auto data_member_it = data.find(member_name); // Check optional if (!is_optional<decltype(obj.[:member:])> && data_member_it == data.end()) { throw std::runtime_error(fmt::format("Non optional property \"{}\" not exist!", member_name)); } // Parsing if (!is_optional<decltype(obj.[:member:])> || (is_optional<decltype(obj.[:member:])> && data_member_it != data.end())) json_to_class_member(obj.[:member:], *data_member_it); // Validation if (data_member_it != data.end()) { template for (constexpr auto& func_member : auto(members)) { if constexpr (std::meta::has_identifier(func_member) && std::meta::is_function(func_member)) { constexpr auto func_name = std::meta::identifier_of(func_member); if constexpr (func_name == std::string(member_name) + "_validate") { obj.[:func_member:](obj.[:member:]); } } } } } } }
Не буду повторно объяснять, как работает обход полей класса, поэтому сразу приступим к новому. В json-данных может не быть чего-то, что не является optional в C++. В таком случае код должен выдавать ошибку. Я сделал exception, чтобы соответствовать библиотеке, но можно и просто вернуть ошибку, выбор за вами. После проверки на optional будет вызвана функция json_to_class_member, которая обработает поле класса.
Определение этой функции:
/** \brief Defining a function for copying data from json to c++ classes \tparam T interpretation of json type in c++ \param [out] member the reference for saving data from json \param [in] data json data */ template <class T> void json_to_class_member(T& member, const json& data);
Она принимает шаблонную ссылку на объект, куда надо сохранить данные. И константную ссылку на json-данные.Обработка базовых типов лежит на библиотеке:
/** \brief Implementing a function for copying basic types from json to c++ types \tparam T one of the basic json types \param [out] member the reference for saving data from json \param [in] data json data with basic types */ template <basic_json_type T> void json_to_class_member(T& member, const json& data) { member = data.get<T>(); }
Из-за концепта базового типа данная функция будет вызываться только для простых типов. Библиотека для работы с json предоставляет шаблонную функцию get, извлекающую указанный тип.
Для объектов будет рекурсивно вызвана функция обработки объекта:
/** \brief Implementing a function for copying object types from json to c++ types \tparam T json object type or, which is the same thing, inheritance of the model class in c++ \param [out] member the reference for saving data from json \param [in] data json data with object type */ template <object_json_type T> void json_to_class_member(T& member, const json& data) { json_to_object(member, data); }
Обработка контейнера уже сложнее. Библиотека умеет обрабатывать контейнеры со стандартными типами: вектор строк или хеш-таблицу чисел она сможет достать из json сама. Но для контейнеров с объектами или другими контейнерами нужно писать отдельную обработку:
/** \brief Implementing a function for copying array types from json to c++ types \tparam T json array or, which is the same thing, c++ emplace_back or emplace container \param [out] member the reference for saving data from json \param [in] data json data with array type */ template <container_json_type T> void json_to_class_member(T& member, const json& data) { if constexpr (std::is_base_of_v<model, std::remove_cvref_t<typename T::value_type>> || scoped_enum_json_type<std::remove_cvref_t<typename T::value_type>> || emplace_back_container_type<std::remove_cvref_t<typename T::value_type>> || emplace_container_type<std::remove_cvref_t<typename T::value_type>>) { for (auto& it : data) { std::remove_cvref_t<typename T::value_type> internal_obj{}; if constexpr (std::is_base_of_v<model, std::remove_cvref_t<typename T::value_type>>) json_to_object(internal_obj, it); else json_to_class_member(internal_obj, it); if constexpr (emplace_back_container_type<T>) member.emplace_back(std::move(internal_obj)); else if constexpr (emplace_container_type<T>) member.emplace(std::move(internal_obj)); } } else // Save container with standart types { member = data.get<T>(); } }
Первый if constexpr в функции выполнится в момент компиляции и разделит шаблонную функцию на два разных множества функций. Функция для контейнера с простым типом будет просто вызывать библиотечный get. Для контейнеров с объектами, с enum class или с другими контейнерами будет отдельная обработка. Объекты будут парситься с помощью json_to_object, а контейнеры — через рекурсивный вызов этой же функции. Добавление в контейнер стандартным образом делится на emplace и emplace_back.
Еще надо обработать optional:
/** \brief Implementing a function for copying any types from json to c++ optional type \tparam T optional with valid json types \param [out] member the reference for saving data from json \param [in] data json data with any type */ template <optional_json_type T> void json_to_class_member(T& member, const json& data) { if (!data.is_null()) { std::remove_cvref_t<typename T::value_type> tmp_member; json_to_class_member(tmp_member, data); member.emplace(std::move(tmp_member)); } }
Эта функция просто проверяет на наличие данных в json, при наличии парсит содержимое и сохраняет в c++ optional поле класса.
Последним обрабатываемым объектом является enum class:
/** \brief Implementing a function for copying string from json to c++ enum class \tparam T a string with the value of one of the enum class \param [out] member the reference for saving data from json \param [in] data json data with string type */ template <scoped_enum_json_type T> void json_to_class_member(T& member, const json& data) { parse_enum(data.get<std::string_view>(), member); }
Валидация
Финальный шаг — валидация. Для нее функция еще раз пройдет по всем полям класса. Она будет искать функцию, которая называется так же, как и поле, но с припиской _validate. Если такая функция есть, то она будет вызвана для поля класса.
Для валидации можно использовать новую возможность стандарта — контракты. Контракт — это ограничение на аргументы функции и возвращаемое значение. С помощью них можно указать, что, например, функция принимает только положительные числа, а возвращает только не пустую строку. Или функция принимает только положительный float:
static void rating_validate(const float& x) pre(x > 0) {}
Такая конструкция позволяет уменьшить объем кода код, убрав такие проверки и выходов из самой функции. Но еще одним преимуществом является возможность отключить эти проверки на этапе компиляции для увеличения производительности. В принципе, ничего не мешает сделать вместо константной ссылки обычную и иметь возможность менять значение. Но я решил не переусложнять проект, поэтому оставил только контрактную валидацию.
Вывод
Мне нравится современный C++, и я рад, что он становится еще более современным. Может быть, к моей пенсии он станет совсем современным, но это не точно. Однако мы уже прямо сейчас можем применять лучшие практики для разработки кода.
Рефлексия делает язык намного гибче и приятнее в использовании для обычных разработчиков. Сериализация, десериализация, логирование и вообще вся обработка объектов значительно упрощаются, и главное — реализуются средствами самого языка. Рефлексия — очень мощный инструмент, и я уверен, что разработчики еще много лет будут придумывать неожиданные применения для нее. Ее синтаксис вначале может показаться сложным. Но после работы над этим проектом для меня он весьма очевиден и логичен.
В проекте я использовал:
рефлексию для генерации кода в момент компиляции,
контракты для удобной и гибко конфигурируемой валидации,
пакетный менеджер conan для подключения библиотек,
cmake для сборки проекта и ctest для запуска тестов,
gtest для написания unit-тестов,
gcov для сбора покрытия кода,
doxygen для документации.
Также я добавил возможность собирать все в контейнере, чтобы не зависеть от окружения, но оставил возможность собирать все на хосте. И настроил статический анализ кода — но пока это не очень полезно, так как анализаторы не умеют в c++26. Единственное, что я еще хотел добавить, но пока не сделал, — C++-модули. Но они будут в следующем проекте.
В заключение хочу сказать: C++ ужасно прекрасен и прекрасно ужасен, за это я и люблю его. Новые функции сильно расширяют функционал языка, и я советую попробовать вам рефлексию, она очень хороша.
Если вы хотите развиваться в C++, возможно, вас заинтересуют наши вакансии: