Compiladores

Revisão de Parsing, AST e LL(1)

Professor: Gabriel Soares Baptista

O problema central

Considere a entrada:

x + 3 * y

O lexer responde bem a perguntas como estas:

  • quais tokens aparecem
  • qual lexema pertence a cada token

Mas ele não responde a perguntas como estas:

  • o operador principal é + ou *
  • 3 * y precisa ser resolvido antes da soma
  • os parênteses e agrupamentos estão corretos

Do fluxo linear à estrutura

O lexer vê algo como:

IDENT  PLUS  NUMBER  STAR  IDENT

O parser precisa reconstruir a hierarquia implícita nessa sequência.

Leitura incorreta:

(x + 3) * y

Leitura correta:

x + (3 * y)

Gramática Livre de Contexto

Toda gramática será lida como:

$$ G = (N, T, P, S) $$

  • $N$: conjunto dos não terminais
  • $T$: conjunto dos terminais
  • $P$: conjunto das produções
  • $S$: símbolo inicial
Reflita

Se a derivação sempre começa em algum lugar, qual símbolo da gramática precisa estar fixado antes de qualquer análise?

Exemplo mínimo

Considere:

$$ G = (N, T, P, S) $$

$$ \begin{split} N &= \{S\} \\ T &= \{0, 1\} \\ S &= S \end{split} \hspace{1cm} \begin{split} P = S &\to 0S \mid 1 \end{split} $$

Essa gramática gera cadeias com zero ou mais 0 seguidos de um 1 final.

Derivação completa

Para gerar 0001:

$$ \begin{split} S &\Rightarrow 0S \\ &\Rightarrow 00S \\ &\Rightarrow 000S \\ &\Rightarrow 0001 \end{split} $$

A derivação começa em $S$ e termina apenas quando restam terminais.

Derivação parcial

Uma derivação também pode parar no meio.

$$ S \Rightarrow 0S \Rightarrow 00S $$

Isso ainda não é uma cadeia final da linguagem.

O motivo é simples:

  • ainda sobra o não terminal $S$
  • portanto a forma obtida é apenas intermediária

Problemas de gramática

Em compiladores, não basta a gramática gerar cadeias válidas.

Ela também precisa:

  • impor uma estrutura correta
  • permitir uma análise sintática viável
  • deixar a decisão do parser bem definida

Três problemas aparecem com frequência:

  • ambiguidade
  • recursão à esquerda
  • fatoração à esquerda

Ambiguidade

Uma gramática é ambígua quando a mesma cadeia admite mais de uma árvore sintática válida.

Isso muda a estrutura da expressão e pode mudar também o significado.

Exemplo de ambiguidade

Considere:

$$ E \to E + E \mid E * E \mid id $$

Para a cadeia:

$$ id + id * id $$

as duas leituras abaixo continuam possíveis:

  • $(id + id) * id$
  • $id + (id * id)$

Resolvendo a ambiguidade

Uma solução clássica é separar os operadores por níveis de precedência.

$$ \begin{split} E &\to E + T \mid T \\ T &\to T * F \mid F \\ F &\to (E) \mid id \end{split} $$

Agora * fica em um nível mais profundo do que +.

Exercício

Considere:

$$ E \to E + E \mid id $$

A cadeia id + id + id admite mais de uma leitura?

Resolução

Sim.

As leituras clássicas são:

  • $(id + id) + id$
  • $id + (id + id)$

Logo a gramática é ambígua.

Recursão à esquerda

Uma gramática tem recursão à esquerda quando existe uma derivação do tipo $A \Rightarrow^+ A\alpha$, isto é, quando um não terminal volta para si mesmo no começo da derivação, antes de consumir terminais.

Exemplo:

$$ \begin{split} E &\to E + T \mid T \\ T &\to id \end{split} $$

Esse formato é ruim para parser top-down porque a análise retorna ao mesmo não terminal antes de consumir entrada.

Exemplo de recursão à esquerda

Se a regra for implementada literalmente, o comportamento conceitual é:

void E() {
    E();
    match('+');
    T();
}

O parser entra em loop recursivo e não avança na entrada.

Eliminando a recursão à esquerda

Uma forma completa e simples fica assim:

$$ \begin{split} E &\to T E' \\ E' &\to + T E' \mid \epsilon \\ T &\to id \end{split} $$

Agora a expansão começa por um pedaço que pode consumir entrada.

O papel de T aqui é só deixar a gramática fechada e mostrar um terminal concreto entrando na derivação.

Exercício

Considere:

$$ A \to A a \mid b $$

Reescreva a gramática sem recursão à esquerda.

Resolução

