Docsity
Docsity

Prepare-se para as provas
Prepare-se para as provas

Estude fácil! Tem muito documento disponível na Docsity


Ganhe pontos para baixar
Ganhe pontos para baixar

Ganhe pontos ajudando outros esrudantes ou compre um plano Premium


Guias e Dicas
Guias e Dicas


Compiladores - Apostilas - Informática Part2, Notas de estudo de Informática

Apostilas de Informática sobre Compiladores, Linguagens e suas Representações, Tipos, Especificação de uma linguagem, Tipos de Compilação, Sistemas de Interpretação Híbridos, Construção de Compiladores.

Tipologia: Notas de estudo

2013

Compartilhado em 27/08/2013

Garoto
Garoto 🇪🇸

4.6

(121)

1 / 21

Toggle sidebar

Esta página não é visível na pré-visualização

Não perca as partes importantes!

bg1
-22-
temp
1
:= 35
temp
2
:= SOMA + temp
1
SOMA:= temp
2
3.6.4 Otimização de código
O processo de otimização de código consiste em melhorar o código intermediário de tal forma
que o programa objeto resultante seja mais rápido em tempo de execução. Por exemplo, um
algoritmo para geração do código intermediário gera uma instrução para cada operador na árvore
sintática, mesmo que exista uma maneira mais otimizada de realizar o mesmo comando. Assim, o
código intermediário:
temp
1
:= 35
temp
2
:= SOMA + temp
1
SOMA:= temp
2
poderia ser otimizado para:
SOMA:= SOMA + 35
No entanto, não existe nada errado com o algoritmo de geração de código intermediário, desde
que o problema pode ser corrigido durante a fase de otimização de código.
3.6.5 Geração de código
A fase final do compilador é a geração do código para o programa objeto, consistindo
normalmente de código em linguagem assembly ou de código em linguagem de máquina:
MOV AX, [soma] % cópia do conteúdo do endereço de memória correspondente ao rótulo SOMA
para o registrador AX
ADD AX, 35 % soma do valor constante 35 ao conteúdo do registrador AX
MOV [soma], AX % cópia do conteúdo do registrador AX para o endereço de memória
correspondente ao rótulo “soma”
3.6.5.1 Geração de Código e Otimização Dependente de Máquina
Observamos antes que uma representação intermediária do programa fonte deve ser construída
durante a fase de análise, para ser usada como base para a geração do programa objeto. Se a
forma dessa representação intermediária é bem escolhida, a complexidade do processo de
geração de código depende apenas da arquitetura da máquina (real ou virtual) para a qual o
código está sendo gerado. Máquinas mais simples oferecem poucas opções e por isso o
processo de geração de código é mais direto.
Por exemplo, se uma máquina tem apenas um registrador (acumulador) em que as operações
aritméticas são realizadas, e apenas uma instrução para realizar cada operação (uma instrução
para soma, uma para produto, ...), existe pouca ou nenhuma possibilidade de variação no código
que pode ser gerado. Considere o comando de atribuição:
x := a + b * c
pf3
pf4
pf5
pf8
pf9
pfa
pfd
pfe
pff
pf12
pf13
pf14
pf15

Pré-visualização parcial do texto

Baixe Compiladores - Apostilas - Informática Part2 e outras Notas de estudo em PDF para Informática, somente na Docsity!

temp 1 := 35 temp 2 := SOMA + temp 1 SOMA:= temp 2

3.6.4 Otimização de código

O processo de otimização de código consiste em melhorar o código intermediário de tal forma que o programa objeto resultante seja mais rápido em tempo de execução. Por exemplo, um algoritmo para geração do código intermediário gera uma instrução para cada operador na árvore sintática, mesmo que exista uma maneira mais otimizada de realizar o mesmo comando. Assim, o código intermediário:

temp 1 := 35 temp 2 := SOMA + temp 1 SOMA:= temp 2

poderia ser otimizado para:

SOMA:= SOMA + 35

No entanto, não existe nada errado com o algoritmo de geração de código intermediário, desde que o problema pode ser corrigido durante a fase de otimização de código.

3.6.5 Geração de código

A fase final do compilador é a geração do código para o programa objeto, consistindo normalmente de código em linguagem assembly ou de código em linguagem de máquina:

