Всем привет! Меня зовут Тимур, я работаю в 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();
}

Что делает эта функция:

  1. Берет рефлексию типа с помощью ^^. Контекст позволяет ограничивать область видимости для рефлексии. Например, с помощью current указывается текущая область видимости. То есть в members попадут только те поля, что доступны из этого места в коде. Это сделано специально, чтобы скрыть непубличные члены.

  2. Проходит в цикле по всем мемберам класса и проверяет, что у мембера есть имя и он не является функцией.

  3. Вызывает шаблонную функцию обработки члена класса и передает в нее ссылку на поле объекта через сплайс. Изначально вместо шаблонной функции я использовал 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, и они будут выполнены в момент компиляции. Всего будет три версии функции:

  1. При совпадении типа, из которого была вызвана функция, и типа в контейнере, будет печататься рекурсивный тип.

  2. Если был передан контейнер с объектом, контейнер с контейнером или контейнер с enum, то будет рекурсивно вызвана эта же функция.

  3. Для контейнера с обычным типом будет напечатан тип.

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++, возможно, вас заинтересуют наши вакансии:

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