Duas metades que se explicam: a teoria — gramáticas, autômatos, o lema do bombeamento
e as árvores — e a implementação, roteiro a roteiro, com o código real de cada versão
e um depurador para você escrever a expressão que quiser e ver o compilador executá-la passo a passo.
A contagem só sobe no último roteiro. Os dois do meio não acrescentam classes — reorganizam as que já existiam.
Comece por aqui
Oficina de diagramas sintáticos
Como esta parte funciona
Os diagramas não são para decorar — são para ler.
Todo diagrama da disciplina é feito de seis peças, e cada peça vira sempre o mesmo pedaço de código. Aprenda as seis, e qualquer diagrama que aparecer na prova você lê como um programa. Depois, refazemos o caminho que o professor faz na Aula 6: começamos com um diagrama só, vemos ele dar a resposta errada, e consertamos — que é o único jeito de entender por que existem três.
1
O alfabeto: as seis peças
É a legenda que o professor apresenta na Aula 6. Repare no círculo (consumir um token) e no retângulo (chamar outro diagrama) — confundir os dois é a origem de quase toda dificuldade com esses diagramas. A bolinha vermelha mostra por onde dá para passar.
elemento (1)Fluxo direto sem operaçãoSó leva de um ponto ao outro. Não consome nada, não decide nada.
# nada: é só a ordem das instruções
elemento (2)Consumo de um token aUm círculo: o token do momento precisa ser a. Se for, consome e segue; se não, é erro de sintaxe.
if next.type != "A":
raise Exception(...)
lexer.select_next()
elemento (3)Condicional: consumo de a ou bO trilho se divide. O token atual decide o caminho — nunca os dois.
elemento (4)Direcionar para outro DS (chamada)Um retângulo: vá percorrer o diagrama X inteiro, e volte aqui com o que ele devolver.
resultado = parse_x()
elemento (5)Repetição com 1 ou mais vezesO elemento está no trilho principal — passa por ele pelo menos uma vez. A volta por baixo permite repetir.
consome a
while next.type == "A":
consome a
elemento (6)Repetição com 0 ou mais vezesO trilho principal passa reto. O elemento fica na volta — dá para pular de vez, ou repetir quantas vezes quiser.
while next.type == "A":
consome a
A diferença entre (5) e (6) é onde o elemento mora
Em (5) o elemento está no trilho principal: você é obrigado a passar por ele uma vez, e a volta permite repetir. Em (6) o trilho principal é reto: o elemento está na volta, então dá para nunca passar por ele. Quando você olhar o diagrama do EXPRESSION, veja que o TERM está no trilho principal e os operadores estão na volta — ou seja: pelo menos um TERM, e zero ou mais pares operador + TERM. Exatamente o que diz a EBNF: TERM, { ("+" | "-"), TERM }.
2
O caso que motiva tudo: 2 + 3 * 4
Antes de ver a solução, veja o problema. Se você põe todos os operadores no mesmo laço, a sintaxe continua perfeita — o diagrama aceita a cadeia. Mas ele calcula da esquerda para a direita:
Um diagrama só, com + − * / no laço= 20
2 + 3 = 5, depois 5 × 4 = 20. Aceita a cadeia, erra a conta.
Dois diagramas: EXPRESSION chama TERM= 14
O TERM resolve 3 × 4 = 12 antes de devolver. Só então 2 + 12.
3
A oficina: rastreie você mesmo
É o rastreamento que o professor desenha nos slides, agora andando. A linha vermelha tracejada é o caminho percorrido, com o número de cada passo. A fita verde em cima são os tokens, riscados conforme são consumidos. E — o mais importante — cada vez que um retângulo chama outro diagrama, aparece uma cópia nova dele, com o seu próprio resultado. É assim que dá para ver a recursão.
Expressão
⇓
—
—
Três coisas para observar enquanto roda
1. Na etapa tentativa errada, com 2+3*4, olhe o resultado mudar a cada volta do laço: 2, depois 5, depois 20. Nada impede o + de ser feito antes do *.
2. Na etapa dois níveis, o EXPRESSION não soma nada até o segundo TERM voltar. O TERM da 2ª chamada fica rodando o seu próprio laço — vê o *, pega o 4, calcula 12 — e só depois devolve. A precedência é esse “não devolver ainda”.
3. Na etapa gramática completa, tente (2+3)*4: repare no EXPRESSION que aparece dentro de um FACTOR. É o único diagrama que chama um diagrama “acima” dele — e é por isso que dá para aninhar parênteses sem limite.
4
Leia o diagrama como código
Os três diagramas finais, lado a lado com o main.py. Passe o mouse sobre uma peça do diagrama para ver a linha de código que ela vira — ou sobre uma linha, para ver de que peça ela veio.
A regra de tradução, em uma frase
Círculo viraselect_next() (depois de conferir o tipo). Retângulo vira chamada de função. Bifurcação viraif/elif. Volta por baixo virawhile. Com essas quatro, você escreve o parser de qualquer diagrama sem precisar pensar — e lê qualquer parser como diagrama.
Parte I
As aulas
Seis aulas que formam um arco: a teoria vira código, as linguagens regulares fecham, o parser ganha precedência, a linguagem sobe de classe, as árvores aparecem, e no fim vêm as estratégias de parsing e os limites formais.
04a teoria vira código
Diagrama sintático, tokenização e o embrião do Parser
A frase que separa as duas fases — e que vale para o semestre inteiro
O Lexer decide "que token é este?". O Parser decide "essa sequência de tokens é válida?". Duas perguntas completamente diferentes, e é por isso que são duas classes.
1
A linguagem de partida
Uma calculadora mínima: só + e -, com números de vários dígitos. São válidas:
1
1+1
1+2+3
11-22-33
A gramática que descreve isso cabe numa linha:
EXPRESSION → NUMBER { ( "+" | "-" ) NUMBER }
E a pergunta da aula é: como transformar essa regra em algo fácil de implementar?
2
O diagrama sintático
É uma terceira forma de representar a mesma gramática — e a mais próxima do código que virá depois. Em vez de símbolos, um trilho: você entra pela esquerda, percorre os caminhos possíveis e sai pela direita. Toda rota completa é uma cadeia válida.
O mesmo que EXPRESSION → NUMBER { ("+"|"-") NUMBER }. Repare que o laço é opcional e pode ser percorrido quantas vezes quiser: é exatamente o que as chaves da EBNF significam.
Por que desenhar, se a EBNF já dizia a mesma coisa
Porque o diagrama já tem o formato do código. O trilho principal vira a sequência de instruções; o laço vira um while; a bifurcação entre + e - vira um if. Você não "traduz" a gramática: você copia o desenho.
3
Primeira responsabilidade: o Lexer
O Lexer recebe caracteres e devolve tokens — os "átomos" do código-fonte. Ele agrupa 1 e 2 num único INT(12), descarta espaços, e não tem opinião nenhuma sobre ordem.
O Roteiro 2 formaliza isso com uma classe Token (type e value) e uma classe Lexer (source, position, next e select_next()).
4
Segunda responsabilidade: o Parser
O Parser não vê caracteres. Ele recebe a sequência de tokens e faz uma única pergunta: essa ordem obedece à gramática?
válidasegue o trilho até a saída
INT PLUS INT MINUS INT EOF
inválidanão há trilho que comece assim
PLUS INT EOF
Acompanhe o Parser percorrendo o diagrama. Troque o exemplo para ver a cadeia que sobra tokens:
Entrada
o Parser espera
encontra
o que faz
—
5
O detalhe do run(): conferir o EOF
O run() recebe o código, cria o Lexer, posiciona no primeiro token, chama parse_expression() e — este é o ponto — confere se o próximo token é EOF.
Sem essa checagem, 1+2 3 passaria
O Parser reconheceria 1+2 perfeitamente, chegaria à saída do diagrama e devolveria 3 — deixando o 3 final para trás, sem reclamar. A checagem de EOF é o que garante que toda a entrada foi consumida, e não apenas um pedaço válido dela. Selecione o exemplo 1+2 3 acima para ver onde ele morre.
def run(code):
Parser.lexer = Lexer(code)
Parser.lexer.select_next() # posiciona no primeiro token
result = Parser.parse_expression()
if Parser.lexer.next.type != "EOF": # ← sem isto, "1+2 3" passariaraise Exception(...)
return result
6
O fluxo completo
Gramática
↓
Diagrama sintático — a gramática com formato de código
↓
Lexer → transforma caracteres em tokens
↓
Parser → consome os tokens
↓
Parser valida a estrutura e confere o EOF
A partir daqui a disciplina deixa de ser sobre gramáticas no papel. A Aula 6 pega esse mesmo parser e o faz respeitar precedência; a Aula 8 o faz construir uma árvore em vez de calcular.
Expressões regulares, AFD/AFN, pumping e fechamento
O que esta aula fecha
Ela amarra tudo que se pode dizer sobre linguagens regulares — três formas equivalentes de descrevê-las, uma ferramenta para provar que algo não é regular, e as operações que preservam a classe. É a última aula antes de a linguagem do compilador precisar de mais poder.
1
O exemplo que motiva: validar e-mail
A aula parte de um problema concreto: descrever o formato de um endereço de e-mail. É o caso perfeito porque um e-mail tem estrutura, mas não tem aninhamento — e é exatamente essa a fronteira das linguagens regulares.
2
Três formas de dizer a mesma coisa
Uma linguagem regular pode ser descrita de três jeitos, e os três têm exatamente o mesmo poder. Para L = { abn : n ≥ 0 }:
Gramática lineargera a cadeia
S → a B
B → b B | ε
Autômato finitoreconhece a cadeia
Expressão regulardescreve o padrão
a b*
Os três operadores das expressões regulares:
operador
nome
significa
a | b
união
"a ou b"
a b
concatenação
"a seguido de b"
a *
estrela de Kleene
"zero ou mais repetições de a"
3
AFD e AFN têm o mesmo poder
AFD — determinísticoum caminho só
Para cada par (estado, símbolo) existe uma única transição. Dado o estado atual e o caractere lido, não há escolha a fazer.
É o que o seu select_next() é.
AFN — não determinísticovários caminhos
Pode haver várias transições possíveis para o mesmo par. A palavra é aceita se algum caminho aceitar.
Converter uma gramática linear direto para autômato costuma produzir um AFN.
O AFN não é mais poderoso — só mais confortável
Esse é o ponto contraintuitivo da aula: AFD e AFN reconhecem exatamente as mesmas linguagens, as regulares. Todo AFN pode ser convertido num AFD equivalente (ao custo de possivelmente muitos mais estados). O não determinismo é uma conveniência de projeto, não um ganho de expressividade.
4
Pumping lemma: provando que algo NÃO é regular
A intuição em uma frase: se uma linguagem regular tem palavras arbitrariamente longas, o autômato que a reconhece precisa repetir algum estado — ele tem um número finito de estados e a palavra não tem limite. Todo estado repetido é um ciclo, e ciclo pode ser percorrido quantas vezes você quiser.
L regular ⇒ ∃ p tal que toda w ∈ L com |w| ≥ p se escreve w = x y z com:
|xy| ≤ p (o ciclo acontece cedo)
|y| > 0 (o ciclo consome pelo menos um símbolo)
x yi z ∈ L para todo i ≥ 0
O uso é sempre por contradição. Para L = { anbn }: suponha regular, pegue o p garantido, escolha w = apbp. Como |xy| ≤ p, o trecho y só contém a. Bombeando com i = 2 você ganha a sem ganhar b — a igualdade quebra. Contradição.
w = xyz — a divisão obrigatória pelo lema
x yi z — a palavra bombeada
5
Fechamento: operações que preservam a classe
Dizer que uma classe é fechada sob uma operação significa: se você começa com linguagens da classe e aplica a operação, o resultado continua na classe. As regulares são generosas nesse quesito — fecham sob praticamente tudo.
Isso não é curiosidade: é ferramenta de prova. Se L ∩ R não for regular e R for regular, então L não podia ser regular — senão a interseção seria. O detalhamento completo, comparando com as livres de contexto, está na Parte II, aba Fechamento.
6
Questões decidíveis
Para linguagens regulares, várias perguntas têm resposta algorítmica garantida — o que, como a Aula 9 vai mostrar, deixa de valer nas classes mais acima:
pergunta
decidível para regulares?
a palavra w pertence a L?
sim
L é vazia?
sim
L é finita ou infinita?
sim
L1 = L2?
sim
L1 ⊆ L2?
sim
L = Σ*?
sim
Gramática linear ↔ Autômato finito ↔ Expressão regular
↓
AFD ↔ AFN — mesmo poder expressivo
↓
Pumping lemma → ferramenta para provar NÃO regularidade
A precedência não é colocada na marra. Ela aparece porque a gramática é organizada em níveis, e os níveis mais internos terminam antes dos externos. Não existe tabela de prioridades em lugar nenhum do código.
A primeira regra lê-se assim: uma expressão começa com um TERM e pode ser seguida por zero ou mais blocos do tipo + TERM ou - TERM. A segunda faz o mesmo para multiplicação e divisão.
2
Por que 3+4*2 vira 3+(4*2)
Porque parse_expression() chama parse_term(), que chama parse_factor(). O TERMtermina de resolver 4*2 antes de devolver esse resultado para o EXPRESSION. Acompanhe o vaivém:
E # parse_expression começa
↓
T # chama parse_term
↓
F lê 3 # parse_factor consome o 3
↑
T vê + e termina # + não é dele, devolve 3
↑
E consome o +
↓
novo T
↓
F lê 4
↑
T vê * # * É dele, fica com ele
↓
novo F lê 2
↑
T termina 4 * 2 = 8 # resolve antes de devolver
↑
E termina 3 + 8 = 11
Repare na linha "T vê + e termina": o + não pertence àquele nível, então o TERM devolve o que tem e deixa o + para quem o chamou. Esse deixar para o de cima é o mecanismo inteiro da precedência.
O depurador da aba v1.1, na Parte III, mostra esses quadros de chamada empilhando e desempilhando de verdade, cada um com o seu próprio result.
●
Os três diagramas, em movimento
Cada regra da gramática é um trilho. Você entra pela esquerda, segue os caminhos e sai pela direita — e um retângulo com nome de regra significa "vá percorrer aquele outro trilho e volte". Digite uma expressão e veja o percurso acontecer.
Expressão
EXPRESSIONpercorrendo
TERMpercorrendo
FACTORpercorrendo
Token atual
Diagramas abertos
Resultado parcial
Que pedaço do texto cada diagrama cobre
FACTORTERMEXPRESSION
passo—
—
O que observar enquanto roda
Com 3+4*2: o EXPRESSION mal começa e já manda para o TERM, que manda para o FACTOR, que finalmente consome o 3. Na volta, o TERM olha se há * ou / — não há — e devolve. Só então o EXPRESSION vê o +. Mais adiante, quando o TERM encontra o *, ele não devolve: fica ali resolvendo 4*2 antes de entregar 8 para a soma. É a precedência acontecendo.
Experimente (2+3)*4 e veja a coluna "trilhos abertos" crescer: dentro do parêntese há um EXPRESSIONpor dentro de outro. Essa pilha é a memória que um autômato finito não tem.
3
Operadores unários
Em -3, o - não é subtração entre dois operandos: ele se aplica diretamente ao 3. Por isso a gramática permite FACTOR → "-" FACTOR e FACTOR → "+" FACTOR.
E como FACTOR chama outro FACTOR, construções como --3, +-3 e +--++3 funcionam sem nenhum código específico para unários encadeados. É recursão fazendo o trabalho.
4
Parênteses — e o ciclo que se fecha
FACTOR → "(" EXPRESSION ")". Quando o parser encontra (, ele começa uma EXPRESSION nova lá dentro. O nível mais interno volta ao mais externo, e o ciclo se fecha:
(2 + 3) * 4
FACTOR
↓
(
↓
EXPRESSION # de volta ao topo da gramática
↓
2 + 3
↓
)
↑
esse FACTOR inteiro vira o operando esquerdo do *
É aqui que a linguagem sobe de classe
Aninhamento arbitrário é justamente o que um autômato finito não consegue controlar. Esta aula introduz recursão na gramática; a Aula 7 explica por que isso exige uma máquina mais forte, com pilha.
5
O que o Lexer precisa ganhar
Quase nada — quatro tokens novos: MULT, DIV, OPEN_PAR, CLOSE_PAR, somados aos já existentes PLUS, MINUS, INT e EOF. Para a análise léxica, * e + são igualmente triviais: ela não sabe nem se importa que um amarra mais forte que o outro.
EXPRESSION — cuida de + e −
↓ chama
TERM — cuida de * e /
↓ chama
FACTOR — NUMBER | ±FACTOR | ( EXPRESSION )
●
Para onde a gramática vai
A partir do Roteiro 5 a linguagem deixa de ser uma expressão solta e vira um programa: várias linhas, variáveis e saída. Vale ver agora, porque quase tudo que você já entendeu continua igual — e o que muda, muda pouco.
PROGRAMUm programa é zero ou mais comandos. Só isso — e é aqui que o laço mais externo do compilador vai morar.STMTUm comando é uma atribuição (x = EXPR), um print, ou nada — repare na linha reta de cima, que pula os dois e vai direto ao EOL: é o que permite linha em branco. E todo comando termina em EOL, o token de fim de linha.FACTOR — com uma alternativa a maisO quarto caminho é novo: além de INT, unário e parêntese, um fator agora pode ser um IDEN — o nome de uma variável. É a única mudança no FACTOR.
O que NÃO muda — e é a maior parte
EXPR e TERM são idênticos aos que você percorreu acima: mesmo laço, mesmos operadores, mesma precedência. A hierarquia de três níveis que você aprendeu na Aula 6 fica intacta enquanto a linguagem cresce por cima dela. Duas camadas novas aparecem acima (PROGRAM e STMT) e uma alternativa nova dentro do FACTOR.
o que aparece
o que significa
consequência no código
PROGRAM
zero ou mais comandos
um while no topo, até o fim do arquivo
STMT
atribuição, print, ou linha vazia
novo nível de parse_statement()
IDEN
nome de variável
o Lexer passa a reconhecer letras; nó Identifier
EOL
fim de linha, um token de verdade
o \n deixa de ser espaço em branco descartável
=
atribuição, não comparação
nó Assignment com dois filhos
PRINT
palavra reservada
o Lexer separa identificadores de palavras-chave
E o que isso exige que a gramática sozinha não dá
Com variáveis entra uma pergunta que nenhum diagrama responde: essa variável foi declarada antes de ser usada? Sintaticamente y = x + 1 é impecável mesmo que x nunca tenha existido. É a fronteira formal que a hierarquia de Chomsky descreve — e por isso entra uma tabela de símbolos, passada como argumento para o evaluate(), numa fase semântica separada.
Gramáticas livres de contexto e autômatos de pilha
A motivação vem direto da Aula 6
Assim que o compilador passou a aceitar parênteses aninhados — (a), ((a)), (((a))) — a linguagem deixou de ser regular. Um autômato finito não tem como controlar quantos parênteses foram abertos e ainda precisam fechar.
1
Gramática livre de contexto
A definição formal continua a mesma quádrupla:
G = ( V , Σ , P , A )
V vocabulário P produções
Σ terminais A símbolo inicial
A novidade está na forma das regras:
α → β com α ∈ N (um único NÃO TERMINAL)
β ∈ V* (qualquer combinação de terminais e não terminais)
Em português: o lado esquerdo é sempre um não terminal sozinho — daí "livre de contexto": não importa o que está em volta, a regra pode ser aplicada. O lado direito, porém, é livre.
A gramática do aninhamento:
A → ( A )
A → a
gera: a · (a) · ((a)) · (((a))) …
Ela não é regular, porque A → (A) tem um terminal de cada lado do não terminal. Uma gramática linear só admite o não terminal numa ponta.
2
Por que um AF não basta
A aula tenta montar um autômato finito para essa linguagem. O resultado aceita os casos certos — e também aceita lixo:
deveria aceitare aceita
a (a) ((a))
deveria rejeitarmas aceita também
a) ((a) (a))
O diagnóstico: o autômato não tem memória para casar aberturas com fechamentos numa quantidade arbitrária. Ele sabe que leu um parêntese; não sabe quantos.
3
O autômato de pilha
Autômato finitosem memória
estado + símbolo atual
Autômato de pilhamemória ilimitada, mas só no topo
estado + símbolo atual + pilha
A regra para parênteses é de uma simplicidade desconcertante: lê ( → empilha; lê ) → desempilha. A pilha guarda exatamente quantas aberturas estão pendentes.
Rode o autômato. Se ao final a entrada acabou e a pilha está vazia, a palavra é aceita:
Fita de entrada
Ação
Pilha
—
4
Aceitação por pilha vazia
O PDA que a aula constrói usa um critério de aceitação diferente do habitual: não há estado final. A palavra é aceita quando a entrada terminou e a pilha está vazia. Isso parece um detalhe técnico, mas é o que torna a equivalência do próximo passo elegante de provar.
5
O resultado central: GLC ↔ PDA
O teorema vale nos dois sentidos:
GLC → PDA
Dada uma gramática livre de contexto G, dá para construir um autômato de pilha não determinístico com aceitação por pilha vazia que reconhece exatamente L(G).
PDA → GLC
E o inverso: dado um PDA com aceitação por pilha vazia, dá para construir uma GLC equivalente.
É o mesmo tipo de correspondência que você já tinha visto um nível abaixo:
classe
gramática
reconhecedor
Regular
gramática linear
autômato finito
Livre de contexto
GLC
autômato de pilha
Cuidado: a equivalência é com PDA NÃO determinístico
Nem toda linguagem livre de contexto pode ser reconhecida por um PDA determinístico. Então não vale pensar "CFL = PDA determinístico". As linguagens que têm PDA determinístico formam uma subclasse própria — e não é coincidência que seja mais ou menos onde vivem as linguagens de programação, que precisam ser analisadas rápido e sem ambiguidade. A Aula 9 volta a esse ponto com LR(k).
6
Onde isso aparece na prática
Toda estrutura aninhada é livre de contexto e não regular. Por isso nenhuma das três abaixo pode ser validada corretamente só com expressão regular:
Na Aula 6, o Parser já sabia calcular a expressão enquanto lia.
Na Aula 8, ele passa a construir uma árvore que representa a expressão —
e quem calcula é a árvore, depois. Todo o resto da aula existe para justificar essa troca.
1
Primeiro: o que é uma derivação
Uma derivação é o passo a passo de como a gramática gera uma cadeia. Você começa no símbolo inicial e vai trocando não-terminais pelas suas regras, até sobrar só texto. Para ver a ideia limpa, a aula usa a menor gramática interessante que existe:
S → a S b | ε # ε (épsilon) = a palavra vazia
Ela gera ab, aabb, aaabbb… sempre a mesma quantidade de a e de b — o mesmo padrão dos parênteses da Aula 7, e pelo mesmo motivo não é regular.
Avance os passos e veja a derivação e a árvore crescerem juntas. A árvore é só a derivação desenhada: cada substituição vira um nó com seus filhos.
Derivação
—
2
O problema: ambiguidade
Uma gramática é ambígua quando uma mesma cadeia admite duas árvores de derivação diferentes. Parece detalhe teórico. Não é: significa que o mesmo programa pode ter dois significados. Considere a gramática ingênua que qualquer um escreveria primeiro:
E → E "+" E | E "*" E | number
Ela aceita 3+4*2. O problema é como — de duas formas, ambas válidas segundo as regras:
Árvore 1 → 3+(4*2)o * agarra primeiro
= 11
Árvore 2 → (3+4)*2o + agarra primeiro
= 14
Mesma entrada, duas respostas
11 ou 14. Nada na gramática decide qual. Um compilador com essa gramática produziria resultados dependentes de um detalhe de implementação — qual regra o parser tenta primeiro. A linguagem deixaria de ter significado definido.
3
A solução: estratificar a gramática
É aqui que EXPRESSION, TERM e FACTOR deixam de parecer burocracia. Eles não foram inventados para organizar o código — foram inventados para eliminar a ambiguidade. Um TERMnunca pode conter um + solto; para isso teria que descer até FACTOR e entrar num parêntese. Então a Árvore 2 acima simplesmente não é derivável. Sobra uma, e só uma.
gramática
árvores para 3+4*2
precedência
E → E+E | E*E | n
duas — ambígua
indefinida
EXPRESSION / TERM / FACTOR
uma só
embutida na hierarquia
4
Um caso clássico: o dangling else
Ambiguidade não é problema só de contas. O exemplo mais famoso em linguagens de programação é o else pendurado:
if (a == b)
if (c <= n)
d++;
else
d--;
Esse else pertence a qualif? A indentação sugere o primeiro, mas indentação não é gramática:
Leitura A — o else é do if de DENTRO
if (a == b) {
if (c <= n)
d++;
else
d--;
}
Leitura B — o else é do if de FORA
if (a == b) {
if (c <= n)
d++;
}
else
d--;
Com a == b verdadeiro e c <= n falso, a Leitura A executa d-- e a Leitura B não executa nada. Comportamentos diferentes, mesmo texto.
Como as linguagens reais resolvem
C, Java, C# e companhia adotam a regra "o else pertence ao if mais próximo ainda sem else" — ou seja, a Leitura A. É uma decisão imposta por fora da gramática, que continua ambígua no papel. Python resolve de raiz: a indentação é sintaxe, então a pergunta nem chega a existir.
5
Da árvore de derivação para a AST
Resolvida a ambiguidade, sobra uma árvore — mas ela é grande demais. Para chegar ao número 3 em 3+4*2, a árvore de derivação desce por EXPRESSION → TERM → FACTOR → NUMBER, mesmo não havendo multiplicação nenhuma naquele ramo.
Esses nós cumpriram o papel deles — provar que a cadeia obedece à gramática — e depois disso não servem para mais nada. A AST é o que sobra quando você joga fora o andaime. Por isso ela é abstrata: o operador, que na derivação era um terminal consumido de passagem, vira o nó; e os não-terminais, que eram nós, desaparecem.
Na Parte II, aba Árvores, há um gerador que desenha as duas lado a lado para a expressão que você digitar.
6
Como a AST é executada
A avaliação é recursiva: cada nó resolve a própria operação, mas só depois de pedir aos filhos que resolvam a parte deles. Acompanhe a conversa para 3+4*2:
+Preciso somar. Quanto vale meu filho esquerdo?
3Sou uma folha, não tenho filhos: valho 3.
+E o meu filho direito?
*Espera — também preciso perguntar aos meus. Filho esquerdo?
4Valho 4.
*Filho direito?
2Valho 2.
*Então 4 × 2 = 8. Toma, +.
+Recebi 3 e 8. Logo, 3 + 8 = 11. Esse é o resultado final.
Três coisas para guardar dessa conversa. A execução vai de baixo para cima — a raiz é a última a produzir um valor, e é o valor final. Nenhum nó sabe quem é o seu pai. E o + não faz ideia de que o filho direito é uma multiplicação: ele pede um número e recebe um número. É exatamente isso que o depurador da aba v1.2 anima nó a nó.
A aula tem duas metades que não parecem conversar — mas conversam
A primeira é prática: LL(k) e LR(k) são duas estratégias para escrever um parser. A segunda é teórica: o pumping lemma para CFL, as propriedades de fechamento e a hierarquia de Chomsky dizem até onde cada classe alcança. O fio que liga as duas: as estratégias de parsing existem porque certas classes de gramática são tratáveis e outras não.
1
LL(k): analisar de cima para baixo
Um parser LL parte do símbolo inicial e vai expandindo regras até chegar na cadeia. As três letras significam:
sigla
significa
na prática
L
Left to right
os tokens são lidos da esquerda para a direita
L
Leftmost derivation
expande sempre o não-terminal mais à esquerda
k
lookahead
quantos tokens precisa espiar para escolher a produção
O exemplo do material:
A → a A b | c L = { am c bm : m ≥ 0 }
Essa gramática é LL(1): olhando um único token, a decisão já está tomada. Se o próximo for a, use A → aAb. Se for c, use A → c. Nunca há dúvida.
O seu compilador é um parser LL(1) — escrito à mão
Olhe o parse_factor(): ele consulta Parser.lexer.next.type — exatamente um token — e decide entre três alternativas (número, unário, parêntese). Isso é literalmente a definição de LL(1). O nome técnico do que você escreveu é descida recursiva (recursive descent): uma função por não-terminal, um token de lookahead. E é por isso que o atributo se chama next — ele é o lookahead.
def parse_factor():
token = Parser.lexer.next # k = 1: um token, e sóif token.type == "INT": # escolhe a produção
...
if token.type in ("PLUS", "MINUS"):
...
if token.type == "OPEN_PAR":
...
2
LR(k): analisar de baixo para cima
Um parser LR faz o caminho inverso: parte da cadeia e vai reduzindo pedaços até sobrar o símbolo inicial. Ele trabalha com uma pilha e duas operações:
shiftempilhar
Tira o próximo token da entrada e empurra na pilha. É o "ainda não sei o que isso vai virar, guarda aí".
reducereconhecer e trocar
O topo da pilha casa com o lado direito de uma produção. Troca pelo lado esquerdo. É o "ah, isso aqui era um TERM".
Acompanhe um shift-reduce completo. A gramática é E → E+T | T e T → n, e a entrada é n+n:
pilha
entrada
ação
—
Repare no formato: a pilha cresce com os shifts e encolhe com os reduces, e no fim resta exatamente E. Foi o caminho oposto ao da árvore de derivação da Aula 8, que ia de S para a cadeia.
3
LL contra LR, lado a lado
As setas apontam em sentidos opostos: é a diferença inteira entre as duas famílias.
LL(k)
LR(k)
direção
top-down — do símbolo inicial para a cadeia
bottom-up — da cadeia para o símbolo inicial
mecanismo
escolher a produção e expandir
shift / reduce sobre uma pilha
como se escreve
à mão, por descida recursiva
quase sempre gerado por ferramenta (yacc, bison, ANTLR)
poder
menos gramáticas aceitas
mais gramáticas aceitas
legibilidade
o código parece a gramática
tabelas de estados ilegíveis para humanos
o seu compilador
é este — LL(1)
—
4
As subclasses de gramáticas livres de contexto
Nem toda gramática livre de contexto é tratável do mesmo jeito. Elas se encaixam umas nas outras:
Ganhar poder de parsing custa simplicidade: quanto mais para fora, mais gramáticas aceitas e menos legível o parser.
Não confunda: ambiguidade da gramática ≠ não determinismo do autômato
São coisas separadas, e o material avisa explicitamente. Ambiguidade é propriedade da gramática: existe cadeia com duas árvores de derivação. Não determinismo é propriedade do autômato: em algum ponto ele teria mais de uma transição possível. Uma gramática não ambígua pode exigir um PDA não determinístico. Uma fala sobre o que a linguagem significa; a outra, sobre como a máquina caminha.
5
Os limites: pumping, fechamento e Chomsky
A segunda metade da aula troca "como analisar" por "o que é possível analisar". Os três tópicos estão desenvolvidos em detalhe na Parte II, cada um na sua aba:
Quatro ideias que explicam por que o compilador tem duas fases, por que o FACTOR precisa ser recursivo, e por que parênteses não viram nó na árvore. Não são pré-requisitos abstratos: cada uma resolve um problema concreto do seu código.
A gramática da linguagem
Uma gramática define quais sequências de símbolos são válidas. A do seu compilador tem três regras, e cada uma vira uma função — quase palavra por palavra.
símbolo que aparece na string final; não se expande mais
"0"…"9", "+", "-", "*", "/", "(", ")"
Não-terminal
nome de uma regra; sempre se expande
EXPRESSION, TERM, FACTOR, NUMBER, DIGIT
Símbolo inicial
por onde toda derivação começa
EXPRESSION
Produção
uma regra, do = ao ;
as cinco acima
Linguagem
o conjunto de todas as strings deriváveis
infinito
O dicionário EBNF → código
EBNF
significa
vira
,
seguido de
uma linha após a outra
{ ... }
repita zero ou mais vezes
while
( A | B )
A ou B
if / elif
[ ... ]
opcional
if
"x"
símbolo literal
consumir um token
NOME
outra regra
chamada de função
Derivação: gerando uma string a partir da gramática
Uma string pertence à linguagem se existir um caminho do símbolo inicial até ela. Cada passo substitui um não-terminal pela sua regra. Compare as duas expressões — repare onde o 3*4 aparece agrupado:
Exemplo
—
Por que "1 1" não pertence à linguagem
Tente derivar. Depois de EXPRESSION ⇒ TERM … você consome o primeiro 1. Para chegar ao segundo, precisaria de uma regra que coloque dois TERM lado a lado sem operador entre eles — e ela não existe. Sem derivação, a string está fora da linguagem. A rejeição não é uma regra ad-hoc que alguém inventou no código: é consequência da gramática.
BNF, EBNF, e o que a recursão à esquerda revela
A mesma linguagem, escrita em BNF pura — sem as chaves de repetição:
Onde a EBNF usa { }, a BNF precisa de recursão. E repare onde: <expression> aparece à esquerda do operador. Isso se chama recursão à esquerda, e não é detalhe de escrita — ela codifica a associatividade:
10-3-2
agrupa como
resultado
corresponde a
recursão à esquerda
(10-3)-2
5
✓ o while do seu código
recursão à direita
10-(3-2)
9
errado para subtração
O while que acumula da esquerda para a direita no parse_expression() é a tradução direta dessa recursão à esquerda. Por isso 10-3-2 dá 5 no seu código sem você ter pensado no assunto.
Um autômato finito determinístico (AFD) é a máquina mais simples que existe para reconhecer padrões: um conjunto de estados, uma transição por símbolo lido, e alguns estados marcados como de aceitação. Ele tem uma limitação definidora — nenhuma memória além do estado atual. Não conta, não empilha, não lembra o que veio antes.
E é exatamente isso que o seu select_next() é. A variável position não é memória do autômato: é só o ponteiro de leitura da fita.
O autômato do select_next(). Cada chamada parte de q0 e para ao chegar num estado de aceitação.
Rode o autômato
Digite algo e avance caractere a caractere. Observe que o estado nunca guarda mais do que "estou lendo um número" ou "não estou".
Fita de entrada
—
Estado
q0
Tokens reconhecidos
Transição
—
Linguagem regular = reconhecível por autômato finito
A camada léxica da sua linguagem — o que é um número, o que é um símbolo — é regular. É por isso que o Lexer quase não mudou do Roteiro 1 ao 4: acrescentar *, /, ( e ) foi só acrescentar transições saindo de q0. A estrutura da máquina continuou a mesma.
O lema do bombeamento (pumping lemma) é uma ferramenta para provar que uma linguagem NÃO é regular — ou seja, que nenhum autômato finito consegue reconhecê-la, por mais estados que você lhe dê.
Ele importa muito concretamente aqui: é a razão formal de o FACTOR precisar chamar a si mesmo.
O enunciado
Se L é regular, existe um número p (o comprimento de bombeamento) tal que toda palavra w ∈ L com |w| ≥ p pode ser escrita como w = xyz, satisfazendo:
(1) |xy| ≤ p (2) |y| ≥ 1 (3) xyiz ∈ L para todo i ≥ 0
A intuição é simples: um autômato com p estados que lê uma palavra de p ou mais símbolos precisa repetir algum estado — não há estados suficientes para todos serem distintos. O trecho lido entre as duas visitas ao mesmo estado forma um laço. E um laço pode ser percorrido quantas vezes você quiser: zero, uma, mil. Se a linguagem é regular, todas essas repetições continuam válidas.
Então, para provar que algo não é regular, basta achar uma palavra que quebra ao ser bombeada.
Aplicando: parênteses balanceados não são regulares
Considere L = { (n)n : n ≥ 1 } — a linguagem dos parênteses que abrem e fecham na mesma quantidade. Suponha, por absurdo, que seja regular. Então existe p, e tomamos w = (p)p.
Pela condição (1), xy cabe nos primeiros p símbolos — que são todos abre-parênteses. Logo y só contém (. Bombeando com i = 2, a palavra ganha parênteses abertos a mais e nenhum fechado. O balanço quebra. Contradição — a linguagem não é regular.
Mexa nos controles e veja a contradição acontecer:
w = xyz (a divisão obrigatória pelo lema)
xyiz (a palavra bombeada)
x ·
y ·
z — o y só pode conter abre-parênteses,
porque pela condição (1) ele tem que caber nos primeiros p símbolos.
A consequência direta no seu código
Se parênteses balanceados não são regulares, nenhum autômato finito os reconhece — portanto o Lexer jamais poderia ser encarregado disso. É preciso uma máquina mais forte: um autômato de pilha, que empilha ao abrir e desempilha ao fechar. E é exatamente isso que o Parser é. Ele nunca declara uma pilha — ele usa a pilha de chamadas do próprio Python, via a recursão do parse_factor(). Cada parêntese aberto é um quadro de chamada a mais.
classe de linguagem
máquina que reconhece
memória
na sua linguagem
Regular
autômato finito
nenhuma
a camada léxica: NUMBER, símbolos
Livre de contexto
autômato de pilha
uma pilha
a camada sintática, a partir dos parênteses
Até o Roteiro 2 a linguagem inteira era regular — sem parênteses, nada se aninhava, e em tese um único autômato finito daria conta. O Roteiro 3 é o momento em que ela sobe de classe.
A versão para linguagens livres de contexto
O mesmo raciocínio sobe um andar. Se uma CFL tem palavras arbitrariamente longas, a árvore de derivação de uma palavra suficientemente comprida precisa ser alta — e numa árvore alta, algum não-terminal reaparece no mesmo caminho, porque a gramática tem um número finito deles.
O A de cima deriva v A x. Como o A de baixo é o mesmo não-terminal, esse pedaço pode ser repetido quantas vezes se queira — e cada repetição acrescenta um v à esquerda e um x à direita.
L livre de contexto ⇒ ∃ n tal que toda z ∈ L com |z| ≥ n se escreve z = u v w x y com:
v x ≠ ε (pelo menos um dos dois trechos é não vazio)
|v w x| ≤ n (os dois trechos ficam perto um do outro)
u vi w xi y ∈ L para todo i ≥ 0
A diferença que importa: duas partes bombeadas, não uma
No caso regular, a palavra quebra em três partes e você repete uma: w = xyz, bombeia o y. Nas CFL, quebra em cinco e você repete duas ao mesmo tempo: z = uvwxy, bombeia v e x juntos. E isso não é uma escolha: é consequência de a recursão numa árvore crescer para os dois lados do não-terminal repetido.
regulares
livres de contexto
decomposição
w = x y z
z = u v w x y
o que se repete
y
v e x, juntos
de onde vem
um ciclo no autômato
um não-terminal repetido na árvore
restrição de tamanho
|xy| ≤ p
|vwx| ≤ n
não vazio
|y| > 0
vx ≠ ε
O exemplo clássico: anbncn não é livre de contexto
A pilha de um PDA consegue casar duas quantidades — abre e fecha, a e b. Casar três ao mesmo tempo é demais para ela, e o pumping lemma prova isso.
Suponha CFL, pegue o n garantido e escolha z = anbncn. Como |vwx| ≤ n, o trecho vwx é curto demais para tocar as três letras — ele cobre no máximo duas delas (a e b, ou b e c). Bombeando com i = 2, as letras tocadas crescem e a terceira fica para trás. As três quantidades deixam de ser iguais. Contradição.
Por que isso importa para um compilador
anbncn parece artificial, mas tem análogo real: "toda variável usada foi declarada antes, com o tipo certo" é uma restrição desse tipo. É por isso que nenhuma gramática livre de contexto valida um programa por completo, e por que existe uma fase semântica separada — com tabela de símbolos — depois da sintática. A sintaxe cuida da forma; ela literalmente não alcança o resto.
Dizer que uma classe de linguagens é fechada sob uma operação significa uma coisa só: se você pega linguagens daquela classe e aplica a operação, o resultado continua na classe.
L₁ , L₂ regulares ⇒ L₁ ∪ L₂ é regular ✓ fechada sob união
L₁ , L₂ livres de contexto ⇒ L₁ ∩ L₂ pode NÃO ser livre de contexto
Isso parece contabilidade abstrata, mas é ferramenta de prova — e é assim que cai em avaliação.
A tabela que você precisa saber
operação
regulares
livres de contexto
observação
UniãoL₁ ∪ L₂
sim
sim
basta um símbolo inicial novo com duas produções
ConcatenaçãoL₁ L₂
sim
sim
S → S₁ S₂
Estrela de KleeneL*
sim
sim
S → S₁ S | ε
ReversãoLᴴ
sim
sim
inverte o lado direito de cada produção
InterseçãoL₁ ∩ L₂
sim
NÃO
contraexemplo abaixo
ComplementoL̄
sim
NÃO
decorre da interseção, por De Morgan
DiferençaL₁ − L₂
sim
NÃO
L₁ ∩ L₂̄
Interseção com regularL ∩ R
sim
SIM
a exceção que salva as provas
Homomorfismo
sim
sim
troca sistemática de símbolos
O contraexemplo que prova que CFL não fecha sob interseção
Tome estas duas, ambas livres de contexto:
L₁ = { an bn cm }
L₂ = { am bn cn }
Cada uma casa um par de quantidades e deixa a terceira livre — uma pilha dá conta disso. Mas a interseção exige que os três casem ao mesmo tempo:
L₁ ∩ L₂ = { an bn cn }
E essa, pelo pumping lemma para CFL (aba anterior), não é livre de contexto. Duas CFL cuja interseção não é CFL: a classe não é fechada sob interseção.
Por que "interseção com regular" ser fechada é tão útil
É o truque padrão nas provas. Para mostrar que uma linguagem L não é livre de contexto, você a intersecta com uma linguagem regular escolhida a dedo, obtendo algo reconhecidamente não-CFL. Como CFL é fechada sob interseção com regular, se L fosse CFL o resultado também seria — contradição. A regular funciona como um filtro que isola a parte difícil.
Como usar isso numa prova
# Para provar que L NÃO é regular usando fechamento:
1. suponha que L é regular
2. escolha uma linguagem R comprovadamente regular
3. como regulares fecham sob ∩, então L ∩ R seria regular
4. mostre (por pumping) que L ∩ R NÃO é regular
5. contradição ⇒ L não era regular
A vantagem sobre aplicar pumping direto em L: às vezes L é bagunçada demais para escolher uma palavra boa, mas L ∩ R fica limpinha.
Quatro classes encaixadas, cada uma estritamente maior que a anterior. A posição de uma linguagem na hierarquia responde a uma pergunta prática: que tipo de máquina é preciso construir para reconhecê-la?
Cada nível é estritamente mais expressivo que o de dentro — e estritamente mais caro de reconhecer.
tipo
linguagem
forma das regras
reconhecedor
exemplo típico
3
Regular
A → aB | A → a
autômato finito
a b*
2
Livre de contexto
A → β
autômato de pilha
aⁿ bⁿ
1
Sensível ao contexto
α → β, com |α| ≤ |β|
fora do escopo
aⁿ bⁿ cⁿ
0
Recursivamente enumerável
α → β, sem restrição
fora do escopo
—
Repare no que muda de um nível para o outro: o lado ESQUERDO das regras
No tipo 3, o lado direito é muito restrito (um terminal, opcionalmente seguido de um não-terminal). No tipo 2, o lado direito se liberta, mas o esquerdo continua sendo um único não-terminal — daí "livre de contexto": a regra vale onde quer que o não-terminal apareça. No tipo 1, o lado esquerdo pode ter contexto em volta (aAb → aβb), e é daí que vem o nome "sensível ao contexto".
Onde o seu compilador está
fase
classe
máquina
no código
Análise léxica
regular (tipo 3)
autômato finito
Lexer.select_next()
Análise sintática
livre de contexto (tipo 2)
autômato de pilha
Parser — a pilha é a de chamadas do Python
Análise semântica
além do tipo 2
precisa de tabela de símbolos
chega nos próximos roteiros
Essa última linha é a moral da história. "Toda variável usada foi declarada" é uma restrição de tipo 1 disfarçada — gramática livre de contexto nenhuma expressa isso. Por isso todo compilador tem uma fase semântica separada: não é preguiça de projeto, é uma fronteira formal.
A palavra "árvore" é usada para duas coisas distintas, e confundi-las atrapalha. A árvore de derivação registra cada regra da gramática que foi aplicada. A AST guarda só o que é necessário para calcular. Compare as duas para a mesma expressão:
Árvore de derivaçãotoda regra aplicada vira um nó
AST — o que o seu código constróisó operadores e valores
A árvore de derivação é alta e repetitiva: para chegar ao número 2, é preciso descer por EXPRESSION → TERM → FACTOR → NUMBER, mesmo que nenhuma multiplicação exista ali. Esses nós intermediários provam que a string obedece à gramática — e depois disso não servem para mais nada.
Por que parênteses somem na AST
Experimente (2+3)*4 acima. Na árvore de derivação os parênteses aparecem como terminais consumidos; na AST, não há vestígio deles. Não é perda de informação: parênteses existem porque a escrita é linear — num texto de uma dimensão você precisa de marcadores para dizer o que agrupa com o quê. A árvore é bidimensional, e o agrupamento já está na posição do nó. Compare as ASTs de (2+3)*4 e 2+3*4: os formatos são diferentes, e é o formato que determina a ordem da conta.
O evaluate() é um percurso em pós-ordem
Percorrer uma árvore tem nomes conforme a ordem em que se visita a raiz:
percurso
ordem
para que serve
pré-ordem
raiz, esquerda, direita
copiar a árvore, imprimir em notação prefixa
em-ordem
esquerda, raiz, direita
reconstruir a expressão original (com parênteses)
pós-ordem
esquerda, direita, raiz
avaliar — é o que o seu evaluate() faz
Faz sentido que seja pós-ordem: um BinOp não tem como somar antes de saber quanto valem os dois filhos. A raiz é sempre a última a produzir um valor — e é o valor final.
def evaluate(self):
left = self.children[0].evaluate() # 1. desce à esquerda
right = self.children[1].evaluate() # 2. desce à direita
... # 3. só então opera — pós-ordem
No depurador da aba v1.2, mais abaixo, você vê esse percurso acontecendo: os nós acendem descendo até as folhas e os valores vão aparecendo de baixo para cima.
Cada aba traz o que mudou naquela versão, o código-fonte completo da tag correspondente, e um depurador que executa aquela versão específica do compilador — escreva a expressão que quiser e percorra passo a passo.
classe
v0.0
v1.0
v1.1
v1.2
Token
nasce
=
=
=
Tokenizer → Lexer
nasce
renomeada
+ SYMBOLS
=
Parser
nasce
vira estática
+ term, factor
devolve nós
Node
—
—
—
nasce
IntVal
—
—
—
nasce
UnOp
—
—
—
nasce
BinOp
—
—
—
nasce
v0.0Roteiro 1 · soma e subtração de naturais
Nascem três classes
TokenTokenizerParser
O enunciado pedia apenas um main.py que recebesse '1+ 2 -3' e imprimisse 0 — nenhuma classe era exigida. Mesmo assim o código já nasceu dividido em três, porque o problema tem uma tensão que só a divisão resolve.
A tensão: o espaço tem dois papéis contraditórios
Em "789 +345" o espaço é lixo e deve sumir. Em "1 1" ele é exatamente o que torna a entrada inválida. Se você começar com codigo.replace(" ", ""), "1 1" vira "11" e o programa imprime onze em vez de dar erro. O espaço não pode ser apagado do texto — precisa ser consumido entre tokens, e isso só é possível varrendo caractere a caractere.
class Token2 atributos · atravessa os 4 roteiros sem mudar
type
a categoria gramatical — em v0.0 apenas INT, PLUS, MINUS, EOF
value
o conteúdo concreto: 22, "+", ""
Duas perguntas diferentes se fazem a um token, e cada uma usa só metade dele. Quem valida a ordem olha o type — "depois de um número cabe um operador" não depende de qual número. Quem calcula olha o value.
class Tokenizer3 atributos · 1 método
source
a string de entrada, guardada inteira e nunca modificada
position
o índice do próximo caractere — a memória do cursor
next
o último Token separado
select_next()
lê o próximo token e o guarda em next
O trecho decisivo é o laço interno dos dígitos — o que faz "22", dois caracteres, virar um token. As duas condições do while são obrigatórias, nessa ordem: o and do Python só avalia o lado direito se o esquerdo for verdadeiro, então numa entrada terminada em dígito ele nunca lê uma posição inexistente.
class Parsercom instâncias · vira estática em v1.0
Em v0.0 o Parser ainda tinha __init__ e métodos com self. Ele implementava EXPRESSION = NUMBER, { ("+" | "-"), NUMBER } em quatro passos: exigir um INT, inicializar o resultado, repetir enquanto houver operador, e conferir o EOF no fim.
Repare que o laço é sobre operadores, não sobre "ainda não acabou". É essa escolha que rejeita "1 1": depois de consumir o primeiro 1, o próximo token é INT, que não entra no while — e sobra para a checagem de EOF reprovar.
Depurador — gramática do Roteiro 1
Só + e -. Não há precedência nem parênteses: o resultado é acumulado da esquerda para a direita numa única variável.
Código completo · main.py na tag v0.0.0
—Extra credit
O Roteiro 1 não tem exercício extra
O enunciado diz explicitamente: "Não há extra credit para esse roteiro". Os exercícios extras começam na versão v1.0, e cada um usa uma tag com prefixo x — x1.0, x1.1, x1.2 — desenvolvida numa branch separada, criada a partir da tag aprovada do roteiro, e nunca mesclada na main.
v1.0Roteiro 2 · mesma linguagem, outra arquitetura
Separação de fases
Tokenizer → LexerParser estáticoprefixos de erro
Nenhuma classe nova, nenhuma funcionalidade nova. A gramática é idêntica à do Roteiro 1 — mesmos tokens, mesmos resultados, mesma bateria de testes. O que muda é só quem executa o quê, e por quê.
class Parser:
lexer = None@staticmethoddef parse_expression():
...
# uso:
print(Parser.run(sys.argv[1]))
Estático significa que o membro pertence à classe, não a um objeto. Faz sentido aqui porque não existe cenário em que dois Parser analisem coisas diferentes ao mesmo tempo: há um programa sendo compilado, e um só.
O conceito que este roteiro realmente introduz: o contrato do next
O atributo next guarda o token já lido mas ainda não consumido — o lookahead. Ele permite ao Parserperguntar sem consumir: if lexer.next.type == "PLUS" só olha; lexer.select_next() é que avança. O contrato: ao entrar em qualquer método do Parser, next já contém o próximo token não consumido; ao sair, o mesmo vale. Daí as duas regras que o enunciado do Roteiro 3 manda conferir — run() chama select_next() uma vez antes de tudo, e parse_expression() nunca chama na primeira linha.
class Lexerrenomeada · agora sob demanda
Mudou o nome, mas o que mudou de verdade foi o modelo: em vez de o Parser receber uma lista pronta de tokens, ele puxa um por vez, quando precisa. Medi a diferença numa entrada de 800 mil caracteres com erro de sintaxe no caractere 4:
modelo
tempo até o erro
tokens em memória
lista completa antes de analisar
1041 ms
800 005
um token por vez
0,1 ms
5
São 13 mil vezes mais rápido — mas velocidade nem é o motivo principal. Em linguagens reais, o mesmo caractere vira tokens diferentes conforme o contexto: o < de a < b é comparação, o de #include <stdio.h> faz parte de um nome de arquivo. Só o Parser sabe qual é o caso — e não tem como informar nada se a lista já foi gerada inteira.
Os prefixos [Lexer] e [Parser]
Toda exceção passa a dizer qual fase reclamou, e isso já elimina metade do código da busca. "1$2" morre no Lexer — caractere que não existe na linguagem. "1 1" passa ileso pelo Lexer, que vê dois números e não tem opinião sobre ordem, e morre no Parser.
Depurador — gramática do Roteiro 2
A mesma gramática do Roteiro 1. O que mudou é interno: o token vem do Lexer sob demanda, e as chamadas são estáticas.
Código completo · main.py na tag v1.0.0
x1.0Extra credit · tag x1.0.0
Operador XOR ^
Enunciado: implementar o XOR entre inteiros. O símbolo é ^, e ele tem a mesma precedência de + e -, logo a operação é realizada por ordem de chegada. Exemplo: 2 ^ 3 → 1.
A pergunta que decide tudo: em que nível ele entra?
"Mesma precedência de + e -" responde sozinha: ele entra no mesmo laço em que já estão o + e o -. Não se cria nível novo, não se mexe na hierarquia. Este é o caso fácil — e é o contraste que faz os dois próximos extras serem entendidos.
EXPRESSION+ − ^o ^ entra aqui, junto dos que já existiam
O que mudou no código
# 1. Lexer: mais um símbolo de um caractereelif char == "^":
self.position += 1self.next = Token("XOR", "^")
# 2. Parser: o XOR entra na MESMA condição do whilewhile Parser.lexer.next.type in ("PLUS", "MINUS", "XOR"):
...
if operator == "PLUS":
result += Parser.lexer.next.value
elif operator == "MINUS":
result -= Parser.lexer.next.value
else:
result ^= Parser.lexer.next.value
Duas mudanças, nenhuma estrutural. O laço já era "enquanto houver operador deste nível" — bastou ampliar a lista.
Casos verificados
entrada
saída
por quê
2 ^ 3
1
10 XOR 11 = 01
5^3
6
101 XOR 011 = 110
1+2^3
0
(1+2)^3 — ordem de chegada, não precedência
2^3+1
2
(2^3)+1 = 1+1
12^10^6
0
((12^10)^6) — associativo à esquerda
^3
[Parser] Expected INT, found XOR
não existe XOR unário
O caso 1+2^3 é o que prova a precedência: se o ^ ligasse mais forte, daria 1+(2^3) = 2. Dá 0, ou seja (1+2)^3 = 3^3. Está no nível certo.
É só isso que a análise léxica sofre. Quatro entradas num dicionário — que, no autômato da aba Autômatos, são quatro transições novas saindo de q0. Precedência e aninhamento não são problema do léxico: para o Lexer, * e + são igualmente simples.
Parser · três níveis+ parse_term · + parse_factor
Não existe, em lugar nenhum do código, uma tabela dizendo que * tem prioridade sobre +. A precedência é consequência de quem chama quem.
O método mais fundo resolve o seu pedaço antes de devolver — por isso amarra mais forte.
Cada função tem a SUA variável local
Uma variável global quebraria 4/(1+1)*2: quando o parse_expression de dentro dos parênteses roda, já existe outro rodando por fora. No depurador abaixo, a coluna de quadros de chamada mostra isso acontecendo — cada quadro carrega o próprio result.
Depurador — gramática do Roteiro 3
Agora com os três níveis. Acompanhe a pilha de chamadas: ela cresce ao entrar em parse_term e parse_factor, e cada quadro tem o seu próprio resultado parcial.
Código completo · main.py na tag v1.1.1
x1.1Extra credit · tag x1.1.0
Potência **
Enunciado: implementar a potência entre inteiros. O símbolo é ** e o token se chama POWER. A precedência é maior que a dos unários, porém menor que a dos parênteses — e o próprio enunciado avisa: "logo será preciso quebrar o Factor em dois pedaços". Exemplo: -3 ** 2 → -9.
A técnica que provavelmente cai na prova: para inserir um nível, divide-se um nível
A precedência pedida fica entre duas coisas que hoje moram no mesmo método, o parse_factor(): o unário e o parêntese. Como a precedência é dada pela profundidade da chamada, não há como espremer algo entre elas sem separá-las em dois métodos. Daí o "quebrar o Factor em dois pedaços".
FACTOR = ("+"|"-"), FACTOR
| BASE, { "**", BASE } ;
BASE = NUMBER
| "(", EXPRESSION, ")" ;
A escada de precedência fica assim — repare no nível novo espremido no meio:
EXPRESSION+ −liga menos forte
↓ chama
TERM* /
↓ chama
FACTOR+ − unários
↓ chama
⋮ potência**nível NOVO — entre o unário e o parêntese
↓ chama
BASEnúmero ( … )liga mais forte
O que mudou no código
# 1. Lexer: o ** precisa ser testado ANTES do *, senão vira dois MULTifself.source.startswith("**", self.position):
self.position += 2self.next = Token("POWER", "**")
elif char in Lexer.SYMBOLS:
...
# 2. Parser: FACTOR trata o unário e delega; o laço de ** opera sobre BASEdef parse_factor():
if Parser.lexer.next.type == "PLUS":
Parser.lexer.select_next()
return Parser.parse_factor()
if Parser.lexer.next.type == "MINUS":
Parser.lexer.select_next()
return -Parser.parse_factor()
result = Parser.parse_base()
while Parser.lexer.next.type == "POWER":
Parser.lexer.select_next()
result **= Parser.parse_base()
return result
def parse_base(): # o pedaço que sobrou: só número e parêntese
...
Casos verificados
entrada
saída
por quê
-3 ** 2
-9
a potência vem antes do unário: −(3**2)
(-3)**2
9
o parêntese vem antes da potência
2*3**2
18
2*(3**2) — potência antes do *
1+2**3
9
1+(2**3)
+-2**2
-4
unários encadeados sobre a potência
2**3**2
64
(2**3)**2 — associativo à esquerda, ver ressalva
2 ** -3
[Parser] ... found MINUS
o expoente é BASE, não FACTOR
2***3
[Parser] ... found MULT
lê ** e sobra um *
As duas primeiras linhas são o par que prova a precedência. -3 ** 2 dá −9 porque a potência resolve antes do sinal; (-3)**2 dá 9 porque o parêntese resolve antes da potência. É exatamente o "maior que unário, menor que parêntese" do enunciado, verificável em dois testes.
Duas decisões que o enunciado não fixa — e podem ser perguntadas
Associatividade:2**3**2 dá 64 aqui, porque o while acumula da esquerda para a direita, como os outros operadores. Matematicamente e em Python a potência é associativa à direita (daria 512). Para inverter, troca-se o while por recursão à direita: return result ** Parser.parse_factor().
Expoente negativo:2 ** -3 é erro de sintaxe, porque o operando da direita é BASE e não FACTOR. É proposital: expoente negativo produziria fração e quebraria a garantia de resultado inteiro.
A mudança mais profunda dos quatro roteiros, e a que menos altera os resultados: a bateria de testes é exatamente a mesma do Roteiro 3. O que muda é quando a conta acontece.
v1.1 — o Parser calcula
while ... in ("PLUS", "MINUS"):
...
if operator == "PLUS":
result += Parser.parse_term()
else:
result -= Parser.parse_term()
# devolve um NÚMERO
v1.2 — o Parser monta
while ... in ("PLUS", "MINUS"):
operator = Parser.lexer.next.value
Parser.lexer.select_next()
result = BinOp(operator,
[result, Parser.parse_term()])
# devolve um NÓ
O programa passa a ter dois tempos: montar (texto → árvore) e avaliar (árvore → número). O main() junta os dois em uma linha: print(Parser.run(code).evaluate()).
class Node(ABC)abstrata · o contrato
class Node(ABC):
def __init__(self, value, children):
self.value = value
self.children = children
@abstractmethoddef evaluate(self):
pass
value
o conteúdo do nó. Num BinOp é o operador; num IntVal, o próprio número
children
uma lista de nós, não um par — a árvore não é binária
evaluate()
abstrato: cada filha escreve o seu
Tentar Node("+", []) levanta TypeError. Um Node genérico não teria o que fazer no evaluate(), e se pudesse existir, o erro só apareceria muito depois, no meio de uma recursão, longe da causa.
As três filhas diferem em uma coisa só: quantos filhos têm.
A regra de ouro: cada nó cuida só da própria vida
Um BinOp("+") não sabe se os filhos são números, contas gigantes ou — nos próximos roteiros — chamadas de função. Ele pede evaluate() e recebe um número. Nenhum nó olha o pai, nenhum olha os irmãos. É essa ignorância deliberada que permite acrescentar tipos de nó novos sem tocar nos existentes.
Depurador — gramática do Roteiro 4
Mesma gramática do Roteiro 3, mas agora você vê a árvore sendo montada nó a nó e, depois, o evaluate() descendo até as folhas e subindo com os valores.
Código completo · main.py na tag v1.2.1
x1.2Extra credit · tag x1.2.0
Fatorial !
Enunciado: implementar o fatorial de um inteiro. O símbolo é !, o token se chama FACT, e ele é posicionado após o valor. A precedência é maior que a dos unários e menor que a dos parênteses. Fatorial de negativo não é aceito. Exemplo: -3! → -6.
Mesma precedência do ** — logo, mesma técnica
"Maior que os unários, menor que os parênteses" é exatamente a posição da potência do extra anterior. Então a solução é a mesma quebra do FACTOR em dois. A única diferença é que o operador vem depois do operando (pós-fixo), e não entre dois operandos.
FACTOR = ("+"|"-"), FACTOR | BASE, { "!" } ;
BASE = NUMBER | "(", EXPRESSION, ")" ;
Nenhuma classe de nó nova
O fatorial é uma operação unária — um operador, um operando. Então ele reusa o UnOp com value = "!". A árvore de -3! fica com dois UnOp empilhados:
UnOp('-')
UnOp('!')
IntVal(3)
Esse é o teste real de que a arquitetura do Roteiro 4 aguentou crescer: três operadores novos nos extras (^, **, !) e zero classes novas.
O que mudou no código
# 1. Lexer: uma entrada a mais na tabela
SYMBOLS = { ..., "!": "FACT" }
# 2. UnOp: mais um ramo no evaluate, com a checagem de negativoifself.value == "!":
if operand < 0:
raise Exception(f"[Semantic] Factorial of negative number {operand}")
return math.factorial(operand)
# 3. Parser: o mesmo split do extra anterior, com o laco pos-fixodef parse_factor():
if Parser.lexer.next.type in ("PLUS", "MINUS"):
operator = Parser.lexer.next.value
Parser.lexer.select_next()
return UnOp(operator, [Parser.parse_factor()])
result = Parser.parse_base()
while Parser.lexer.next.type == "FACT":
Parser.lexer.select_next()
result = UnOp("!", [result]) # embrulha o que ja existiareturn result
Repare na diferença entre infixo e pós-fixo no laço. Num binário, você consome o operador e depois busca o operando da direita. Aqui o operando já está na mão (é o result): basta consumir o ! e embrulhar.
Casos verificados
entrada
saída
por quê
-3!
-6
fatorial antes do unário: −(3!)
(-3)!
[Semantic] Factorial of negative number -3
parêntese antes do fatorial
(2+1)!
6
o parêntese resolve primeiro
2*3!
12
2*(3!) — fatorial antes do *
0!
1
caso de borda
3!+2
8
(3!)+2
3!!
720
(3!)! — o while aceita encadear
!3
[Parser] ... found FACT
é pós-fixo, não prefixo
De novo, as duas primeiras linhas são o par que prova a precedência. -3! vale −6 (o fatorial agarra o 3 antes do sinal), e (-3)! nem chega a calcular — o parêntese fez o −3 existir antes, e aí o [Semantic] barra o negativo.
v2.0Roteiro 5 · variáveis, blocos, Println e comentários · em Java
De calculadora a interpretador
PreProSymbolTable · VariableIdentifier · Print · Assignment · Block · NoOpASSIGN END PRINT IDENparseProgram · parseStatementevaluate(st)entrada por arquivo
O Roteiro 5 é o maior salto do curso: o compilador deixa de avaliar uma expressão e passa a executar um programa, uma instrução por linha, com variáveis que guardam valores entre uma linha e outra. A meta do roteiro:
Esta aba é em Java, com o seu código de verdade: o painel "Código" mostra o main.java da tag v2.0.2, sem nenhuma alteração. O depurador reimplementa cada método e foi conferido contra o javac: mesmas saídas e mesmas mensagens de erro.
peça
v1.2 (Roteiro 4)
v2.0 (Roteiro 5)
main()
args[0] é a expressão
args[0] é o arquivo · + "\n" · PrePro · cria a SymbolTable
Node raiz = Parser.run(codigoFonte);
int resultado = raiz.evaluate();
System.out.println(resultado);
v2.0 — quem imprime é o nó Print
String codigoLimpo = PrePro.filter(conteudo);
Node raiz = Parser.run(codigoLimpo);
SymbolTable st = new SymbolTable();
raiz.evaluate(st); // sem println aqui
Depurador animado — Roteiro 5
Veja o \n virar o token END na fita, o parseStatement() escolher o caminho pelo primeiro token e o Block raiz ganhar um filho por linha (inclusive os NoOp das linhas vazias). Na fase 2, a SymbolTable nasce vazia e vai sendo preenchida: a linha pisca em laranja quando é escrita e fica azul quando é lida.
Base de testes
O roteiro pede um programa com bloco de instruções, atribuições com operações entre variáveis e impressão, além dos testes de variável inválida e de variável inexistente.
programa
saída
o que mostra
7
comentário, 3 atribuições, Println
10 · -2
setValue atualiza; linha vazia vira NoOp
[Parser] Unexpected token in statement: NUMERO
variável inválida: tokens válidos, ordem errada
[Lexer] Invalid Symbol _
variável inválida: '_' não começa token
[Semantic] Variable y not declared
variável inexistente: só a execução descobre
[Parser] Expected =
p minúsculo vira IDEN, e IDEN no começo da linha é atribuição
[Parser] Expected end of line
um statement por linha
NumberFormatException
Integer.parseInt estoura, sem prefixo
Perguntas do Roteiro 5 · treino e extra credit
Três tipos de pergunta: sobre o roteiro (conceitos), sobre as funções (o porquê de cada linha do seu código) e implemente você, no formato de extra credit e de prova: um símbolo novo, uma variável imutável… o que muda no Lexer, no Parser, na AST e na EBNF. Tente responder antes de abrir a resposta e marque ✓ sei ou ↺ revisar. As marcações ficam salvas neste navegador.
v2.1Roteiro 6 · booleanos, if, for e Scanln · em Java
A linguagem aprende a decidir e a repetir
AND OR NOTEQ GT LTIF WHILE ELSE READOPEN_BRA CLOSE_BRAIf · While · ReadparseBlockparseBoolExpression · parseBoolTerm · parseRelExpression
Até o Roteiro 5, todo programa rodava cada linha exatamente uma vez, de cima para baixo. O Roteiro 6 traz duas coisas novas: a execução passa a escolher um caminho (if/else) e a voltar para trás (for, que em Go faz o papel do while). Para isso aparecem os operadores booleanos e relacionais, as chaves de bloco e a leitura do terminal. A meta do roteiro é o fatorial:
i = 1
n = 5
f = 1
if n < 2 {
f = 1
} else {
for i < n || i == n {
f = f * i
i = i + 1
}
}
Esta aba é em Java. O código ativo do depurador é uma referência do Roteiro 6, escrita no mesmo estilo de um main.java do Roteiro 5 (classes Lexer, Parser, Node, enum TipoToken). O enunciado pede que você escreva a sua própria EBNF: use isto para estudar, não para copiar.
peça
v2.0 (Roteiro 5)
v2.1 (Roteiro 6)
Lexer
= ASSIGN, END, PRINT, IDEN
+ lookahead de 1 caractere (== && ||), ! > < { }, if for else Scanln
BinOp / UnOp
+ - * / e sinal
+ == > < && || e ! (devolvem 1 ou 0)
If
—
nasce · 2 ou 3 filhos
While
—
nasce · 2 filhos
Read
—
nasce · 0 filhos, lê o stdin
Block
só a raiz
também cada { … }, via parseBlock()
Parser
program → statement → expression → term → factor
+ block, boolExpression → boolTerm → relExpression por cima do expression
A gramática (uma proposta)
O diagrama da aula é o de C, com parênteses no if. Adaptado para o Go, fica assim. As linhas marcadas com ★ são novas em relação ao Roteiro 5:
A precedência sai da escada de funções, igual ao Roteiro 3: quanto mais fundo o degrau, mais forte o operador. Da mais forte para a mais fraca: ! + - (unários) → * / → + - → == > < → && → ||. Por isso 1 + 2 * 3 == 7 && !0 || 0 se lê como (((1 + (2*3)) == 7) && (!0)) || 0. Carregue esse exemplo no depurador e veja a árvore.
v2.0 — '=' é sempre ASSIGN
} else if (c == '=') {
next = new Token(ASSIGN, 0);
position++;
}
v2.1 — olha um caractere à frente
} else if (c == '=') {
if (source.charAt(position + 1) == '=') {
next = new Token(EQ, 0); position += 2;
} else {
next = new Token(ASSIGN, 0); position++;
}
}
Depurador animado — Roteiro 6
Escreva um programa (ou escolha um exemplo) e aperte ▶ Rodar. Na fita, cada token entra quando o Parser chama selectNext(); os consumidos ficam riscados e o next fica em destaque. A pilha mostra quem chamou quem: a moldura vermelha está rodando, a verde está devolvendo um valor. Na AST, os nós surgem enquanto o Parser os cria e, na fase 2, acendem conforme o evaluate() desce e mostram o valor (↩) quando sobem. O código é o método Java que está rodando, com a linha acesa. A faixa colorida embaixo da linha do tempo mostra as fases: verde é o Léxico, azul o Sintático, laranja o Semântico.
Base de testes
O roteiro pede um programa com laços e condicionais usando and/or/not e leitura do terminal. Clique numa entrada para carregá-la no depurador. As saídas abaixo foram conferidas rodando a referência Java de verdade.
programa
stdin
saída
o que mostra
10
0 1 2
laço com and + not; para no 3 mesmo com n = 10
7, 3
7
condicional com and + not + Scanln
—
0 1 10 11 20 21
Block dentro de Block: a pilha de evaluate() fica funda
—
1
! é unário, mais forte que ==: (!1) == 0
—
[Parser] Unexpected token in statement: ELSE
o "\n" depois do } já fechou o if
—
[Parser] Expected }
parseBlock encontrou EOF
—
[Lexer] Invalid Symbol &
só existe &&
—
[Parser] Unexpected token in factor: ASSIGN
>= não existe: vira GT e depois ASSIGN
abc
[Semantic] Scanln: invalid integer abc
só na execução se sabe o que o usuário digitou
—
[Semantic] Division by zero
o BinOp avalia os DOIS filhos antes de olhar o ||
Pegadinhas
O "\n" depois do "{"
É só uma linha vazia dentro do bloco: vira um NoOp. Nenhuma regra especial é necessária, e você vê esse NoOp na AST.
O "\n" depois do "}"
Fecha o statement do if/for. Por isso o } else { precisa ficar numa linha só, que é também a regra do Go.
Booleano é int
Relacionais devolvem 1 ou 0, e If/While testam != 0. Por isso Println(3 > 2) imprime 1.
Scanln é FACTOR
Ele devolve valor, então pode aparecer no meio da conta: x = Scanln() + 1. Já o Println é statement.
While reavalia a condição
Um laço de 3 voltas avalia a condição 4 vezes. No depurador, veja volta subir na pilha.
Laço infinito
Esquecer o i = i + 1 trava o Java para sempre. O depurador para em 20 000 passos e avisa.
Perguntas do Roteiro 6 · treino e extra credit
Mesmo formato do Roteiro 5: sobre o roteiro, sobre as funções e implemente você (!= e >=, else if, for estilo C, break, repeat, tipagem forte…). Os exercícios adicionais do enunciado estão aqui.
Extra credit
if como expressão (n = if x > 2 { 5 } else { 3 }): um novo caminho no FACTOR, com a maior precedência de todas, que devolve um nó de 3 filhos (condição, valor-então, valor-senão). O evaluate dele devolve um dos dois valores, em vez de 0. for estilo C (for i = 0; i < n; i = i + 1 { … }) pode ser só açúcar sintático: o Parser monta Block[ Assignment(init), While(cond, Block[ corpo…, Assignment(passo) ]) ], sem nenhuma classe nova.
Os três extra credits são três variações do mesmo problema, e juntos formam um procedimento. Diante de um operador novo, são três perguntas — nesta ordem.
1
O símbolo tem um caractere ou mais?
caso
o que fazer no Lexer
exemplo
um caractere
acrescentar uma entrada em SYMBOLS
^ !
dois ou mais
startswith testado antes do caractere isolado
** antes de *
A ordem no segundo caso não é opcional: se o * for testado primeiro, 2**3 vira dois MULT e a potência nunca é reconhecida.
2
A precedência já existe, ou é nova?
Já existecaso do XOR
Acrescente o token à condição do while daquele nível e trate o operador no if/elif. Nenhuma função nova.
while next.type in ("PLUS", "MINUS", "XOR"):
É novacaso do ** e do !
Um nível de precedência é uma função. Para inserir um nível entre dois existentes, é preciso dividir um deles em dois, e encadear a chamada.
FACTOR → FACTOR + BASE
A regra de ouro da precedência
Quanto mais fundo na cadeia de chamadas, mais forte o operador liga. Se o operador novo precisa ligar mais forte que X e mais fraco que Y, ele vai numa função chamada por X e que chama Y. Não existe tabela de prioridade — a hierarquia de chamadas é a tabela.
3
Onde o operador fica em relação ao operando?
forma
exemplo
no laço / na função
nó
infixo
a + b
consome o operador, depois chama o nível de baixo para pegar a direita
BinOp
prefixo
-a
consome o operador antes de tudo, e chama a si mesmo (recursão)
UnOp
pós-fixo
a!
o operando já está na mão: consome o operador e embrulha o resultado
UnOp
# infixo — busca o operando da direita depois de consumir o operadorwhile next.type == "MULT":
select_next()
result = BinOp("*", [result, parse_factor()])
# prefixo — consome o operador e recursa no MESMO nívelif next.type == "MINUS":
select_next()
return UnOp("-", [parse_factor()])
# pós-fixo — o operando ja veio; so embrulhawhile next.type == "FACT":
select_next()
result = UnOp("!", [result])
4
Como testar que a precedência ficou certa
Não basta um caso que funcione: é preciso o par que prova. Escolha duas entradas que dariam resultados diferentes se a precedência estivesse trocada:
operador
par que prova
o que cada um mostra
^
1+2^3 = 0 · 2^3+1 = 2
ordem de chegada, sem precedência entre eles
**
-3**2 = −9 · (-3)**2 = 9
acima do unário, abaixo do parêntese
!
-3! = −6 · (-3)! = erro
idem
E o fluxo de entrega dos extras
Cada extra vive numa branch separada, criada a partir da tag aprovada do roteiro — git checkout -b x1.2 v1.2.1 — com a tag solta na própria branch. Nunca se faz merge na main: o operador extra quebraria a verificação sintática do roteiro seguinte. Por isso a main não conhece ^, ** nem !, e cada extra desconhece os outros dois.
Parte IV
Referência completa
Todas as classes da versão atual (v1.2.1), com cada atributo e cada método, o que devolvem e quem os chama.
membro
tipo
devolve
quem chama
papel
class Token
type
atributo · str
—
Parser
categoria gramatical do token
value
atributo · int | str
—
Parser
conteúdo concreto
__init__(type, value)
construtor
—
Lexer
só guarda os dois campos
class Lexer
SYMBOLS
atributo de classe · dict
—
select_next
mapeia caractere → tipo de token
source
atributo · str
—
select_next
a entrada, guardada inteira
position
atributo · int
—
select_next
ponteiro de leitura; a memória do cursor
next
atributo · Token
—
Parser
o lookahead de um token
__init__(source)
construtor
—
Parser.run
zera position, next = None
select_next()
método
Token
Parser (todos)
pula espaços, reconhece um token, atualiza next
class Parser
lexer
atributo de classe · Lexer
—
todos os métodos
a fonte de tokens, compartilhada
run(code)
estático
Node
main()
cria o Lexer, posiciona no 1º token, exige EOF no fim
parse_expression()
estático
Node
run, parse_factor
nível +-; monta BinOp
parse_term()
estático
Node
parse_expression
nível */; monta BinOp
parse_factor()
estático
Node
parse_term, si mesmo
INT, unário, parênteses; monta IntVal e UnOp
class Node(ABC) — abstrata
value
atributo · variant
—
as filhas
operador ou valor, conforme a classe
children
atributo · list[Node]
—
as filhas
0, 1 ou 2 filhos hoje; N no futuro
__init__(value, children)
construtor
—
Parser
herdado por todas as filhas sem alteração
evaluate()
abstrato
int
—
força cada filha a implementar o seu
class IntVal(Node) · class UnOp(Node) · class BinOp(Node)
IntVal.evaluate()
override
int
o nó pai
devolve self.value — o caso base da recursão
UnOp.evaluate()
override
int
o nó pai
avalia children[0], aplica o sinal
BinOp.evaluate()
override
int
o nó pai · main() na raiz
avalia os dois filhos, opera; // na divisão
Quem conhece quem
Três tipos de ligação, e confundi-los é o que costuma atrapalhar. Herança é "é um tipo de". Criação é "fabrica instâncias de". Uso é "chama métodos de, sem criar".
O Token não conhece ninguém. O Node só conhece os próprios filhos. O Parser é a única ponte entre as duas pontas.
Para onde isso vai
O investimento do Roteiro 4
Roteiro 8: os nós ganham um generate()
Irmão do evaluate(): percorre a mesma árvore com a mesma recursão, mas em vez de devolver um número, emite instruções de assembly. É nesse momento que o interpretador vira compilador de verdade — e nada disso seria possível se a conta tivesse sido feita durante a leitura, como era até o Roteiro 3.
Variáveis: o evaluate() passa a receber um argumento
Entram um nó Identifier, um Assignment e uma tabela de símbolos. O detalhe que importa é como a tabela chega aos nós: passada como argumento — evaluate(symbol_table) — e repassada aos filhos, nunca como global. Assim, quando surgirem funções com escopo próprio, basta passar outra tabela na chamada; a árvore não muda.
Os extras não criaram classes
As branches de extra credit acrescentaram três operadores — XOR (^), potência (**) e fatorial (!) — e nenhuma classe nova. Potência e fatorial dividiram o parse_factor() em dois níveis para acomodar a precedência, e reusaram BinOp e UnOp tal como estavam. É o primeiro teste real de que a arquitetura aguenta crescer.
Parte V
A prova do colega
Dezessete questões de provas anteriores
Todas com gabarito comentado e, mais importante, com onde a nota foi perdida na correção original. O conteúdo vai de linguagens formais até o projeto do compilador.
Marque os critérios que a sua resposta cumpriria; o placar se ajusta sozinho e fica salvo no navegador.
Gramáticas, classes de linguagens, reconhecedores e pumping lemma.
Questão 13,50 ptobteve 2,45
“Linguagens são conjuntos de cadeias. E existem diversas classes de linguagens.” Discorra sobre a frase usando o conteúdo e os diagramas da disciplina. Use exemplos.
Ver resposta comentada
Comece pela definição. Dado um alfabeto Σ, o conjunto Σ* reúne todas as cadeias finitas formáveis com seus símbolos. Uma linguagem é qualquer subconjunto: L ⊆ Σ*.
Depois classifique, e diga o critério. As classes se distinguem pelo poder de máquina necessário para reconhecê-las — e se encaixam umas nas outras (hierarquia de Chomsky):
Regulares ⊂ Livres de contexto ⊂ Sensíveis ao contexto ⊂ Recursivamente enumeráveis
Então dê exemplos E explique o que cada um mostra.
L₁ = { abⁿ : n ≥ 0 } é regular: basta lembrar “já vi o a?”, o que cabe num número fixo de estados. Autômato finito resolve.
L₂ = { aⁿbⁿ : n ≥ 0 }não é regular: é preciso lembrar quantos a apareceram, e esse número não tem teto — nenhum conjunto finito de estados dá conta. É livre de contexto, e o autômato de pilha resolve porque a pilha é a memória que falta.
Essa última frase é o que amarra tudo: a classificação não é uma taxonomia arbitrária, ela mede quanta memória o reconhecimento exige.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A resposta listou os exemplos e desenhou o autômato, mas não explicou o que os exemplos significam nem por que a classe muda. O professor anotou exatamente isso ao lado do desenho. Citar não é discorrer: em questão discursiva, cada exemplo precisa vir com a frase que diz o que ele demonstra.
Questão 23,00 ptobteve 1,50
Explique o que são Gramáticas e qual a sua utilidade no escopo da disciplina.
Ver resposta comentada
Uma gramática formal é a quádrupla
G = ( V , Σ , P , S )
V conjunto de símbolos NÃO TERMINAIS (variáveis)
Σ conjunto de símbolos TERMINAIS
P conjunto de regras de produção
S ∈ V símbolo inicial
As regras dizem como partir de S e fazer derivações até sobrar só terminais. Por exemplo, S → aSb | ε gera { aⁿbⁿ }:
S ⇒ aSb ⇒ aaSbb ⇒ aabb
A utilidade — que é metade da nota: a gramática descreve formalmente a sintaxe de uma linguagem. No compilador, ela é o contrato que o parser implementa: ele usa a gramática para decidir se a sequência de tokens que o lexer produziu tem estrutura válida, e para construir a árvore. Cada regra da gramática vira uma função do parser — EXPRESSION, TERM, FACTOR. Sem gramática não há critério objetivo de “programa válido”.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
Dois problemas. Primeiro, V foi definido como “o vocabulário da linguagem” — é o conjunto de não terminais. Segundo, e mais caro: a utilidade foi resolvida em uma linha (“definir uma única linguagem”). A questão vale 3,00 e a palavra utilidade está no enunciado: metade da nota está em ligar a gramática ao trabalho concreto do parser.
Questão 3.a0,50 ptobteve 0,30
Seja L = { ambn : m,n ≥ 0 } ∪ { bman : m,n ≥ 0 }. Liste 5 cadeias possíveis de L.
Ver resposta comentada
O critério é simples e vale decorar: a cadeia pode ter no máximo uma troca de símbolo. Ou é um bloco de a seguido de um bloco de b, ou o contrário.
ε (m = 0, n = 0)
a
bbb
aaabb
bbbaaaa
Teste você mesmo — o campo aceita qualquer cadeia de a e b:
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
abba foi marcada como errada, e está mesmo: ela troca de símbolo duas vezes (a→b→a). Nem a*b* nem b*a* aceitam isso. A pegadinha é ler a união como “qualquer mistura de a e b” — não é: é um bloco seguido de outro bloco, e só.
Questão 3.b1,00 ptobteve 0,00
Faça um reconhecedor adequado para a linguagem L acima.
Ver resposta comentada
Não precisa de pilha. Repare que o enunciado usa m e nindependentes — não exige que sejam iguais. Logo:
L = a* b* | b* a*
Isso é uma expressão regular, então um autômato finito basta. Convenção dos diagramas: círculo duplo = estado de aceitação; a seta início marca o estado inicial.
Versão 1 · AFN. Do estado inicial saem duas transições ε (sem consumir nada): o autômato “escolhe” em qual ramo apostar. Em cima fica a*b*, embaixo b*a*.
q1: só viu a até agora — aceita, porque aⁿ ∈ L.
q2: viu a’s e depois b’s. Daqui em diante só pode vir b; um a não tem transição e a cadeia morre nesse ramo.
q3 e q4: o mesmo raciocínio, espelhado.
A cadeia vazia é aceita porque, pelas ε, q0 já alcança estados de aceitação.
Versão 2 · AFD (se a questão pedir determinístico). Sem ε: é a primeira letra que escolhe o ramo. Aparece um estado novo, q5, o estado morto — para onde vai toda cadeia que já trocou de símbolo duas vezes. Ele não é de aceitação e não tem saída.
estado
o que ele “lembra”
lendo a
lendo b
aceita?
→ q0
nada lido ainda
q1
q3
sim (ε)
q1
só a’s
q1
q2
sim
q2
a’s e depois b’s
q5
q2
sim
q3
só b’s
q4
q3
sim
q4
b’s e depois a’s
q4
q5
sim
q5
trocou de símbolo duas vezes
q5
q5
não
Repare: os estados guardam só em que fase a leitura está, nunca quantas letras vieram. Seis estados bastam para qualquer tamanho de cadeia — é exatamente isso que faz a linguagem ser regular. Para aⁿbⁿ seria preciso um estado por valor de n, e aí não há autômato finito que dê conta.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A resposta propôs um autômato de pilha não determinístico — máquina forte demais para o problema. O erro nasce de ler ambn como se fosse anbn. Sempre confira se os expoentes são o mesmo símbolo: essa única letra decide entre regular e livre de contexto.
Questão 3.c1,00 ptmarcada 1,00 — ver ressalva
“Essa linguagem é Livre de Contexto, mas não é Regular.” Concorde ou discorde. Justifique.
Ver resposta comentada
Pela linguagem como está impressa no enunciado, a resposta correta é discordo:
L = a*b* ∪ b*a* é regular — tem expressão regular, logo tem autômato finito.
Toda linguagem regular também é livre de contexto (a inclusão é própria, mas vale). Então “é livre de contexto” não é falso; o que é falso é o “mas não é regular”.
A afirmação só seria verdadeira se a linguagem fosse { aⁿbⁿ }, com o mesmo expoente dos dois lados.
Ressalva importante
Esta questão recebeu nota cheia na correção original, com a justificativa de que “os a e b têm o mesmo número” — o que não é o que o enunciado impresso diz. Há inconsistência entre o enunciado fotografado e a correção. Não memorize a justificativa dada: memorize o critério. Se os expoentes forem independentes, é regular; se forem amarrados, é livre de contexto.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
Aqui a armadilha é de leitura, não de conceito. A justificativa escrita (“o mesmo número de a e de b”) descreve uma linguagem diferente da que está no enunciado. Numa prova, releia os índices antes de decidir a classe.
Questão 3.d1,00 ptobteve 1,00
“Para provar que ela é Regular, bastaria fazer um Pumping Lemma.” Concorde ou discorde. Justifique.
Ver resposta comentada
Discordo. O lema do bombeamento é uma condição necessária, não suficiente:
L regular ⇒ L satisfaz o pumping lemma ✓
L satisfaz o lema ⇒/ L regular ✗
Por isso ele é usado por contradição, para provar que algo NÃO é regular: você supõe regular, aplica o lema, e chega a uma palavra bombeada que sai da linguagem.
Para provar que é regular, os caminhos válidos são construtivos: exibir uma expressão regular, um AFD/AFN, uma gramática regular, ou usar propriedades de fechamento a partir de linguagens sabidamente regulares.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
Nada perdido aqui — a resposta acertou o raciocínio. Vale apenas reforçar a formulação: o problema não é o lema “ser inconclusivo”, é que a implicação só vale num sentido.
Questão 1.d1,00 ptobteve 1,00
Considere a função f(x) = x + 3 em unário, representada pela linguagem L = { 1n # 1n+3 : n ≥ 0 } — a entrada, um separador, e a saída. “L pode ser representada por uma Linguagem Livre de Contexto.” Concorde ou discorde. Justifique.
Ver resposta comentada
Concordo. E dá para provar das duas formas que a disciplina ensina — com gramática e com autômato.
Pela gramática. Cada 1 da esquerda precisa de um 1 correspondente na direita, e o separador vem acompanhado de três 1 extras:
S → 1 S 1 | # 1 1 1
A primeira regra gera os 1 aos pares, um de cada lado; a segunda fecha com o separador e os três a mais. Para n = 2:
S ⇒ 1S1 ⇒ 11S11 ⇒ 11#11111 (2 à esquerda, 5 à direita)
O lado esquerdo de toda regra é um único não-terminal: é livre de contexto.
Pelo autômato de pilha. Empilha um X por 1 antes do #; ao ler o #, empilha mais três; depois desempilha um por 1. Aceita com pilha vazia. Rode:
Pilha
E ela não é regular: é preciso lembrar quantos 1 vieram antes do # para conferir a direita, e esse número não tem teto — nenhum conjunto finito de estados dá conta. Então a resposta completa é: livre de contexto, e não regular.
O que uma resposta de nota máxima precisa ter
O que dá para aprender com a resposta original
O colega escreveu “colocar 3 1’s na pilha fixos e, para cada 1 de entrada, colocar 1 na pilha” — e levou nota cheia, com a anotação “ok, apesar de 1a estar errado”. A intuição estava certa: é exatamente o autômato acima, que empilha três fixos no separador. O que faltou foi formalizar: dizer qual é a linguagem, mostrar que o reconhecimento acaba com a pilha vazia, e justificar por que um autômato finito não bastaria.
Questão extra+1,00 ptobteve 0,00
Prove formalmente que L = { wcw : w ∈ {a,b}* }não é Livre de Contexto.
Ver resposta comentada
Use o pumping lemma para CFL. O passo decisivo é escolher uma palavra que realmente pertença a L.
Suponha L livre de contexto. Existe então o comprimento p do lema.
Escolha w = apbp, logo z = apbp c apbp. Confira: z ∈ L, porque as duas metades em volta do c são idênticas.
O lema garante z = uvxyz' com |vxy| ≤ p e vy ≠ ε.
Como |vxy| ≤ p, esse trecho é curto demais para tocar as duas metades e o c ao mesmo tempo. Dois casos: ou ele fica inteiro de um lado do c, ou engloba o c mas só a vizinhança dele.
Bombeando com i = 2: no primeiro caso uma metade cresce e a outra não, então deixam de ser iguais. No segundo, ou o c se duplica (e a palavra nem tem a forma wcw), ou o fim da primeira metade e o começo da segunda mudam de tamanhos diferentes.
Em todos os casos uv²xy²z' ∉ L. Contradição — L não é livre de contexto.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A tentativa escolheu z = akbkck — que nem pertence a L. Em wcw existe umc, no meio, e as duas metades são cópias. Se a palavra escolhida não está na linguagem, a prova inteira desmorona antes de começar. Primeiro passo de todo pumping: verificar que z ∈ L.
Bloco B · Compiladores
Fases de compilação, implementação de operadores, AST e classes de linguagem.
Questão 14,00 ptobteve 1,00
Usando como base a v2.0 do compilador, explique passo a passo como implementaria o operador >= entre duas expressões. A desigualdade teria a menor precedência possível e retornaria 1 (true) ou 0 (false). Explore detalhadamente todas as etapas de compilação.
Ver resposta comentada
A palavra que vale os 4 pontos é detalhadamente. Uma fase por vez, com o que muda em cada uma:
1 · Lexer. Token novo GE. Testar >=antes de > sozinho, senão o lexer devolve dois tokens errados (mesma armadilha do ** no extra credit).
x >= 4 + 1 → IDEN(x) GE INT(4) PLUS INT(1)
2 · Gramática e parser. Menor precedência significa um nível NOVO acima do atual topo. O que hoje é EXPRESSION passa a ser chamado por uma camada relacional:
4 · Semântico. O evaluate() do BinOp ganha o ramo >=, devolvendo 1 ou 0. É aqui que entra a checagem de tipos: comparar inteiro com string deve levantar [Semantic]. A tabela de símbolos resolve o Identifierx.
5 · Geração de código. Avalia os dois lados, compara e materializa o booleano como inteiro:
6 · Testes.5>=5 → 1 · 6>=5 → 1 · 4>=5 → 0 · 2 >= 4+1 → 0 (este é o que prova a precedência).
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A resposta nomeou as fases (“adicionaria no lexer, depois no parser, depois na symbol table”) sem dizer o que muda em cada uma. Duas falhas específicas: não tratou a precedência, que é o coração do enunciado, e citou a tabela de símbolos como se ela guardasse os valores 0 e 1 — ela guarda variáveis, não resultados de comparação. Em questão de 4 pontos que diz “detalhadamente”, cada fase precisa de um exemplo concreto.
Questão 23,00 ptobteve 3,00
Explique em quais etapas de compilação o seu compilador dispara erros (exceções). Dê exemplos de entradas inválidas para cada etapa.
Ver resposta comentada
Resposta modelo — esta questão saiu com nota cheia. A estrutura que funciona é uma pergunta por fase:
etapa
a pergunta que ela faz
exemplo que falha
prefixo
Léxica
“esses caracteres formam tokens?”
@ x = 10;
[Lexer]
Sintática
“esses tokens estão numa ordem válida?”
x = 10
[Parser]
Semântica
“essa estrutura faz sentido?”
x = 10 + "Raul";
[Semantic]
O detalhe que dá força à resposta: nos três exemplos, o erro anterior não aconteceu. Em x = 10 todos os tokens são válidos — só falta o ;. Em 10 + "Raul" os tokens e a ordem estão perfeitos — o problema é o significado. Mostrar essa progressão prova que você entendeu a separação de fases, não só decorou a lista.
O que uma resposta de nota máxima precisa ter
Questão 3.aparte de 3,00 ptobteve 0,75
“O projeto do compilador implementa uma sintaxe que pode ser representada por uma Linguagem Sensível ao Contexto.” Verdadeiro ou falso? Justifique.
Ver resposta comentada
Verdadeiro, mas pelo motivo certo — e com a ressalva que vale o resto da nota.
A inclusão da hierarquia é própria mas vale para cima: Regulares ⊆ LC ⊆ LSC. Como a sintaxe do compilador é descrita por uma gramática livre de contexto, ela também pertence à classe das sensíveis ao contexto. Toda LC é LSC.
A ressalva: isso não significa que seja necessária uma LSC. Uma gramática livre de contexto é suficiente para a parte sintática, e é o que o projeto usa. Dizer “pode ser representada” é diferente de “precisa ser”.
Onde o compilador realmente esbarra em sensibilidade ao contexto é na fase semântica: “toda variável usada foi declarada” não é expressável por GLC. Mas isso não se resolve com uma gramática mais forte — resolve-se com tabela de símbolos.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A cadeia de inclusão estava certa, mas faltou a ressalva: o enunciado pergunta se pode ser representada, e responder só isso deixa metade da questão de fora. Diga também o que é suficiente.
Questão 3.bparte de 3,00 ptobteve 0,00
“A AST gerada pelo Parser é uma árvore de derivação não-ambígua.” Verdadeiro ou falso? Justifique.
Ver resposta comentada
Falso. AST e árvore de derivação são coisas diferentes:
árvore de derivação
AST
contém
todo não-terminal aplicado: EXPRESSION, TERM, FACTOR, NUMBER
só operadores e valores
prova
que a cadeia obedece à gramática
nada — ela já assume isso
serve para
a análise sintática
avaliar e gerar código
parênteses
aparecem como terminais
desaparecem
E a segunda metade da afirmação também não se sustenta: quem elimina a ambiguidade é a gramática, estratificada em EXPRESSION/TERM/FACTOR, ou regras explícitas de precedência e associatividade. A AST é consequência de uma gramática não ambígua, não a causa dela. Uma gramática ambígua produziria ASTs diferentes para a mesma entrada — a árvore não teria como se defender.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A resposta afirmou verdadeiro e justificou com “a profundidade dos tokens tira a ambiguidade”. Dois erros num só: tratou AST e árvore de derivação como sinônimos, e inverteu a causalidade — a profundidade reflete a precedência que a gramática já definiu; ela não a cria.
Bloco C · Projeto e paradigmas
Escopo, evolução do projeto, tipos, paradigmas e otimizações.
Questão 13,00 ptobteve 2,75
Explique como foi implementado o escopo local de variáveis no projeto.
Ver resposta comentada
Cada escopo tem a sua própria tabela de símbolos, e cada tabela guarda uma referência para a tabela do escopo que a contém (parent).
Ao entrar numa função ou num bloco { ... }, cria-se uma tabela filha apontando para a atual.
Ao declarar, a variável entra apenas na tabela atual.
Ao buscar, procura-se na tabela atual; se não achar, sobe para o parent, e assim até a global. Se chegar ao topo sem achar, é erro semântico.
Essa cadeia de busca é o que produz o shadowing:
let x = 5;
{
let x = 10;
print(x); // 10 - acha na tabela local e para
}
print(x); // 5 - a tabela local morreu ao fechar o bloco
Além do valor, a tabela guarda o tipo e a posição na pilha — esta última é o que a geração de código usa para emitir [EBP-4], [EBP-8] e assim por diante.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
Quase nota cheia. O que faltou foi fechar o ciclo: além de criar a tabela filha, dizer que ela é descartada ao sair do bloco — é isso que faz o x externo voltar a valer 5.
Questão 23,00 ptobteve 3,00
Explique detalhadamente as principais diferenças de implementação entre a v2.3 e a v3.0 do projeto.
Ver resposta comentada
Resposta modelo — saiu com nota cheia. O eixo é: v2.3 acrescenta funções; v3.0 troca interpretar por compilar.
v2.3 · funções. Tokens e palavras reservadas novas (fn, return); o parser ganha a estrutura de declaração e chamada; a AST ganha nós FuncDec, FuncCall, Block e Return. Cada chamada cria uma tabela de símbolos nova — é aqui que o escopo da questão anterior vira essencial.
v3.0 · geração de assembly. O evaluate() ganha um irmão, generate(), que percorre a mesma árvore mas emite instruções em vez de calcular. As mudanças concretas:
variáveis deixam de viver num dicionário e passam a ter posição na pilha, acessadas como [EBP-4];
EAX vira o registrador de trabalho para o resultado de cada expressão;
condicionais e laços viram CMP + JE/JMP com rótulos únicos;
chamadas de função exigem prólogo e epílogo (PUSH EBP / MOV EBP, ESP / POP EBP).
A frase que amarra: a árvore não muda. O que muda é o que se faz ao percorrê-la.
O que uma resposta de nota máxima precisa ter
Questão 32,00 ptobteve 0,00
Discorra sobre Paradigmas de Programação.
Ver resposta comentada
Paradigma é um modelo de organizar e pensar a construção de programas — não tem relação com velocidade nem com a forma de execução.
paradigma
ideia central
linguagens
Imperativo / procedural
sequência de comandos que modificam estado
C, Pascal
Orientado a objetos
objetos que combinam estado e comportamento
Java, C#, C++
Funcional
funções, composição, imutabilidade, sem efeitos colaterais
Haskell, Lisp
Lógico / declarativo
descreve relações e deixa a máquina buscar a solução
Prolog
// imperativo: eu digo COMO fazer
soma = 0;
for (i = 0; i < 10; i++) soma += i;
// funcional: eu digo O QUE quero
soma = reduce(add, range(0, 10))
Vale acrescentar que a maioria das linguagens modernas é multiparadigma: Python permite procedural, OO e funcional no mesmo arquivo. E citar o próprio projeto: o compilador foi escrito em Python orientado a objetos (a hierarquia Node é polimorfismo puro), e compila uma linguagem imperativa.
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A resposta falou de compilado × interpretado e de desempenho. Isso é uma classificação de como a linguagem é executada, não de paradigma — C e Haskell são ambas compiladas e estão em paradigmas opostos. Zerou por responder outra pergunta. Quando o enunciado é uma palavra-chave do vocabulário da disciplina, comece definindo a palavra.
Questão 42,00 ptobteve 2,00
Qual foi o impacto em cada etapa de compilação com a adoção de tipos na linguagem compilada?
Ver resposta comentada
Resposta modelo — nota cheia. A estrutura vencedora é descer a pipeline dizendo o que cada fase ganhou:
Lexer reconhece as palavras de tipo como tokens
(let, mut, i32, str, bool) — via dicionário de keywords
↓
Parser a gramática aceita declarações tipadas:
let mut x : i32 = 5 ;
↓
Symbol Table passa a guardar o TIPO junto do valor
↓
Semântico verifica compatibilidade nas operações
↓
Codegen escolhe a instrução adequada ao tipo
A tabela de símbolos vira o centro da coisa:
id
tipo
valor
x
bool
true
y
i32
3
E o exemplo que fecha: x = 10 + "Raul" passa pelo léxico e pelo sintático sem problema — só o semântico, consultando os tipos, consegue reprovar.
O que uma resposta de nota máxima precisa ter
Questão extra+1,00 ptobteve 0,00
Proponha otimizações no projeto do interpretador. Justifique em que etapa poderiam ser aplicadas e por que preservam o comportamento do programa.
Ver resposta comentada
A otimização canônica para esse projeto é constant folding (dobramento de constantes): resolver em tempo de compilação tudo que não depende de dados de execução.
x = 3 + 4 * 2;
+ a AST é reescrita
/ \ → para:
3 *
/ \ IntVal(11)
4 2
Em que etapa: sobre a AST, depois do parsing e preferencialmente depois das verificações semânticas — se você dobrar antes, perde a chance de reportar erro de tipo em uma subexpressão que sumiu.
Por que preserva o comportamento: as operações envolvidas são puras — não leem nem alteram estado, não dependem de entrada, não têm efeito colateral. 3+4*2 e 11 são indistinguíveis para o resto do programa. O cuidado: a aritmética do compilador precisa seguir exatamente a semântica da linguagem-alvo (mesma divisão inteira, mesmo overflow), senão a otimização muda o resultado.
Outras respostas aceitáveis: propagação de constantes, eliminação de código morto, eliminação de subexpressões redundantes, simplificações algébricas seguras (x*1, x+0).
O que uma resposta de nota máxima precisa ter
Onde a nota foi perdida
A proposta foi “criar uma AST no lexer” para paralelizar. Não existe: a AST é produto do parser, porque só ele conhece a estrutura sintática. O lexer vê uma fita de caracteres e não tem como saber que 4*2 agrupa antes de 3+. Antes de propor otimização, confirme qual fase produz o quê.
As lições que se repetem
Olhando as dezessete correções juntas, os pontos perdidos caem em cinco padrões. Eles valem mais que qualquer conteúdo específico.
1 · Citar não é discorrerQ1 e Q2 do bloco A, Q1 do bloco B
Listar exemplos ou nomear as fases não pontua. Cada item precisa vir com a frase que diz o que ele demonstra. Em questões de 3 ou 4 pontos, conte quantas ideias distintas o enunciado pede — normalmente é uma por ponto.
2 · Leia os índicesQ3.b e Q3.c
ambn e anbn são linguagens de classes diferentes. Uma única letra decide entre regular e livre de contexto, entre autômato finito e pilha. Circule os expoentes antes de responder qualquer coisa.
3 · Pumping só prova negativaQ3.d e a extra
Ele é condição necessária. Serve para provar que algo não pertence à classe, por contradição. E o primeiro passo de toda aplicação é verificar que a palavra escolhida pertence à linguagem — foi o que derrubou a questão extra.
4 · Não confunda os artefatosQ3.b do bloco B, extra do bloco C
AST ≠ árvore de derivação. AST nasce no parser, nunca no lexer. Tabela de símbolos guarda variáveis, não resultados. Saber qual fase produz o quê evita três zeros diferentes.
5 · Defina a palavra do enunciadoQ3 do bloco C
“Discorra sobre paradigmas” zerou porque a resposta falou de compilado × interpretado. Quando o enunciado traz um termo do vocabulário da disciplina, a primeira frase tem que ser a definição dele.
√ O que funcionouas quatro questões com nota cheia
Todas seguiram o mesmo formato: uma tabela ou pipeline percorrendo as fases, com um exemplo concreto por linha, e uma frase final amarrando. Erros de compilação, v2.3×v3.0 e tipos saíram com 100% assim.
Revisão
Os meus erros no simulado
Dez questões erradas, mas seis ideias por trás delas. Errar a mesma ideia de dois jeitos diferentes é o que mais custa na prova. Por isso a revisão é organizada pela ideia, e não pela questão.
Em cada bloco: tente responder as perguntas antes de abri-las, leia a regra e faça os testes rápidos. Depois, refaça o bloco novo do simulado sobre os extras.
Por que a armadilha pega: a gente associa “entrada errada” com a primeira fase que lê a entrada. Mas cada fase só consegue ver um tipo de problema, e só reclama do que consegue ver.
Pense antes de abrirEm 1 1, existe algum caractere que o Lexer não conheça?
Não. 1, espaço, 1: tudo conhecido. O Lexer devolve INT(1) INT(1) EOF tranquilamente. Ordem não é assunto dele.
Então quem percebe que sobrou um número? Em que linha do código?
O run(), depois de parse_expression() voltar: if Parser.lexer.next.type != "EOF". Sobrou um INT → [Parser] Unexpected token INT.
Em 1/0, o Parser consegue saber que o divisor é zero? E se a entrada fosse 1/(2-2)?
O Parser só vê a forma: número, barra, número. Em 1/(2-2) o zero nem está escrito: ele só existe depois de calcular. Quem calcula é o evaluate(), por isso o erro é [Semantic].
Leve para a prova
fase
a única pergunta que ela faz
exemplo que ela barra
[Lexer]
esse caractere existe na linguagem?
2 $ 3
[Parser]
essa sequência de tokens está numa ordem permitida?
1 1 · (2+3 · 1--
[Semantic]
essa conta faz sentido quando executada?
1/0 · (-3)!
Confira agora
Quem rejeita (2+3 ?
Parser. Todos os caracteres são válidos; o parse_base() espera CLOSE_PAR e encontra EOF.
Quem rejeita (-3)! no x1.2?
Semantic. A forma é válida; só ao avaliar se descobre que o operando é negativo.
Quem rejeita 1+2*3 no x1.0?
Lexer! O x1.0 nasceu da v1.0, e ali o * ainda não existe. Aqui o caractere realmente não pertence à linguagem.
Por que a armadilha pega: na cabeça, -0,5 “vira 0”, porque é assim que calculadoras e C/Go truncam. Mas o compilador usa //, e o // do Python não trunca.
Pense antes de abrir-2/4 é -0,5. Na reta numérica, “para baixo” leva a 0 ou a −1?
A −1. “Para baixo” é em direção ao menos infinito, e não em direção ao zero. -2 // 4 == -1, então 3 - (-1) = 4.
Quando truncar e arredondar para baixo dão o mesmo resultado?
Com resultado positivo: 7 // 2 = 3 dos dois jeitos. A diferença só aparece com negativos. Por isso a pegadinha sempre coloca um sinal de menos.
Leve para a prova
// = piso (floor). Resultado negativo não exato → vai para o inteiro menor. -7//2 = -4, e não -3.
Confira agora
-7/2 na v1.2 vale:
−4. -3.5 arredondado para baixo é −4.
7/-2 na v1.2 vale:
−4. O unário no divisor é aceito pelo FACTOR, e 7 // -2 = -4.
Por que a armadilha pega: a gente traz a precedência da matemática (ou do Python) para dentro do nosso compilador. Mas aqui a precedência é só uma coisa: em qual função e em qual laço o operador foi colocado.
Pense antes de abrirNo x1.0, em qual while o ^ está? Quem mais está nesse while?
No while de parse_expression(), junto de + e -. Mesmo while = mesma precedência = da esquerda para a direita: (1+2)^3 = 3^3 = 0.
Em -3!, qual função vê o - primeiro? O que ela chama logo depois?
parse_factor() vê o MINUS e chama parse_factor() de novo. Esse segundo parse_factor() lê o 3, vê o ! e devolve 3! = 6. Só então o primeiro aplica o menos: −6.
E o que é preciso escrever para o menos ser aplicado antes do !?
Parênteses: (-3)!. Aí o −3 existe como BASE antes do fatorial, e o [Semantic] barra.
Leve para a prova
EXPRESSION+ − ^ (x1.0)liga menos forte
↓ chama
TERM* /
↓ chama
FACTORunário + −, depois BASE com o laço de ** (x1.1) ou ! (x1.2)
↓ chama
BASEnúmero · ( EXPRESSION )liga mais forte
Mais fundo = liga mais forte. Mesma função = mesma precedência = esquerda para a direita. Repare que o ** e o ! ficam abaixo do unário: por isso -2**2 = -4 e -3! = -6.
Confira agora
2*3! no x1.2 vale:
12. O ! fica mais fundo que o *: 2 * 6.
-2**2 no x1.1 vale:
−4. Mesmo raciocínio do -3!: a potência é calculada dentro do FACTOR antes de o menos ser aplicado.
2^3+1 no x1.0 vale:
2. Esquerda para a direita: 2^3 = 1, depois 1+1 = 2.
Q07Por que o Lexer testa ** antes de *? — para não virar dois MULT
Q09O fatorial exigiu classe nova na AST? — Falso
Por que a armadilha pega: parece natural que o ** “ganhe prioridade” no Lexer por ter prioridade na conta, e que um operador novo mereça um nó novo. Nos dois casos se atribui a uma peça uma responsabilidade que é de outra.
Pense antes de abrirSe o Lexer testasse * primeiro, que tokens sairiam de 2**3?
INT(2) MULT MULT INT(3). O Parser receberia 2 * * 3 e daria erro. Isso é problema de cortar o texto, não de ordem de conta. Regra do Lexer: tente sempre o token mais longo primeiro.
Quantos operandos tem o !? Qual classe já existe com exatamente um filho?
Um operando → UnOp, com value = "!". As classes da AST são separadas pela quantidade de filhos, e não por qual operador é: IntVal (0), UnOp (1), BinOp (2).
Leve para a prova
Lexer: onde um token começa e termina. Nunca precedência. Parser: precedência e associatividade, pela posição nas funções. AST: uma classe por aridade. evaluate(): o que cada operador faz.
Confira agora
Implementando >=, o Lexer deve testar:
>= antes. Mesmo motivo do **: o mais longo primeiro, senão >= vira > seguido de =.
Um operador % binário precisa de classe nova?
Não. Dois operandos → BinOp; basta um ramo "%" no evaluate().
Qual peça decide que o fatorial liga mais forte que o unário?
O Parser: o laço do ! fica dentro do parse_factor() chamado pelo unário.
Q14L₁, L₂ livres de contexto ⇒ L₁ ∩ L₂ livre de contexto? — Falso
Q26Livres de contexto são fechadas sob complemento? — Falso
Por que a armadilha pega: as regulares fecham sob tudo, e é fácil generalizar. Mas a pilha tem um limite: ela consegue casar um par de contagens, não dois ao mesmo tempo.
Pense antes de abrirEm {aⁿbⁿcᵐ}, o que precisa casar? E em {aᵐbⁿcⁿ}?
Na primeira, os a’s com os b’s (o c é livre). Na segunda, os b’s com os c’s. Cada uma casa um par: a pilha resolve, as duas são livres de contexto.
E uma cadeia que está nas duas ao mesmo tempo precisa cumprir o quê?
Os dois pares: a = b e b = c. Isso é {aⁿbⁿcⁿ}, que o pumping lemma mostra não ser livre de contexto. A interseção saiu da classe.
Se as livres de contexto fechassem sob complemento, o que daria para provar sobre a interseção?
Por De Morgan, L₁ ∩ L₂ = ¬(¬L₁ ∪ ¬L₂). A união elas já fecham. Se fechassem sob complemento, fechariam sob interseção, o que acabamos de ver que é falso. Logo não fecham sob complemento.
Leve para a prova
operação
regulares
livres de contexto
união, concatenação, estrela
fecham
fecham
interseção com uma regular
fecham
fecham — a exceção
interseção geral
fecham
não fecham
complemento
fecham
não fecham
Confira agora
Livre de contexto ∩ regular é sempre livre de contexto?
Verdadeiro. É a exceção: a máquina roda o autômato de pilha e o autômato finito lado a lado, com uma pilha só.
O complemento de uma linguagem regular é regular?
Verdadeiro. Num AFD completo basta trocar os estados de aceitação pelos de não aceitação.
A união de duas livres de contexto é livre de contexto?
Q21O parser é LR(1) porque olha um token à frente? — Falso, é LL(1)
Por que a armadilha pega: o “olhar um token à frente” é real, e salta aos olhos no código (o lookahead). Mas esse é o número entre parênteses, e vale igual para LL e LR.
Pense antes de abrirNosso parser começa chamando qual função? Isso é partir do símbolo inicial ou da cadeia?
Começa em parse_expression(), o símbolo inicial, e vai expandindo até chegar aos tokens. De cima para baixo: top-down. Isso é LL.
Onde estão o shift e o reduce no nosso código?
Em lugar nenhum. Não existe pilha de símbolos explícita nem reduções. Quem tem shift/reduce é o LR, que parte dos tokens e vai reduzindo até o símbolo inicial (bottom-up).
Leve para a prova
L R ( 1 ) se lê assim: 1ª letra = lê a entrada da esquerda para a direita (os dois fazem isso) · 2ª letra = L: derivação mais à esquerda, top-down; R: derivação mais à direita ao contrário, bottom-up · número = quantos tokens de lookahead.
Confira agora
Descida recursiva (uma função por regra) é:
LL. Cada função expande uma regra, de cima para baixo.
Um parser que empilha tokens e troca o topo da pilha pelo lado esquerdo de uma regra é:
10 questões sobre o nosso código, 20 no estilo da prova e, no fim, 24 sobre como os extras foram implementados — todas conferidas rodando o código de cada tag. Há pegadinhas espalhadas — o selo só aparece depois que você responde, para não entregar o jogo.
Nas de verdadeiro ou falso, pense na justificativa antes de clicar: na prova, V/F sem justificativa vale zero. Para as questões de código, a colinha tem o código completo de todas as versões.
0acertos
0/54respondidas
0pegadinhas em que caiu
Mostrar
Parte VII
Flashcards
Os conceitos que mais caem, um por cartão. Clique (ou aperte espaço) para virar, e marque se já sabe ou se precisa revisar — o filtro só os que revisar monta a próxima rodada com o que ficou.
clique ou espaço para ver a resposta
← → para navegar
Parte VIII
Colinha · todo o código
O main.py completo de cada tag, incluindo os três extras. No modo o que mudou, cada versão aparece como diff em relação à anterior — e nos extras, em relação ao roteiro de onde a branch saiu. Linhas verdes entraram; vermelhas saíram.
Primeiro vem o código, com a lista peça por peça ao lado explicando cada classe e cada função. Clique numa peça para destacar as linhas dela. Logo abaixo fica a descrição da versão: o que o programa faz, o que mudou, a gramática e exemplos de execução, todos gerados rodando aquele código.
Depois da descrição, cada versão mostra a gramática regra por regra com o diagrama sintático de cada regra (círculo ou pílula = token consumido, retângulo = chamada de outra regra), as árvores de um exemplo (a de derivação, gerada instrumentando o parser real, e a AST quando existe) e as classes e como se relacionam, extraídas do código.
Códigoclique numa peça à esquerda para destacar as linhas
Animação: como as funções são chamadasa execução real deste código, passo a passo
Descrição desta versãoo que faz, o que mudou, gramática, diagramas, árvores e classes