ESPNNFL Week 1's fashionable arrivals, featuring Joe Burrow and Lamar JacksonThe Jerusalem PostOff-duty IDF soldier suspended after shooting toward Palestinians who allegedly threw stonesRTP DesportoFC Barcelona continua imparável e soma quinta vitória na Liga espanholaESPN DeportesJC Chávez Jr. vence por KO técnico a Jeison TroncosoDaily MaverickANALYSIS: Where is Paul Mashatile, and does the vanishing deputy act threaten his presidential ambitions?וואלהבמהלך מתואם: מנהיגי סקוטלנד, ויילס וצפון אירלנד ידרשו להתפרק מבריטניהCollider‘Spider-Man: Brand New Day’ Officially Crosses the Biggest Domestic Box Office Milestone EverThe Hollywood ReporterNick Bilton, Bari Weiss, Tom Cibrowski Send Congratulatory ’60 Minutes’ Memo to Staff Ahead of Season PremiereWirtualna PolskaEksplozje przy granicy. Tusk ostrzega [SKRÓT DNIA]SportstarIndia vs Sri Lanka LIVE score, Women's Asia Cup 2026 final: SL 62/2 (9.3); Deepti takes Athapaththu wicket; Target 184Guardian SportManchester United v Manchester City: Premier League – liveHabertürkİsrail basını: Cumhurbaşkanı Erdoğan, Türkiye'yi Orta Doğu'da görmezden gelmesi zor bir aktöre dönüştürdü
The Daily Newsstand · Free, Always
Sunday, September 13, 2026

YOLO-JSON: знание [на этапе компиляции] — сила

Translate

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

Идея оптимизация парС++инга JSON выглядит в целом странной. Если важна производительность, наверное будут выбраны другие инструменты для передачи данных. И, наверно, архитектор стороннего API, зная что его клиентам важна скорость, предоставит альтернативу в виде, например, gRPC. Казалось бы это абсолютно бесполезное занятие. Но тем не менее у нас существует simdjson - великолепная библиотека, которая, пожалуй выжимает максимум производительности из доступного нам арсенала. Люди вложили огромный труд ради того, чтобы оптимизировать инструмент, который выбирают, когда производительность не так важна в целом. Давайте поддержим эту философию!

У меня есть опыт разработки выскочастотных систем и там становится очевидным следующая философия - "Настоящая С++корость является компромиссом с гибкостью решения". Самое быстрое решение скорее всего состоит только из компонентов, написанных специально для него. Если мы говорим о проблеме парсинга - идеальный инструмент должен уметь парсить только конкретный вид сообщения. И именно поэтому я бы никогда не использовать simdjson в таких проектах - ведь это общее решение проблемы.

Очевидно, что ради каждого вида сообщения писать свой парсер было бы безумием. В этой статье мы рассмотрим, какого компромисса мы можем достичь с применением новинок стандарта С++26.

Оглавление:

Концепция

Достаточно очевидно, где мы можем ограничить общность решения. Если нам заранее доступна схема JSON и во всех ее вариациях, то мы на стадии компиляции можем составить оптимизированный план парсинга этого JSON. Более очевидно, что нам почти всегда известен объем памяти, который займет выходной объект, что позволяет статически зарезервировать нужный буфер. Так же, если мы на стадии разработки подтвердили постоянство сообщений, нужно ли нам вообще формально валидировать JSON на боевой сборке?

Внедрением именно этой логики и занимается написанный мною С++ фреймворк yolo-json. Название говорит само за себя - у вас появляется еще один способ выстрелить себе в ногу взамен на хороший компромисс между удобством и производительностью. Мы будем интегрировать известные нам данные о приходящих JSON в наш код с помощью новых инструментов рефлекС++ии.

Если СИЛЬНО утрировать, мы будем занимать примерно чем-то таким:

char * curr = "{\"int\":1}";

assert(*curr == '{');
curr++;

assert(strncmp(curr, "\"int\":", 6));
curr += 6;

int num = atoi(curr);

Все просто - идем по строке и, зная какой формат JSON (с точным порядком полей и тому подобное) нас ждет, быстро передвигаемся по строке сразу к полям значений. На данном примере валидация сообщения происходит только в режиме отладки. В боевом режиме же мы достигаем около оптимальной скорости - программа просто прибавляет +7 к пойнтеру и считывает число. Под каждый тип данных или вложенный объект будет своя логика. Разработчик тут превращается в обитателя Завода, где, оперируя макросами и разными утилитами мы адаптируем парсер к каждому отдельному сообщению. И когда порядок полей вдруг решил поменяться - надо руками все переставлять и проверять.

