No desenvolvimento web e na comunicação entre sistemas, a linguagem de descrição de dados mais amplamente utilizada hoje é sem dúvida o JSON (JavaScript Object Notation). Já existem no mundo parsers JSON excelentes e de alta velocidade como o RapidJSON e o simdjson. Embora na prática as oportunidades de implantar um parser criado por você mesmo em código de produção sejam raras, “criar o próprio parser JSON” é um excelente material de estudo para aprender sobre análise sintática (parsing), gerenciamento de memória, processamento de strings e otimização de desempenho.
Neste artigo, explicarei detalhadamente o processo de construção do zero de um parser JSON rápido e com alta eficiência de memória, aproveitando os recursos modernos do C++17/C++20 (como std::string_view, std::variant, std::from_chars, etc.).
1. Revisão da especificação JSON (RFC 8259)
A especificação do JSON é estritamente definida na RFC 8259. Para escrever um parser, primeiro você precisa entender corretamente a especificação.
Os tipos de dados do JSON são limitados aos seguintes 6 tipos:
- Object (Objeto): Uma coleção não ordenada de pares de chave (string) e valor. É cercado por
{}e cada par é separado por,. - Array (Matriz/Arranjo): Uma lista ordenada de valores. É cercada por
[]e os valores são separados por,. - String (Cadeia de caracteres): Uma sequência de caracteres Unicode cercada por aspas duplas
"". Inclui escapes com a barra invertida\. - Number (Número): Número inteiro ou de ponto flutuante. Infinito (
Infinity) ou não-número (NaN) não são permitidos. - Boolean (Booleano):
trueoufalse. - Null (Nulo):
null.
De acordo com a especificação, caracteres de espaço em branco (Space, Horizontal Tab, Line Feed, Carriage Return) podem ser inseridos em qualquer lugar entre os tokens, e é necessário analisar a sintaxe enquanto se ignora esses caracteres.
2. Arquitetura do Parser
O processo de análise (Parsing) é geralmente dividido em duas fases: Análise Léxica (Lexical Analysis) e Análise Sintática (Syntactic Analysis).
graph TD
A["String JSON de Entrada"] --> B["Lexer (Tokenizer)"]
B --> C["Fluxo de Tokens"]
C --> D["Parser (Descida Recursiva)"]
D --> E["AST / Árvore DOM (JsonValue)"]
style A fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#bbf,stroke:#333,stroke-width:2px
- Lexer (Analisador Léxico / Tokenizer): Lê a string bruta (matriz de caracteres) de entrada a partir do início e a divide em “unidades mínimas com significado (tokens)”.
- Parser (Analisador Sintático): Lê a sequência de tokens recebida do lexer e constrói uma estrutura em árvore (Árvore DOM: Document Object Model) seguindo as regras gramaticais.
Na implementação atual, para aumentar a eficiência de memória, o lexer será projetado de forma a não copiar strings, mantendo um ponteiro e o comprimento (std::string_view) da string de entrada original.
3. Design do modelo AST (DOM) e C++ moderno
Para representar os vários tipos de dados do JSON em C++, utilizaremos o std::variant, introduzido no C++17. O std::variant é uma união (Union) com segurança de tipos, sendo ideal para representar dados JSON que possuem tipagem dinâmica.
| |
Projetando dessa forma, é possível expressar de forma concisa e segura as estruturas de dados recursivas JsonArray e JsonObject (em algumas implementações da biblioteca padrão do C++, o uso de tipos incompletos dentro de std::variant é restrito, podendo exigir a alocação de heap com ponteiros inteligentes, mas em compiladores modernos, o código acima geralmente funciona perfeitamente).
4. Implementação do Lexer (Analisador Léxico)
O papel do lexer é ler a string e extrair tokens. Primeiro, definiremos os tipos de tokens.
| |
Visualizando as transições de estado internas do lexer com Mermaid, teremos algo assim:
stateDiagram-v2
[*] --> Start : "Pular Espaços em Branco"
Start --> ParseString : "Aspa Dupla ('\"')"
Start --> ParseNumber : "Dígito ou Menos ('-')"
Start --> ParseKeyword : "Caractere ('t', 'f', 'n')"
Start --> ParseSymbol : "Pontuação ('{', '[', etc.)"
ParseString --> Start : "Fim da Aspa ('\"')"
ParseNumber --> Start : "Não-dígito"
ParseKeyword --> Start : "Correspondência de Palavra-chave"
ParseSymbol --> Start : "Caractere Único"
O corpo da implementação do lexer é o seguinte. Ele pula espaços em branco e se ramifica (usando uma instrução switch ou if) dependendo do caractere atual.
| |
O ponto aqui é que os valores de strings (String) e números (Number) são extraídos como std::string_view. Com isso, durante a fase do lexer, não ocorre nenhuma alocação dinâmica de memória (heap allocation) ou cópia. Trata-se de um design fundamental e diretamente ligado ao desempenho.
5. Implementação do Parser (Analisador Sintático): Análise Sintática de Descida Recursiva
Após a conclusão do lexer, agora é finalmente a vez do parser. Como a gramática do JSON é uma gramática LL(1), ela combina perfeitamente com a Análise Sintática de Descida Recursiva (Recursive Descent Parsing), na qual é possível decidir qual função chamar em seguida “observando apenas um token atual”.
| |
O parser de descida recursiva resulta em um código intuitivo e fácil de ler porque a estrutura do código corresponde perfeitamente 1 para 1 com a gramática JSON (BNF). Por exemplo, em parseObject, a sintaxe é analisada na ordem: Chave (String) -> Dois-pontos (Colon) -> Valor (Value).
6. Técnicas de Otimização de Desempenho
Apenas implementar um parser simples não o fará superar as bibliotecas práticas. Apresentarei a seguir algumas técnicas de otimização exclusivas do C++.
6.1. Arquitetura Zero-Copy e std::string_view
A maior parte do gargalo de desempenho de um parser reside em “cópias de strings” e “alocações dinâmicas de memória no heap”.
O uso excessivo de std::string causará alocações de memória cada vez que uma substring for criada. Para evitar isso, utilizamos extensivamente std::string_view no lexer.
O tempo de construção de um std::string_view é concluído em $O(1)$, independentemente do comprimento da string $L$.
6.2. Otimização do Parsing de Números (std::from_chars)
As funções padrão std::stod ou sscanf operam dependendo das configurações atuais de localidade (Locale), o que pode resultar na aquisição de travas (locks) para exclusão mútua internamente ou gerar sobrecargas de localização (overhead).
O std::from_chars introduzido no C++17 é independente de localidade e não envolve cópia de memória, ostentando portanto um desempenho formidável no parsing de números. A complexidade de tempo é $O(M)$, sendo $M$ o número de dígitos.
6.3. Alocação de Memória e std::pmr (Polymorphic Memory Resources)
Durante a construção da AST, ocorre uma grande quantidade de pequenas alocações (fragmentação) devido à criação de nós de std::vector e std::map.
Para evitar isso, é eficaz adotar std::pmr::monotonic_buffer_resource do C++17 como um alocador customizado. Ele aloca um grande bloco de memória previamente e de uma só vez; depois disso, a memória é fatiada simplesmente avançando um ponteiro, de forma que o custo de alocação torna-se praticamente zero.
6.4. Aproveitando SIMD (Avançado)
Em parsers de última geração como o simdjson, são utilizadas instruções SIMD, como AVX2 ou NEON, para rastrear strings de 32 ou 64 bytes de uma só vez. Isso acelera drasticamente o salto de espaços em branco ou a procura por aspas. A implementação deste artigo faz o rastreamento caractere por caractere, mas se você buscar ir ainda mais longe, a programação branchless (sem ramificações) e o uso de SIMD são essenciais.
7. Avaliação de Complexidade e Algoritmo
Vamos avaliar a complexidade algorítmica deste parser. Consideramos o comprimento total da string JSON de entrada como $N$ bytes.
Complexidade de Tempo (Time Complexity): O lexer referencia cada caractere um número constante de vezes (geralmente uma), e o parser realiza processamento de tempo constante para cada token. Não há ocorrência de backtracking (releitura). Portanto, a complexidade de tempo global é linear.
$$ T(N) = O(N) $$Complexidade de Espaço (Space Complexity):
A memória alocada para construir a AST (Árvore DOM) é proporcional à quantidade de elementos na string JSON. Mesmo no pior dos casos (por exemplo: um enorme array aninhado [[[[...]]]]), a quantidade de memória necessária permanecerá dentro de um múltiplo constante do tamanho da entrada $N$.
No entanto, no parsing de descida recursiva, a pilha de chamadas (call stack) é consumida proporcionalmente à profundidade (Depth) de aninhamento do JSON. Uma memória de pilha de $O(D)$ é requerida para uma profundidade $D$. Dado que passar um JSON malicioso aninhado infinitamente tem o risco de causar um Stack Overflow, um parser prático precisa implementar um limite na profundidade de recursão (ex: 256 ou 512) ou desenrolar a recursão em loops.
8. Conclusão
Neste artigo, explicamos os passos para criar um parser JSON do zero em C++.
- Com o lexer, a string é dividida em tokens e a cópia inútil é reduzida usando
std::string_view. - No parser, usa-se a análise sintática de descida recursiva para converter tokens numa AST (
std::variant). - Focando em desempenho, aplicações como
std::from_charse estratégias de gerenciamento de memória são utilizadas.
A experiência de ler e interpretar uma especificação de linguagem e implementá-la em código elevará suas habilidades como programador. Usando este artigo como ponto de partida, tente estender seu próprio parser ou serializador (geração de strings JSON) e aventurar-se em otimizações ainda maiores (como introduzir um alocador customizado ou implementação de SIMD).