MOV AX, [soma] % cópia do conteúdo do endereço de memória correspondente ao rótulo SOMA para o registrador AX ADD AX, 35 % soma do valor constante 35 ao conteúdo do registrador AX MOV [soma], AX % cópia do conteúdo do registrador AX para o endereço de memória correspondente ao rótulo “soma”

3.6.5.1 Geração de Código e Otimização Dependente de Máquina

Observamos antes que uma representação intermediária do programa fonte deve ser construída durante a fase de análise, para ser usada como base para a geração do programa objeto. Se a forma dessa representação intermediária é bem escolhida, a complexidade do processo de geração de código depende apenas da arquitetura da máquina (real ou virtual) para a qual o código está sendo gerado. Máquinas mais simples oferecem poucas opções e por isso o processo de geração de código é mais direto.

Por exemplo, se uma máquina tem apenas um registrador (acumulador) em que as operações aritméticas são realizadas, e apenas uma instrução para realizar cada operação (uma instrução para soma, uma para produto, ...), existe pouca ou nenhuma possibilidade de variação no código que pode ser gerado. Considere o comando de atribuição:

x := a + b * c

A primeira operação a ser realizada é o produto de b por c. Seu valor deve ser guardado numa posição temporária, que indicaremos aqui por t1. (Para sistematizar o processo, todos os resultados de operações aritméticas serão armazenados em posições temporárias.) Em seguida, devemos realizar a soma de a com t1, cujo valor será guardado numa posição temporária t2. (Naturalmente, neste caso particular, o valor poderia ser armazenado diretamente em x, mas no caso geral, a temporária é necessária.) Finalmente, o valor de t2 é armazenado em x.

t1:=b*c t2:=a+t x:=t

Podemos fazer um gerador de código relativamente simples usando regras como:

  1. toda operação aritmética (binária) gera 3 instruções:

instrução exemplo: b * c carrega o primeiro operando no acumulador Load b usa a instrução correspondente a operação com o segundo operando, deixando o resultado no acumulador

Mult c

armazena o resultado em uma temporária nova

Store t

  1. um comando de atribuição gera sempre duas instruções:

instrução exemplo: x := t carrega o valor da expressão no acumulador Load t armazena o resultado na variável Store x

O comando de atribuição:

x := a + b * c,

gera o código:

1 Load b { t1:=b*c } 2 Mult c 3 Store t 4 Load a { t2:=a+t1 } 5 Add t 6 Store t 7 Load t2 { x:=t2 } 8 Store x

Embora correto, este código pode obviamente ser melhorado:

  • a instrução 7 é desnecessária e pode ser retirada: copia para o acumulador o valor de t2, que já se encontra lá.
  • (após a remoção da instrução 7) a instrução 6 é desnecessária e pode ser retirada: o valor da variável t2 nunca é utilizado.
  • (considerando que a soma é comutativa) as instruções 4 e 5 podem ser trocadas por 4' e 5 ', preparando novas alterações:

4' Load t

Fonte código intermediário original Código intermediário otimizado w:=(a+b)+c; t1:=a+b t2:=t1+c w:=t

t1:=a+b t2:=t1+c w:=t x:=(a+b)d; t3:=a+b t4:=t3d x:=t

t4:=t1d x:=t y:=(a+b)+c; t5:=a+b t6:=t5+c y:=t6 y:=t z:=(a+b)d+e; t7:=a+b t8:=t7*d t9:=t8+e z:=t

t9:=t4+e z:=t

Claramente, as (sub-)expressões a+b, (a+b)+c, e (a+b)*d não precisam ser calculadas mais de uma vez. (Isto só é verdade porque os valores de a, b, c e d não se alteram no trecho em questão.) Podemos alterar a representação intermediária correspondente (segunda coluna) para a forma intermediária equivalente otimizada apresentada na terceira coluna.

Exemplo : Retirada de comandos invariantes de loop. Considere o trecho de código a seguir:

for i:=1 to n do begin pi:=3.1416; pi4:=pi/4.; d[i]:=pi4 * r[i] * r[i]; end;

Claramente, os dois primeiros comandos de atribuição podem ser retirados do loop , uma vez que seu funcionamento é independente do funcionamento do loop. Obteríamos