Так мы делали в 2022 году когда такой подход обеспечивал лучший компромисс с точки зрения удобства и скорости разработки инструментария. Скорость парсинга частью компромисса быть не могла. Мы смогли пристроить где-то шаблоны, где-то макросы, где-то разные утилиты, позволяющие уменьшить количество ударов молотком в нашей кузне. Тем не менее, все это было все противно. Однако, такое решение очень даже было рабочим ввиду того, что диалогов на JSON было мало и менялись они почти никогда.

Но теперь на дворе 2026. Теперь в компиляторе есть c++26. Теперь у нас есть шанс сократить код в 26 раз и сохранить эталонную скорость. На этой мотивации и появился yolo-json. Ключевая идея утилиты в том, чтобы пользователь мог в удобной манере задать всю известную ему информацию о JSON который необходимо спарсить. Концепт проще показать на примере ниже:

struct[[= yjson::NotCompressed{}]] A
{
  [[ = yjson::Position{2}, = yjson::MayAbsent{} ]] 
  std::optional<int> a = 5;
  
  [[= yjson::FixSize{1}]] 
  int c = 9;

  [[= yjson::DisplayName{"s"}]]
  std::string  ss = "temp";
  
  B bb;
};

Прям руки чешутся дописать Derive(Debug). Мы берем структуру в которую надо распарсить сообщение и читаем ему аннотации. Например, в аннотации к самой структуре мы помечаем, что сообщение может быть не в сжатой форме и могу присутствовать пробелы, новые линии и прочие ненужные символы. Position здесь указывает на то, какой порядок займет наше поле в сообщении. MayAbsent намекает, что данное поле может вовсе отсутствовать и парсеру к этому надо быть готовым. Ну и так далее. Все это позволит нам, с применением метапрограмирования, сгенерировать оптимальный парсер именно для такой конфигурации.

Рассмотрим как такое может быть реализовано.

Реализация

Скрытый текст

Немного про С++ и метапрограмирование

Как вы уже догадались, фреймворк активно использует свойства рефлекС++ии. РефлекС++ия это во многом про метапрограмирование и тут я бы хотел сделать небольшое отступление и поговорить о С++ и как мы пришли к тому где мы есть.

Все началось со времен когда С++ был C с классами. В нашем арсенале были препроцессинговые команды и макросы. Мы могли сделать условную компиляцию, вставлять большие куски кода макросами, но можно ли это было назвать метапрограммированием? Можно ли это было назвать жизнью? Макросы имеют ряд ограничений и могли лишь вставить код, а арсенал условной препроцессорной компиляции имел массу ограничений.

Но все изменилось, когда Эрвин в 1994 году продемонстрировал необычную програмку на шаблонах и сказал "Смотрите, я метапрограммирую!". Оказывается, манипулируя подставками шаблона, можно управлять, какой код должен быть исполнен. Такое неожиданное открытие получило продолжение ввиду развития шаблонов, которые по сей день являются самым популярным инструментом метапрограмирования на С++.

Стандарт С++11 уже позволял в удобной манере осуществлять широкий набор логики и вычислений на этапе компиляции. И теперь настало время задуматься о рефлекС++ии. К тому моменту идея уже давно прорабатывалась (начиная аж с 2005 года). Но в виду привычки работы с шаблонами, рефлекС++ировать нам предлагали так же через шаблоны, проводя манипуляции подстановками. И тут подкрадывается Некоторое подозрение. Что-то подсказывает, что футурологи из 90х не представляли себе метапрограммирование на С++ как некоторый новый подязык на шаблонах со своими правилами и ловушками. Казалось бы, что если уж мы говорим о метапрограммировании, вещи, которая очень даже фундаментальна для языка С++, мы не должны говорить о случайно раскрытой механике, которую мы развили до абсурда с вариадическими параметрами шаблонов и прочим.

Так что после многолетних споров, было решено - если уж мы делаем С++ - надо делать С++. Мы получили инструменты рефлексии, которые могут быть использованы процедурно и привести к более привычному для С++ программиста из прошлого виду. Во фреймворке yolo-json так же были сделаны шаги для продвижения именно такого стиля метапрограммирования. Мы далее продемонстрируем возможности метапрограммирования с минимальным применением шаблонной уличной магии.

