В веб-разработке и межсистемных коммуникациях в настоящее время наиболее широко используемым языком описания данных, несомненно, является JSON (JavaScript Object Notation). В мире уже существуют отличные и очень быстрые парсеры JSON, такие как RapidJSON и simdjson. На практике возможность внедрения парсера собственной разработки в продакшн-код выпадает редко, но “создание собственного JSON парсера” — это превосходная тема для изучения синтаксического анализа, управления памятью, обработки строк и оптимизации производительности.
В этой статье подробно описывается процесс создания быстрого и эффективного с точки зрения использования памяти парсера JSON с нуля, с использованием современных возможностей C++17/C++20 (таких как std::string_view, std::variant, std::from_chars).
1. Обзор спецификации JSON (RFC 8259)
Спецификация JSON строго определена в RFC 8259. Чтобы написать парсер, сначала нужно правильно понять эту спецификацию.
Типы данных в JSON ограничены следующими шестью видами:
- Object (Объект): Неупорядоченный набор пар ключ-значение, где ключом выступает строка. Заключается в
{}, а каждая пара разделяется запятой,. - Array (Массив): Упорядоченный список значений. Заключается в
[], а значения разделяются запятой,. - String (Строка): Последовательность символов Unicode, заключенная в двойные кавычки
"". Включает экранирование с помощью обратного слеша\. - Number (Число): Целое число или число с плавающей точкой. Бесконечность (
Infinity) или не число (NaN) не допускаются. - Boolean (Логическое значение):
trueилиfalse. - Null:
null.
Согласно спецификации, пробельные символы (Space, Horizontal Tab, Line Feed, Carriage Return) могут вставляться в любое место между токенами, и при синтаксическом анализе их необходимо игнорировать.
2. Архитектура парсера
Процесс синтаксического анализа (Parsing) обычно делится на две фазы: лексический анализ (Lexical Analysis) и синтаксический анализ (Syntactic Analysis).
graph TD
A["Входная JSON строка"] --> B["Лексер (Токенизатор)"]
B --> C["Поток токенов"]
C --> D["Парсер (Рекурсивный спуск)"]
D --> E["AST / DOM дерево (JsonValue)"]
style A fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#bbf,stroke:#333,stroke-width:2px
- Лексер (Lexer / Tokenizer): Считывает введенную сырую строку (массив символов) с начала и разделяет ее на “наименьшие значимые единицы (токены)”.
- Парсер (Parser): Считывает последовательность токенов, полученных от лексера, и строит древовидную структуру (DOM дерево: Document Object Model) в соответствии с грамматическими правилами.
В нашей реализации, для повышения эффективности использования памяти, лексер не будет копировать строки, а будет сохранять указатель на исходную входную строку и ее длину (std::string_view).
3. Проектирование модели AST (DOM) и современный C++
Для представления различных типов данных JSON в C++ мы воспользуемся std::variant, представленным в C++17. std::variant — это типобезопасное объединение (Union), которое идеально подходит для представления данных JSON с динамической типизацией.
| |
Разработав структуру таким образом, мы можем лаконично и безопасно представлять рекурсивные структуры данных, такие как JsonArray и JsonObject (хотя некоторые реализации стандартной библиотеки C++ ограничивают использование неполных типов внутри std::variant, из-за чего может потребоваться выделение памяти в куче с помощью умных указателей, но в современных компиляторах описанный выше код часто работает).
4. Реализация лексера (лексического анализатора)
Задача лексера — чтение строки и выделение токенов. Сначала определим типы токенов.
| |
Визуализация внутренних переходов состояний лексера с помощью Mermaid выглядит следующим образом:
stateDiagram-v2
[*] --> Start : "Пропуск пробелов"
Start --> ParseString : "Двойная кавычка ('\"')"
Start --> ParseNumber : "Цифра или минус ('-')"
Start --> ParseKeyword : "Символ ('t', 'f', 'n')"
Start --> ParseSymbol : "Пунктуация ('{', '[', и т.д.)"
ParseString --> Start : "Закрывающая кавычка ('\"')"
ParseNumber --> Start : "Не цифра"
ParseKeyword --> Start : "Совпадение с ключевым словом"
ParseSymbol --> Start : "Одиночный символ"
Основная реализация лексера выглядит так. Мы пропускаем пробелы и выполняем ветвление (оператор switch или if) в зависимости от текущего символа.
| |
Ключевой момент здесь заключается в том, что значения строк (String) и чисел (Number) извлекаются как std::string_view. Благодаря этому на этапе лексера не происходит никакого динамического выделения памяти в куче (heap allocation) или копирования. Это важное архитектурное решение, напрямую влияющее на производительность.
5. Реализация парсера (синтаксического анализатора): Рекурсивный спуск
Когда лексер готов, следующим шагом является парсер. Поскольку грамматика JSON является LL(1)-грамматикой, она отлично сочетается с синтаксическим анализом методом рекурсивного спуска (Recursive Descent Parsing), при котором достаточно «посмотреть только на один текущий токен», чтобы определить, какую функцию вызывать следующей.
| |
Парсер рекурсивного спуска имеет интуитивно понятный и легко читаемый код, поскольку структура кода однозначно соответствует грамматике JSON (BNF). Например, в parseObject синтаксический анализ выполняется в порядке: Ключ (String) -> Двоеточие (Colon) -> Значение (Value).
6. Методы оптимизации производительности
Простая реализация парсера не сможет превзойти библиотеки, используемые на практике. Вот несколько методов оптимизации, характерных для C++.
6.1. Архитектура Zero-Copy и std::string_view
Большая часть узких мест производительности парсера связана с “копированием строк” и “динамическим выделением памяти в куче”.
Если часто использовать std::string, каждый раз при создании подстроки будет происходить выделение памяти. Чтобы предотвратить это, мы использовали std::string_view в лексере.
Время создания std::string_view не зависит от длины строки $L$ и выполняется за $O(1)$.
6.2. Оптимизация парсинга чисел (std::from_chars)
Стандартные функции, такие как std::stod и sscanf, зависят от текущих настроек локали (Locale), из-за чего внутри них могут устанавливаться блокировки для взаимного исключения и возникать накладные расходы на локализацию.
Введенная в C++17 функция std::from_chars не зависит от локали и не требует копирования памяти, что обеспечивает невероятную производительность при парсинге чисел. Временная сложность составляет $O(M)$, где $M$ — количество цифр.
6.3. Выделение памяти и std::pmr (Polymorphic Memory Resources)
При построении AST создание узлов для std::vector или std::map приводит к большому числу мелких выделений памяти (фрагментации).
Чтобы избежать этого, эффективно использовать std::pmr::monotonic_buffer_resource из C++17 в качестве кастомного аллокатора. Он один раз выделяет большой блок памяти заранее и просто сдвигает указатель при необходимости новой памяти, благодаря чему затраты на выделение становятся практически нулевыми.
6.4. Использование SIMD (Продвинутый уровень)
В передовых парсерах, таких как simdjson, используются инструкции SIMD, например AVX2 или NEON, для сканирования строк блоками по 32 или 64 байта за раз. Это значительно ускоряет пропуск пробелов и поиск кавычек. Реализация в этой статье сканирует символ за символом, но если вы стремитесь к максимальной производительности, программирование без ветвлений (branchless) и SIMD являются обязательными.
7. Оценка сложности и алгоритма
Оценим алгоритмическую сложность данного парсера. Пусть $N$ — общая длина входной строки JSON в байтах.
Временная сложность (Time Complexity): Лексер обращается к каждому символу константное количество раз (обычно 1 раз), а парсер выполняет константное количество операций для каждого токена. Откат (перечитывание) не происходит. Таким образом, общая временная сложность является линейной.
$$ T(N) = O(N) $$Пространственная сложность (Space Complexity):
Память, выделенная для построения AST (DOM дерева), пропорциональна количеству элементов в строке JSON. Даже в худшем случае (например, при огромном количестве вложенных массивов [[[[...]]]]) требуемый объем памяти останется в пределах константы, умноженной на размер входных данных $N$.
Однако при синтаксическом анализе методом рекурсивного спуска стек вызовов потребляется пропорционально глубине (Depth) вложенности JSON. Для глубины $D$ требуется память стека $O(D)$. Передача злонамеренного JSON с бесконечной вложенностью может вызвать переполнение стека (Stack Overflow), поэтому в практических парсерах необходимо установить ограничение на глубину рекурсии (например, 256 или 512) или преобразовать рекурсию в цикл.
8. Заключение
В этой статье мы рассмотрели шаги по созданию собственного парсера JSON на C++ с нуля.
- Лексер разделяет строку на токены, а использование
std::string_viewпредотвращает лишнее копирование. - Парсер использует синтаксический анализ методом рекурсивного спуска для преобразования токенов в AST (
std::variant). - С учетом производительности применяются такие решения, как
std::from_charsи стратегии управления памятью.
Опыт разбора спецификации языка и ее преобразования в код поднимает навыки программиста на новый уровень. Используя эту статью как отправную точку, попробуйте расширить собственный парсер или сериализатор (генерация строк JSON) и попытаться реализовать дальнейшие оптимизации (например, внедрение кастомного аллокатора или SIMD).
