Análise Sintática em Compiladores
Análise Sintática em Compiladores
Propósito
Apresentar as características da Análise Sintática, a estrutura funcional do analisador, as formas de os
implementar e as especificações das gramáticas, para que este passo da compilação seja realizado
corretamente.
Objetivos
• Descrever o analisador sintático e o tratamento de erros.
Introdução
Na etapa de Análise da Compilação, o seu segundo passo é a Análise Sintática.
O analisador sintático é o componente do compilador responsável por, a partir dos tokens gerados pela
Análise Léxica, verificar se as expressões de entrada são sentenças válidas na gramática da linguagem-fonte,
gerando uma árvore sintática que é repassada para o analisador semântico visando à continuidade da
compilação.
Neste tema, apresentaremos as características do analisador sintático, as técnicas que emprega e as formas
de o implementar. Além disso, veremos as características das gramáticas que balizam o seu funcionamento.
1. Analisador sintático e o tratamento de erros
A Análise Sintática
A Análise Sintática é o segundo passo da etapa de Análise da Compilação, ela recebe do analisador léxico
uma lista de tokens, os processa e passa uma árvore de sintaxe à Análise Semântica (Figura 1).
O analisador sintático (AS), também chamado de parser, tem como principal objetivo verificar se os tokens
gerados no passo de Análise Léxica formam uma sentença (estrutura sintática) aceita na gramática que
especifica a linguagem-fonte.
Exemplo
A3 := 6 + B
A ID A
3 NumInt 3
= ATRIB Atribuição
6 NumInt 6
+ OpArit Soma
B ID B
Do ponto de vista léxico, está tudo correto. Porém, se a gramática especificar que um identificador (Id), por
exemplo, A3 e B, somente pode ser formado por letras e que antes de uma atribuição (atrib) somente pode vir
um identificador, a expressão não corresponde a uma sentença correta do ponto de vista da sintaxe da
linguagem, e será gerado um erro.
Na verdade, o AS não necessita de todos os tokens terem sido estabelecidos para fazer o seu trabalho.
Na maioria dos casos, é possível construir a árvore de sintaxe de parte do programa examinando apenas um
pequeno número de tokens de cada vez. Devido a isso, eles normalmente funcionam em conjunto com o
parser, comandando todo o processo de análise e acionando o analisador léxico pedindo um novo token
sempre que necessitar (função pedetoken()). Já o scanner deve manter controle de que partes já foram
processadas do programa-fonte. (Figura 2).
Saiba mais
Este tipo de funcionamento tem origem nas limitações que os computadores antigos possuíam, pois sua
pouca memória era insuficiente para armazenar toda a sequência de tokens do programa-fonte, bem
como toda a árvore sintática gerada. Devido a estas limitações, o parser pedia a geração do token,
criava árvores parciais e as passava para o analisador semântico já gerando a representação
intermediária. Assim, após a geração da representação intermediária, a parte da árvore que havia sido
gerada podia ser descartada, e o processo prosseguia pedindo novos tokens, gerando uma nova árvore,
uma nova parte da representação intermediária, e assim, sucessivamente.
Conforme vimos, a partir do conjunto de sentenças válidas, o parser deve gerar a árvore de sintaxe, mas o
que vem a ser exatamente uma árvore de sintaxe?
Árvores de sintaxe
A árvore de sintaxe nada mais é que a estrutura de dados denominada árvore, onde a raiz fica no topo (Figura
3) e a partir da qual toda a estrutura se origina. Cada um dos nós pode ou não ter filho. Os que possuem filhos
estão abaixo ligados por um arco ou aresta. Já os nós sem filhos são chamados folhas e aparecem na “ponta”
da árvore.
plain-text
Se
Então
Senão
• A condição.
Quando o compilador encontra o SE, ele necessita acessar estas partes todas para poder gerar o código
executável do comando, por isso não basta identificar os tokens – ele precisa montar uma estrutura sintática
para o comando. A árvore que representaria o SE seria a da Figura 4.
Figura 4 ‒ Árvore do SE.
Comentário
Entretanto, a árvore da Figura 4 está incompleta, pois a condição e os comandos do então e do senão
possuem estruturas que também precisam ser representadas na árvore, porém num tipo diferente: A
árvore de expressão.
Árvore de expressão
Este tipo de árvore é construído para representar expressões aritméticas, lógicas ou de comparação. Uma
expressão é composta por operadores que designam a operação que será realizada e operandos, que são os
valores ou expressões que irão interagir na operação.
Devido a estas características, uma árvore de expressão possui uma estrutura de representação diferente da
árvore de sintaxe de um comando, já que os operadores são nós internos (possuem filhos) e os valores são as
folhas das árvores.
Onde:
• O operador é +.
• Os operandos são 2 e 3.
Vejamos um exemplo um pouco mais complexo. Considere a expressão (2 + 3) * 7, cuja árvore seria:
Neste tipo de árvore, o percurso é das folhas para a raiz. Então, a interpretação seria:
1. Pegue os operandos 2 e 3.
2. Realize a soma.
4. Realize a multiplicação.
Note que, pela forma como a árvore foi construída, ela representa a ordem de execução das operações, não
necessitando dos parênteses como acontece quando representamos a expressão como uma expressão
matemática.
Normalmente, as operações em expressões são binárias, ou seja, possuem dois operandos. Algumas poucas
expressões podem ser unárias com o Não lógico ou negativar um número.
Devido a estas características, árvores de expressão são do tipo binário, ou seja, um nó pode ter 0, 1 ou no
máximo dois filhos.
plain-text
Se (x > 2)
Então
y = (x - 2) * 7;
Senão
y = x + 2 * 5;
A estrutura da árvore deste comando é construída a partir da árvore da Figura 4, substituindo Cond, Então e
Senão pelas árvores de expressão correspondentes, conforme você pode ver na Figura 5.
Figura 5 ‒ Árvore sintática completa do SE.
Tratamento de erros
Se todo programa fosse escrito sem erros, o projeto do compilador seria muito simplificado.
Comentário
Mormente, muitos erros ocorrem, e um bom compilador deve ajudar o programador a corrigir o seu
código provendo mensagens de erros que sejam compreensíveis.
Em verdade, as linguagens de programação não são projetadas tendo os erros em mente, já que elas não
especificam em seus padrões como os tratar. Esta tarefa acaba ficando a cargo do projeto do compilador,
gerando maior dificuldade na recuperação das incorreções dos programas.
Léxicos
Como colocar um símbolo não existente na linguagem.
Sintáticos
Como uma expressão que abre um parêntese, mas não o fecha.
Semânticos
Como um operador aritmético aplicado a um valor de string.
Lógicos
Como colocar um programa em loop infinito.
A maior parte dos erros é detectada durante a Análise Sintática, pois, em sua maioria, são expostos quando o
fluxo de tokens é validado a partir da gramática de linguagem e a cadeia não gera uma sentença válida. Outro
fator que contribui para isso é a eficiência dos modernos algoritmos de AS nesta detecção. Sendo que fazer a
detecção semântica ou lógica é muito mais difícil.
• A recuperação de cada erro deve ser rápida, de forma a não impactar a detecção de um novo erro.
Estas metas, apesar de parecerem simples, são, na realidade, difíceis de serem implementadas, mas
felizmente os erros mais comuns são simples e podem ser tratados de forma relativamente direta.
Atenção
A maior dificuldade é que um erro, muitas vezes, ocorre bem antes no código em relação ao ponto onde
foi detectado, fazendo com que o mecanismo de tratamento tenha que “adivinhar” o que o programador
pretendia fazer.
Devido a isto, os algoritmos de parser, sejam LL ou LR, tentam detectá-los tão cedo quanto possível. Para tal,
usam o conceito de prefixo viável, que consiste em considerar que ocorreu um erro assim que percebem que
determinado token não irá corresponder a uma sentença válida.
Para tornar o assunto mais claro, vejamos os aspectos obtidos por Ripley e Druseikis (1978), que realizaram
uma pesquisa em um conjunto de programas escrito na linguagem Pascal por estudantes da área de TI.
Eles descobriram que os erros ocorrem com uma frequência relativamente baixa, já que 60% dos programas
não continham erros. Dos restantes, 80% possuíam apenas 1 erro e 13% dois erros, sendo que 90% eram erros
triviais que afetavam apenas um token.
Dos erros, 60% eram de pontuação, 20% de operandos e operadores, 15% de palavras-chave e os 5%
restantes de outros tipos, sendo que, dos erros de pontuação, a maioria era em torno do uso incorreto do
ponto e vírgula, por exemplo:
◦ Certo – A:= 8;
◦ Errado – A = 8;
Atenção
Os exemplos estão em pseudocódigo com sintaxe similar à linguagem Pascal. Vários dos erros decorrem
também das diferenças entre as linguagens que usam os mesmos símbolos, mas com significados
diferentes, o que confunde os programadores, como acontece com o “=”, que em C é atribuição e em
Pascal comparador de igualdade, já que nesta linguagem a atribuição é “:=”.
A maior parte dos compiladores lidam com esses tipos de erro sem dificuldade. Emitem um alerta e
prosseguem a análise, porém, outros tipos de erros, como deixar de marcar o início ou fim de um bloco de
instruções, podem ser de tratamento mais complexo.
plain-text
Da forma como está escrito, o senão corresponde apenas ao comando b:= b+10; e a opção da atribuição a “c”
sempre irá ocorrer. Porém, se desejássemos que isso fosse parte do senão, teríamos que abrir e fechar um
bloco no senão. Este seria um erro que o tratamento não conseguiria detectar.
O compilador deve reportar a presença de erro informando no mínimo o local no programa-fonte onde ele foi
detectado, já que existe uma boa chance de o erro ser na verdade em um token anterior.
Uma estratégia comumente usada é imprimir a linha ilegal marcando o local onde o erro foi gerado, com uma
mensagem apontando, sempre que possível, o tipo do erro, por exemplo, a falta de ponto e vírgula. No
exemplo acima, foi informado que não tem uma tabela especificada.
Mas você pode dizer que está ali o nome da tabela CURSO. O que acontece é que o ponto e vírgula antes do
from faz o SGBD entender que o comando acabou ali, ou seja, o erro foi gerado por um token anterior ao from.
Se retirarmos o ponto e vírgula, o comando funciona.
Chave de resposta
Existem várias estratégias, a ideia principal é que o parser volte a um estado que permita que continue o
processamento da lista de tokens sem que novos erros sem sentido sejam gerados.
Programa exemplo
Var
a,b,c: inteiro;
Inicio
a:=10;
b:= 5;
Se a > b entao a:= a -5
senao b:= b +10;
c:= b + a;
Fim;
Note que falta o ponto e vírgula após o nome do programa. O correto seria:
plain-text
Programa exemplo;
Se o compilador não atentar para este fato, pode gerar erro em todas as outras linhas, que seriam indevidos,
já que o restante do programa está correto.
Outro exemplo seria, se ao declarar uma variável, ocorresse um erro sintático. Ele prosseguiria na análise e
não seriam gerados novos erros sintáticos, mas no próximo passo ocorreria um erro na Análise Semântica, já
que a variável não teria sido criada na tabela de símbolos.
Se ocorrem muitos erros logo no início da Análise Sintática, pode ser melhor abortar o processo. Imagine a
seguinte situação: Um compilador C recebe como entrada um programa-fonte em Pascal. Melhor abortar logo.
Modalidade do desespero
É o método mais simples de implementar. Ao descobrir um erro, o parser descarta os símbolos de
entrada, um de cada vez, até encontrar um token que pertença ao conjunto de tokens de
sincronização.
Este tipo de correção normalmente pula uma parte considerável da entrada sem verificar. Tem como
vantagens a simplicidade e o fato de não ficar em loop infinito, o que pode ocorrer com outros
métodos.
Recuperação de frases
Ao detectar um erro, o parser pode corrigir a entrada restante, ou seja, substituir um prefixo da
entrada por alguma cadeia que permita que ele siga em frente.
Um exemplo deste tipo de recuperação seria substituir uma vírgula entre os parâmetros de uma
função por um ponto e vírgula.
A partir daí, ela pode ser utilizada para construir um parser que gere diagnósticos apropriados para
indicar o reconhecimento das construções ilegais ocorridas na lista de tokens de entrada.
Correção global
O ideal é que um compilador faça o mínimo de mudanças no programa-fonte ao processar uma cadeia
inválida.
Existem algoritmos que determinam a menor quantidade de mudanças a partir de uma cadeia de
entrada incorreta X e uma Gramática G. Estes algoritmos construirão uma árvore gramatical para
outra cadeia Y que seja derivada de X e atenda a G.
Cabe observar que atualmente, com a substituição dos antigos computadores de grande porte, a compilação
passou a ser uma atividade tipicamente interativa. Desta forma, interromper o processo no momento da
ocorrência do erro se tornou menos problemático e permite que a correção seja realizada imediatamente,
como acontece na interpretação, o que facilita a correção dos programas e a construção dos compiladores em
relação à recuperação de erros.
Conteúdo interativo
Acesse a versão digital para assistir ao vídeo.
Verificando o aprendizado
Questão 1
Durante a realização da análise, podem ser detectados erros. Em princípio, o parser não deve abortar a
análise, deve reportar o erro e realizar sua recuperação. Existem diversas formas de realizar o tratamento de
erro. Quando a gramática de linguagem-fonte já prevê o que fazer para os erros mais comuns, podemos
utilizar o método de tratamento:
Modalidade do desespero
Recuperação de frases
Correção global
Recuperação sintática
Questão 2
Árvores de sintaxe são o produto do parser e servem para representar a forma de execução
de um comando. Observe a figura abaixo e indique a opção que apresenta o comando que a
produz.
A
Se X <= Z
Entao Y = X * 2 + 7
Senao Y = X * Z - 5
Se X < Z
Entao Y = X * 2 + 7
Senao Y = X * (Z – 5)
Se X <= Z
Entao Y = X * 2 + 7
Senao Y = X * (Z – 5)
Se X <= Z
Entao Y = X * (2 + 7)
Senao Y = X * Z – 5
Se X <= Z
Entao Y = X * (Z - 5)
Senao Y = X * 2 + 7
Dentre os vários tipos de linguagem, as livres de contexto são as mais adequadas para a especificação de
linguagens de programação, pois, apesar de as linguagens regulares poderem definir de forma muito eficiente
os aspectos léxicos, não possuem expressividade suficiente para definir as regras de escrita da linguagem, ou
seja, sua sintaxe.
Atenção
Desta forma, as linguagens livres de contexto e as respectivas Gramáticas Livres de Contexto (GLC) vão
especificar os padrões que serão utilizados na verificação da sintaxe do programa-fonte.
Uma Gramática Livre de Contexto G funciona para especificar formalmente as regras da linguagem e possui
em sua especificação quatro componentes:
O conjunto de produções P
Que nos permitem gerar cadeias ou sentenças da linguagem que está sendo analisada.
O símbolo inicial S
Sendo que este símbolo S deve ser um dos símbolos variáveis (S ∈ V).
Antes de continuarmos, vamos estabelecer alguns conceitos básicos que são muito importantes para o
entendimento de nosso tema.
Símbolos terminais
São símbolos na especificação da gramática que não possuem regras que os modifiquem. Por
exemplo, na seguinte gramática abaixo, 0 e 1 são símbolos terminais.
S pode se tornar 1
S pode se tornar 0
São aqueles que pelas regras da gramática podem ser substituídos por outros, ou seja, seriam como
variáveis. No exemplo da gramática acima, S é um não terminal.
Regras de produção
Especificam os lexemas que podem ser substituídos por outros. Estas regras podem ser
representadas de várias formas, mas em nossos exemplos, usaremos uma notação que deriva da
teoria dos conjuntos, por exemplo, a gramática:
S pode se tornar 1
S pode se tornar 0
S → 0S0
S → 1S1
S→0
S→1
S→Ɛ
Onde do lado esquerdo temos um símbolo não terminal ligado por uma seta (→) à produção que o
pode substituir.
Forma Sentencial – Sequência de símbolos terminais e variáveis (símbolos não terminais). Ela pode ter
apenas símbolos terminais, apenas variáveis ou uma mistura dos dois, o que implica que uma
sentença é sempre uma forma sentencial, mas nem toda forma sentencial é uma sentença.
Por exemplo, considerando a gramática com que temos feito os nossos exemplos, que valida se um
número binário possui as seguintes características:
• Se o número tiver uma quantidade par de dígitos, a primeira metade deve ser o “espelho“ da
segunda metade, por exemplo 011110 – note que a primeira metade é 011, logo a segunda deve
ser 110.
• Se o número tiver uma quantidade ímpar de dígitos, o digito do meio deve ser separado e os
dígitos antes dele devem ser espelho dos dígitos posteriores, por exemplo 0110110 – o dígito
do meio é 0, os anteriores 011 e os posteriores 110.
Derivações
A derivação de uma gramática é a sequência de formas sentenciais tal que:
3. As formas intermediárias podem ser obtidas pela substituição de um único símbolo não terminal pelo
lado direito de uma das produções.
Exemplos
Exemplo 1
Considerando nossa gramática de números binários, considere a seguinte derivação:
S⟹1
Esta derivação possui duas formas sentenciais que são sempre separadas pelo símbolo ⟹
2. Utilizando a quarta produção, substituímos S por 1, criando a segunda forma sentencial, que no caso
também é uma sentença e, portanto, a última forma da sequência, já que não existem mais
substituições que possam ser realizadas.
Exemplo 2
Vejamos um exemplo um pouco mais complexo. Você deseja validar se 00 pertence à gramática. Para isso, a
seguinte derivação seria gerada e validaria a cadeia:
S ⟹ 0S0 ⟹ 00
2. A seguir, utilizando a primeira produção, o substituímos por 0S0, que também é uma forma sentencial,
mas não uma sentença, pois possui uma variável.
3. Por último, utilizando a produção 5, substituímos S por vazio, gerando a sentença 00.
Exemplo 3
Um último exemplo, agora você deseja validar a cadeia 01110.
2. A seguir, utilizando a primeira produção, o substituímos por 0S0, que também é uma forma sentencial,
mas não uma sentença, pois possui uma variável.
3. O próximo passo é, utilizando a produção 2, substituir S por 1S1, e a cadeia fica então 01S10, que
também é uma forma sentencial, mas não uma sentença, pois possui uma variável.
4. Por último, utilizando a produção 4, substituímos S por 1 gerando a sentença 01110 e validando a
entrada.
Árvore de derivação
Outra forma de representar as derivações é usar árvores de derivação, em vez da sequência linear.
Atenção
Árvores de derivação são similares a árvores de sintaxe, mas possuem mais elementos referentes às
produções da gramática, inclusive das variáveis utilizadas, para balizar o funcionamento do parser.
Numa árvore de derivação, as folhas são os símbolos terminais, e os nós internos são as variáveis.
Um nó de variável V vai ter como filhos na árvore os símbolos pelos quais é substituído na derivação.
Utilizando nosso último exemplo S ⟹ 0S0 ⟹ 01S10 ⟹ 01110, a árvore seria a da Figura 6:
S → 0TS
S→0
T → S1T
T → SS
T → 10
• Os nós 2, 6, 8, 9, 10 e 11 são folhas e possuem os símbolos terminais que formam uma sentença.
◦ O nó 3 - T → S1T.
◦ Os nós 4 e 5 - S → 0.
◦ O nó 7 - T → 10.
A partir da árvore, a sentença gerada seria percorrendo as folhas da esquerda para a direita, ou seja, a
sequência seria 2, 9, 6, 10, 11 e 8, sendo a sentença 001100.
Comentário
Se tivesse sido utilizada uma derivação mais à esquerda para a árvore, a ordem seria: S ⟹ 0TS ⟹
0S1TS ⟹ 001TS ⟹ 00110S ⟹ 001100 Já uma derivação mais à direita produziria a seguinte
sequência: S ⟹ 0TS ⟹ 0T0 ⟹ 0S1T0⟹0S11000 ⟹ 001100 Mas a sentença final seria a mesma.
Considere uma gramática que realiza a soma ou subtração de dois inteiros. Inicialmente, teríamos que definir
através de expressões regulares o que é um número e os operadores, por exemplo:
int → D D*
D → [0-9]
+→+
-→-
PRODUÇÃO REGRA
E→E+E 1
E → E- E 2
E → int 3
Este conjunto de regras poderia ser representado como uma sentença da seguinte forma: E →E +E | E -E | int
Pois bem, a partir destas regras, vamos validar a expressão 125 + 79.
Intuitivamente, podemos perceber que, pela aplicação da regra 3 duas vezes e da regra 1, a sentença é válida
na gramática, mas vamos passo a passo.
A primeira coisa que temos que notar é que a Análise Sintática ocorre a partir dos tokens, ou seja, o analisador
léxico passaria a seguinte lista de tokens:
TOKEN LEXEMA
Int 125
+ +
Int 79
Desta forma, a sentença a ser analisada pelo parser seria int + int.
Passo 1
Aplicando a Regra 3 uma primeira vez, substituímos um dos int por E, e a expressão fica E + int.
Passo 2
Aplicando a Regra 3 uma segunda vez, substituímos o outro int por E, e a expressão fica E + E.
Passo 3
Aplicando a Regra 1, substituímos E + E por E.
Como chegamos ao final da análise e obtivemos o símbolo inicial, a expressão existe na linguagem, ou, em
outras palavras, o comando está sintaticamente correto.
Esta forma que apresentamos foi ascendente. Outra forma de fazer seria descendente, com os seguintes
passos:
Passo 1
Aplicando a Regra 1, substituímos E por E + E.
Passo 2
Aplicando a Regra 3 uma primeira vez, substituímos um dos E por int, e a expressão fica E + int.
Passo 3
Aplicando a Regra 3 uma segunda vez, substituímos o outro E por int, e a expressão fica int + int.
Observe que, na realidade, as duas formas produzem a mesma árvore de derivação (Figura 8a) e de sintaxe
(Figura 8b), a primeira forma a partir das folhas e a segunda a partir da raiz.
Exemplo
Considere que agora nossa gramática irá se referir a todas as operações aritméticas entre dois inteiros, mais o
uso de parênteses.
Vejamos a sua especificação:
int → D D*
D → [0-9]
+→+
-→-
*→*
/→/
(→(
)→)
Conjunto de produções:
PRODUÇÃO REGRA
E→E+E 1
E→E-E 2
E→E*E 3
E→E/E 4
E → (E) 5
E → int 6
(60 + 40)5
Que seria entregue pelo Scanner como (int + int ) int
Como não existe regra que gere a produção EE, o comando está errado!
Gramáticas Ambíguas
Determinadas gramáticas podem permitir a derivação de mais de uma árvore para a mesma expressão. São as
Gramáticas Ambíguas.
Atenção
Este tipo de gramática é um problema para os compiladores, pois significa que, dependendo do
contexto, uma das árvores geradas será correta e a outra não.
PRODUÇÃO REGRA
E→E+E 1
E→E-E 2
E→E*E 3
E→E/E 4
E → (E) 5
E → int 6
Note que temos uma situação de ambiguidade, pois as sequências gerariam árvores diferentes (Figura 9):
Para acabar com a ambiguidade, temos que estender a nossa gramática, de forma a deixar explícita a
existência das precedências. Uma possível solução seria:
int → D D*
D → [0-9]
+→+
-→-
*→*
/→/
(→(
)→)
Conjunto de produções:
PRODUÇÃO REGRA
E→E+T 1
E→E-T 2
E→T 3
T→T*F 4
T→T/F 5
T→F 6
F → int 7
F → (E) 8
Esta gramática permite apenas gerar uma árvore de derivação (Figura 10), pois não é mais ambígua.
Conteúdo interativo
Acesse a versão digital para assistir ao vídeo.
Verificando o aprendizado
Questão 1
As derivações resultantes das produções de uma gramática podem ser descritas de forma
linear ou em forma de árvore de derivação. Considere a árvore de derivação abaixo:
Sabendo-se que as produções da gramatica são E →E +E | E -E | int e que foi realizada uma
derivação mais à direita descendente, escolha a opção que mostra a representação correta na
forma linear:
Como estamos utilizando derivação mais à direita, expandimos o E mais à direita por int.
Questão 2
Gramáticas Livres de Contexto são definidas pela lista de símbolos terminais, de símbolos não
terminais, pelo símbolo inicial e pelas produções. Considere a gramática abaixo:
S → 0S0
S → 1S1
S→0
S→1
S→Ɛ
A partir de sua análise, escolha a opção abaixo que corresponde a uma sentença:
0S1
0001110
SS0
110S
010
A alternativa B está correta.
Os símbolos terminais da gramática são 0 e 1 e o não terminal S.
Estruturas formadas por símbolos terminais e não terminais que pertençam à gramática correspondem a
formas sentenciais.
Métodos de implementação
Os analisadores sintáticos podem utilizar os seguintes métodos para sua implementação:
Métodos universais
Que podem tratar qualquer gramática. Apesar de lidarem com qualquer tipo de gramática, são
ineficientes e, portanto, não utilizados em compiladores comerciais.
Os dois últimos métodos varrem a entrada da esquerda para a direita e são amplamente utilizados tanto em
implementações manuais, que normalmente empregam métodos descendentes, quanto por geradores de
analisadores sintáticos, mormente métodos ascendentes.
Ele começa sua análise com uma entrada de tokens e tenta reescrever a árvore até o símbolo inicial. Na
realidade, ele tenta localizar os símbolos mais básicos e, a partir destes, elementos maiores que enquadrem
os mais básicos, e assim, sucessivamente.
Atenção
Se ao final da análise for obtida uma árvore de sintaxe válida pela gramática da linguagem, ou seja, o
símbolo inicial está na raiz e nos rótulos das folhas, temos a cadeia de entrada, formando, desta forma,
uma sentença da linguagem, e ela é passada para o próximo passo da compilação. Se isto não for
possível, a cadeia não pertence à linguagem especificada pela gramática.
Este processo de construção das folhas para a raiz é chamado redução, já que a cada passo se tenta reduzir
uma cadeia de tokens (ou subcadeia) ao seu símbolo de origem, seguindo sucessivamente até ser atingido o
símbolo inicial da gramática.
O funcionamento geral é apresentado a seguir:
• Procura-se por uma subcadeia S que possa ser substituída pelo seu símbolo de origem R na gramática.
• Repetir o passo (2) até que R = símbolo inicial da gramática. Se isso não for possível, S não pertence à
gramática.
Vejamos um exemplo:
PRODUÇÃO REGRA
E→E+T|T 1
T→T*F|F 2
F → x | y | z | (E) 3
Passo 1
x+y*z⟹F+y*z
Aplicar a regra 2 a F.
F+y*z⟹T+y*z
A árvore fica então:
Passo 3
Aplicar a regra 1 a T.
T+y*z⟹E+y*z
Passo 4
T+y*z⟹E+F*z
Passo 5
Aplicar a regra 2 a F.
E+F*z⟹E+T*z
Passo 6
E+T*z⟹E+T*F
E+T*F⟹E+T
E+T⟹E
Também conhecida como Análise LL (do inglês left left) ela lê a entrada de texto da esquerda para a direita, e
produz uma derivação mais à esquerda.
O que significa que, observando apenas o primeiro símbolo da cadeia, o algoritmo decide qual regra de
derivação deve utilizar.
Funcionamento geral:
3. Repete-se o passo 2 até que a cadeia não tenha mais símbolos não terminais.
4. Compara-se a cadeia obtida com β; se for igual à derivação, foi bem-sucedida. Se forem diferentes,
significa que a cadeia analisada não pertence à linguagem.
S→cTd
T→ a
Passo 2
A partir desta árvore, deriva-se a primeira produção expandindo-se S.
Passo 3
A seguir, expande-se T, o que origina a árvore representada na figura.
Note que a lista de tokens cad aparece nas folhas da esquerda para a direita, o que quer dizer que são válidas
na gramática.
A utilização do retrocesso permite que um conjunto maior de GLCs sejam analisadas, entretanto apresenta
algumas desvantagens que derivam da natureza não determinística do método:
S → xTz
T → xy | y
1 – Inicialmente, é criada a árvore com o símbolo inicial da gramática na raiz, e a entrada não é processada.
5 - Como a próxima folha é um não terminal (T), faz-se a sua expansão. Como existem duas produções
possíveis, xy ou y, vamos utilizar a primeira.
6 - Faz-se a comparação da folha mais à esquerda ainda não processada com o símbolo apontado na cadeia
de entrada.
7 - Como a folha em análise possui um símbolo terminal e ele não é igual ao símbolo da entrada, ocorreu uma
falha. Ocorre, então, o retrocesso, eliminando a última expansão.
8 - Como ainda existe uma produção que pode ser utilizada para expandir T, ela é realizada. Se não houvesse
mais produções para fazer a expansão de T, o ASD deveria retornar um erro e fazer o seu tratamento.
9 - Faz-se a comparação da folha mais à esquerda ainda não processada com o símbolo apontado na cadeia
de entrada.
10 - Como a folha em processamento possui o mesmo terminal da cadeia, o símbolo é consumido e o
apontador avança para o próximo símbolo.
11 - Como a próxima folha a ser processada possui um símbolo terminal, ele é comparado com o símbolo
apontado na entrada.
12 - Como a folha em processamento possui o mesmo terminal da cadeia, o símbolo é consumido. Como a
cadeia de entrada acabou e todos os tokens foram reconhecidos, a cadeia é aceita, pois pertence à
gramática.
Resumindo
Este exemplo nos mostra que: A construção da árvore ocorre na raiz para as folhas, utilizando o símbolo
inicial na raiz. À medida que símbolos não terminais são encontrados, eles são expandidos. Se a
expansão de um não terminal gerar um símbolo terminal que não tem correspondência com a cadeia de
entrada, ocorre o retrocesso, o que implica realizar tentativas e erros até encontrar a produção
correta. Símbolos terminais são consumidos, ou seja, retirados da entrada quando correspondem a uma
produção correta na gramática. Se ao final não for gerada uma sentença correta nas folhas, será
reportado um erro. O sistema é não determinístico, pois mais de uma produção pode ter que ser testada
para um mesmo símbolo não terminal. A quantidade de derivações cresce de forma exponencial em
função do tamanho da cadeia de entrada e do número de produções da gramática.
Neste tipo de analisador, a ideia é descobrir qual construção utilizar, evitando, assim, o retrocesso.
Comentário
Imagine: Se no exemplo do ASD com retrocesso, no passo 5, tivéssemos utilizado a produção T → y em
vez da produção T → xy, não teriam ocorrido o erro e o retrocesso.
Fica a questão: Então, como o ASD consegue “prever” a produção a ser utilizada?
Inicialmente, o que você deve entender é que nem toda gramática pode ser analisada por este tipo de ASD. É
necessário que ela permita que, ao olhar o primeiro símbolo da cadeia de entrada, possa se determinar qual
produção deve ser utilizada. Veja que a gramática do último exemplo não permitia isto.
O lado direito das produções deve começar Para cada símbolo não terminal, deve existir
com terminais. apenas uma regra que comece com o mesmo
terminal.
Este tipo de gramática é chamado de LL(1) (Left to Right - Leftmost derivation 1), que significa que a cadeia é
lida da esquerda para a direita, fazendo uma derivação mais à esquerda, e que é utilizado o símbolo à frente,
daí o 1, para determinar que produção utilizar na expansão.
O conjunto de símbolos à frente que são analisados para determinar a produção denomina-se lookahead.
Vejamos um exemplo.
E → +EE Produção 1
E → *EE Produção 2
E→a Produção 3
E→b Produção 4
Note que ela obedece a todas as regras, pois todas as produções começam com um símbolo terminal e não
existem duas produções que começam com o mesmo símbolo.
Inicialmente, criamos a árvore com o símbolo inicial na raiz e, ao fazermos a análise do primeiro símbolo (+),
notamos que temos que aplicar a produção 1.
Após a expansão da raiz, analisamos o próximo símbolo a. Ele indica que temos que aplicar a produção 3 na
expansão do segundo E.
Após a expansão, o próximo símbolo * indica que temos que aplicar a produção 2 na expansão do terceiro E.
Após a expansão, o próximo símbolo b indica que temos que aplicar a produção 4 na expansão do quarto E.
Após a expansão, o próximo símbolo a indica que temos que aplicar a produção 3 na expansão do quinto E.
Finalmente, chegamos ao final da análise e verificamos que a sentença gerada corresponde a uma construção
válida na gramática.
Note que, neste tipo de ASD, nunca irá ocorrer retrocesso, e seu funcionamento é bem mais rápido que o
anterior, pois não existe tentativa e erro.
Comentário
Um fato que você deve ter achado estranho é a forma da notação utilizada + a * ba em vez de a + b * a,
que utilizamos normalmente. Esta notação é chamada de polonesa. Ela foi utilizada no exemplo para
podermos definir a gramática como LL(1), pois, se utilizássemos a notação normal, a produção 1, por
exemplo, seria E → E + E, que começaria com um não terminal, ou seja, teríamos uma recursão à
esquerda.
Analisadores descendentes preditivos
Neste vídeo, você encontrará exemplos de analisadores descendentes.
Conteúdo interativo
Acesse a versão digital para assistir ao vídeo.
Verificando o aprendizado
Questão 1
S → (L)
S→a
L:→L,S
L→S
Questão 2
Durante o processo de análise, o parser, a partir das produções da gramática e da cadeia de
entrada, gera árvores de derivação que representam a forma de ser executado o comando
que consta na entrada.
E → E + T|T
T→T*F|F
F → int |(E)
Durante a análise da expressão int * int – int, em determinado ponto foi gerada a seguinte
árvore intermediária:
O analisador é ascendente.
• Uma tabela de regras gramaticais a serem aplicadas no token de entrada com as ações a serem
realizadas.
A base de funcionamento do algoritmo são duas ações – Reduzir e deslocar (em inglês, shift reduce).
Redução Deslocamento
E→E+E
E → int
O primeiro fator a ser identificado são os handles, que são uma subcadeia de caracteres que correspondem a
uma produção, ou seja, ao lado direito de uma regra da gramática.
A próxima estrutura é a criação da tabela de regras de ação que possui basicamente a seguinte lógica, com
base nos símbolos da pilha e na entrada:
Símbolo diferente do
$ Erro Realizar tratamento de erro
inicial
$ Diferente de $ Deslocar 1
+ Diferente de $ Deslocar 4
E Indiferente Deslocar 5
$ $ Aceitar 6
Diferente de $ $ Erro 7
Passo 1
Na inicialização, colocamos na pilha o símbolo $ para identificar o seu final e na entrada também
acrescentamos o mesmo símbolo ao final da expressão que está sendo analisada. Ficamos, então, da seguinte
forma:
Passo 2
Aplicando a regra 1, lemos o próximo símbolo e o empilhamos retirando-o da entrada:
Passo 3
Lemos o primeiro elemento da pilha e verificamos se é uma produção:
Passo 4
No caso, int é uma produção. Aplicamos, então, a redução 1 e o substituímos por E, salvando E na pilha:
Passo 5
Retiramos novamente o primeiro símbolo da pilha e vamos analisar se é uma produção:
Como E não é uma produção, a pilha está vazia e temos símbolos na entrada, salvamos ‘E’ na pilha e
aplicamos a regra 1. Lemos o próximo símbolo na entrada colocando-o na pilha:
Passo 6
Retiramos novamente o primeiro símbolo da pilha e vamos analisar se é uma produção:
Como ‘+’ não é uma produção e ainda temos símbolos na pilha, fazemos uma nova leitura para a análise:
Como ‘E + ‘não é uma produção, a pilha está vazia e temos símbolos na entrada, salvamos E na pilha.
Aplicamos a regra 1 e lemos o próximo símbolo na entrada colocando-o na pilha:
Passo 7
Lemos o primeiro elemento da pilha e verificamos se é uma produção:
Como, no caso, int é uma produção, aplicamos, então, a redução 1 e o substituímos por E, salvando E na pilha:
Passo 8
Lemos novamente o primeiro elemento da pilha e verificamos se é uma produção:
Como ‘E’ não é uma produção e temos mais símbolos na pilha, continuamos lendo até encontrar uma produção
ou chegarmos ao final da fila. No caso, achamos uma produção:
Passo 9
Lemos novamente o primeiro elemento da pilha e verificamos se é uma produção:
Como ‘E’ não é uma produção, mas é o símbolo inicial da gramática, e tanto a pilha quanto a entrada estão
vazias, significa que ocorreu a aceitação, não havendo erro na expressão.
• A gramática tem que ser LL(1), ou seja, sem recursão à esquerda e fatorada.
• Construir os conjuntos first e follow, que indicam como escolher a produção a ser aplicada na expansão
do símbolo não terminal.
• Um fluxo de saída.
• Uma pilha cujo fundo é marcado por $ e inicializada com o símbolo da gramática.
Se X = $ e a = $
Significa que toda a entrada foi analisada e não ocorreu, desta forma ela é reconhecida.
Se X = a e <> $
Se X é não terminal
• Se contém uma produção, por exemplo, X :: = UVW, então substitui na pilha X por UVW (U no
topo).
S→ cAa
A→ cB | B
B→ bcB| ε
Conjunto First
O conjunto First é composto pelos símbolos terminais que podem iniciar (que podem aparecer mais à
esquerda) das sentenças derivadas de uma forma sentencial ou de suas sequências derivadas.
Para o criar, utilizamos uma função First(α), tal que α representa uma forma sentencial, que retorna o conjunto
de First da forma sentencial.
Para maior clareza, vejamos como iremos derivar o conjunto First dos não terminais em nossa gramática.
Primeiramente criamos uma tabela com uma linha para cada um dos não terminais da gramática com o seu
conjunto First Vazio:
S ∅
A ∅
B ∅
Muito bem. Começamos observando que, em S→ cAa, temos um terminal c na primeira posição; portanto, c
pertence a First(S):
S {c}
A ∅
B ∅
Tratemos agora o A. Como ele tem duas produções, mas somente a primeira tem um terminal c, este pertence
ao conjunto de A:
S {c}
A {c}
B ∅
Vamos agora tratar o B. Com ele, há duas produções que atendem às regras, que são bc e ε. E as duas entram
em seu conjunto First:
S {c}
A {c}
B {b, ε }
Agora, temos que rever se S ou A herdam algo. Como a única produção de S é iniciada por terminal, não existe
nada a ser reprocessado; porém, A tem uma produção do tipo A→ B. Portanto, First(A) = First(A) ∪ First(B), e a
tabela final fica sendo então:
S {c}
A { b, c, ε }
B {b, ε }
Conjunto Follow
O conjunto Follow(X) é definido como sendo composto pelos símbolos terminais que podem aparecer
imediatamente após X.
• Se um símbolo X é o não terminal mais à direita de alguma forma sentencial, então $ estará no
conjunto.
• Se existir produção na forma V→ cST, então tudo em First(T), exceto vazio(Ɛ), está em Follow(S).
• Se existir produção do tipo A → aB ou produção A → aBC onde First(C) contém ɛ, inclua Follow(A) em
Follow(B).
S→ cAa
A→ cB | B
B→ bcB| ε
S {c}
A { b, c, ε }
B {b, ε }
Comecemos analisando S. Como ele é o símbolo inicial da gramática, terá como Follow $:
A ∅
B ∅
Analisando as possibilidades do símbolo A, que pela produção cAa após A pode vir o símbolo A, sendo esta a
única produção onde ele aparece à direita:
S {$}
A {a}
B ∅
• A→ cB
• A→ B
Pelas regras, a primeira produção faz com que Follow(B) receba Follow(A):
S {$}
A {a}
B {a}
Atenção
No conjunto Follow nunca aparece ɛ
Tabela sintática
De posse desses conjuntos, podemos construir a tabela sintática para o nosso exemplo:
Isto feito, podemos fazer um exemplo passo a passo da análise da expressão “cbca”.
1. Inicializamos a pilha com o símbolo inicial da gramática S e posicionamos a leitura no primeiro símbolo da
entrada “c”:
3. Como o símbolo no topo de pilha é c e a entrada c, fazemos o casamento dos símbolos, desempilhamos c e
avançamos o ponteiro de entrada:
4. Como agora o símbolo no topo da pilha é A e o da entrada b, fazemos a expansão de A obedecendo a
tabela sintática:
5. Após a expansão no topo de pilha, temos B, e na entrada b, pela tabela, devemos fazer uma nova expansão,
substituindo B na pilha por bcB:
6. Agora, tanto na pilha como na entrada, temos b; portanto, fazemos o casamento entre eles, desempilhamos
b e avançamos o ponteiro da entrada:
7. Como novamente temos no topo da pilha e na entrada o mesmo símbolo, fazemos o casamento,
desempilhamos e avançamos o ponteiro:
8. Como no topo da pilha temos B e na entrada a consultamos a tabela, e observamos que devemos fazer uma
expansão vazia, então retiramos B da pilha:
9. Como novamente temos no topo da pilha e na entrada o mesmo símbolo, fazemos o casamento,
desempilhamos e avançamos o ponteiro:
10. Como agora temos $ tanto na pilha como na entrada, indicando que as duas estão vazias, isto significa
que a análise foi bem-sucedida e a sentença aceita.
Utilizando o GALS
Assista, agora, o emprego do software GALS.
Conteúdo interativo
Acesse a versão digital para assistir ao vídeo.
Verificando o aprendizado
Questão 1
Durante a preparação para a criação da tabela sintática de um analisador descendente
preditivo, temos que definir os conjuntos Follow e First que mostram, respectivamente, os
terminais que podem seguir um determinado símbolo e os terminais que aparecem mais à
direita em uma forma sentencial.
S {$}
A {a, Ɛ}
B {a}
Questão 2
A implementação de um analisador sintático envolve, normalmente, um buffer com a cadeia
de entrada e uma pilha, onde são armazenados os símbolos em processamento. Analisando as
opções, diga qual a situação que indica que ocorreu um erro no processamento da cadeia.
Lorem Ipsum
Lorem Ipsum
Lorem Ipsum
Lorem Ipsum
Lorem Ipsum
Considerações finais
Ao longo deste tema, fizemos uma viagem pelos conceitos relacionados à Análise Sintática.
Iniciamos estudando o que é o analisador sintático, o que é uma árvore de sintaxe e como construí-la e o
tratamento de erro.
A seguir, tratamos dos tipos de algoritmos, ascendentes ou descendentes, que podem ser utilizados pelos
parser e suas principais características.
Podcast
Conteúdo interativo
Acesse a versão digital para ouvir o áudio.
Explore+
Para saber mais sobre os assuntos tratados neste tema, leia:
Referências
AHO, A. V. et al. Compiladores: Princípios, técnicas e ferramentas. 2. ed. São Paulo: Pearson, 2008.
COOPER, K. D.; TORCZON, L. Construindo Compiladores. 2. ed. Rio de Janeiro: Elsevier, 2014.
RIPLEY, G.; DRUSEIKIS, F. A Statistical Analysis of Syntax Errors. In: J. Computer Languages, v. 3, Issue 4, USA,
1978. pp. 227-240.
SANTOS, P. R.; LANGLOIS, T. Compiladores: Da teoria à prática. Rio de Janeiro: LTC, 2018.