Основные понятия рефлексии.

Про базовые методы рефлексии для целей парсинга JSON достаточно неплохо рассказал Тимур в своей статье. Если вы совсем c этим не знакомы, рекомендую ознакомится с ней. Я же у себя приведу краткую сводку ключевых моментов.

Нам предоставляют std::meta::info, который мы можем взять у большинства объектов и типов. Эта структура - аналог std::any для мира метапрограмирования - он содержит в себе название индентификатора, все данные о типе, аннотации и много чего еще. В примере ниже указано, как можно вывести в печать все поля структуры и их значения:

template <typename T> 
void print_(T & A, std::ostream & stream)
{
  // Берем информацию о типе
  constexpr auto ref = ^^T;
  // Получаем список членов структуры в виде статического массива
  constexpr auto flds = std::define_static_array(
      std::meta::members_of(ref, std::meta::access_context::unchecked()));
  // Убираем ненужные члены структуры
  constexpr auto rel_flds = std::define_static_array(
    std::views::filter(
      flds, [](auto & v)
      { return std::meta::has_identifier(v) && !std::meta::is_function(v); }));
  
  constexpr auto sz = std::ranges::size(rel_flds);
  // Выводим статическую итерацию по всем членам структуры
  template for (constexpr auto idx : std::views::indices(sz))
  {
    constexpr auto & member = rel_flds[idx];
    constexpr auto name = std::meta::identifier_of(member);
    // конвертируем рефлексию обратно для подстановки в выражение
    auto & val = A.[:member:];
    std::print(stream, "\"{}\":{}\n", name, val);
  }
}

Да, синтаксис не очень привычный. Может даже показаться, что ничем не лучше шаблонного кунг-фу (особенно если пройти мимо 20 и 23 стандарта и сразу нырнуть в ranges, views и тому подобные компоненты). Однако, код максимально прямолинейный и когда глаз привыкает к дополнительным новым словам, он становится хорошо читаем. Ошибки в операциях std::meta имеют формальные исключения и их можно отлавливать и обрабатывать во время компиляции.

Скрытый текст

Отмечу тут немного странное поведение компилятора (у меня GCC 16.2). Обычно, нам в целом не важен порядок имплементации функций. Если одна функция зависит от другой, мы имеем право сначала задекларировать функцию и спокойно ее использовать с имплементацией где-нибудь в другом месте. Но когда в дело вступает рефлексия с шаблонными функциями что-то идет не так и декларация воспринимается как имплементация, что дает ошибку о вторичной перегрузке. У себя я это обошел имплементацией статических функций в пустой структуре, что дало инвариантность последовательности. Но это конечно странно...

Помимо этого, мы теперь можем предоставлять дополнительную информацию к нашим типам и объектам с помощью Аннотаций. К этой теме мы передем позже.

Парсинг значений

Сделаем разбор реализации с конца - с разбора парсинга значений полей. Как известно, мы имеем дело со следующими типами данных:

  • Другой JSON

  • Строка

  • Число (целое и нет)

  • Массив (включая массивы произвольных типов, которые у нас трактуются как кортежи)

Кроме этого, каждый элемент может обладать специальными качествами:

  • Может быть null

  • Может иметь несколько допустимых типов

  • Может иногда отсутствовать вовсе

В связи с этим, мы должны продумать правильный роутер для дистрибьюции кода для парсинга. Рассмотрим следующую вариацию ниже. Обратите внимание, что некоторые утилиты (например, IsOption<T>) относительно тривиальны и являются consteval, так что их разбор мы опустим.

// в шаблон подставляется рефлексия требуемого к парсингу типа
template <std::meta::info T, <...>>
static std::pair<char *, typename[:T:]> ParseVal
(
  // указатель на начало объекта
  char * curr, 
  // указатель на крайнюю границу всего JSON
  char * end
)
{
  // Проверяем, что тип поддержан текущей версией
  static_assert(IsSupported<T>());
  // Создаем форму для заполнения. Это уже тип, ожидаемый пользователем
  typename[:T:] out;

  <...>
  // Проверка на null значение
  if constexpr (IsOption<T>())
  {
    <...>
  }

  // Проверка на булеан
  if constexpr (IsBool(base_cls))
  {
    <...>
  }

  // Проверка на массив из разных типов
  else if constexpr (IsTuple(T) || IsTuple(base_cls))
  {
    <...>
  }

  // Проверка на базовый тип (число, строка)
  else if constexpr (IsBase<T>())
  {
    <...>
  }
  // Проверка на контейнер
  else if constexpr (IsContainer<base_cls>())
  {
    <...>
  }
  // Здесь должен быть другой JSON объект
  else
  {
    <...>
  }
  return {curr, out};
}

