В веб-разработке и межсистемных коммуникациях в настоящее время наиболее широко используемым языком описания данных, несомненно, является 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).