pi:=3.1416; pi4:=pi/4.; for i:=1 to n do d[i]:=pi4 * r[i] * r[i];

que é uma versão "otimizada" do trecho de código anterior. Note, entretanto, que só há uma melhora no tempo de execução se o valor de n for maior que zero. Se n=0, o código foi piorado: os dois comandos de atribuição serão sempre executados.

Normalmente, as transformações realizadas no programa durante a otimização são simples: eliminar ou alterar instruções, ou ainda mover instruções para outras posições. A parte mais trabalhosa é a verificar que a transformação pode ser feita. Por exemplo, para eliminar um comando da forma v:=e, é preciso verificar que o valor de v calculado neste comando não será usado por nenhum outro comando, e, portanto, examinar toda a parte do programa que poderá ser executada a seguir. Por essa razão, a análise de fluxo de dados (dataflow analysi s) é uma parte essencial do estudo da otimização, pois visa obter informação sobre o funcionamento do programa, em particular especificando os pontos do programa onde as variáveis recebem valores, e onde os valores são usados.

3.7 FORMAS DE CONSTRUÇÃO DE UM COMPILADOR

Um compilador pode ser composto de várias fases denominadas de passos do compilador.

Dependendo da implementação, certos passos podem ser executados seqüencialmente ou ter execução entrelaçada, enquanto alguns passos podem ser omitidos. Numa compilação em vários passos, a execução de um passo termina antes de iniciar-se a execução dos passos seguintes. Assim, o compilador de dois passos, por exemplo, poderia combinar a análise léxica e análise sintática num primeiro passo e a análise semântica e a geração de código num segundo passo. De outra forma, pode-se utilizar o analisador sintático como módulo principal: para construir a árvore sintática, obtém os tokens necessários através de chamadas ao analisador léxico e chama o processo de geração de código para executar a análise semântica e geração de código objeto. Os critérios para escolha da forma de implementação envolvem: memória disponível, tempo de compilação ou tempo de execução, características da linguagem e equipe de desenvolvimento.

A principal vantagem de se construir compiladores de vários passos é a modularização alcançada no projeto e na implementação dos processos que constituem o compilador. A principal desvantagem é o aumento do projeto total, com a necessidade de introdução das linguagens intermediárias.

A figura abaixo (FIGURA 3.10) apresenta a estrutura geral de um compilador:

FIGURA 3.10: estrutura geral de um compilador

3.8 FERRAMENTAS PARA A CONSTRUÇÃO DE COMPILADORES

Na construção de compiladores faz-se uso de ferramentas de software tais como ambientes de programação, depuradores, gerenciadores de versões, etc. E ainda, foram criadas algumas ferramentas para projeto e geração automática de alguns processos componentes de compiladores. Essas ferramentas são freqüentemente referidas como compiladores de compiladores , geradores de compiladores ou sistemas de escrita de tradutores. Normalmente, são orientados a um modelo particular de linguagem e mais adequados para a construção de compiladores de linguagens similares ao modelo. Segundo AHO et. al. (1995), alguns tipos de ferramentas são:

  1. geradores de analisadores léxicos: geram automaticamente analisadores léxicos, normalmente a partir de uma especificação baseada em expressões regulares e uma lista de palavras-chave da linguagem (LEX);
  2. geradores de analisadores sintáticos: produzem analisadores sintáticos a partir da especificação de uma gramática livre de contexto (YACC, T-gen, JACK).

Analisador Léxico tokens

Analisador Sintático

Analisador Semântico

Geração de Código Simulação código HIPO (^) Máquina HIPOtética

resultados

Recuperação de ERROS

tabela de literais tabela de constantes tabela de símbolos

FIGURA 4.1: exemplo de uma máquina de estados (ZILLER, 1997)

Inicialmente a máquina está em repouso. A máquina entra em atividade quando é ligada (evento ON ) e volta ao estado de repouso quando é automaticamente desligada (evento OFF ) após a produção de uma determinada peça. Assim, o funcionamento normal consiste numa seqüência de eventos ON e OFF. No entanto, pode ocorrer uma pane quando a máquina estiver em atividade. Nesse caso, quando da ocorrência do evento PANE , a máquina muda para o estado de manutenção, retornando ao estado de repouso após serem realizados os devidos ajustes (evento OK ).

