Agora que temos os tokens produzidos pelo analisador léxico, como descobrir se eles formam um comando válido? A análise sintática responde a essa pergunta verificando não apenas a ordem dos símbolos, mas também a estrutura hierárquica que eles formam na linguagem.
Ideia central
Nesta aula, vamos comparar as duas grandes formas de ler a estrutura de um programa (top-down e bottom-up) e construir na prática nosso primeiro algoritmo de parser: o descendente recursivo.
Duas formas de analisar uma entrada10.2
Antes de focarmos na implementação, precisamos entender a direção do raciocínio. A análise sintática pode ser feita de duas maneiras principais: de cima para baixo (descendente) ou de baixo para cima (ascendente).
Para entender a diferença prática, vamos observar como uma mesma palavra pode ser estruturada a partir da gramática de duas formas inversas. Considere a gramática:
$$
G = (N, T, P, S)
$$
com os elementos abaixo:
$$
\begin{split}
N &= \{S, A, B, C, D\} \\
T &= \{a, b, c, d, e\} \\
S &= S
\end{split}
\hspace{1cm}
\begin{split}
P = S &\to aA \\
A &\to bB \\
B &\to cC \\
C &\to dD \\
D &\to e
\end{split}
$$
e a cadeia de entrada: $abcde$. Como as duas abordagens leem essa palavra?
Leitura descendente (Top-down)
O parser começa do símbolo inicial $S$ e tenta derivar até a palavra. O raciocínio é: "como a estrutura inicial gera esta entrada específica?".
$$
\begin{split}
S &\Rightarrow aA \\
&\Rightarrow abB \\
&\Rightarrow abcC \\
&\Rightarrow abcdD \\
&\Rightarrow abcde
\end{split}
$$
Leitura ascendente (Bottom-up)
O parser parte da palavra já pronta e vai reduzindo as partes conhecidas até chegar ao símbolo inicial. A pergunta é: "que pedaços reduzem ao topo?".
$$
\begin{split}
abcde &\Rightarrow abcdD \\
&\Rightarrow abcC \\
&\Rightarrow abB \\
&\Rightarrow aA \\
&\Rightarrow S
\end{split}
$$
A leitura descendente imita o pensamento de fora para dentro. Ela começa no símbolo inicial, escolhe uma produção, e repete o processo até cobrir todos os terminais. Porém, se o parser usar tentativa e erro (o chamado backtracking), ele pode perder muito tempo explorando caminhos incorretos. Por isso, na prática, buscamos um parser preditivo: aquele que olha para o próximo token da entrada e consegue adivinhar com segurança qual regra usar. É aqui que os problemas vistos na aula anterior (ambiguidade, recursão à esquerda, e fatoração) atrapalham: eles impedem essa previsão segura.
Já a leitura ascendente constrói a árvore de baixo para cima, reconhecendo pedaços menores e aglutinando-os em não terminais maiores. Mas, podemos nos perguntar, por que a família de analisadores LR (bottom-up) lida melhor com algumas gramáticas mais naturais do que a abordagem top-down?
Considere uma gramática clássica com recursão à esquerda:
$$
G = (N, T, P, S)
$$
com os elementos abaixo:
$$
\begin{split}
N &= \{E\} \\
T &= \{id, +\} \\
S &= E
\end{split}
\hspace{1cm}
\begin{split}
P = E &\to E + id \mid id
\end{split}
$$
Para reconhecer $id + id$, um parser descendente preditivo fica preso: ele tenta iniciar a derivação de $E$, vê a regra $E \to E + id$, e ao expandir $E$, precisa expandir $E$ novamente, entrando em loop infinito sem consumir nenhum id.
Um parser ascendente, por outro lado, leria o primeiro id, reconheceria a regra $E \to id$, e reduziria para $E$. Depois leria o + e o próximo id, formando $E + id$, que seria inteiramente reduzido para o $E$ final. Ele lida bem com recursões à esquerda porque a redução ocorre apenas depois que os tokens já foram lidos da entrada.
Reflita
Se a abordagem descendente pensa a estrutura como derivações a partir do símbolo inicial expandindo em partes menores, qual recurso comum de programação mapeia perfeitamente para essa ideia de "uma estrutura chamando suas partes constituintes"?
O algoritmo descendente recursivo10.3
O parser descendente recursivo é a forma mais direta de transformar a ideia top-down em um programa. A ideia central é mapear diretamente a gramática para funções. O princípio do algoritmo é:
cada não terminal da gramática vira uma função;
a função main chama a função do símbolo inicial;
o parser observa a entrada um símbolo por vez;
quando ele espera um terminal, usa uma rotina auxiliar (geralmente chamada de match) para consumir esse terminal se ele coincidir com o próximo token;
se a produção é vazia ($\epsilon$), a função apenas retorna.
A rotina match é responsável por avançar a leitura e lidar com falhas. Ela compara o símbolo que a gramática exige naquele momento com o símbolo atual da entrada.
Se o token bater, o ponteiro avança. Se vier um token inesperado (ou a entrada acabar antes da hora), o match levanta um erro sintático. Na aula anterior, vimos que a recursão à esquerda é inimiga de parsers descendentes. Agora podemos ver o motivo exato disso em código. Se tivéssemos a produção $E \to E + T$, a implementação direta seria:
void E() {
E(); // Chamada recursiva infinita sem consumir entrada!
match('+');
T();
}
Ao executar E(), a função chama E() novamente na primeira linha. Isso causa um stack overflow antes que qualquer caractere seja lido ou testado. É por isso que eliminamos a recursão à esquerda antes de implementar esse algoritmo.
Exemplo de execução: Entrada Válida10.3.1
Vamos acompanhar passo a passo a leitura de uma entrada válida.
Considere a gramática já sem recursão à esquerda:
$$
G = (N, T, P, S)
$$
com os elementos abaixo:
$$
\begin{split}
N &= \{E, E', T\} \\
T &= \{i, +\} \\
S &= E
\end{split}
\hspace{1cm}
\begin{split}
P = E &\to T\ E' \\
E' &\to +\ T\ E' \mid \epsilon \\
T &\to i
\end{split}
$$
e a cadeia: $i + i$. Como as funções reconhecem essa entrada passo a passo? As funções correspondentes na linguagem C seriam:
Observe como a função E_linha() no último nível percebe que não há mais o símbolo + na entrada. Como ela possui a regra $\epsilon$, ela apenas retorna, concluindo o reconhecimento com sucesso.
Exemplo de execução: Entrada Inválida10.3.2
O que acontece quando o programador escreve código inválido? Utilizando a mesma gramática anterior, imagine a entrada incompleta: $i +$. Onde exatamente o parser percebe a falha?
O processo ocorre normalmente até faltar o segundo i.
A chamada de E_linha() reconhece o +, mas obrigatoriamente tem que chamar T() em seguida. A função T() invoca match('i'). Como a entrada já terminou e não há mais o token i, o match dispara a falha sintática. É assim que o parser garante que a cadeia obedeça a uma estrutura completa.
Cuidado conceitual
O parser não aceita a entrada só porque conseguiu reconhecer um prefixo dela. Para haver sucesso, a análise precisa terminar tendo consumido toda a cadeia esperada. O fato de algumas chamadas retornarem por vazio não significa fim de análise, mas sim fim de uma estrutura específica.
Próximos Passos10.4
Agora que a base conceitual está clara, o próximo passo é aplicar esse algoritmo à nossa linguagem. No capítulo seguinte, Parser Descendente Recursivo para Expressões do Slang, vamos pegar essa mesma lógica e transformá-la em código dentro do compilador.