YOLO-JSON: Рефлексия + #embed + JSON schema = сверхоптимизированный парсер

В прошлой статье я представил свою библиотеку для парсинга JSON. Краткое содержание - мы используем новые инструменты стандарта С++26 (в основном, рефлексию) для автоматической генерации оптимизированных парсеров для конкретных JSON.

Оптимизированы они за счет отсутствия валидации (в прод сборке), быстрого пропуск названий полей и дополнительных аспектов, передаваемых через аннотации. По итогу получается парсер, который в несколько раз быстрее simdjson. Сам API выглядит примерно так:
struct B
{
int b;
}
// Для структуры обозначаем, что в json могут быть пробелы и друшие ненужные символы
struct[[= yjson::NotCompressed{}]] A
{
// поле будет третьим по порядку, может отсутвовать вовсе или иметь значение null
[[ = yjson::Position{2}, = yjson::MayAbsent{} ]]
std::optional<int> a;
// значение поля может быть только 1 char (от 0-9)
[[= yjson::FixSize{1}]]
int c;
// в объекте названием поля является "s"
[[= yjson::DisplayName{"s"}]]
std::string ss;
B bb;
};
std::string input = R"({"c": 2, "s": "str", "bb": {"b":42})"
A out = yjson::PraseJson<^^A>(input);В качестве одного из направлений для дальнейшего развития я обозначил импорт схемы JSON из константной строки напрямую в структуру С++. Было бы удобно, если вместо документирования схемы в структуре C++ мы могли бы сохранить это в привычном формате JSON схемы и импортировать эту структуру.
Так как это позволяет продемонстрировать новые (по сравнению с предыдущим разбором) свойства рефлексии, решил посвятить этому небольшой обзор.
Наш план прост как семантика языка С: имея схему JSON во время компиляции мы хотим:
Разложить его на токены
На основе стрима токенов разложить на спецификации объектов
Сложить иерархию объектов в пустую структуру и вернуть ее рефлексию.
На выходе мы получим новую структуру, которая подходит для хранения описанных в JSON схеме данных.
Токенизация схемы
На этом этапе буду краток так как проблема тривиальна и решается без интересных трюков. Главным образом мы хотим массив токенов простого вида:
enum class TokenType : std::uint8_t
{
ObjectBegin, //!< '{'
ObjectEnd, //!< '}'
ArrayBegin, //!< '['
ArrayEnd, //!< ']'
Colon, //!< ':'
Comma, //!< ','
String, //!< "..." (including the enclosing quotes)
Number, //!< numeric literal
True, //!< true
False, //!< false
Null, //!< null
};
struct Token
{
TokenType type;
std::string_view text;
};
Единственный трюк здесь - это сделать функцию и входящую строку consteval, что может потребовать небольших телодвижений, чтобы это сделать без применения кунг-фу с вариабельными шаблонами. Например, вот моя вариация через статическую строку в рефлексии:
template <std::meta::info S>
consteval std::string_view AsStringView()
{
constexpr const char *data = std::meta::extract<const char *>(S);
constexpr std::size_t len = std::meta::extent(std::meta::type_of(S), 0) - 1;
return std::string_view{data, len};
}Далее нам надо очистить входящие символы от мусора (например, пробелы) и классифицировать токены - все прямолинейно и ноль затрат во времени исполнения.
Составление спецификаций объектов
Для финального шага нам надо собрать данные для каждого объекта. Для того, чтобы понять, что это, обратимся к документации cppreference. Нас интересует структура data_member_spec - для каждого члена будущей структуры ее необходимо предоставить некоторые данные:
struct data_member_options
{
std::optional</*name-type*/> name;
std::optional<int> alignment;
std::optional<int> bit_width;
bool no_unique_address = false;
std::vector<std::meta::info> annotations;
};
consteval std::meta::info data_member_spec(
std::meta::info type,
std::meta::data_member_options options
);Тут мы видим, что требуются 2 вещи - это рефлексия типа и конфигурация члена структуры. Для наших целей из последнего нам нужны параметры name и annotations. Итого получается, что нам надо заполнить 3 поля для каждого поля объекта.
С другой же стороны, мы имеем схему JSON, которая имеет необходимые нам сведения:
{
"type": "object",
"properties": {
"values": {
"type": "array",
"items": {
"type": "integer"
},
"minItems": 4,
"maxItems": 4
}
},
"required": ["values"]
}Как видно, все нужные данные нам тут доступны. Тип поля напрямую. Имя поля так же тривиально извлекаемо. Используя атрибуты схемы, мы можем приписать аннотации. Напомню, что в yolo-json аннотации (Обратитесь в исходник если хотите ознакомится с полным списком аннотаций.) это информация, позволяющая нам ускорить парсинг. В примере выше, например, мы можем указать, что массив имеет статический размер 4. Или, например, поле required позволит нам сразу определить какие поля могут отсутствовать, что дает нам важную аннотацию MayAbsent. Так как в данном примере нам не известно в каком порядке будут прибывать объекты JSON, по умолчанию мы так же можем добавить RandomOrder аннотацию.
Применяя базовую логику обработки потока токенов, мы можем собрать мета объекты о типах и спецификации членов данных для каждого объекта.
Детали реализации тут приводить не буду так как принципы были описаны в предыдущей статье.
Составление структур
Как мы показали выше, для каждого объекта JSON мы можем получить метаданные будущей структуры. Теперь остается превратить это в конкретный тип. Для типов JSON integer, string и number это тривиально - мы просто можем вернуть рефлексию соответствующего примитива. Для array тоже относительно тривиально - это агрегация других типов. Для типа object же нам понадобится новая механика стандарта.
Функция std::meta::define_aggregate позволяет соединить собранные нами данные в структуру. Рассмотрим пример ниже:
struct MyAggregate;
consteval
{
std::meta::info members[]
{
std::meta::data_member_spec(^^int, {.name = "id"}),
std::meta::data_member_spec(^^double, {.name = "value"})
};
std::meta::define_aggregate(^^MyAggregate, members);
};
// ок, тип определен
MyAggregate a{42, 3.14};Мы декларируем неполный тип данных и заполняем его нашими спецификациями и типами. Далее мы можем взять рефлексию этого объекта и вернуть как единый пакет.
Но есть НЮАНС. Как указано в примере, это должно быть сделано в consteval блоке. Несмотря на то, что все исполнение происходит в consteval функции, переменные в ней не являются constexpr из-за чего, этот блок по факту не выполняется и на выходе получается неполная структура. Это можно обойти, применяя шаблон, например, таким образом:
template <std::meta::info... Ms> struct SchemaType
{
struct Inner;
consteval
{
std::meta::define_aggregate(^^Inner, { Ms... });
}
};
using T = SchemaType<Specs>::Inner;Такое уже будет работать. Однако, шаблонные параметры мы так же сможем подставить напрямую по тем же причинам. Здесь нам на помощь приходит std::meta::substitute. Это позволит нам подставить метаданные в шаблон и получить std::meta::info итогового типа:
template <std::meta::info... Ms>
using SchemaTypeInner = SchemaType<Ms...>::Inner;
std::vector<std::meta::info> specs;
// Заполняем массив спецификациями...
<...>
// Создаем тип с массивом шаблонных параметров specs
std::meta::info final_type =
std::meta::substitute(^^SchemaTypeInner, specs);
// Можно использовать как
using T = typename[:final_type:];И таким образом мы получаем свежую структуру со всеми аннотациями и полями. Это позволяет нам рекурсивно собрать полноценный JSON в С++ структуре.
Импорт из файла
Теперь вспомним, что у нас теперь есть директива #embed в арсенале С++26. Это позволяет нам интегрировать файлы JSON схем во время компиляции. Таким образом финальный API может выглядеть как-то так:
//!tmp.json
//{
// "type": "object",
// "properties": {
// "name": { "type": "string"},
// "age": { "type": "integer", "minimum": 0 }
// },
// "required": ["name", "age"]
// }
constexpr char s[] = {
#embed "tmp.json"
};
constexpr auto jsonType =
yjson::ParseSchema<std::meta::reflect_constant_string(s)>();
using T = typename[:jsonType:];
char * input = R"({"name":"Alex","age":22})";
T out = yjson::ParseJson<jsonType>(input);Заключение
Мы кратко рассмотрели, как мы можем использовать стандарт 26 для извлечения нужных типов из формальной JSON схемы. Новый стандарт позволяет нам генерировать типы "на лету" до тех пор, пока исходные данные доступны нам на этапе компиляции. По реализации пробежался довольно быстро так как она состоит или из приемов, рассмотренных ранее или представляют собой рутинный код. Полный код представлен в репозитории.
Помимо чтения стандартных атрибутов JSON схемы, мы можем туда теперь добавлять свои для ускорения парсинга. На мой взгляд, можно было бы создать стандарт схем JSON для быстрого парсинга. За базу можно было бы взять существующий 2020-12 и добавить туда атрибуты для целей ускорения парсинга.
Инструменты С++26 позволят разработчикам сократить сотни тысяч строк кода в местах, где ранее было необходимо прибегать к странным хакам и вставкам из assembly. Жаль, что в рамках проекта yolo-json, вероятно, не получится применить на практике другие интересные вещи из рефлексии (что-то там было про вставку последовательности токенов в код например).
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.