













Estude fácil! Tem muito documento disponível na Docsity
Ganhe pontos ajudando outros esrudantes ou compre um plano Premium
Prepare-se para as provas
Estude fácil! Tem muito documento disponível na Docsity
Prepare-se para as provas com trabalhos de outros alunos como você, aqui na Docsity
Encontra documentos específicos para os exames da tua universidade
Prepare-se com as videoaulas e exercícios resolvidos criados a partir da grade da sua Universidade
Responda perguntas de provas passadas e avalie sua preparação.
Ganhe pontos para baixar
Ganhe pontos ajudando outros esrudantes ou compre um plano Premium
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
1 / 21
Esta página não é visível na pré-visualização
Não perca as partes importantes!














temp 1 := 35 temp 2 := SOMA + temp 1 SOMA:= temp 2
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.
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”
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:
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
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:
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:
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
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.
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.
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:
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
| 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
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:
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:
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:
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
F
a + a * a a a a
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:
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:
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.
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):
Exercícios de autômatos de pilha:
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 ::= ε.
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.