Функция получает информацию о целевом объекте, границы json строки и возвращает сам объект и указатель на последний обработанный символ. Информация о принадлежности к какой-либо ветки роутера известна на этапе компиляции. Можно было это реализовать при помощи концептов и перегрузки шаблонов, но мне кажется, так получается более наглядно. Рассмотрим по-очереди каждую из возможных веток парсинга. Начнем с Optional:

template <std::meta::info T, bool compressed, <...>>
static std::pair<char *, typename[:T:]> ParseVal
(
  char * curr, 
  char * end
)
{
<...>

  if constexpr (IsOption<T>())
  {
    // есть ли действительно null?
    if (*curr == 'n')
    {
      // В режиме отладки проверяем, что действительно null,
      // В режиме релиз это эквивалентно curr += 4
      SKP_STR("null")
      out = std::nullopt;
      // Если стоит флаг об отсутствии сжатого режима, надо пропустить
      // лишние символы
      if constexpr (!compressed)
        SKP_SPC();
      // Возврат null и указателя на разделитель после значения (',','}' или ']')
      return {curr, out};
    }
    // Если это не null - продолжаем работу
  }

<...>
}

Все, что нам тут интересно, стоит ли там null или нет. Для этого нам надо посмотреть на первый символ значения. Это не будет путаться со строкой так как тогда бы указатель стоял на открывающие ковычки. Макрос SKP_STR делает assert на соответствие входящей строки и прибавляет к переменной curr длину входящей строки.

Похожий принцип наблюдаем в парсинге Булеан:

template <std::meta::info T, bool compressed, <...>>
static std::pair<char *, typename[:T:]> ParseVal
(
  char * curr, 
  char * end
)
{
<...>
  // Изымаем базовый класс в случае Optional
  constexpr auto base_cls =
      IsOption<T>() ? std::meta::template_arguments_of(T)[0] : T;
<...>

  if constexpr (IsBool(base_cls))
  {
    if (*curr == 't')
    {
      SKP_STR("true")
      out = true;
    }
    else
    {
      SKP_STR("false")
      out = false;
    }
    if constexpr (!compressed)
      SKP_SPC();
  }

<...>
}

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

Парсинг кортежа делегируется на отдельную функцию, которую мы рассмотрим ниже:

template <std::meta::info T, bool Compressed = true>
static std::pair<char *, typename[:T:]> 
ParseTuple
(
  char * curr, 
  char * end
)
{
  static_assert(IsTuple(T));
  assert(curr && end);
  assert(*curr == '[');

  // Вывод типов кортежа
  constexpr auto types =
      std::define_static_array(std::meta::template_arguments_of(T));
  constexpr auto sz = types.size();

  typename[:T:] out;
  
  // развертка цикла по типам
  template for (constexpr auto idx : std::views::indices(sz))
  {
    // Алиас конкретного типа элемента кортежа
    constexpr auto tt = types[idx];

    if constexpr (idx > 0)
      assert(*curr == ',');

    curr++;
    if constexpr (!Compressed)
      SKP_SPC();
    
    // рекурсивный парсинг элемента кортежа
    std::tie(curr, std::get<idx>(out)) = ParseVal<tt, Compressed, <...>>(curr, end);

  }
  curr++;

  return {curr, out};
}

Как видно, простой цикл по типам кортежа выглядит органично и не требует вариативных шаблонов. Парсинг каждого элемента рекурсивно делегируется в общую фнукцию для парсинга значений.

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

template <
  std::meta::info T,    // Тип объекта
  char Delim1 = ',',    // 1й Вариант разделителя  
  bool Ignore = false,  // Нужно ли нам само значение или можно игнорировать
  int MinSz = 0,        // Минимальный размер значения
  int FxSz = -1,        // Фиксированный размер значения 
  char Delim2 = Delim1  // 2й Вариант разделителя