4.3 IMPLEMENTAÇÃO

Na implementação do analisador léxico deve-se: desconsiderar brancos à esquerda; considerar como marca de final de sentença o primeiro caractere que não pertencer ao alfabeto da seqüência que está sendo reconhecida; eliminar delimitadores e comentários usados por questões de legibilidade pelo programador, os quais são totalmente irrelevantes do ponto de vista de geração de código. O analisador léxico pode ser implementado de forma mista: usa-se a implementação específica de autômatos para o reconhecimento do primeiro caractere e a implementação genérica de autômatos para o reconhecimento do restante da sentença, exceto os símbolos especiais cujo o reconhecimento poderá ser totalmente efetuado de forma específica. Deve-se também implementar estratégias para a recuperação e tratamento de erros léxicos, quais sejam: símbolos que não fazem parte da linguagem em questão bem como seqüências de símbolos que não obedecem às regras de formação dos tokens especificados. JOSÉ NETO (1987) apresenta algumas técnicas para recuperação de erros léxicos. AHO et. al. (1995) descreve a técnica panic-mode.

atividade

manutenção

repouso

OK (^) PANE

ON

OFF

5 ANALISADOR SINTÁTICO

5.1 FUNÇÃO

O analisador sintático agrupa os tokens fornecidos pelo analisador léxico em estruturas sintáticas, construindo a árvore sintática correspondente. Para isso, utiliza uma série de regras de sintaxe, que constituem a gramática da linguagem fonte. O analisador sintático tem também por tarefa o reconhecimento de erros sintáticos, que são construções do programa fonte que não estão de acordo com as regras de formação de estruturas sintáticas especificadas através de uma gramática livre de contexto.

5.2 ESPECIFICAÇÃO DAS REGRAS SINTÁTICAS: GRAMÁTICA LIVRE DE CONTEXTO

Segundo PRICE; EDELWEISS (1989), dentro da hierarquia de Chomsky, as gramáticas livres de contexto (GLC) são as mais importantes na área de compiladores e linguagens formais, pois podem especificar a maior parte das construções sintáticas usuais.

5.2.1 Notações

Existem inúmeras notações pelas quais a representação de uma linguagem pode ser especificada. Segundo JOSÉ NETO (1987), "a tais notações dá-se o nome de metalinguagens , já que elas próprias são linguagens, através das quais as linguagens são especificadas". A sintaxe de uma linguagem de programação pode ser descrita usando-se as seguintes notações:

  • Suponha que uma linguagem de programação só tem variáveis do tipo inteiro e que a declaração de variáveis deve ser feita usando a palavra reservada variáveis seguida do tipo, de uma lista de identificadores separados por vírgula, e de um ponto e vírgula. Para especificar a sintaxe dessa declaração de variáveis pode-se escrever a seguinte gramática:

EXEMPLO : sintaxe da declaração de variáveis usando a notação de regras de produção

D → variáveis inteiro L ; L → identificador | identificador , L

  • a notação BNF (Backus-Naur Form), muito usada para a especificação de linguagens livres de contexto, adota a seguinte simbologia:

representa um símbolo não-terminal, cujo o nome é dado pela cadeia x de caracteres quaisquer.

::= ββββ representa as regras de produção, associando o não-terminal à sentença ββββ. O significado de uma regra de produção ::= ββββ é é definido por ββββ.

| separa as diversas regras de produção que estão à direita do símbolo ::= , desde que o símbolo não-terminal à esquerda seja o mesmo. O significado de ::= ββββ 1 | ββββ 2 | ... | ββββ n é é definido por ββββ 1 OU é definido por ββββ 2 OU ... é definido por ββββ n.

Note que outras derivações são possíveis para a mesma cadeia. Por exemplo, temos

(2) E  E+T  E+TF  E+Ta  E+Fa  E+aa  T+aa  F+aa  a+a*a

(3) E  E+T  E+TF  E+FF  E+aF  T+aF  T+aa  F+aa  a+a*a

As derivações (1), (2) e (3) acima são equivalentes, no sentido de que ambas geram a cadeia da mesma maneira, aplicando as mesmas regras aos mesmos símbolo s, e se diferenciam apenas pela ordem em que as regras são aplicadas. A maneira de verificar isso é usar uma árvore de derivaçã o, que para este caso seria a descrita na figura abaixo:

Figura - Árvore de derivação para a+aa*

Num certo sentido, a árvore de derivação caracteriza as gramáticas livres de contexto: a expansão correspondente a cada sub-árvore pode ser feita de forma absolutamente independente das demais sub-árvores. Por essa razão, consideramos equivalentes todas as derivações que correspondem à mesma árvore de derivação. Dois tipos de derivação são especialmente interessantes:

  • a derivação esquerda (leftmost derivatio n), em que uma regra é sempre aplicada ao primeiro não terminal da cadeia, o que fica mais à esquerd a;
  • a derivação direita (rightmost derivatio n), em que uma regra é sempre aplicada ao último não terminal da cadeia, o que fica mais à direit a.

Para especificar uma derivação de uma cadeia x, podemos, equivalentemente, apresentar uma derivação esquerda de x, uma derivação direita de x, ou uma árvore de derivação de x. A partir de uma dessas três descrições, é possível sempre gerar as outras duas. Por exemplo, dada uma árvore de derivação de x, basta percorrer a árvore em pré-ordem (primeiro a raiz, depois as sub- árvores em ordem, da esquerda para a direita), e aplicar as regras encontradas sempre ao primeiro não terminal, para construir uma derivação esquerda. Para a derivação direita a árvore deve ser percorrida em uma ordem semelhante: primeiro a raiz, depois as sub-árvores em ordem, da direita para a esquerda.

Exemplo (continuação): A derivação (1) é uma derivação esquerda, e a derivação (2) é uma derivação direita. A derivação (3) nem é uma derivação esquerda, nem é uma derivação direita. Dada uma cadeia x, todas as derivações possíveis de x correspondem exatamente à mesma árvore de derivação. Veremos abaixo que isto quer dizer que a gramática anterior não é ambígua.

Uma gramática é ambígua se, para alguma cadeia x, existem duas ou mais árvores de derivação. Podemos mostrar que uma gramática é ambígua mostrando uma cadeia x e duas árvores de derivação distintas de x (ou duas derivações esquerdas distintas, ou duas derivações direitas distintas).

E

E T

T T * F

F a

a

F

a

Por outro lado, para mostrar que uma gramática não é ambígua, é necessário mostrar que cada cadeia da linguagem tem exatamente uma árvore de derivação. Isso costuma ser feito pela apresentação de um algoritmo para construção (dada uma cadeia qualquer x da linguagem) da única árvore de derivação de x, de uma forma que torne claro que a árvore assim construída é a única árvore de derivação possível para x.

Exemplo: Considere a gramática

S  0 S 1 | e

A linguagem dessa gramática é formada pelas cadeias da forma 0n 1 n^ , onde n>=0. Vamos mostrar que essa gramática não é ambígua, mostrando, para cada valor de n, como construir a única derivação de 0 n 1 n. (No caso, essa derivação é ao mesmo tempo, uma derivação esquerda e uma derivação direita, porque no máximo há um não terminal para ser expandido.)

Para derivar 0n 1 n,

"use n vezes a regra S0S1, e uma vez a regra Se."

Essa derivação é a única:

  • cada aplicação da regra S0S1 introduz um 0 e um 1; as n aplicações introduzem n pares 0,
  • a aplicação da regra Se é necessária para obter uma cadeia sem não terminais.

Por exemplo, para n=3, temos:

S  0S1  00S11  000S111  000111

Exemplo: A gramática

E  E+E | E*E | (E) | a

é ambígua. Por exemplo, a cadeia a+a*a pode ser derivada de duas maneiras diferentes, de acordo com as duas árvores mostradas a seguir:

E

E E

a E * E

a a

E

E E

E + E

a a

a

Figura - análise descendente

Note que as regras são consideradas na ordem 1 2 4 6 3 4 6 6, a mesma ordem em que as regras são usadas na derivação esquerda

E  E+T  T+T  a+T  a+TF  a+FF  a+aF  a+aa

Usando-se um método de análise ascendente, por outro lado, as regras são identificadas na ordem 6 4 2 6 4 6 3 1, e os passos de construção da árvore podem ser vistos na figura abaixo. Neste caso, a ordem das regras corresponde à derivação direita, invertid a:

a+aa  F+aa  T+aa  E+aa  E+Fa  E+Ta  E+Τ*F  E+T  E

ou seja,

E  E+T  E+T F  E+T a  E+F a  E+aa  T+aa  F+aa  a+a*a

E

E T

E

E T

T

E

E T

T

F E

E T

T

F

a

E

E T

T T * F

F F

a

E

E T

T T * F

F F

a (^) a

E

E T

T T * F

F F

a (^) a

a

E

E T

T T * F

F

a

Figura - análise ascendente

Embora a árvore de derivação seja usada para descrever os métodos de análise, na prática ela nunca é efetivamente construída. Às vezes, se necessário, construímos “árvores sintáticas”, que guardam alguma semelhança com a árvore de derivação, mas ocupam um espaço de memória significativamente menor. A única estrutura de dados necessária para o processo de análise é uma pilha, que guarda informação sobre os nós da árvore de derivação relevantes, em cada fase do processo. No caso da análise descendente, os nós relevantes são aqueles ainda não expandidos; no caso da análise ascendente, são as raízes das árvores que ainda não foram reunidas em árvores maiores.

E

E E

T

F

a

T F

a a

F

E E

T

F

a

T F

a a

F

E

T

F

a

T F

a a

F

E

T

F

a

T

a a

F

E

T

F

a a a

F

E

T

F

a a a

T

F

  • (^) *^ a^ a a

F

a + a * a a a a

  • uma pilha sintática, inicialmente vazia;
  • um buffer de entrada, contendo a sentença a ser analisada;
  • uma gramática GLC com as produções numeradas de 1 a p;
  • um procedimento de análise que consiste em: transferir ( shift ) os símbolos da entrada um a um para a pilha até que o lado direito de uma produção apareça no topo da pilha. Quando isto ocorrer, o lado direito da produção deve ser trocado ( reduced ) pelo lado esquerdo da produção em questão. Esse processo deve ser repetido até que toda a sentença de entrada tenha sido analisada. Ao final do processo, a sentença estará sintaticamente correta se e somente se a entrada estiver vazia e a pilha sintática contiver apenas o símbolo inicial da gramática. Observa-se que as reduções são prioritárias em relação às transferências.

Pode-se enumerar as seguintes deficiências do algoritmo acima: detecta erro sintático após analisar toda a sentença; pode rejeitar sentenças corretas, visto que nem sempre que o lado direito de uma produção aparece na pilha a ação correta é uma redução. Dessa forma, o método shift-reduce pode ser caracterizado como não-determinístico.

O problema de não-determinismo pode ser contornado através do uso da técnica de back-track. Contudo, o uso dessa técnica inviabiliza o método na prática em função do tempo e do espaço requeridos para implementação. Na prática, as técnicas ascendentes de análise sintática superam as deficiências do algoritmo shift-reduce por serem determinísticas, ou seja, em qualquer situação existe somente uma ação a ser efetuada; e por detectarem erros sintáticos no momento da ocorrência.

Existem várias classes de analisadores sintáticos ascendentes. No entanto, apenas duas classes têm utilização prática:

  • a classe de analisadores LR ( left-to-right ) tem como principais técnicas os métodos SLR(1), LALR(1) e LR(1) em ordem crescente no sentido de força de abrangência da gramática e complexidade de implementação. Esses analisadores são chamados LR porque analisam as sentenças da esquerda para a direita ( left-to-right ) e constróem uma árvore de derivação mais à direita na ordem inversa. Além do procedimento de análise do algoritmo shift-reduce, um analisador LR compõe-se também de uma tabela de análise sintática (ou tabela de parsing ) específica para cada técnica e para cada gramática. Os analisadores LR são mais gerais que os outros analisadores ascendentes e que a maioria dos descendentes sem back-track. No entanto, a construção da tabela de parsing , bem como o espaço necessário para armazená-la, são complexos.
  • a classe de analisadores de precedência compreende os métodos de precedência simples, precedência estendida e precedência de operadores. Esses analisadores também se baseiam no algoritmo shift-reduce acrescido de relações de precedência entre os símbolos da gramática, as quais definem de forma determinística a ação a ser efetuada em uma dada situação.

5.4.2 Analisador sintático descendente ( top-down)

