В прошлой статье я представил свою библиотеку для парсинга 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 во время компиляции мы хотим:

  1. Разложить его на токены

  2. На основе стрима токенов разложить на спецификации объектов

  3. Сложить иерархию объектов в пустую структуру и вернуть ее рефлексию.

На выходе мы получим новую структуру, которая подходит для хранения описанных в 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, вероятно, не получится применить на практике другие интересные вещи из рефлексии (что‑то там было про вставку последовательности токенов в код например).

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


  1. SilverTrouse
    21.09.2026 08:47

    Glaze же умеет тоже самое в режиме с++26 рефлексии?


    1. rvetrin Автор
      21.09.2026 08:47

      Похоже на то! Умудрился не наткнутся на эту библиотеку когда изучал что есть в наличии. Видимо тут тогда у меня особенность только в аннотациях, которые могут ускорить парсинг в редких сценариях.


  1. Mingun
    21.09.2026 08:47

    Окей, получили мы

    T out = yjson::ParseJson<jsonType>(input);
    

    Дальше что делать с этим out? Кроме вывода на печать.

    Попробуем представить, как этим пользоваться. Предположим что это большой вложенный JSON. Как нам сохранить в своей структурке одно из его полей (структурного типа конечно же)? Какой тип писать в коде?


    1. rvetrin Автор
      21.09.2026 08:47

      Здесь T это уже готовый тип. Он генерируется на основе схемы. В данном примере это

      struct T
      {

      std::string_view name;

      int age;
      };

      Этот тип заполняется эквивалентно T{"Alex", 22}. Если в JSON присутствуют вложенные объекты, они так же будут частью итоговой структуры как отдельные поля со своим типом.
      Для того, чтобы распарсить только часть JSON, надо использовать механику из библиотеки, которая пока не внедрена в функционал парсинга JSON схемы. А именно, механика аннотации Ignore. Это говорит парсеру, что данное поле не обязательно заполнять.

      Таким образом, для извлечения конкретного поля надо распарсить весь объект (получаем тип со вложенными структурыми) и использовать интересующую часть получившейся структуры.


      1. Mingun
        21.09.2026 08:47

        Вопрос был про то, как нам декомпозировать обработку большого JSON. Понятно же, что писать все в одной функции, используя километровые field.item[42].answer.lives.here, не вариант? Даже если мы их вывели из схемы. Вот мы хотим передать во вспомогательную функцию на обработку field.item[i], что нам писать в ее параметрах?


        1. rvetrin Автор
          21.09.2026 08:47

          На данный момент мы можем только распарсить целиком JSON и дальше уже работать с итоговой структурой (оттуда уже можно применить стандартные С++ приемы вроде move семантики или референсов для передачи отдельных объектов дальше для обработки).


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