>
std::pair<char *, typename[:T:]> ParseBase
(
  char * curr, 
  char * end
)
{
  static_assert(IsBase<T>());
  constexpr int AddLen = MinSz > FxSz ? MinSz : FxSz;
  constexpr bool integral = std::meta::is_integral_type(T);
  constexpr bool floating = std::meta::is_floating_point_type(T);
  constexpr bool str = !integral && !floating;

  // Проверка, надо ли нам искать нужный разделитель. Для строки нам это
  // не требуется, так как и в случаях, когда разделитель точно известен
  constexpr bool precEnd = Delim1 != Delim2 && !str;

  char Delim = Delim1;

  // Если нам не известен разделитель - его надо определить 
  if constexpr (precEnd)
  {
    char * tmp = curr;
    do
    {
      tmp++;
    } while (*tmp != Delim1 && *tmp != Delim2);
    Delim = *tmp;
    end = tmp;
  }

  if constexpr (Ignore)
  {
    char * after;
    // Два варианта для пропуска поля - когда нам известен точный конец
    // сообщения (найденный вместе с вариантом разделителя) и когда нам
    // известен только разделитель - тогда поиск делегируется в парсинг 
    // утилиту
    if constexpr (precEnd)
    {
      after = JSONParser::SkipVal<typename[:T:]>(curr, end, AddLen);
    }
    else
    {
      after = JSONParser::SkipVal<typename[:T:]>(curr, Delim, AddLen);
    }
    assert(*after == Delim);
    return {after, typename[:T:]{}};
  }
  else
  {
    if constexpr (integral || floating)
    {
      char * after;
      typename[:T:] val;
      // то же самое - два варианта парсинга в зависимости от информации о
      // разделителе
      if constexpr (precEnd)
      {
        std::tie(val, after) =
            JSONParser::ReadNumber<typename[:T:]>(curr, end, AddLen);
      }
      else
      {
        std::tie(val, after) =
            JSONParser::ReadNumber<typename[:T:]>(curr, Delim, AddLen);
      }

      assert(*after == Delim);
      return {after, val};
    }
    // парсинг строки
    else
    {
      // Создает указатель Out и складывает туда указатель на начало итоговой строки
      // В строке curr создает null terminator в конце считанной строки.
      GET_STR(Out);
      // curr теперь указывает на запятую после закрывающей ковычки
      const std::size_t len = static_cast<std::size_t>(curr - Out - 1);

      // здесь применяем конструктор строки в виде начала и длины (совместимо как с
      // std::string так с std::string_view)
      return {curr, typename[:T:](Out, len)};
    }
  }
  std::unreachable();
}

