Introdução9.1
Na aula de hoje veremos três problemas clássicos no projeto de gramáticas para compiladores, como reconhecê-los na prática e que tipo de transformação usar em cada caso para obter uma descrição mais correta e mais amigável ao parser. Antes de avançarmos, faremos uma breve revisão sobre Gramáticas Livres de Contexto (GLC). Considere a forma abaixo.
$$G = (N, T, P, S)$$
em que cada componente tem o papel descrito abaixo
- Não-Terminais ($N$): Variáveis que representam estruturas.
- Por exemplo: $E$, $T$, $Cmd$.
- Terminais ($T$): Símbolos básicos da linguagem (tokens).
- Por exemplo:
id,+,if.
- Por exemplo:
- Produções ($P$): Regras de substituição
- Por exemplo: $A \to \alpha$.
- Símbolo Inicial ($S$): O ponto de partida da linguagem.
Em uma derivação, escolhemos um não terminal da sentença atual e o substituímos por um lado direito permitido pela gramática. Repetimos isso até restarem apenas terminais, ou até chegarmos à forma desejada da sentença.
Quando a sequência termina ainda com não terminais, o processo não produziu uma cadeia final da linguagem. Nesse caso, o que temos é uma derivação parcial, isto é, uma forma sentencial intermediária. Por exemplo, $aaS$ ainda não é uma resposta final porque o símbolo $S$ ainda precisa ser expandido.
Como ao longo do texto vamos falar bastante em o parser "consumir" símbolos da entrada, vale fixar desde já essa ideia. Consumir um token significa reconhecer o próximo símbolo da cadeia de entrada e avançar a leitura para o símbolo seguinte. Por exemplo, se a entrada for id + id, um parser pode primeiro consumir id, depois consumir + e, em seguida, consumir o segundo id.
Em geral, a cadeia de entrada é pensada com um marcador especial de fim, representado por $. A leitura ideal termina quando toda a entrada foi consumida e sobra apenas esse $, que indica que não há mais símbolos reais a reconhecer. Se a derivação chegar ao que queríamos, mas ainda restarem tokens antes do $, então a análise ainda não terminou de verdade: a gramática reconheceu só uma parte da entrada. Nesse caso, a sentença não foi completamente aceita, porque ainda existe conteúdo não consumido.
Quando dizemos que um parser não consome nada, queremos dizer que ele continua aplicando regras sem avançar sobre a entrada.
Considere a seguinte gramática.
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{S\} \\ T &= \{a, b\} \\ S &= S \end{split} \hspace{1cm} \begin{split} P = S &\to aS \mid b \end{split} $$
Aqui, o símbolo inicial é $S$ e a linguagem gerada contém cadeias formadas por zero ou mais $a$ seguidos de um $b$, como $b$, $ab$ e $aaab$. Veja como seria uma derivação possível para $aaab$:
$$ \begin{split} S &\Rightarrow aS \\ &\Rightarrow aaS \\ &\Rightarrow aaaS \\ &\Rightarrow aaab \end{split} $$
Esse exemplo mostra duas coisas importantes, a primeira é que a gramática não é apenas uma lista de regras, e a segunda que a derivação é o caminho concreto que explica como a cadeia foi construída.
Uma ideia importante que deve nos acompanhar do início ao fim é que duas gramáticas podem gerar a mesma linguagem e, ainda assim, serem muito diferentes do ponto de vista da interpretação e da implementação. Em compiladores, não basta gerar cadeias válidas, precisamos também impor a estrutura correta e permitir uma análise sintática viável.
Nesta aula, vamos comparar três dificuldades diferentes:
- Uma gramática que permite interpretações demais;
- Uma gramática que faz um parser descendente entrar em loop e
- Uma gramática que obriga o parser a decidir cedo demais qual produção seguir.
Ambiguidade e desambiguação9.2
Uma gramática é ambígua quando a mesma cadeia admite mais de uma árvore sintática. Como a árvore carrega a estrutura da expressão, isso significa que a entrada pode receber mais de uma interpretação válida dentro da própria gramática.
Antes de pensar em casos maiores, vamos observar como a ambiguidade pode aparecer de casos mais simples.
Considere a gramática
$$ 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 + E \mid id \end{split} $$
e a cadeia:
$$ id + id + id $$
Essa entrada pode ser lida de duas formas diferentes? Se sim, quais são elas?
Sim. A mesma cadeia admite duas árvores sintáticas, porque a gramática não fixa sozinha como os operandos devem ser agrupados:
- $(id + id) + id$;
- $id + (id + id)$.
Podemos enxergar isso também por duas derivações diferentes, ambas começando no símbolo inicial $E$.
Leitura 1
$$ \begin{split} E &\Rightarrow E + E \\ &\Rightarrow E + E + E \\ &\Rightarrow id + E + E \\ &\Rightarrow id + id + E \\ &\Rightarrow id + id + id \end{split} $$
Leitura 2
$$ \begin{split} E &\Rightarrow E + E \\ &\Rightarrow id + E \\ &\Rightarrow id + E + E \\ &\Rightarrow id + id + E \\ &\Rightarrow id + id + id \end{split} $$
Como as duas árvores são válidas para a mesma cadeia, a gramática é ambígua. Em outras palavras, a gramática não determina sozinha como os operandos devem ser agrupados.
Agora vamos para um caso mais rico, em que o problema não é apenas a associatividade, mas também a precedência entre operadores.
Considere a gramática
$$ 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 + E \mid E * E \mid (E) \mid id \end{split} $$
e a cadeia:
$$ id + id * id $$
Quando misturamos soma e multiplicação, qual agrupamento a gramática deveria impor?
Ela admite pelo menos estas duas leituras:
Também podemos construir duas derivações diferentes a partir do símbolo inicial $E$.
Soma primeiro
$$ \begin{split} E &\Rightarrow E * E \\ &\Rightarrow E + E * E \\ &\Rightarrow id + E * E \\ &\Rightarrow id + id * E \\ &\Rightarrow id + id * id \end{split} $$
$$ (id + id) * id $$
Multiplicação primeiro
$$ \begin{split} E &\Rightarrow E + E \\ &\Rightarrow id + E \\ &\Rightarrow id + E * E \\ &\Rightarrow id + id * E \\ &\Rightarrow id + id * id \end{split} $$
$$ id + (id * id) $$
Matematicamente, a segunda leitura é a desejada. O problema é que a gramática original não obriga isso, ela permite ambas. Portanto, a ambiguidade aqui não é um detalhe estético da árvore. Ela muda o significado da expressão.
Se a mesma expressão pode gerar duas árvores diferentes, onde exatamente devemos mexer, na entrada, no parser ou na própria estrutura da gramática? Como podemos escrever a gramática de modo que a multiplicação fique naturalmente mais "funda" do que a soma?
Precedência e associatividade9.2.1
Como não existe um algoritmo capaz de decidir, para qualquer gramática, se ela é ambígua ou não, em vez de tentar verificá-la exaustivamente, utilizamos padrões de construção conhecidos (como níveis de precedência e associatividade) para projetar gramáticas que já são garantidamente não ambíguas desde a sua definição.
Para desambiguar expressões, normalmente reorganizamos a gramática em níveis.
- operadores de menor precedência ficam em níveis mais altos;
- operadores de maior precedência ficam em níveis mais profundos;
- a direção da recursão ajuda a determinar a associatividade.
Uma forma clássica de reescrever o exemplo da questão 2 seria:
Agora $+$ aparece em $E$, enquanto $*$ aparece em $T$. Como $T$ está mais perto das folhas, a multiplicação é reconhecida em um nível mais interno da árvore. Isso faz com que ela seja agrupada antes da soma, mesmo sem precisarmos escrever uma regra externa dizendo isso em palavras.
Como a gramática reescrita força id + id * id a ser lida com a multiplicação primeiro?
Vamos derivar a cadeia
$$ id + id * id $$
usando a nova gramática.
$$ \begin{split} E &\Rightarrow E + T \\ &\Rightarrow T + T \\ &\Rightarrow F + T \\ &\Rightarrow id + T \\ &\Rightarrow id + T * F \\ &\Rightarrow id + F * F \\ &\Rightarrow id + id * F \\ &\Rightarrow id + id * id \end{split} $$
Repare que a derivação não cria a estrutura $(id + id) * id$. A própria organização em níveis impede esse agrupamento e força a leitura em que a multiplicação aparece dentro da soma.
Precedência diz qual operador deve ficar mais "fundo" na árvore quando operadores diferentes aparecem juntos. Associatividade responde a outra pergunta. Quando dois operadores de mesma precedência aparecem em sequência, quem agrupa primeiro? Precisamos disso porque a mesma expressão pode continuar ambígua mesmo depois de decidir a precedência.
- associatividade à esquerda: $a - b - c$ é lido como $(a - b) - c$;
- associatividade à direita: $a^{b^{c}}$ é lido como $a^{(b^{c})}$.
Como expressar associatividade à direita para o expoente sem perder a associatividade à esquerda de + e *?
Vamos estender a gramática de expressões com um nível para potência. Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{E, T, P, F\} \\ T &= \{id, +, *, \text{\textasciicircum}, (, )\} \\ S &= E \end{split} \hspace{1cm} \begin{split} P = E &\to E + T \mid T \\ T &\to T * P \mid P \\ P &\to F^{P} \mid F \\ F &\to (E) \mid id \end{split} $$
Nessa organização, $+$ e $*$ continuam associativos à esquerda, enquanto o expoente passa a ser associativo à direita. Isso acontece porque, na produção de $P$, a continuação da potência reaparece do lado direito.
Vamos derivar:
$$ id^{id^{id}} $$
$$ \begin{split} E &\Rightarrow T \\ &\Rightarrow P \\ &\Rightarrow F^{P} \\ &\Rightarrow id^{P} \\ &\Rightarrow id^{F^{P}} \\ &\Rightarrow id^{id^{P}} \\ &\Rightarrow id^{id^{F}} \\ &\Rightarrow id^{id^{id}} \end{split} $$
Como o não terminal $P$ reaparece do lado direito da potência, a árvore induzida é:
Portanto, a leitura será: $id^{(id^{id})}$
Por que id - id - id é agrupado da esquerda para a direita nessa gramática?
Para enxergar a associatividade à esquerda, pense agora em:
$$ id - id - id $$
com a gramática:
$$ E \to E - T \mid T $$
A estrutura gerada tende a ser:
ou seja: $(id - id) - id$
Esse detalhe é importante porque mudar a associatividade muda o resultado de várias operações, como subtração, divisão e potenciação.
Ambiguidade não aparece apenas em expressões aritméticas. Ela também surge em comandos e estruturas de controle.
Em if c then if c then a else a, com qual if o else deve se ligar?
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{S\} \\ T &= \{if, then, else, c, a\} \\ S &= S \end{split} \hspace{1cm} \begin{split} P = S &\to \text{if } c\ \text{then } S \mid \text{if } c\ \text{then } S\ \text{else } S \mid a \end{split} $$
e a cadeia:
$$ \text{if } c\ \text{then if } c\ \text{then } a\ \text{else } a $$
O else pode se ligar ao if mais interno ou ao mais externo. Como a gramática não deixa explícito qual ligação deve ser preferida, surgem duas árvores possíveis.
Else no $if$ interno
Else no $if$ externo
Esse exemplo mostra que ambiguidades são problemas de estrutura e interpretação, não apenas de cálculo numérico.
A solução clássica para este problema consiste em separar os comandos em duas categorias, comandos "casados" (Matched) e comandos "não casados" (Unmatched). Um comando é Matched se todos os seus if possuem um else correspondente. Ele é Unmatched se houver pelo menos um if sem else.
A regra de ouro é a seguinte. Sempre que aparece um else, ele deve se ligar ao if mais próximo que ainda está sem fechamento. A separação entre Matched e Unmatched serve exatamente para codificar essa ideia dentro da gramática.
$$ \begin{split} S &\to \text{Matched} \mid \text{Unmatched} \\ \text{Matched} &\to \text{if } c \text{ then Matched else Matched} \mid a \\ \text{Unmatched} &\to \text{if } c \text{ then } S \mid \text{if } c \text{ then Matched else Unmatched} \end{split} $$
Podemos enxergar o papel dessas categorias em dois casos pequenos.
Caso 1
Para a cadeia
$$ \text{if } c\ \text{then } a\ \text{else } a $$
temos uma derivação toda dentro de Matched:
$$ \begin{split} S &\Rightarrow \text{Matched} \\ &\Rightarrow \text{if } c\ \text{ then Matched else Matched} \\ &\Rightarrow \text{if } c\ \text{ then } a\ \text{ else Matched} \\ &\Rightarrow \text{if } c\ \text{ then } a\ \text{ else } a \end{split} $$
Aqui não sobra nenhum if aberto. Por isso o comando é "casado".
Caso 2
Para a cadeia
$$ \text{if } c\ \text{then if } c\ \text{then } a\ \text{else } a $$
a derivação passa por Unmatched no nível externo:
$$ \begin{split} S &\Rightarrow \text{Unmatched} \\ &\Rightarrow \text{if } c\ \text{ then } S \\ &\Rightarrow \text{if } c\ \text{ then Matched} \\ &\Rightarrow \text{if } c\ \text{ then if } c\ \text{ then Matched else Matched} \\ &\Rightarrow \text{if } c\ \text{ then if } c\ \text{ then } a\ \text{ else Matched} \\ &\Rightarrow \text{if } c\ \text{ then if } c\ \text{ then } a\ \text{ else } a \end{split} $$
Repare que o else fecha o if interno. O if externo continua sendo o comando não casado.
Dessa forma, a gramática força o else a se acoplar ao if mais interno, eliminando a possibilidade de interpretação ambígua. Em outras palavras, o else fica “pendurado” no if mais próximo que ainda não recebeu fechamento.
Desambiguar não significa apenas "arrumar a aparência" das produções. O objetivo real é fazer a gramática impor uma única estrutura para cada cadeia relevante.
Recursão à esquerda9.3
Agora o problema muda. Uma gramática pode estar representando bem a linguagem e, mesmo assim, ser inadequada para um parser descendente. Isso acontece quando existe recursão à esquerda. Uma produção da forma:
$$ A \to A\alpha \mid \beta $$
faz o parser tentar expandir $A$, chamando $A$ de novo antes de consumir qualquer símbolo de entrada. Em um parser top-down, isso pode levar a um loop infinito, porque a análise fica presa repetindo a mesma decisão sem avançar sobre a cadeia.
Por que a produção abaixo trava um parser descendente?
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{E, T\} \\ T &= \{id, +\} \\ S &= E \end{split} \hspace{1cm} \begin{split} P = E &\to E + T \mid T \\ T &\to id \end{split} $$
Observe a produção:
$$ E \to E + T \mid T $$
Se o parser começar tentando reconhecer $E$, ele pode seguir este raciocínio:
$$ E \Rightarrow E + T \Rightarrow E + T + T \Rightarrow E + T + T + T \Rightarrow \cdots $$
Repare no ponto central. Ele continua expandindo $E$ sem consumir a entrada. Esse é exatamente o motivo do problema, o parser fica preso numa chamada recursiva que não avança sobre a cadeia.
Se a recursão à esquerda faz o parser se chamar de novo antes de ler qualquer terminal, como poderíamos reescrever a mesma ideia para que ele consuma primeiro uma parte segura da entrada e deixe as repetições para depois?
Eliminando a recursão à esquerda imediata9.3.1
Quando temos:
$$ A \to A\alpha \mid \beta $$
reescrevemos como:
$$ \begin{split} A &\to \beta A' \\ A' &\to \alpha A' \mid \epsilon \end{split} $$
Na prática, $A'$ representa as continuações que antes estavam embutidas na recursão à esquerda. Assim, o parser reconhece primeiro uma base segura e depois processa zero ou mais repetições.
Como eliminar a recursão à esquerda de A -> Aa | b sem alterar a linguagem?
Elimine a recursão à esquerda de:
$$ A \to Aa \mid b $$
Temos uma alternativa recursiva e uma não recursiva. Aplicando o molde:
$$ \begin{split} A &\to bA' \\ A' &\to aA' \mid \epsilon \end{split} $$
Agora o parser reconhece primeiro $b$ e só depois aceita quantas continuações $a$ forem necessárias.
Como reescrever uma lista com várias alternativas recursivas à esquerda?
Agora considere um caso um pouco mais rico. Aqui, a vírgula é um símbolo literal da linguagem, então ela deve aparecer como token, não como separador da escrita matemática.
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{L, Stmt\} \\ T &= \left\{\begin{array}{l} \text{;} \\ \text{,} \end{array}\right. \\ S &= L \end{split} \hspace{1cm} \begin{split} P = L &\to L\text{;}Stmt \mid L\text{,}Stmt \mid Stmt \end{split} $$
Aqui, Stmt representa um comando atômico qualquer. Se quiser um exemplo concreto, pense nele como algo como a ou b.
As partes recursivas são $L\text{;}Stmt$ e $L\text{,}Stmt$, enquanto a parte não recursiva é $Stmt$. Se quisermos visualizar isso com uma lista concreta, podemos imaginar Stmt como a. Nesse caso, uma cadeia como $a\text{,}a\text{;}a$ segue o mesmo molde:
$$ \begin{split} L &\Rightarrow Stmt\ L' \\ &\Rightarrow a\ L' \\ &\Rightarrow a\text{,}Stmt\ L' \\ &\Rightarrow a\text{,}a\ L' \\ &\Rightarrow a\text{,}a\text{;}Stmt\ L' \\ &\Rightarrow a\text{,}a\text{;}a \end{split} $$
Dessa forma:
$$ \begin{split} L &\to Stmt\ L' \\ L' &\to \text{;}Stmt\ L' \mid \text{,}Stmt\ L' \mid \epsilon \end{split} $$
Essa nova forma preserva a linguagem, mas evita a expansão infinita pela esquerda. Além disso, deixa explícita a ideia de que uma lista começa com um elemento base e pode ser seguida por várias continuações.
Recursão à esquerda indireta9.3.1.1
Às vezes a recursão não é imediata. Em vez de aparecer logo na própria regra, ela surge depois de passar por outro não terminal. Considere a forma abaixo.
$$ \begin{split} A &\to B a \mid b \\ B &\to A c \mid d \end{split} $$
Aqui, $A$ chama $B$, que por sua vez chama $A$. Ou seja, o ciclo existe, mas ele está escondido em duas produções. Para resolver, usamos a substituição. A ideia é expandir $B$ dentro da regra de $A$ para tornar esse ciclo visível.
$$ A \to (A c \mid d) a \mid b \implies A \to A c a \mid d a \mid b $$
Agora temos uma recursão à esquerda imediata em $A$. A vantagem é que, depois dessa substituição, o problema volta ao caso que já sabemos tratar com o método anterior. Basta identificar $\beta_1=da$, $\beta_2=b$ e $\alpha=ca$. Aplicando a transformação, obtemos:
$$ \begin{split} A &\to da\ A' \mid b\ A' \\ A' &\to ca\ A' \mid \epsilon \end{split} $$
O caso das expressões aritméticas9.3.2
A gramática que reorganizamos antes para impor precedência e associatividade resolve bem o problema semântico da expressão. Ela decide corretamente quem agrupa antes e como os operadores se associam. Mesmo assim, ela ainda não está pronta para um parser top-down, porque $E$ e $T$ continuam com recursão à esquerda. Considere a gramática:
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{E, T, F\} \\ T &= \{id, +, -, *, /, (, )\} \\ S &= E \end{split} \hspace{1cm} \begin{split} P = E &\to E + T \mid E - T \mid T \\ T &\to T * F \mid T / F \mid F \\ F &\to (E) \mid id \end{split} $$
$E$ e $T$ ainda possuem recursão à esquerda.
Como transformar a gramática de expressões para preservar a semântica e remover a recursão à esquerda?
Aplicando o procedimento em $E$:
$$ E \to E + T \mid E - T \mid T $$
Identificamos:
$$ \alpha_1 = +T, \qquad \alpha_2 = -T, \qquad \beta = T $$
Assim:
$$ \begin{split} E &\to T E' \\ E' &\to + T E' \mid - T E' \mid \epsilon \end{split} $$
Fazendo o mesmo para $T$:
$$ \begin{split} T &\to F T' \\ T' &\to * F T' \mid / F T' \mid \epsilon \end{split} $$
A gramática resultante fica:
$$ \begin{split} E &\to T E' \\ E' &\to + T E' \mid - T E' \mid \epsilon \\ T &\to F T' \\ T' &\to * F T' \mid / F T' \mid \epsilon \\ F &\to (E) \mid id \end{split} $$
A gramática transformada ainda aceita id + id - id?
Vamos verificar a derivação de
$$ id + id - id $$
na gramática transformada.
$$ \begin{split} E &\Rightarrow T E' \\ &\Rightarrow F E' \\ &\Rightarrow id E' \\ &\Rightarrow id + T E' \\ &\Rightarrow id + F E' \\ &\Rightarrow id + id E' \\ &\Rightarrow id + id - T E' \\ &\Rightarrow id + id - F E' \\ &\Rightarrow id + id - id E' \\ &\Rightarrow id + id - id \end{split} $$
Observe que a linguagem foi preservada. Continuamos aceitando as mesmas combinações, mas agora em uma forma apropriada para análise descendente.
Eliminar recursão à esquerda resolve um problema operacional do parser. Isso, por si só, não garante que a gramática tenha deixado de ser ambígua.
Fatoração à esquerda9.4
O terceiro problema aparece quando duas ou mais produções de um mesmo não terminal começam com o mesmo prefixo. Nessa situação, um parser preditivo vê o início da entrada e ainda não consegue decidir com segurança qual regra usar.
Em termos gerais, se temos:
$$ A \to \alpha\beta_1 \mid \alpha\beta_2 $$
podemos fatorar para:
$$ \begin{split} A &\to \alpha A' \\ A' &\to \beta_1 \mid \beta_2 \end{split} $$
A ideia é adiar a decisão. Primeiro reconhecemos o prefixo comum, depois escolhemos a continuação adequada.
O que o parser deve fazer quando vê duas produções com o mesmo começo?
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{A\} \\ T &= \{a, b, c\} \\ S &= A \end{split} \hspace{1cm} \begin{split} P = A &\to ab \mid ac \end{split} $$
As duas alternativas começam por $a$. Então, fatoramos:
$$ \begin{split} A &\to aA' \\ A' &\to b \mid c \end{split} $$
Antes, o parser precisava escolher entre duas produções que começavam igual. Agora ele consome $a$ primeiro e deixa a escolha para o momento em que $b$ ou $c$ aparecer.
Se duas produções começam exatamente com o mesmo prefixo, como um parser preditivo poderia decidir imediatamente entre elas? Não parece mais seguro consumir primeiro a parte comum e adiar a escolha?
Qual é o prefixo comum destas declarações e como a fatoração ajuda a leitura?
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{D\} \\ T &= \{int, id, ;, =, num\} \\ S &= D \end{split} \hspace{1cm} \begin{split} P = D &\to \text{int } id\ ; \mid \text{int } id = \text{num}\ ; \end{split} $$
O prefixo comum é $\text{int } id$. Assim:
Aqui, num representa um token léxico de número. Não é uma variável conceitual da gramática.
$$ \begin{split} D &\to \text{int } id\ D' \\ D' &\to ; \mid = \text{num} ; \end{split} $$
Agora a decisão só precisa ser tomada depois que o parser já reconheceu a parte compartilhada pelas duas declarações.
Como fatorar uma gramática em que todas as alternativas começam com id?
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{S, E, L\} \\ T &= \{id, :=, (, )\} \\ S &= S \end{split} \hspace{1cm} \begin{split} P = S &\to id := E \mid id(L) \mid id \end{split} $$
Todas as alternativas começam por $id$. Assim, fatoramos para:
$$ \begin{split} S &\to id\ S' \\ S' &\to := E \mid (L) \mid \epsilon \end{split} $$
Essa nova versão deixa claro o comportamento do parser. Primeiro reconhecer um identificador, depois decidir se ele inicia uma atribuição, uma chamada ou se a produção termina ali.
O que fazer quando o prefixo comum é curto, mas as continuações ainda se parecem muito?
Um caso um pouco mais desafiador é:
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{Cmd, Args, Params, Body, Expr\} \\ T &= \{id, (, ), \{, \}, =, ;\} \\ S &= Cmd \end{split} \hspace{1cm} \begin{split} P = Cmd &\to id(Args); \mid id(Params)\{Body\} \mid id = Expr; \end{split} $$
Aqui, Args, Params, Body e Expr também são placeholders. Eles representam o conteúdo interno de cada forma de comando.
O prefixo comum imediato é $id$. A primeira fatoração fica:
$$ \begin{split} Cmd &\to id\ Cmd' \\ Cmd' &\to (Args); \mid (Params)\{Body\} \mid = Expr; \end{split} $$
Essa transformação já melhora a decisão inicial do parser. Ainda assim, perceba que as duas alternativas que começam com $($ continuam próximas demais. Dependendo da definição de $Args$ e $Params$, talvez ainda seja preciso fatorar mais uma vez ou reorganizar essas categorias.
Fatoração não é desambiguação9.4.1
Este é um ponto que costuma gerar confusão. Fatorar à esquerda remove não determinismo local para o parser, mas não garante interpretação semântica única. Em termos práticos, o parser passa a decidir melhor qual produção seguir ao olhar os primeiros símbolos da entrada, mas isso não significa que a estrutura resultante tenha deixado de ser ambígua.
Considere a gramática
$$ G = (N, T, P, S) $$
com os elementos abaixo:
$$ \begin{split} N &= \{S, E, S'\} \\ T &= \{if, then, else\} \\ S &= S \end{split} \hspace{1cm} \begin{split} P = S &\to \text{if } E\ \text{then } S\ \text{else } S \mid \text{if } E\ \text{then } S \end{split} $$
Podemos fatorar para:
$$ \begin{split} S &\to \text{if } E\ \text{then } S\ S' \\ S' &\to \text{else } S \mid \epsilon \end{split} $$
Aqui, o prefixo comum é if E then S. A fatoração ajuda o parser a consumir primeiro essa parte comum e adiar a decisão sobre a presença ou não de else. Isso melhora a previsibilidade da análise, mas não elimina automaticamente o problema conceitual do else pendente. São técnicas diferentes para problemas diferentes. Uma melhora a previsibilidade da análise. A outra resolve a multiplicidade de interpretações.
Fatoração à esquerda melhora a previsibilidade do parser. Desambiguação melhora a unicidade da interpretação. Uma coisa não substitui a outra.
Colocando lado a lado9.5
Agora que os três conceitos já apareceram em exemplos concretos, fica mais fácil compará-los sem misturar seus objetivos.
A ordem das coisas9.5.0.1
Na prática, as transformações muitas vezes ocorrem em sequência. Por exemplo, ao desambiguar uma gramática de expressões para impor precedência, é comum introduzirmos recursão à esquerda. Logo em seguida, precisamos eliminá-la para permitir o uso em um parser descendente. O resultado final é uma gramática que é ao mesmo tempo não ambígua (pela estrutura de níveis) e previsível (pela ausência de recursão à esquerda).
| Problema | Sintoma principal | Pergunta útil | Técnica principal |
|---|---|---|---|
| Ambiguidade | Mais de uma árvore para a mesma cadeia | A entrada admite mais de uma interpretação? | Desambiguação |
| Recursão à esquerda | Loop em parser top-down | O parser volta ao mesmo não terminal antes de consumir entrada? | Eliminação de recursão à esquerda |
| Não determinismo local | Várias produções com o mesmo prefixo | O parser consegue decidir cedo qual regra seguir? | Fatoração à esquerda |
Questões9.6
1. Considere a gramática $G = (N, T, P, S)$ dada por:
$$ \begin{split} N &= \{S\} \\ T &= \{a, b\} \\ S &= S \end{split} \hspace{1cm} \begin{split} P = S &\to aS \mid b \end{split} $$
(a) Explique o papel de cada componente da quádrupla $G = (N, T, P, S)$.
(b) Faça uma derivação completa para a cadeia aaab.
(c) Mostre uma derivação parcial que ainda não tenha produzido uma cadeia final da linguagem.
(d) Explique, com suas palavras, a diferença entre uma forma sentencial intermediária e uma cadeia final reconhecida.
2. Suponha que um parser esteja tentando reconhecer a entrada id + id $ e que sua gramática permita começar por um não terminal que gera expressões. Explique o que significa, operacionalmente, dizer que o parser:
(a) consumiu um token;
(b) não consumiu nada;
(c) reconheceu apenas um prefixo da entrada;
(d) só pode declarar sucesso quando sobra apenas o marcador $.
3. Considere a gramática ambígua:
$$ E \to E + E \mid id $$
para a cadeia id + id + id.
(a) Mostre dois agrupamentos distintos aceitos por essa gramática.
(b) Explique por que esses dois agrupamentos correspondem a duas árvores sintáticas diferentes.
(c) Diga, com precisão, por que essa gramática é ambígua.
4. Considere a gramática:
$$ E \to E + E \mid E * E \mid (E) \mid id $$
e a cadeia id + id * id.
(a) Mostre duas leituras possíveis para essa entrada.
(b) Explique por que uma dessas leituras é a interpretação aritmética usual e a outra não.
(c) Explique por que, nesse caso, a ambiguidade afeta o significado da expressão e não apenas a aparência da árvore.
5. Reescreva a gramática da questão anterior para impor precedência de * sobre +, usando níveis como E, T e F. Depois:
(a) apresente a nova gramática;
(b) faça uma derivação para id + id * id;
(c) explique por que a estrutura (id + id) * id deixa de ser possível nessa nova versão.
6. Considere a gramática abaixo, que deve manter + e * associativos à esquerda e tornar ^ associativo à direita:
$$ \begin{split} E &\to E + T \mid T \\ T &\to T * P \mid P \\ P &\to F^P \mid F \\ F &\to (E) \mid id \end{split} $$
(a) Explique por que a produção de $P$ induz associatividade à direita para a potência.
(b) Mostre o agrupamento correto de id^id^id.
(c) Explique por que id - id - id, em uma gramática do tipo $E \to E - T \mid T$, fica associado à esquerda.
7. Considere a gramática clássica do dangling else:
$$ S \to \text{if } c\ \text{then } S \mid \text{if } c\ \text{then } S\ \text{else } S \mid a $$
para a cadeia if c then if c then a else a.
(a) Explique quais são as duas leituras possíveis para o else.
(b) Mostre por que essa cadeia é ambígua nessa gramática.
(c) Reescreva a gramática usando as categorias Matched e Unmatched.
(d) Explique como essa separação força o else a se ligar ao if mais próximo ainda não fechado.
8. Considere a gramática:
$$ \begin{split} E &\to E + T \mid T \\ T &\to id \end{split} $$
(a) Identifique a recursão à esquerda imediata.
(b) Explique por que um parser descendente pode entrar em loop ao tentar reconhecer $E$.
(c) Mostre uma sequência de expansões que evidencie o problema de não consumir entrada.
9. Elimine a recursão à esquerda imediata das gramáticas abaixo, preservando a linguagem em cada caso:
(a) $A \to Aa \mid b$
(b) $L \to L;Stmt \mid L,Stmt \mid Stmt$
(c) $E \to E + T \mid E - T \mid T$
Em cada item, identifique claramente quais são as partes recursivas ($\alpha$) e quais são as bases não recursivas ($\beta$).
10. Considere a gramática com recursão à esquerda indireta:
$$ \begin{split} A &\to Ba \mid b \\ B &\to Ac \mid d \end{split} $$
(a) Explique por que a recursão à esquerda não aparece de forma imediata na regra de $A$.
(b) Faça a substituição necessária para tornar o ciclo visível.
(c) Depois da substituição, elimine a recursão à esquerda resultante.
(d) Explique por que esse procedimento resolve o problema para um parser top-down.
11. A gramática de expressões abaixo já codifica precedência e associatividade corretamente, mas ainda não é adequada para parser descendente:
$$ \begin{split} E &\to E + T \mid E - T \mid T \\ T &\to T * F \mid T / F \mid F \\ F &\to (E) \mid id \end{split} $$
(a) Transforme a gramática para remover a recursão à esquerda.
(b) Mostre uma derivação de id + id - id na gramática transformada.
(c) Explique por que essa transformação resolve um problema operacional do parser, mas não é, por si só, uma técnica geral de desambiguação.
12. Faça a fatoração à esquerda das gramáticas abaixo e explique qual decisão foi adiada em cada caso:
(a) $A \to ab \mid ac$
(b) $D \to \text{int } id\ ; \mid \text{int } id = \text{num}\ ;$
(c) $S \to id := E \mid id(L) \mid id$
(d) $Cmd \to id(Args); \mid id(Params)\{Body\} \mid id = Expr;$
13. Considere a gramática fatorada:
$$ \begin{split} S &\to \text{if } E\ \text{then } S\ S' \\ S' &\to \text{else } S \mid \epsilon \end{split} $$
(a) Explique qual prefixo comum foi extraído da gramática original.
(b) Explique por que essa fatoração ajuda o parser preditivo a decidir melhor.
(c) Explique por que, mesmo fatorada, essa gramática não resolve sozinha o problema semântico do dangling else.
14. Para cada situação abaixo, identifique qual técnica principal deve ser aplicada: desambiguação, eliminação de recursão à esquerda ou fatoração à esquerda. Em seguida, justifique em uma ou duas frases.
(a) A mesma cadeia admite duas árvores sintáticas diferentes.
(b) O parser chama o mesmo não terminal novamente antes de consumir qualquer token.
(c) Duas produções de um mesmo não terminal começam com o mesmo prefixo.
(d) Uma gramática de expressões já impõe precedência corretamente, mas ainda faz um parser descendente entrar em loop.
(e) Uma gramática foi fatorada, mas continua permitindo duas interpretações estruturais diferentes para a mesma entrada.
1.
(a) $N$ é o conjunto de não-terminais, $T$ é o conjunto de terminais, $P$ é o conjunto de produções e $S$ é o símbolo inicial.
(b) Uma derivação possível é:
$$ \begin{split} S &\Rightarrow aS \\ &\Rightarrow aaS \\ &\Rightarrow aaaS \\ &\Rightarrow aaab \end{split} $$
(c) Um exemplo de derivação parcial é $S \Rightarrow aS \Rightarrow aaS$.
(d) Forma sentencial intermediária ainda pode conter não-terminais; cadeia final reconhecida contém apenas terminais da linguagem.
2.
(a) Consumir um token significa reconhecer o próximo símbolo da entrada e avançar o ponteiro de leitura.
(b) Não consumir nada significa continuar expandindo regras sem avançar na entrada.
(c) Reconhecer apenas um prefixo significa aceitar só uma parte da cadeia, deixando tokens ainda não consumidos.
(d) O sucesso completo exige que toda a entrada válida tenha sido lida e que reste apenas $, indicando fim da entrada.
3.
(a) Os dois agrupamentos são (id + id) + id e id + (id + id).
(b) No primeiro, a soma da esquerda é formada antes; no segundo, a soma da direita é formada antes. Isso produz árvores com estruturas internas diferentes.
(c) A gramática é ambígua porque a mesma cadeia id + id + id admite mais de uma árvore sintática válida.
4.
(a) As duas leituras são (id + id) * id e id + (id * id).
(b) A leitura aritmética usual é id + (id * id), porque * tem precedência maior que +.
(c) A ambiguidade muda o significado da expressão, pois cada árvore impõe uma ordem diferente de avaliação.
5.
(a) Uma reescrita clássica é:
$$ \begin{split} E &\to E + T \mid T \\ T &\to T * F \mid F \\ F &\to (E) \mid id \end{split} $$
(b) Uma derivação possível é:
$$ \begin{split} E &\Rightarrow E + T \\ &\Rightarrow T + T \\ &\Rightarrow F + T \\ &\Rightarrow id + T \\ &\Rightarrow id + T * F \\ &\Rightarrow id + F * F \\ &\Rightarrow id + id * id \end{split} $$
(c) Como * aparece em um nível mais profundo que +, a multiplicação fica necessariamente dentro do lado direito da soma, impedindo a estrutura (id + id) * id.
6.
(a) Em $P \to F^P \mid F$, a continuação da potência reaparece à direita, então cada novo ^ se encaixa no operando da direita.
(b) O agrupamento correto é id^(id^id).
(c) Em uma gramática como $E \to E - T \mid T$, a recursão ocorre pela esquerda, o que induz o agrupamento (id - id) - id.
7.
(a) O else pode se ligar ao if interno ou ao if externo.
(b) Como as duas estruturas são válidas para a mesma cadeia, a gramática é ambígua.
(c) Uma solução é:
$$ \begin{split} S &\to \text{Matched} \mid \text{Unmatched} \\ \text{Matched} &\to \text{if } c \text{ then Matched else Matched} \mid a \\ \text{Unmatched} &\to \text{if } c \text{ then } S \mid \text{if } c \text{ then Matched else Unmatched} \end{split} $$
(d) Essa separação garante que todo else feche o if mais próximo ainda pendente, eliminando a dupla interpretação.
8.
(a) A recursão à esquerda imediata está em $E \to E + T$.
(b) Um parser descendente pode expandir E chamando E de novo antes de consumir qualquer token.
(c) Uma sequência típica é:
$E \Rightarrow E + T \Rightarrow E + T + T \Rightarrow E + T + T + T \Rightarrow \cdots$
O parser fica preso repetindo a expansão sem avançar sobre a entrada.
9.
(a) Para $A \to Aa \mid b$: $\alpha = a$ e $\beta = b$. Resultado:
$$ \begin{split} A &\to bA' \\ A' &\to aA' \mid \epsilon \end{split} $$
(b) Para $L \to L;Stmt \mid L,Stmt \mid Stmt$: $\alpha_1 = ;Stmt$, $\alpha_2 = ,Stmt$ e $\beta = Stmt$. Resultado:
$$ \begin{split} L &\to Stmt\ L' \\ L' &\to ;Stmt\ L' \mid ,Stmt\ L' \mid \epsilon \end{split} $$
(c) Para $E \to E + T \mid E - T \mid T$: $\alpha_1 = +T$, $\alpha_2 = -T$ e $\beta = T$. Resultado:
$$ \begin{split} E &\to T\ E' \\ E' &\to +T\ E' \mid -T\ E' \mid \epsilon \end{split} $$
10.
(a) Ela é indireta porque $A$ não chama a si mesmo diretamente; o ciclo ocorre por meio de $B$.
(b) Substituindo $B$ em $A$:
$$ A \to (Ac \mid d)a \mid b \implies A \to Aca \mid da \mid b $$
(c) Eliminando a recursão à esquerda:
$$ \begin{split} A &\to da\ A' \mid b\ A' \\ A' &\to ca\ A' \mid \epsilon \end{split} $$
(d) Depois da transformação, o parser reconhece primeiro uma base segura (da ou b) e deixa as repetições para depois, sem laço imediato pela esquerda.
11.
(a) A forma sem recursão à esquerda é:
$$ \begin{split} E &\to T\ E' \\ E' &\to +T\ E' \mid -T\ E' \mid \epsilon \\ T &\to F\ T' \\ T' &\to *F\ T' \mid /F\ T' \mid \epsilon \\ F &\to (E) \mid id \end{split} $$
(b) Uma derivação para id + id - id é:
$$ \begin{split} E &\Rightarrow T E' \\ &\Rightarrow F E' \\ &\Rightarrow id E' \\ &\Rightarrow id + T E' \\ &\Rightarrow id + F E' \\ &\Rightarrow id + id E' \\ &\Rightarrow id + id - T E' \\ &\Rightarrow id + id - F E' \\ &\Rightarrow id + id - id \end{split} $$
(c) A transformação elimina um obstáculo operacional para o parser top-down, mas não garante, sozinha, unicidade de interpretação em qualquer gramática; desambiguação e remoção de recursão à esquerda são objetivos diferentes.
12.
(a) $A \to ab \mid ac$ fatora para:
$$ \begin{split} A &\to aA' \\ A' &\to b \mid c \end{split} $$
A decisão adiada é se, após a, a continuação será b ou c.
(b) $D \to \text{int } id\ ; \mid \text{int } id = \text{num}\ ;$ fatora para:
$$ \begin{split} D &\to \text{int } id\ D' \\ D' &\to ; \mid =\ \text{num}\ ; \end{split} $$
A decisão adiada é se a declaração termina imediatamente ou se possui inicialização.
(c) $S \to id := E \mid id(L) \mid id$ fatora para:
$$ \begin{split} S &\to id\ S' \\ S' &\to := E \mid (L) \mid \epsilon \end{split} $$
A decisão adiada é se o identificador inicia uma atribuição, uma chamada ou aparece sozinho.
(d) $Cmd \to id(Args); \mid id(Params)\{Body\} \mid id = Expr;$ fatora para:
$$ \begin{split} Cmd &\to id\ Cmd' \\ Cmd' &\to (Args); \mid (Params)\{Body\} \mid = Expr; \end{split} $$
A decisão adiada é se o id inicia uma chamada, uma definição com parâmetros e bloco, ou uma atribuição.
13.
(a) O prefixo comum extraído é if E then S.
(b) A fatoração ajuda porque o parser consome primeiro a parte compartilhada e só depois decide se existe um else.
(c) Isso melhora a previsibilidade local da análise, mas não basta para resolver sozinho o problema estrutural do dangling else, que exige desambiguação explícita.
14.
(a) Desambiguação, porque o problema é haver mais de uma árvore para a mesma cadeia.
(b) Eliminação de recursão à esquerda, porque o parser retorna ao mesmo não terminal antes de consumir entrada.
(c) Fatoração à esquerda, porque o problema é o prefixo comum entre produções concorrentes.
(d) Eliminação de recursão à esquerda, porque a semântica já está correta, mas a forma ainda é inadequada para análise descendente.
(e) Desambiguação, porque fatoração melhora a decisão local do parser, mas não elimina por si só múltiplas interpretações semânticas.
Próximos Passos9.7
Agora que entendemos como preparar uma gramática para análise descendente, o próximo passo é estudar o algoritmo que executa essa ideia em tempo de compilação. No capítulo Recursive Descent Parsing, vamos transformar não-terminais em funções, ver como o parser consome tokens com match e preparar o terreno imediato para o laboratório em que implementaremos nosso primeiro analisador sintático recursivo.