$$ \begin{split} A &\to b A' \\ A' &\to a A' \mid \epsilon \end{split} $$

Agora o parser reconhece primeiro b e depois continua a repetição por A'.

Fatoração à esquerda

Esse problema aparece quando duas produções começam com o mesmo prefixo.

Exemplo:

$$ A \to ab \mid ac $$

Com lookahead em apenas a, o parser ainda não sabe qual produção escolher.

Fatorando o prefixo comum

Retiramos o prefixo compartilhado e adiamos a decisão:

$$ \begin{split} A &\to a A' \\ A' &\to b \mid c \end{split} $$

Agora o parser consome a primeiro e decide depois.

Exercício

Considere:

$$ \begin{split} Stmt &\to if\ Expr\ then\ Stmt\ else\ Stmt \\ &\mid if\ Expr\ then\ Stmt \end{split} $$

Fatore a gramática pelo prefixo comum.

Resolução

O prefixo comum é:

$$ if\ Expr\ then\ Stmt $$

Uma fatoração possível é:

$$ \begin{split} Stmt &\to if\ Expr\ then\ Stmt\ S' \\ S' &\to else\ Stmt \mid \epsilon \end{split} $$

Exercício de classificação

Classifique o problema principal de cada gramática.

$$ E \to E + E \mid id $$

$$ A \to A a \mid b $$

$$ S \to aB \mid aC $$

Resolução

  1. $E \to E + E \mid id$

Ambiguidade.

  1. $A \to A a \mid b$

Recursão à esquerda.

  1. $S \to aB \mid aC$

Fatoração à esquerda.

Parse tree e AST

As duas são árvores, mas servem a papéis diferentes.

  • Parse tree preserva a gramática e seus não terminais
  • AST preserva a estrutura relevante para o compilador

Se a sua pergunta é “como a gramática derivou a cadeia”, a parse tree ajuda.

Se a sua pergunta é “qual é a estrutura útil para verificar, otimizar ou gerar código”, a AST é a resposta.

Parse tree de a + b * c

ParseTreeE0ExprE1ExprE0->E1plus+E0->plusT0TermE0->T0T1TermE1->T1T2TermT0->T2star*T0->starF2FactorT0->F2idaid(a)T1->idaidbid(b)T2->idbidcid(c)F2->idc

Essa árvore ainda carrega muita estrutura da gramática.

AST de a + b * c

ASTplus+aid(a)plus->astar*plus->starbid(b)star->bcid(c)star->c

Agora a árvore mostra apenas o que importa para a semântica da expressão.

Comparação

Quando esses dois papéis se misturam, normalmente três coisas se confundem:

  • a gramática que gera a cadeia
  • a estrutura que o parser reconhece
  • a estrutura que o compilador usa depois

Resumo:

  • a parse tree explica a derivação
  • a AST explica a estrutura útil do programa

A gramática ingênua das expressões

Uma primeira tentativa costuma ser:

$$ E \to E + T \mid T $$

Ela parece natural, mas é ruim para parser top-down.

O problema é a recursão à esquerda.

Por que ela quebra

Se a implementação seguir a gramática literalmente, teremos algo conceitualmente assim:

void E() {
    E();
    match('+');
    T();
}

O parser volta para E() antes de consumir qualquer token.

Resultado:

  • loop recursivo
  • estouro de pilha
  • nenhuma leitura real da entrada

Gramática adequada para parser descendente

Uma forma clássica para expressões é:

$$ \begin{split} E &\to T\ E' \\ E' &\to +\ T\ E' \mid \epsilon \\ T &\to F\ T' \\ T' &\to *\ F\ T' \mid \epsilon \\ F &\to (E) \mid id \end{split} $$

Essa reescrita faz duas coisas ao mesmo tempo:

  • elimina a recursão à esquerda
  • embute precedência na estrutura das funções

Regra de implementação

No parser recursivo descendente:

  • cada não terminal vira uma função
  • o símbolo inicial vira a primeira chamada
  • match consome terminais esperados
  • produções com $\epsilon$ viram retorno sem consumo

Mapa direto:

  • E vira parse_expression
  • T vira parse_term
  • F vira parse_factor ou parse_primary

Esqueleto do algoritmo

AstNode *parse_expression(Parser *parser) {
    AstNode *left = parse_term(parser);

    while (match(parser, TOK_PLUS)) {
        Token op = previous(parser);
        AstNode *right = parse_term(parser);
        left = make_binary(op, left, right);
    }

    return left;
}

O parser não apenas reconhece a estrutura.

Ele constrói a AST enquanto reconhece.

AST no laboratório