Um analisador sintático descendente constrói a derivação mais à esquerda da sentença de entrada a partir do símbolo inicial da gramática. Como os analisadores ascendentes, também os analisadores descendentes podem ser implementados com ou sem back-track. No entanto, embora as implementações com back-track permitam a análise de um número maior de gramáticas (GLC), o uso dessas técnicas dificulta a recuperação de erros e causa problemas na análise semântica e geração de código, além de aumentar o tempo necessário para a análise. Já as implementações sem back-track limitam o conjunto de gramáticas que podem ser analisadas

mas superam as deficiências das anteriores. Assim sendo, para uma gramática GLC poder ser analisada por um analisador sintático descendente sem back-track , deve satisfazer as seguintes condições:

  • não possuir recursão à esquerda;
  • ser determinística (estar fatorada), isto é, se X → α 1 | α 2 | ... | αn são as produções para o não-terminal X, então FIRST (α 1 ) ∩ FIRST (α 2 ) ∩ ... ∩ FIRST (αn) = ∅
  • para todo X ∈ VN, tal que X ⇒* ε, FIRST (X) ∩ FOLLOW (X) = ∅.

Serão estudadas duas técnicas de análise sintática descendente: análise descendente recursiva e análise preditiva LL(1). A técnica de análise descendente recursiva consiste na construção de um conjunto de procedimentos, normalmente recursivos, um para cada símbolo não-terminal da gramática em questão. É uma técnica simples que aceita uma classe maior de gramáticas considerando que a última condição pode ser relaxada, no entanto, exige a implementação de procedimentos específicos para cada gramática. Assim, por exemplo, para a gramática:

E → T E’ E‘ → + T E’ | ε T → (E) | id

5.5 A IMPLEMENTAÇÃO DA ANÁLISE SHIFT-REDUCE ATRAVÉS DE PILHAS

A maneira conveniente de implementar o esquema de shift-reduce é através de uma pilha que conterá os símbolos da gramática e de um buffer que conterá a sentença a ser reconhecida.

Tem-se ainda a tabela de precedência de operadores(tabela de parsing) que determinará as relações de precedência entre os operadores e será consultada para determinar o curso de ação a tomar em cada passo.

Convencionando que $ é o símbolo inicial da pilha e, ainda, marca o final da sentença a ser reconhecida, e que w é a sentença a ser reconhecida teremos inicialmente:

PILHA: $ e BUFFER: w$

O analisador opera “passando”( shifting ) zero ou mais símbolos de entrada para a pilha até que um handle se encontre no topo da pilha. Então o analisador reduz aquele handle ao apropriado lado esquerdo da produção. Esse ciclo é repetido até um erro ser detectado ou chegar-se ao símbolo inicial da gramática e o buffer estiver vazio (neste caso a sentença é aceita), ou seja:

PILHA: $S e BUFFER: $

Existem quatro possíveis cursos de ação no analisador shift-reduce , quais sejam: SHIFT, REDUCE, ACEITAÇÃO E ERRO.

Devemos observar que gramáticas ambíguas não podem ser reconhecidas com analisadores shift-reduce , mas podemos adaptá-las, eliminando ambigüidades.

Chamamos a classe de gramáticas que podem ser reconhecidas através de um analisador shift- reduce de gramática LR. Dentro dessa classe, particularizamos um grupo menos mas bastante importante, cujos reconhecedores podem ser construídos de uma forma bem menos complexa, os analisadores de gramáticas de precedência de operadores. Apresentaremos a seguir as duas categorias.

  • Se não existe relação de precedência determinada na tabela entre o terminal da pilha e o terminal corrente no buffer. No exemplo anterior, e1, e2, e3, e4;
  • Numa situação de reduce , se não existe lado direito nas produções igual ao handle que está na pilha. Para o exemplo dado anteriormente, teríamos basicamente as seguintes situações de erros em reduce possíveis:
  • Se +, -, * ou / são reduzidos, verifica-se se aparecem terminais em ambos os lados. Se não, diagnostica-se um erro do tipo “falta um operando”;
  • Se id é reduzido, verifica-se se aparece um não terminal em algum dos lados. Se aparecer diagnostica-se um erro do tipo “falta operador”;
  • Se ( ) é reduzido, verifica-se se aparece um não-terminal entre os parênteses. Se não houver, diagnostica-se um erro do tipo “ nenhum expressão entre os parênteses ”. Ainda nesse caso, verifica-se se aparece um não terminal aparece do lado externo aos parênteses. Se aparecer, diagnostica-se um erro do tipo “falta operador”.