Репозиторий еще находится в активной (скорее в ленивой) разработке поэтому пока остается такое решение хоть и явно можно сделать более прямолинейно. Для парсинга чисел используется старая и проверенная временем библиотека utxx, обеспечивая быстрый парсинг чисел при условии, что явно предоставлен указатель на конец числа в строке. Парсинг строки пока сделан без затей - мы идем по строке и ищем закрывающие ковычки учитывая все символы экранирования (подсчет слешей '\').

Парсинг аннотаций

В рамках мета информации, мы так же можем получить Аннотации - еще один инструмент, предоставленный нам в с++26. По смыслу, аннотации это именно то, как звучит - некоторый объект который мы можем приписать нашим данным и далее использовать. На примере ниже можно увидеть, как получается список аннотаций и проводится условная проверка во время компиляции:

// Декларирована переменная с аннотацикей
[[= 3.15]] int x;
// Проверяем, что аннотация действительно найдена (сохраняем результат в pos)
if constexpr (constexpr auto pos =
                  std::define_static_array(std::meta::annotations_of(^^x));
              pos.size() > 0)
{
  // Получаем значение аннотации и проверяем его
  static constexpr auto v = std::meta::extract<double>(pos[0]);
  static_assert(v == 3.15);
}
else
{
  std::unreachable();
}

В качестве объекта мы можем обозначить свою структуру. Эти значения будут доступны нам на этапе компиляции.

Особые свойства входящих JSON будут учитываться в виде аннотаций к структуре и конкретным полям. Для аннотаций на уровне структуры мы используем:

  • Alphabetical - ставит дефолтный порядок полей в алфавитный порядок

  • NotCompressed - пометка о том, что входящая строка содержит пробелы или прочие лишние символы

  • RandomOrder - пометка об отсутствии определенного порядка полей в структуре. В таком режиме допускается любая очередность полей во входящей строке

Как очевидно, вся эта информация достаточно важна и влияет на скорость прохождения по строке. Среди аннотаций для полей у нас выделяется следующее:

  • Position{int} - указатель высшего уровня о нахождении данного поля

  • FixedSize{int} - значение поля имеет известную фиксированную длину

  • MinSz{int} - минимальный размер значения поля

  • Ignore - парсинг поля не требуется

  • DisplayName{str} - Имя поля в JSON (вместо названия в структуре)

  • StaticSz{int} - массив имеет фиксированное количество элементов

  • MayAbsent - поле может полностью отсутствовать (не просто null)

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

Парсинг массивов

Разберем интересующие нас аспекты в вопросе парсинга массивов. У нас могут быть массивы разных типов (которые по факту являются кортежами). Помимо этого, массив может быть фиксированной или произвольной длины. И, наконец, целевой контейнер, в который мы должны положить результат тоже может быть заранее аллоцирован (std::array, например) или динамически расширяемый (причем нельзя делать допущение, что если пользователь хочет фиксированный контейнер, то обязательно именно такое количество элементов будет присутствовать).

Ввиду таких ограничений, получается три разных функции для парсинга: (1) для динамического контейнера (2) для статическог контейнера и (3) для кортежей (см. пасринг тут). Приводить код ниже не буду так как он состоит из элементов, разобранные выше. Точно так же нам известен тип конкретного элемента и особые свойства, передаваемые через аннотации и рефлексию. Стоит отметить, что, очевидно, когда количество элементов нам не известно, мы не можем разложить цикл во время компиляции, что тормозит процесс. Более того, для динамических массивов, мы вынуждены каждый раз делать проверку на предмет конца элементов, что всегда неприятно когда мы говорим о высокочастотных системах.

Скрытый текст

На самом деле код во многих местах еще немного сыроват и не учитывает многих Corner case. Например, в качестве типов фиксированных массивов С++ для нас пока существует только std::array, что достаточно грубовато. Но Ты, да, именно Ты, можешь сделать исправить эту ситуацию и сделать pull request!

Парсинг объекта JSON

Итак, имея все компоненты для парсинга, пора собрать их вместе! Выше мы разобрали, как можно собрать аннотации, спарсить значения и спарсить массивы. Осталось разобрать сам объект JSON. Выглядеть это будет следующим образом:

// Получение релевантных полей из рефлексии
template <std::meta::info T> 
consteval auto GetRelFuncs()
{
  return std::define_static_array(std::views::filter(
    std::meta::members_of(T, std::meta::access_context::unchecked()),
    [](auto & v)
    { return std::meta::has_identifier(v) && std::meta::is_function(v); }));
}

// Верхний уровень парсера
template <std::meta::info T>
std::pair<char *, typename[:T:]> ParseJson
(
  char * curr, 
  char * end
)
{
  using T_ = typename[:T:];
  // Получение аннотаций структуры
  constexpr StructAnnots strAnnots = StructAnnots::MkStrAnnots<T_>();

  // Делегирование непостоянного порядка в отдельную функцию
  if constexpr (strAnnots.m_random_order)
    return ParseJsonRandomOrder<T>(curr, end);

  T_ out{};
  assert(*curr == '{');

  // Получение нужных полей
  constexpr auto flds = GetRelFields<T_>();

  // Получение позиционного порядка полей
  // (Функция будет рассмотрена ниже)
  constexpr auto flds_ord = GetOrderedField<T_>();
  constexpr auto sz = flds.size();

  template for (constexpr auto idx : std::views::indices(sz))
  {
    curr++;
    // Получение следующего по позиции поля
    constexpr auto curr_fld = flds[flds_ord[idx]];

    // Получение аннотаций к полю
    constexpr FieldAnnots curr_ann = FieldAnnots::MkFieldAnnots<curr_fld>();

    constexpr char delim = idx + 1 == sz ? '}' : ',';

    // Делегация парсинга дальше
    std::tie(curr, out.[:curr_fld:]) = 
      ParseObj<curr_fld, curr_ann.m_sz, curr_ann.m_min_sz,
               strAnnots.m_compressed, delim>
    (curr, end);
  }
  if constexpr (!strAnnots.m_compressed)
    SKP_SPC();
  assert(*curr == '}');
  curr++;
  if constexpr (!strAnnots.m_compressed)
    SKP_SPC();
  return {curr, out};
}

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

Теперь рассмотрим сам парсер:

template <
  std::meta::info curr_fld, // информация о поле для парсинга
  int sz,                   // фиксированный размер в строке (если известен)
  int min_sz,               // минимальный размер в строке (если известен)
  bool compressed,          // присутствуют ли лишние пробелы, табуляции и т.п.?
  char Delim                // разделитель после значения (запятая или полускобка)
>
static std::pair<char *, typename[:std::meta::type_of(curr_fld):]>
ParseObj
(
  char * curr, 
  char * end
)
{
  constexpr auto t = std::meta::type_of(curr_fld);

  // Получение аннотаций
  constexpr FieldAnnots curr_ann = FieldAnnots::MkFieldAnnots<curr_fld>();

  // Получение индентификатора
  constexpr std::string_view ident = std::meta::identifier_of(curr_fld);

  typename[:t:] out;

  // выявление имени поля - или идентификатор или аннотация DisplayName
  std::string_view disp_name = std::string_view(curr_ann.m_disp_name.begin());
  std::string_view name = curr_ann.m_disp_name[0] == '\0' ? ident : disp_name;

  if constexpr (!compressed)
    SKP_SPC();

  // Проверка на отсутствие поля
  if constexpr (curr_ann.m_may_absent)
  {
    static_assert(IsOption<std::meta::type_of(curr_fld)>());
    if (end - curr < name.size() + 2)
      return {curr, out};
    char * old = curr;

    // Попытка пропуска поля при условии соответствия названия
    SKP_IF_SV_G(name);
    // field is absent?
    if (curr == old)
    {
      out = std::nullopt;
      --curr;
      return {curr, out};
    }
  }
  // пропуск поля с верификацией в режиме отладки
  else
  {
    if constexpr (!compressed)
      SKP_SPC();

    assert(*curr == '"');
    SKP_STR_SV(name);
  }
  <...>

  // парсинг самого значения (разобрано выше)
  auto [after, v] = ParseVal<t, sz, min_sz, curr_ann.m_static_sz, compressed,
                             Delim, curr_ann.m_ignore>(curr, end);

  return {after, v};
}

Как видно, мы точно так же получаем релевантные характеристики конкретного поля и пропускаем его, прибавляя длину поля к нашему указателю. Далее, когда указатель попадает на начало значения, вызывается разобранная ранее функция парсинга значения поля.

Бенчмарк

Что же мы получаем за все эти проблемы с дополнительными аннотациями и строгими требованиями к порядку полей? Сравним наш результат с самой быстрой (насколько мне известно) библиотекой парсинга JSON, находящейся в открытом доступе - simdjson. Мы попробуем пропустить 1 Гигабайт разных видов JSON через оба фреймворка и посмотрим на результаты.

Мы будем исследовать следующие виды JSON:

1) Структура с динамическими массивами и строками

