10

Parsing Descendente Recursivo

Introdução10.1

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.

void match(char esperado) {
    if (entrada[ponteiro] == esperado) {
        ponteiro++;
    } else {
        erro_sintatico();
    }
}

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:

void E() {
    T();
    E_linha();
}

void E_linha() {
    if (entrada[ponteiro] == '+') {
        match('+');
        T();
        E_linha();
    }
}

void T() {
    match('i');
}

Podemos representar a execução como uma pilha de chamadas que espelha exatamente a derivação top-down:

$$ \begin{split} E &\Rightarrow T\ E' \\ &\Rightarrow i\ E' \\ &\Rightarrow i + T\ E' \\ &\Rightarrow i + i\ E' \\ &\Rightarrow i + i \end{split} $$

main
└── E()
    ├── T()
    │   └── match('i')
    └── E_linha()
        ├── match('+')
        ├── T()
        │   └── match('i')
        └── E_linha() -> retorna (vazio)

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.

main
└── E()
    ├── T()
    │   └── match('i')     // Avança o ponteiro, sobra "+"
    └── E_linha()           
        ├── match('+')     // Avança o ponteiro, entrada acaba
        ├── T()
        │   └── match('i') // ERRO SINTÁTICO!

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.