No laboratório do compilador, a AST guarda:

  • o tipo do nó em kind
  • o token principal em token
  • relações fixas em first, second e third
  • filhos variáveis em children

Essa escolha simplifica o parser porque evita muito código cerimonial.

Execução guiada

Vamos acompanhar a entrada:

x + 3 * y

Usaremos a ideia geral:

$$ E \to T\ E' \qquad T \to F\ T' \qquad F \to id \mid num \mid (E) $$

Queremos observar duas coisas ao mesmo tempo:

  • a pilha de chamadas
  • a AST que vai nascendo

Passo 1 da execução

Entrada atual:

x + 3 * y $
^
CallStack1mainmaineparse_expression()main->etparse_term()e->tfparse_factor()t->fpparse_primary()lookahead = id(x)f->p

Neste ponto, parse_primary() reconhece x e devolve um nó id(x).

Passo 2 da execução

Depois de reconhecer x, o parser sobe um nível e olha o próximo token.

Entrada atual:

x + 3 * y $
  ^
CallStack2eparse_expression()left = id(x)tparse_term() retornoue->tpluslookahead = +e->plus

Como + pertence ao nível de E', o parser decide continuar nesse nível e parsear o operando da direita.

Passo 3 da execução

Agora o lado direito começa em 3 * y.

Entrada atual:

x + 3 * y $
    ^
CallStack3eparse_expression()tparse_term()para o lado direitoe->tf1parse_factor()left = num(3)t->f1starlookahead = *f1->starf2parse_factor()reconhece id(y)f1->f2

Aqui o parser resolve 3 * y antes de voltar ao nível da soma.

AST ao final da execução

FinalAstplusAST_BINARY (+)xAST_IDENTIFIER xplus->xleftstarAST_BINARY (*)plus->starrightn3AST_NUMBER 3star->n3leftyAST_IDENTIFIER ystar->yright

Essa árvore codifica a precedência sem exigir uma regra extra no algoritmo.

Resumo da execução

  • a pilha de chamadas espelha a gramática
  • a precedência aparece no nível em que cada função opera
  • o parser constrói a AST durante a leitura
  • o retorno das chamadas faz a estrutura subir pronta

Em outras palavras, a forma da gramática controla a forma da árvore.

Onde entram os erros sintáticos

Considere a entrada inválida:

x +

O parser reconhece x, consome + e então precisa de outro operando.

Nesse momento, a função responsável pelo nível seguinte falha porque a entrada acabou antes do esperado.

Mensagem conceitual:

  • reconhecer um prefixo correto não basta
  • a análise só tem sucesso se a estrutura inteira for consumida corretamente

Parser top-down e parser bottom-up

Para fechar a revisão do módulo, vale lembrar a diferença de direção:

  • Top-down parte do símbolo inicial e tenta derivar a entrada
  • Bottom-up parte da entrada e tenta reduzir até o símbolo inicial

Nesta revisão, o foco é top-down porque ele conecta diretamente:

  • gramática
  • funções recursivas
  • decisão local

A pergunta central do LL(1)

Como o parser escolhe a produção certa sem voltar atrás?

O LL(1) organiza essa decisão por meio de três elementos:

  • buffer de entrada
  • pilha de análise
  • tabela de análise

O que significa LL(1)

  • primeiro L: leitura da esquerda para a direita
  • segundo L: derivação mais à esquerda
  • 1: um símbolo de lookahead

Não é apenas uma sigla.

Ela resume exatamente a estratégia operacional do parser.

Visão operacional do LL(1)

LL1FlowentradaBuffer de entrada... a b c $lookaheadSímbolo atualda entradaaentrada->lookaheadpilhaPilha de análise$ / símbolo inicialtopoTopo da pilhaXpilha->topotabelaTabela LL(1)M[A, a]decisaoExpandir, casarou errotabela->decisaotopo->tabelalookahead->tabela

FIRST

O conjunto $FIRST(A)$ responde:

  • com que terminal uma derivação de $A$ pode começar
  • se $A$ pode desaparecer por $\epsilon$

Exemplo mínimo:

$$ A \to b \mid \epsilon $$

$$ FIRST(A) = \{b, \epsilon\} $$

FOLLOW

O conjunto $FOLLOW(A)$ responde:

  • o que pode aparecer imediatamente à direita de $A$
  • qual contexto assume o lugar se $A$ desaparecer

Lembretes importantes:

  • $\epsilon$ não entra em FOLLOW
  • $ sempre entra em $FOLLOW(S)$, onde $S$ é o símbolo inicial

Exemplo novo de FIRST e FOLLOW

Considere:

$$ \begin{split} S &\to A d \\ A &\to bA \mid \epsilon \end{split} $$

Primeiro o FIRST.

$$ FIRST(A) = \{b, \epsilon\} $$

Como $A$ pode sumir, o começo de $S$ pode ser b ou d.

$$ FIRST(S) = \{b, d\} $$

Fechando o FOLLOW do exemplo

Ainda na gramática:

$$ \begin{split} S &\to A d \\ A &\to bA \mid \epsilon \end{split} $$

Depois de $A$ vem d, então:

$$ FOLLOW(A) = \{d\} $$

Como $S$ é o símbolo inicial:

$$ FOLLOW(S) = \{\$\} $$

Como preencher a tabela LL(1)

Para cada produção $A \to \alpha$:

  1. coloque a produção em todas as colunas de $FIRST(\alpha)$, exceto $\epsilon$
  2. se $\epsilon \in FIRST(\alpha)$, coloque a produção nas colunas de $FOLLOW(A)$
Regra crucial

Não existe coluna de vazio. O papel do $\epsilon$ é mandar a produção para o contexto de FOLLOW.

Exemplo curto de tabela

Considere:

$$ \begin{split} S &\to aB \mid c \\ B &\to b \mid \epsilon \end{split} $$

Conjuntos:

$$ FIRST(S)=\{a,c\}, \qquad FIRST(B)=\{b,\epsilon\} $$

$$ FOLLOW(S)=\{\$\}, \qquad FOLLOW(B)=\{\$\} $$

Tabela LL(1) do exemplo curto

$$ \begin{array}{c|cccc} & a & b & c & \$ \\ \hline S & S \to aB & & S \to c & \\ B & & B \to b & & B \to \epsilon \end{array} $$

Cada célula preenchida representa uma decisão local única.

Se alguma célula recebesse duas produções, a gramática deixaria de ser LL(1).

Exemplo clássico de expressões

Gramática:

$$ \begin{split} E &\to T E' \\ E' &\to + T E' \mid \epsilon \\ T &\to F T' \\ T' &\to * F T' \mid \epsilon \\ F &\to (E) \mid id \end{split} $$

Conjuntos principais:

$$ FIRST(E)=FIRST(T)=\{(, id\}, \qquad FIRST(E')=\{+,\epsilon\} $$

$$ FOLLOW(E)=\{\$, )\}, \qquad FOLLOW(T)=\{+,\$, )\} $$

Tabela central das expressões

$$ \begin{array}{c|cccccc} & id & + & * & ( & ) & \$ \\ \hline E & E \to T E' & & & E \to T E' & & \\ E' & & E' \to + T E' & & & E' \to \epsilon & E' \to \epsilon \\ T & T \to F T' & & & T \to F T' & & \\ T' & & T' \to \epsilon & T' \to * F T' & & T' \to \epsilon & T' \to \epsilon \\ F & F \to id & & & F \to (E) & & \end{array} $$

Traço LL(1) com pilha

Agora vamos observar a execução para a entrada:

id + id * id $

A ordem da pilha será mostrada com o topo na parte superior.

Passo 1 do LL(1)

Estado inicial:

LL1Step1cluster_stackPilhacluster_inputEntradas1EtabelaConsulta M[E, id]usa E -> T E's1->tabelas2$i1idi1->tabelai2+i3idi4*i5idi6$

O parser expande E em T E'.

Passo 2 do LL(1)

Após expandir E:

LL1Step2cluster_stackPilhacluster_inputEntradas1TtabelaConsulta M[T, id]usa T -> F T's1->tabelas2E's3$i1idi1->tabelai2+i3idi4*i5idi6$

O topo da pilha sempre guia a próxima decisão.

Passo 3 do LL(1)

Depois de expandir F e casar o primeiro id:

LL1Step3cluster_stackPilhacluster_inputEntradas1T'tabelaConsulta M[T', +]usa T' -> epsilons1->tabelas2E's3$i1+i1->tabelai2idi3*i4idi5$

Como + está em FOLLOW(T'), a produção vazia é a decisão correta.

Passo 4 do LL(1)

Agora o topo é E' e a entrada continua em +.

LL1Step4cluster_stackPilhacluster_inputEntradas1E'tabelaConsulta M[E', +]usa E' -> + T E's1->tabelas2$i1+i1->tabelai2idi3*i4idi5$

O parser continua a expressão porque ainda existe soma pendente.

Traço resumido da execução inteira