struct LargeDynamic
{
  std::array<int, 100> ints;
  std::array<double, 100> doubles;
  std::array<std::string_view, 100> strings;
  std::array<Point, 100> points;              
  std::array<std::array<int, 10>, 10> matrix; 
  int id;
  std::string_view label;
};

2-6) Структуры с фиксированными массивами разных типов

// struct Point
// {
//   double x;
//   double y;
//   double z;
// };

struct FixedDoubles
{
  [[= yjson::StaticSize{100}]] std::array<double, 100> doubles;
};

struct FixedStrings
{
  [[= yjson::StaticSize{100}]] std::array<std::string_view, 100> strings;
};

struct FixedPoints
{
  [[= yjson::StaticSize{100}]] std::array<Point, 100> points;
};

struct FixedInts
{
  [[= yjson::StaticSize{100}]] std::array<int, 100> ints;
};

7-8) Структуры со статическими массивами и динамическим выделением памяти

struct DynamicInts
{
  [[= yjson::StaticSize{100}]] std::vector<int> ints;
};

struct DynamicPoints
{
  [[= yjson::StaticSize{100}]] std::vector<Point> points;
};

9) Структура с присутствием пробелов, табуляция и прочее

struct[[= yjson::NotCompressed{}]] LargeNotCompressed
{
  int i0;
  int i1;
  int i2;
  int i3;
  double d0;
  double d1;
  double d2;
  double d3;
  bool b0;
  bool b1;
  Point origin;
};

10) Структура с известной длиной чисел

struct LargeFixed
{
  [[= yjson::Size{8}]] int zero_padded1;
  [[= yjson::Size{8}]] int zero_padded2;
  [[= yjson::Size{8}]] int zero_padded3;
  [[= yjson::Size{8}]] int zero_padded4;
  [[= yjson::Size{8}]] int zero_padded5;
  [[= yjson::Size{8}]] int zero_padded6;
  [[= yjson::Size{8}]] int zero_padded7;
  [[= yjson::MinSize{4}]] int min_width;
  int tail;
};