Exemplo:

Para a sentença incorreta id + ) , as ações seriam as seguintes (símbolo da pilha na primeira coluna e símbolo do buffer na primeira linha):

  • PILHA: $, BUFFER: id + )$. Assim, $ < id, logo SHIFT id;
  • PILHA: $id, BUFFER: +)$. Assim, id > +, logo uma redução deve ser feita. Como temos o handle id, podemos fazer REDUCE id para E;
  • PILHA: $E, BUFFER: + ) $. Assim, $ < +, logo SHIFT +;
  • PILHA: $E+; BUFFER: )$. Assim, + > ), logo uma redução deve ser feita. Entretanto não temos handle na pilha, por isso não podemos fazer o REDUCE (E + E para E). Por isso tem- se uma situação de erro de “falta de operando”. A mensagem deveria ser emitida e a redução é feita da mesma maneira para continuar o processo;
  • PILHA: $E, BUFFER: )$. Não existe na tabela precedência entre $ e ), logo chama-se a rotina de erro E2;
  • Agora temos a configuração final do Analisador: PILHA: $E BUFFER: $

Exercícios de autômatos de pilha:

E  E + E E  E – E E  E * E E  E / E E  (E)

E  -E

E  id

a) id1 * id2 + id3 * (id4 + id5) b) (id1 + id2) * (id3 – id4) c) (id1 – id2 * ( id3 + id4)) / id d) id1 + id2 / id3 * id e) id1 + id2 * (id3/id4) f) id1 / (id2 – id3) * id g) id1 * (id2+id3) h) id1 + id2 * id3 + (id4* id5) i) id1 * id2 * id3 * (id4 + id5) j) id1 * ( id2 + id3 * (id4 + id5)) k) id1 * (+id2) l) id1 + ((id2*id3) – id4))

5.6 SIMPLIFICAÇÕES DE GRAMÁTICAS LIVRES DE CONTEXTO

Uma GLC usada na representação de uma linguagem de programação eventualmente deve apresentar determinadas características para poder ser aplicada na implementação de determinados métodos de análise sintática. Assim, uma GLC: não pode possuir símbolos inúteis; deve ser ε-livre; não pode possuir produções unitárias; não pode ser ambígua; deve estar fatorada, ou seja, deve ser determinística; não pode possuir recursão à esquerda.

Existem várias maneiras de restringir as produções de uma gramática livre de contexto sem reduzir seu poder expressivo. Se L é uma linguagem livre de contexto não-vazia, então L pode ser gerada por uma gramática livre de contexto G com as seguintes propriedades :

a) Cada variável e cada terminal de G aparecem na derivação de alguma palavra de L. b) Não há produções da forma A ::= B , onde A e B são variáveis.

Além disso, se ε não está em L , então não há necessidade de produções da forma A ::= ε.

5.6.1 Símbolos Inúteis ou Improdutivos

A exclusão de símbolos inúteis (não-usados na geração de palavras de terminais) é realizada excluindo as produções que fazem referência a estes símbolos, bem como os próprios símbolos inúteis. Não é necessária qualquer modificação adicional nas produções da gramática. O algoritmo apresentado é dividido em duas etapas , como segue :

a) Qualquer variável gera palavra (sentença) de terminais. O algoritmo gera um novo conjunto de variáveis, analisando as produções da gramática a partir de terminais gerados. Inicialmente, considera todas as variáveis que geram terminais diretamente (exemplo : A → a). A seguir, sucessivamente são adicionadas as variáveis que geram palavras de terminais indiretamente (exemplo : B → Ab);

b) Qualquer símbolo é atingível a partir do símbolo inicial. Após a execução da etapa acima, o algoritmo analisa as produções da gramática a partir do símbolo inicial. Inicialmente, considera exclusivamente o símbolo inicial. Após, sucessivamente as produções da gramática são aplicadas e os símbolos referenciados adicionados aos novos conjuntos.