Passo Pilha Entrada Ação
1 [$, E] id + id * id $ usa E -> T E'
2 [$, E', T] id + id * id $ usa T -> F T'
3 [$, E', T', F] id + id * id $ usa F -> id
4 [$, E', T', id] id + id * id $ faz match de id
5 [$, E', T'] + id * id $ usa T' -> epsilon
6 [$, E'] + id * id $ usa E' -> + T E'
7 [$, E', T, +] + id * id $ faz match de +
8 [$, E', T] id * id $ usa T -> F T'
9 [$, E', T', F] id * id $ usa F -> id
10 [$, E', T', id] id * id $ faz match de id
11 [$, E', T'] * id $ usa T' -> * F T'
12 [$, E', T', F, *] * id $ faz match de *
13 [$, E', T', F] id $ usa F -> id
14 [$, E', T', id] id $ faz match de id
15 [$, E', T'] $ usa T' -> epsilon
16 [$, E'] $ usa E' -> epsilon
17 [$] $ sucesso

Resumo do traço LL(1)

  • a pilha substitui a pilha de chamadas do parser manual
  • a tabela substitui a decisão “adivinhada”
  • FIRST escolhe por onde a produção entra
  • FOLLOW resolve o momento em que uma variável pode desaparecer

O parser LL(1) é top-down, mas a decisão fica mecanizada.

Exercício 1

Considere:

$$ \begin{split} S &\to 0S \mid 1 \end{split} $$

Derive a cadeia 001 e mostre também uma derivação parcial.

Exercício 1 - Resolução

Derivação completa:

$$ \begin{split} S &\Rightarrow 0S \\ &\Rightarrow 00S \\ &\Rightarrow 001 \end{split} $$

Derivação parcial possível:

$$ S \Rightarrow 0S \Rightarrow 00S $$

Ela é parcial porque ainda resta o não terminal $S$.

Exercício 2

Para a expressão a + b * c, explique por que a AST correta não é:

*
├── +
│   ├── a
│   └── b
└── c

Exercício 2 - Resolução

Essa árvore representaria:

(a + b) * c

Mas a precedência usual da linguagem exige:

a + (b * c)

Logo a AST correta é:

+
├── a
└── *
    ├── b
    └── c

Exercício 3

Considere a gramática:

$$ E \to E - T \mid T $$

Explique por que ela não deve ser implementada diretamente por parser recursivo descendente.

Exercício 3 - Resolução

Ela possui recursão à esquerda direta.

Na implementação literal, E() chamaria E() novamente antes de consumir qualquer token.

Consequências:

  • loop recursivo
  • nenhuma redução real da entrada
  • falha do parser top-down direto

Forma adequada:

$$ \begin{split} E &\to T E' \\ E' &\to - T E' \mid \epsilon \end{split} $$

Exercício 4

Considere:

$$ \begin{split} S &\to A d \\ A &\to bA \mid \epsilon \end{split} $$

Calcule FIRST(A), FIRST(S) e FOLLOW(A).

Exercício 4 - Resolução

Como $A \to bA \mid \epsilon$:

$$ FIRST(A) = \{b, \epsilon\} $$

Como $S \to A d$ e $A$ pode sumir:

$$ FIRST(S) = \{b, d\} $$

Como depois de $A$ vem d:

$$ FOLLOW(A) = \{d\} $$

Exercício 5

Monte a tabela LL(1) para:

$$ \begin{split} S &\to aB \mid c \\ B &\to b \mid \epsilon \end{split} $$

Exercício 5 - Resolução

Primeiro os conjuntos:

$$ FIRST(S)=\{a,c\}, \qquad FIRST(B)=\{b,\epsilon\} $$

$$ FOLLOW(S)=\{\$\}, \qquad FOLLOW(B)=\{\$\} $$

Tabela:

$$ \begin{array}{c|cccc} & a & b & c & \$ \\ \hline S & S \to aB & & S \to c & \\ B & & B \to b & & B \to \epsilon \end{array} $$

Exercício 6

Considere:

$$ A \to aB \mid aC $$

Explique por que essa gramática não é LL(1) e indique a transformação adequada.

Exercício 6 - Resolução

As duas produções começam com o mesmo terminal a.

Então a célula correspondente a M[A, a] receberia mais de uma produção.

Isso produz conflito na tabela.

Transformação por fatoração:

$$ \begin{split} A &\to aA' \\ A' &\to B \mid C \end{split} $$

Agora a decisão inicial fica única.

Pergunta de encerramento

Quando uma linguagem parece correta mas o parser LL(1) falha, o problema pode estar em três níveis diferentes:

  • na ambiguidade da gramática
  • na forma da gramática para top-down
  • no limite da própria estratégia com um lookahead

Essa distinção é exatamente o que a revisão de hoje queria consolidar.