11) Структура с полями, которые можно пропустить

struct LargeIgnored
{
  [[= yjson::Ignore{}]] int skip_00;
  [[= yjson::Ignore{}]] int skip_01;
  [[= yjson::Ignore{}]] int skip_02;
  [[= yjson::Ignore{}]] int skip_03;
  [[= yjson::Ignore{}]] int skip_04;
  [[= yjson::Ignore{}]] int skip_05;
  [[= yjson::Ignore{}]] int skip_06;
  [[= yjson::Ignore{}]] int skip_07;
  [[= yjson::Ignore{}]] int skip_08;
  [[= yjson::Ignore{}]] int skip_09;
  [[= yjson::Ignore{}]] int skip_10;
  [[= yjson::Ignore{}]] int skip_11;
  [[= yjson::Ignore{}]] int skip_12;
  [[= yjson::Ignore{}]] int skip_13;
  [[= yjson::Ignore{}]] int skip_14;
  [[= yjson::Ignore{}]] int skip_15;
  [[= yjson::Ignore{}]] int skip_16;
  [[= yjson::Ignore{}]] int skip_17;
  [[= yjson::Ignore{}]] int skip_18;
  [[= yjson::Ignore{}]] int skip_19;
  int id;
  std::array<double, 10> nums;
  double value;
  Point origin;
};

12) Структура со случайным порядком полей

struct[[= yjson::RandomOrder{}]] LargeRandomOrder
{
  int f00;
  int f01;
  int f02;
  int f03;
  int f04;
  int f05;
  int f06;
  int f07;
  int f08;
  int f09;
  int f10;
  int f11;
  int f12;
  int f13;
  int f14;
  int f15;
  int f16;
  int f17;
  int f18;
  int f19;
  bool flag;
  double ratio;
};

Посмотрим на результаты бенчмарка для кадой категории. Ниже представлено время, которое потребовалось для обработки 1 Гб соответствующего JSON обоими инструментами:

Результаты сравнения парсинга 1Гб JSON сообщений фреймворками yolo-json и simdjson

Результаты сравнения парсинга 1Гб JSON сообщений фреймворками yolo-json и simdjson

Как видно на графике, даже текущая сыроватая версия в 2-3 раза обгоняет simdjson во многих случаях. Можно заметить, что достаточно узким местом у нас остается парсинг строк и структур, которые полностью состоят из массивов, что ожидаемо, так как поиск конца строки у меня пока не оптимизирован от слова совсем, а для парсинга массивов у нас мало пространства для экономии. Так же ожидаемо плохо получаются динамически алоцируемые массивы (но пользователь в праве предоставить свой тип контейнера с собственной алокацией памяти). Более сильно преимущество складывается в JSON, значимую часть которых составляют названия полей (LargeJust, LargeIgnore). Тут, очевидно, экономия достигается пропуском проверки и парсинга названий полей. Информация о точном размере значений (LargeFixed) так же сыграло огромную роль, позволив обогнать бейзлайн в 5 раз.

Заключение

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

На выходе мы получаем header-only библиотеку, позволяющую скомпилировать оптимизированный парсер именно под определенный тип JSON. С грамотным применением аннотаций, инструмент обгоняет лидера среди парсеров с открытым исходным кодом более чем в 5 раз.

Мною будет продолжена работа над yolo-json. В частности следующие фичи будут реализованы (информация актуальна на 10 сентября 2026. См. на вкладку issues репозитория):

  • Failable parse - более дорогой, но допускающий провал парсер для реализации std::variance и других фич

  • Сериалайзер - с учетом аннотаций

  • Генерация структур из #embed JSON схем (возможно с использованием комментариев в схеме для аннотаций). Технически есть возможность внедрить схему JSON и получить готовую С++ структуру.

  • Генерация структур и аннотаций на основе анализа примеров JSON

  • Более системный подход к определению классов типов (в первую очередь, правила для выявление статических контейнеров)

Считаю, что 26 стандарт - это фундамент для нового подхода к производительности на этапе компиляции. Yolo-json - это эксперимент, который продемонстрировал, что с относительной легкостью мы можем отказаться от тяжёлых рантайм-парсеров в пользу "замороженной" на этапе сборки логики.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.