Academia.eduAcademia.edu

Livro Eloi Nov05

A Implementação de Processadores de Linguagens Análise Léxica, Sintática e Semântica Ferramentas em Prolog, Pascal, C++ e Java Eloi L. Favero Departamento de Informática CCEN - UFPA 2002 [email protected] ii • Parte I: Fundamentos e Técnicas de Programação de Gramáticas – – – – Conceitos de Linguagens Formais Gramáticas Regulares e Automatos Gramaticas Livres de Contexto Gramaticas de Atributos ∗ Léxico : Regulares e Automatos – Programação de Gramáticas: ∗ Sintático: Livres de Contexto ∗ Semântico: Gramática de Atributos • Parte II:Estudos de Casos e Aplicações – Programação de Gramáticas em : Prolog, Pascal, C, C++, Java – Ferramentas para Processadores de Linguagens – Programação de Compiladores – Processamento de Linguagem Natural: Léxico/Morfologia, Sintaxe, Geração de Linguagem Natural iii Eloi L. Favero Departamento de Informática [email protected] Copyright c 2002 2002 Dedicatória Para Flori, Emmanuel, Ayun e Thiago. i Sumário Dedicatória i 1 Fundamentos de Linguagens Formais 1.1 Nı́veis lingüı́sticos . . . . . . . . . . . . . 1.2 Notações gramaticais:BNF (Backus Naur 1.3 Hierarquia de Chomsky . . . . . . . . . . 1.3.1 Sem restrições . . . . . . . . . . . 1.3.2 Sensı́vel ao contexto . . . . . . . 1.3.3 Livre de contexto . . . . . . . . . 1.3.4 Regular . . . . . . . . . . . . . . 1.4 Mais sobre classificação de linguagens . . 1.5 Gramáticas em Prolog: DCG . . . . . . 1.5.1 Gramática regular . . . . . . . . 1.5.2 Gramática livre de contexto . . . 1.5.3 Gramática sensı́vel ao contexto . 1.6 Exercı́cios avançados . . . . . . . . . . . . . . . Form) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Fundamentos para GRs, GLCs e GAs 2.1 Gramáticas Regulares e Autômatos . . . . . . . . . . . . . . 2.1.1 Transformação de expressão regular para autômato . 2.1.2 Transformação de gramática regular para autômato . 2.1.3 Transformado uma expressão regular numa gramática 2.1.4 Removendo não determinismo, com fatoração . . . . 2.2 Gramáticas Livres de Contexto (GLC) . . . . . . . . . . . . 2.2.1 Análise ascendente LR(k) e descendente LL(k) . . . . 2.2.2 Recursividade à esquerda ou à direita . . . . . . . . . 2.2.3 Fatoração . . . . . . . . . . . . . . . . . . . . . . . . 2.2.4 Análise sintática descendente . . . . . . . . . . . . . 2.2.5 Análise sintática ascendente . . . . . . . . . . . . . . 2.3 Gramáticas de Atributos . . . . . . . . . . . . . . . . . . . . 2.4 Calculando o valor de um número binário . . . . . . . . . . . 2.5 Avaliar expressões aritméticas . . . . . . . . . . . . . . . . . 2.5.1 Programando a GLC como DCG . . . . . . . . . . . 2.5.2 Calculando o valor com equações semânticas . . . . . ii . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 2 4 6 7 7 8 8 10 12 13 14 15 16 . . . . . . . . . . . . . . . regular . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 18 18 19 21 25 26 27 28 30 31 33 35 40 43 44 45 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . iii SUMÁRIO 2.5.3 O problema da associatividade à esquerda para LL(k) . . . . . . . 2.5.4 Gerando notação polonesa com ações semânticas . . . . . . . . . . 2.6 Regras gramaticais reversı́veis: geração x reconhecimento . . . . . . . . . 3 Técnicas para Programação de Gramáticas 3.1 Medidas de tempo . . . . . . . . . . . . . . . . 3.2 Programação de gramáticas regulares . . . . . . 3.3 Programação de gramáticas livres de contexto . 3.4 Programação de Gramáticas de Atributos (GAs) 3.4.1 Método da costura com atributos . . . . 3.4.2 Exercı́cios de Revisão . . . . . . . . . . . 46 48 50 . . . . . . 53 54 56 60 65 66 67 . . . . . . . 68 69 69 72 74 74 79 84 . . . . . . . . 87 87 87 90 92 93 94 95 96 6 Programação de Gramáticas Livres de Contexto 6.1 Versão em Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2 Versão em C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.3 Versão em Pascal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99 99 101 103 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Programação de Gramáticas em Prolog 4.1 Análise sintática e semântica . . . . . . . . . . . . . . . . 4.1.1 Calcular expressões aritméticas com variáveis . . 4.1.2 Traduzir SQL para álgebra relacional . . . . . . . 4.2 Análise léxica e Autômatos . . . . . . . . . . . . . . . . . 4.2.1 DCGs para análise léxica . . . . . . . . . . . . . . 4.2.2 Autômatos trabalhando com arquivos . . . . . . . 4.2.3 Gerando palavras reservadas e números de linhas 5 Programação de autômatos 5.1 Métodos de codificação de reconhecedores 5.1.1 Versão em C++ . . . . . . . . . . . 5.1.2 Versão em Pascal . . . . . . . . . . 5.1.3 Versão em Java . . . . . . . . . . . 5.2 Contagem de tempo . . . . . . . . . . . . 5.2.1 Versão em C++ . . . . . . . . . . . 5.2.2 Versão em Pascal . . . . . . . . . . 5.2.3 Versão em Java . . . . . . . . . . . 7 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Programação de Gramáticas de Atributos 106 7.1 Versão em Pascal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106 7.2 Versão em C(C++) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107 7.3 Versão em Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108 8 Exerı́cios e Projetos de Programação 111 8.1 Programação de Gramáticas . . . . . . . . . . . . . . . . . . . . . . . . . 111 8.2 Integrando Léxico e Sintático . . . . . . . . . . . . . . . . . . . . . . . . 113 8.3 Gramática fatorada: sem retrocesso . . . . . . . . . . . . . . . . . . . . . 114 SUMÁRIO 8.4 Gramática não fatorada: método da costura . . . . . . . . . . . . . . . . 8.4.1 Calcular expressões aritméticas com variáveis . . . . . . . . . . . iv 115 117 Lista de Figuras 2.1 2.9 Autômato finito correspondente a a expressão regular a*b*; obtido pelo algoritmo de Thompson. . . . . . . . . . . . . . . . . . . . . . . . . . . . Autômato finito não determinı́stico, obtido pelo método de Thompson, a partir da expressão regular (a|b)*abb. . . . . . . . . . . . . . . . . . . Autômato finito não determinı́stico para a expressão regular (a|b)*abb, sem as transições vazias . . . . . . . . . . . . . . . . . . . . . . . . . . . Autômato finito determinı́stico associado a versão GLUD da gramática sr. Autômato finito determinı́stico, para a expressão regular (a|b)*abb, obtido pelo método de subconjuntos de estados alcançáveis, a partir da versão não determinı́stica do autômato, da Figura 2.3. . . . . . . . . . . . . . . Autômato finito para valores inteiros; versões: não determinı́stica e determinı́stica. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Árvore de uma sentença ”aaa”, para uma gramática S-GA, com atributos só sintetizados (sobem) para contar os (a)s. . . . . . . . . . . . . . . . . Árvore para a sentença ”bbb”, da gramática L-GA, com atributos herdados (descem) e sintetizados (sobem), contando os (b)s. . . . . . . . . . . . . . Árvore com atributos herdados (descem) e sintetizados (sobem). . . . . . 38 41 4.1 4.2 Autômato finito para tokens de expressões aritméticas. . . . . . . . . . . Integração entre os componentes Léxico e Sintático. . . . . . . . . . . . 75 80 8.1 Integração entre os componentes Léxico e Sintático. 2.2 2.3 2.4 2.5 2.6 2.7 2.8 v . . . . . . . . . . . 19 20 20 22 24 25 37 113 Capı́tulo 1 Fundamentos de Linguagens Formais Prolog (1972) Definite Clause Grammar - DCG Kowalski, Colmerauer, ... Pereira Bakus e Naur (1960) – Bakus Naur Form Chomski (1956, 1959) Gramáticas regulares Neste capı́tulo revisamos os principais conceitos de Linguagens Formais. Apresentamos uma classificação das gramáticas baseada na hierarquia de Chomsky e também uma outra classificação mais simples baseada nos nı́veis lingüı́sticos: léxico, sintático e semântico. Mostramos como utilizar o formalismo gramatical embutido no Prolog, DCG, para programar os diferentes tipos de gramáticas. A disciplina de Linguagens Formais nasceu na metade dos anos 50 a partir de estudos para descrição de linguagens naturais. Porém, tivemos grandes avanços nesta disciplina devido ao uso destes mesmos formalismos para especificação de linguagens artificiais (de programação) já no final da década de 50, quando foi especificado o ALGOL 60, usando-se a notação BNF de gramáticas livres de contexto. Aqui são enunciados vários resultados de Linguagens Formais mas não são mostradas provas dos resultados, as quais devem ser buscadas, se necessário for, em livros de Linguagens Formais. 1 CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 1.1 2 Nı́veis lingüı́sticos A disciplina da Teoria das Linguagens Formais começou com os trabalhos de Chomsky (1956 e 1959). Chomsky como lingüista estudava formalismos para descrever linguagens naturais (Português, Inglês, etc). Naquela época, Chomsky definiu uma hierarquia para classificação das linguagens. A partir desta classificação iniciou-se a disciplina de Linguagens Formais. Em 1960 a sintaxe do ALGOL foi especificada usando uma BNF que um formalismo do tipo gramática livre contexto (tipo 2 na classificação de Chomsky). Na tabela abaixo apresentamos a hierarquia de Chomsky para as linguagens, onde cada tipo de linguagem define um nı́vel lingüı́stico que está associado a um mecanismo de reconhecimento: tipo tipo tipo tipo tipo 0 1 2 3 nome nı́vel lingüı́stico reconhecedor sem restrições semântico e pragmático máquina de Turing sensı́vel ao contexto semântico gramática de atributos livre de contexto sintático autômato de pilha regular léxico autômato Gramáticas são formalismos usados para especificar linguagens. Linguagens são descritas em diferentes nı́veis, visando facilitar o seu estudo. Na tabela acima mencionamos quatro nı́veis de especificação de linguagens, que se aplicam tanto a linguagens de computadores como a linguagens naturais. Aqui, estudaremos em detalhes três destes nı́veis: léxico, sintático e semântico. • O nı́vel léxico é associado a especificação das palavras de uma linguagem, também chamadas de tokens. Separar uma frase em palavras e sı́mbolos de pontuação; ou separar uma linha de comandos em tokens ( identificadores, operadores, delimitadores, etc.) é atividade deste nı́vel. • O nı́vel sintático é associado a construção de frases e comandos. Verificar a sintaxe de uma frase (sujeito, verbo e objeto); ou verificar a sintaxe de uma linha de comandos de uma linguagem de programação é atividade deste nı́vel. • O nı́vel semântico estuda a semântica, significado ou tradução de uma frase. Estudar a tradução de uma linha de comandos para uma linguagem de mais baixo nı́vel; ou a tradução de frases de uma lı́ngua para outra é atividade deste nı́vel. Um formalismo de um nı́vel superior (mais próximo do tipo 0) tem o poder expressivo para definir todos os outros nı́veis inferiores. Por exemplo, com um formalismo livre de contexto podemos definir uma gramática regular, mas o inverso não é verdadeiro. Cada tipo de linguagem (0, 1, 2 e 3) está associado um mecanismo que é usado no reconhecimento daquele nı́vel da linguagem. Os mecanismos dos nı́veis gramaticais inferiores são mais eficientes para computação. Chomsky definiu uma hierarquia para linguagens, com base em regras de produção que eram usadas para especificar gramáticas de linguagens naturais. Regras de produção CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 3 começaram a ser usadas para descrever a sintaxe de linguagens de programação por J. Backus e P. Naur, no inı́cio dos anos 60, na especificação da linguagem ALGOL 60. A notação que foi usada por eles é conhecida como BNF (Bakus-Naur Form). Linguagens do tipo regular podem ser descritas por BNFs, mas também são descritas por expressões regulares que foram inicialmente introduzidas por Kleene (1959). Para estas expressões usamos a seguinte notação: P Q denotando P seguido de Q; P |Q denotando P ou Q; P ∗ denotando zero ou várias ocorrências de P; P + denotando pelo menos uma ocorrência de P; [] denotando o string vazio. Por exemplo, a(b|c)∗ denota a linguagem {a, ab, ac, abb, abc, ...}. Gramáticas sensı́veis ao contexto são descritas por formalismos semânticos associados aos formalismos sintáticos: cada unidade de especificação de sintaxe é associada a uma unidade de especificação de semântica. Esta idéia nasceu em 1961, quando Irons construiu um ”compilador guiado pela sintaxe” para o ALGOL 60. Para Irons um compilador é especificado com um conjunto de procedimentos cada qual associado a uma unidade sintática. Especificações semânticas dentro de um paradigma declarativo, fazem uso do formalismo Gramática de Atributo (GA), que associa equações semânticas às produções gramaticais. Knuth (1968) deu um tratamento formal para GAs, sistematizando os trabalhos anteriores de especificação de semântica associada a estruturas sintáticas. A linguagem Prolog executa diretamente GAs, como exemplificaremos neste capı́tulo. Nos próximos capı́tulos mostraremos também como executar GAs em linguagens imperativas (C++, Pascal e Java). Antes de estudarmos a implementação prática de ferramentas para os diferentes nı́veis lingüı́sticos revisamos conceitos técnicos e resultados das disciplinas de Linguagens Formais e de Compiladores. Os conceitos aqui apresentados são detalhados em em livros como: [1], [12] e [14]. Exercı́cio 1.1.1 O que é especificado no nı́vel léxico, sintático e semântico de uma linguagem? Exercı́cio 1.1.2 Quais são os tipos de linguagens formais definidos por Chomsky? Com que tipo de mecanismo de reconhecimento cada um está associado? Exercı́cio 1.1.3 Qual é o formalismo gramatical mais adequado para especificar cada um dos nı́veis de uma linguagem: léxico, sintático e semântico? Solução: O nı́vel léxico por uma gramática regular; o sintático por uma gramática livre de contexto e o semântico por uma gramática sensı́vel ao contexto. Exercı́cio 1.1.4 Em que época e como iniciou a disciplina de Linguagens Formais? (2 linhas) Exercı́cio 1.1.5 Quando uma linguagem formal foi primeiramente usada na especificação de uma linguagem de programação? CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 1.2 4 Notações gramaticais:BNF (Backus Naur Form) Abaixo temos uma gramática na notação BNF especificando uma linguagem do tipo regular para os números binários (1|0)∗ = {0, 1, 00, 10, 11, ...}. BNF <numBinario> ::= <digito> <numeroBinario> <numBinario> ::= λ <digito> ::= 0 | 1 notação para produções G --> D G G --> [] D --> 0 | 1 Regras gramaticais são chamadas de produções – participam na produção de sentenças. Uma produção tem a forma LHS --> RHS, onde lemos (left hand side) e (right hand side). Por exemplo: 1 2 LHS --> RHS N --> D G O lado direito (LHS) define um nome para um não terminal que nomeia o corpo da regra (RHS). Numa forma básica de abstração, podemos pensar uma produção similar a um procedimento imperativo onde o LHS é um nome e o lado direito são as chamadas a subprocedimentos dentro do seu corpo como segue. Esta é a semântica imperativa para regras de produção. 1 2 3 4 5 procedure <numBinario>; begin <digito> <numBinario> end; Para um mesmo nome (LHS) podemos ter várias regras alternativas que podem ser escritas de duas formas, como segue: • como em D-->0|1 (que se lê: D gera 0 ou 1) ou • fazendo uso de duas regras, por exemplo, D-->0, D-->1. Produções podem ser recursivas, quando no corpo se faz referencia ao nome da produção, por exemplo G-->D G; e, produções podem ser vazias (ter um elemento vazio como corpo) por exemplo G-->[]. Formalmente, uma gramática é um tupla na forma <G,N,T,P> onde G é sı́mbolo inicial, N é conjunto dos sı́mbolos não terminais (ou variáveis) N={G,D}. T é o conjunto dos sı́mbolos terminais T={0,1} e P é o conjunto das regras de produção. O alfabeto da linguagem compreende os sı́mbolos terminais. Uma derivação é uma seqüência de sı́mbolos, terminais ou não terminais, gerada a partir do sı́mbolo inicial, aplicando-se uma mais produções. Por exemplo, na derivação CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 5 G-->DG-->DDG-->DDDG-->1DDG-->10DG-->101G-->101 a regra G --> DG gera a derivação: DG e, novamente, com mais duas aplicações desta regra gera-se a derivação: DDDG; a partir de DDDG, com as regras D -->1 e D-->0 chega-se à 101G e, com a regra G-->[], chega-se à 101. Uma derivação formada apenas por sı́mbolos terminais é chamada de sentença (101)1 . Uma derivação formada também com não terminais é chamada de forma sentencial (10DG). O número de sı́mbolos de uma forma sentencial é chamado de comprimento (|10DG| = 4). Uma derivação pode ser representada numa árvore de derivação, ou árvore sintática, cuja raı́z é o sı́mbolo inicial e cuja fronteira da árvore é a sentença gerada. Cada regra gramátical usada num passo de derivação é uma relação pai-filho(s) na árvore. Segue uma árvore de derivação parcial, com a aplicação de três regras, com fronteira 1DG; e, uma árvore para a sentença 101. G / \ D G / / \ 1 D G G / \ D G / / \ 1 D G / / \ 0 D G / | 1 [ ] G-->DG-->DDG-->1DG G-->DG-->DDG-->DDDG-->1DDG-->10DG-->101G-->101 Numa árvore incompleta (onde ainda existem derivações a serem executadas) a fronteira corresponde a uma forma sentencial. Numa árvore completa a fronteira corresponde a uma sentença. Uma linguagem é o conjunto de todas as sentenças geradas pela gramática. O poder expressivo de uma gramática está associado ao poder que um número finito de regras tem para expressar um número infinito de sentenças. Por exemplo, a gramática G, fazendo uso de 5 produções, define uma linguagem que compreende todos os possı́veis valores binários, que é um conjunto infinito. Exercı́cio 1.2.1 Defina os termos de linguagens formais listados abaixo? (com no máximo 10 palavras, para cada um) 1. BNF 2. produção 1 Uma gramática no nı́vel léxico gera palavras; no nı́vel sintático gera sentenças. CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 6 3. gramática 4. linguagem 5. alfabeto 6. derivação 7. sentença 8. forma sentencial 9. árvore sintática 1.3 Hierarquia de Chomsky Chomsky classificou as linguagens a partir da complexidade das produções que as definem. Seja a gramática G=(N,T,P,V) e seja α e β formas sentenciais (strings formados por não terminais e/ou terminais). Então dizemos que uma linguagem é do: • tipo 0 (sem restrição): α → β , onde α e β são formas sentencias; β pode ser vazia. • tipo 1 (sensı́vel ao contexto): α → β, onde em todas as produções o comprimento de α é menor ou igual ao comprimento de β; salvo para produções vazias; • tipo 2 (livre de contexto): A → β, onde A é um único não terminal e β é uma forma sentencial. • tipo 3 (regular): A → a ou A → aB, onde a é um terminal (podendo ser o vazio); e, A e B são não terminais. Vamos examinar alguns exemplos destes tipos de linguagens com suas gramáticas associadas. O objetivo destes exemplos é desenvolver uma intuição sobre a classificação de linguagens. Segue uma lista de exemplos: • regulares: (0|1)∗ ={[],0,1,01,10,...}; a∗ b∗ = {[], a, b, ab, aa, bb, ...}; • livres de contexto: an bn = {ab, aabb, aaabbb, ...}; • sensı́veis ao contexto: an bn cn = {abc, aabbcc, ...}; an bm cn dm = {abcd, abbcdd, ... }; {x x | x ∈ (0|1)∗ }; • sem restrição (ou irrestrita): {hn f n! } onde o comprimento de f representa a computação do fatorial do comprimento de h, {[]f, hf, hhf f, hhhf f f f f f, ..., hn f n! }. CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 1.3.1 7 Sem restrições Acima apresentamos um exemplo de linguagem irrestrita que tem o poder para calcular o fatorial – linguagens do tipo 0 são associadas a uma Máquina de Turing. No entanto, na prática, gramáticas do tipo sem restrições, escritas na forma de produções, são pouco usadas para descrever linguagens de programação pois são difı́ceis de serem lidas e especificadas. Segue um exemplo de uma gramática irrestrita, que não possui restrições na escrita de produções: 1 2 3 4 S --> ab | aASb A --> bSb | [] AS --> bSb aASAb --> aa Esta gramática gera sentenças tais como: {aa, ababbb, ...}. Esta gramática é irrestrita porque tem uma produção na forma α → β onde o comprimento de α é maior que o de β ( |aASAb| > |aa| ). 1.3.2 Sensı́vel ao contexto O nome de gramática sensı́vel ao contexto é motivado pela regra que segue αAγ → αβγ onde o não terminal A é substituı́do por β no contexto definido à esquerda por α e à direita por γ. Por exemplo, abaixo temos uma gramática sensı́vel ao contexto com 13 produções para a linguagem {w w | w ∈ (0|1)∗ } = 0 0, 1 1, 10 10, 01 01, 11 11, 00 00, ... 1 2 3 4 5 6 7 8 S --> ABC AB --> 0AD DC --> B0C EC --> B1C C --> [] D0 --> 0D, D1 --> 1D, 0B --> B0, | 1AD | [] E0 --> 0E E1 --> 1E 1B --> B1 %1 %2,3,4 %5 %6 %7 %8,9 %10,11 %12,13 Outro exemplo de gramática sensı́vel ao contexto é dado abaixo para a linguagem a b c = {[], abc, aabbcc, ...}. n n n 1 2 3 4 S-->abc | [] ab -->aabbC Cb -->bC Cc -->cc CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 8 Uma derivação para esta linguagem é dada abaixo: S-->abc-->aabbCc-->aaabbCbCc-->aaabbCbcc-->aaabbbCcc-->aaabbbccc. 1.3.3 Livre de contexto A gramática livre de contexto L = an bn é descrita abaixo, com apenas duas produções. 1 2 L --> a L b L --> []. Para nós, o estudo destas linguagens tem como objetivo o desenvolvimento de noções intuitivas para a classificação de linguagens de programação. Por exemplo, as linguagens {an }, {an bn } e {an bn cn } são respectivamente regular (tipo 3), livre de contexto (tipo 2) e sensı́vel ao contexto (tipo 1). Já a linguagem {an bn cn dn } continua sendo sensı́vel ao contexto (tipo 1). Uma linguagem equivalente a an bm cn dm é a linguagem de parênteses não corretamente aninhados (n [m )n ]m , trocando-se ”abcd” por ”( [ ) ]”. Esta linguagem é sensı́vel ao contexto. Porém, existem algumas linguagens bem próximas a esta que são do tipo livre de contexto: (n )n [m ]m e (n [m ]m )n . Isto sugere que somente sentenças formadas por parênteses corretamente aninhados pertencem a linguagens livres de contexto. Outra linguagem que não é livre de contexto é wcw onde w ∈ (a|b)∗ , gerando sentenças como: abbcabb. Entretanto, a linguagem wcwr , onde wr é o reverso de w é uma linguagem livre do contexto, gerando sentenças tais como: abbcbba. 1.3.4 Regular A gramática regular a*b* = {[], a, b, ab, aa, bb, ...} é descrita pelas cinco (ou três) regras dadas abaixo. 1 2 3 R --> A B. A --> a A | []. B --> b B | []. A seguir veremos algumas variações de gramáticas regulares, com base em alguns resultados teóricos. Uma gramática regular na classificação de Chomsky é definida numa forma um pouco limitada. Podemos definir outras subclasses de gramáticas regulares: • linear recursiva à direita (GLD) : A-->wB, A-->w • linear recursiva à esquerda (GLE) : A-->Bw, A-->w CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 9 Se w tem comprimento menor ou igual a um, então a GLE é também chamada de Unitária à Esquerda (GLUE) e a GLD é chamada de Unitária à Direita (GLUD). Temos então cinco subclasses de gramáticas regulares (regular sem restrição na escrita das produções, GLD, GLE, GLUE e GLUD). Como resultado teórico estas diferentes sub-classes são equivalentes. Resultado 1.3.1 Duas gramáticas são equivalentes se geram a mesma linguagem. Resultado 1.3.2 As diferentes classes de gramáticas regulares são equivalentes. Isto significa que se temos uma gramática regular recursiva à esquerda podemos escreve-la como recursiva à direita; e, se temos uma linear simples podemos escreve-la como unitária; e, assim por diante. A gramática linear à esquerda, que segue, gera a expressão regular (a|b)*(aa|bb)*. Esta gramática pode ser rescrita como GLUD e GLUE, como solicitado nos exercı́cios abaixo. 1 2 S --> Aaa | Abb A --> Aa | Ab | [] Exercı́cio 1.3.1 Rescreva a gramática S acima como uma GLUD. Exercı́cio 1.3.2 Rescreva a gramática S acima como uma GLUE. Exercı́cio 1.3.3 Rescreva a gramática R=a*b*, dada acima, como GLUD. Cabe notar que uma gramática de uma classe pertence também as suas classes superiores na hierarquia de Chomsky: regular ⊆ livre de contexto ⊆ sensı́vel ao contexto ⊆ irrestrita. Porém, sempre devemos rescrever uma gramática buscando classifica-la com a menor categoria possı́vel, pois, as ferramentas de processamento das categorias inferiores são mais eficientes e simples de serem implementadas. Exercı́cio 1.3.4 Quando duas gramáticas são equivalentes? Exercı́cio 1.3.5 Caracterize os tipos de linguagens 0, 1, 2, 3. Como eles se diferenciam? Dê dois exemplos de linguagens para cada tipo, na notação de conjuntos de sentenças. Exercı́cio 1.3.6 Porque uma gramática tipo 1 é chamada sensı́vel ao contexto? (2 linhas) CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 1.4 10 Mais sobre classificação de linguagens Os tipos de gramática 1, 2 e 3 são amplamente estudados na disciplina de Linguagens Formais, dentro dos cursos de Ciências da Computação, onde estuda-se os formalismos gramaticais (para descrever e estudar linguagens) e suas máquinas associadas (para implementar ferramentas). Além da hierarquia de Chomsky, apresentamos abaixo uma classificação de gramáticas associadas aos três nı́veis de especificação de linguagens. Esta classificação simplifica a hierarquia de Chomsky reunindo numa mesma classe os tipos de linguagens 0 e 1. Estes dois tipos de linguagens podem ser especificados por uma gramática de atributos (GA) ou uma DCG, como será ilustrado a seguir. Semântico - Gramáticas de Atributos - GA (tipo 0) Definite Clause Grammar - DCG (tipo 0) Gramáticas sensı́veis ao contexto (tipo 1) Sintático - Gramáticas livres de contexto (tipo 2) Léxico - Gramáticas regulares (tipo 3) Estas três categorias estão associadas a diferentes tipos de processamento necessário para manipular linguagens naturais ou de programação: léxico, sintático e semântico. • No processamento léxico são identificados os tokens básicos de uma linguagem, por exemplos, os identificadores, os números, os delimitadores, etc. São produzidas fitas de palavras para o processamento sintático. • No processamento sintático são verificados os erros de sintaxe, por exemplo, indicando a falta de um parêntese. A análise sintática permite construir árvores sintáticas (abstratas) como representações intermediárias para o processamento semântico. • No processamento semântico são identificados erros tais como: variável não declarada. O processamento semântico refere-se principalmente a uma tradução das construções da linguagem para alguma forma executável, tipicamente uma linguagem de mais baixo nı́vel. Segue abaixo a tentativa de especificação da linguagem an bm cn dm , sensı́vel ao contexto, com produções do tipo livre de contexto (apenas um não terminal no lado esquerdo das produções). 1 2 3 4 S --> A(1) B(1) C(1) D(1). S --> A(1) B(2) C(1) D(2). .... A(1) --> a. A(2) --> aa. ... CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 5 6 B(1) --> b. ... B(2) --> bb. 11 ... Como vemos, precisamos de um número infinito de regras de produção. A regra S aponta para uma solução, uma produção parametrizada, tal como S --> A(n) B(m) C(n) D(m). Este problema também acontece com a gramática abaixo que tenta descrever a linguagem do tipo 0 para a computação do fatorial. O cálculo do fatorial, {[]f, hf, hhf f, hhhf f f f f f, ..., hn f n! }, além de parâmetros, precisa também de uma ”anotação” tipo uma restrição de igualdade: M é igual ao fatorial de N. 1 2 3 F-->H(N) F(M) {onde M=N!}. H(1)--> h H(2)--> hh 4 5 6 7 8 ... F(1)-->f F(2)-->ff ... Estes problemas, especificados com infinitas produções, contradizem o princı́pio do uso de gramáticas que é de definir infinitas sentenças a partir de um número finito de regras de produção. Isso já era esperado: uma gramática sensı́vel ao contexto não pode ser descrita apenas com regras livres de contexto. O formalismo de gramática de atributos (GA) estende uma gramática livre de contexto com mecanismos para descrever semântica. A idéia é manter a simplicidade das gramáticas livres de contexto adicionando parâmetros e equações para aumentar o poder computacional da notação livre de contexto até alcançar o poder de uma máquina de Turing. Por exemplo, a linguagem an bn cn , é facilmente descrita em GA, a partir de uma versão livre do contexto para a linguagem regular a∗ b∗ c∗ , como segue. 1 2 3 4 5 6 7 G A A B B C C -->A B C -->a A -->[] -->b B -->[] -->c C -->[] Nesta versão sintática da gramática não temos a restrição que devemos ter o mesmo números de as, bs e cs. Abaixo temos uma solução como GA. Inicialmente as, bs e cs são contados nas variáveis x, y e z. Na contagem lemos assim, A(0)-->[] – o números de as numa produção vazia é zero; A(x+1)-->a A(x) – o número de as numa produção recursiva é um (do terminal) mais o números de as do não terminal. No final da produção CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 12 principal temos uma equação que diz que eles devem ter um mesmo valor {x=y=z}2 . A produção G é válida numa derivação somente se for válida a equação {x=y=z}. 1 2 3 4 5 6 7 G --> A(x+1)--> A( 0 )--> B(y+1)--> B( 0 )--> C(z+1)--> C( 0 )--> A(x)B(y)C(z) {x=y=z} a A(x) [] b B(y) [] c C(z) [] Podemos comparar esta versão em GA com outra versão equivalente representada por produções do tipo sensı́vel ao contexto, da seção 1.3.2. As especificações em GA são mais fáceis de serem lidas e entendidas. Com GA, podemos especificar qualquer tipo de linguagem, por exemplo, a linguagem da computação do fatorial, {[]f, hf, hhf f, hhhf f f f f f, ..., hn f n! }, pode ser descrita de forma similar contando-se hs e fs e, fazendo-se uma restrição na produção principal y=x!, como segue. 1 2 3 4 5 G -->H(x) F(y){x!=y} H(x+1)-->h H(x). H( 0 )-->[] F(y+1)-->f F(y). F( 0 )-->[] Nesta solução a computação do cálculo do fatorial não é executada pelo mecanismo gramatical, que é livre do contexto, mas sim por um mecanismo extra-gramatical que executa as equações da GA. A seguir estudaremos como programar os três principais tipos de gramáticas: regulares, livres de contexto e gramáticas de atributos. Exercı́cio 1.4.1 O que é uma gramática de atributos? Corresponde a que tipo de gramática na classificação de Chomsky? Exercı́cio 1.4.2 Qual o formalismo gramatical mais adequado para especificar cada um dos nı́veis de uma linguagem: léxico, sintático e semântico? 1.5 Gramáticas em Prolog: DCG Prolog possui um mecanismo embutido na sua notação de cláusulas definidas, para processar gramáticas. Este mecanismo, conhecido como DCG: Definite clause grammar, permite a codificação direta de GAs, como regras executáveis em Prolog. 2 Note que estamos usando as chaves {} com dois significados, um para denotar linguagens como conjuntos de sentenças e outro para denotar restrições semanticas associadas às produções de uma gramática. CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 13 Resultado 1.5.1 As DCGs (Definite Clause Grammars) tem o mesmo poder computacional que as GAs (gramáticas de atributos). As DCGs (como GAs) são baseadas em gramáticas livres de contexto. Uma DCG, no Prolog padrão, processa gramáticas livres de contexto sem recursividade à esquerda. Por exemplo, a regra R-->Ra que é recursiva à esquerda deve ser rescrita como R-->aR antes de ser codificada como regra DCG. De modo similar a regra R-->[]|aR, com uma alternativa vazia, deve ser rescrita como R-->aR|[] onde a alternativa vazia é a derradeira. 1.5.1 Gramática regular A gramática R = a∗ b∗ apresentada anteriormente é traduzida para as regras DCG, que seguem: 1 2 3 4 5 r a a b b --> --> --> --> --> a, b. [a],a. []. [b],b. []. Os sı́mbolos terminais são representados entre colchetes. Os não terminais são representados por letras minúsculas (pois em Prolog maiúsculas são variáveis). Dada uma DCG podemos perguntar sobre as sentenças da linguagem gerada pela gramática. Sabemos que a gramática R gera as sentenças {a,b,aa,bb,ab,aab,abb,...} e que não são válidas as sentenças {ba,aba,...}. Portando, podemos perguntar: ?- r([a,b,b],X). X=[], Yes ?- r([a,b,a],[]). NO ?- r([a,b,a],X). X=[a] Yes As sentenças são representadas em listas de sı́mbolos terminais. Numa pergunta são passados dois argumentos: uma cadeia de entrada e uma cadeia de saı́da, respectivamente. Em ?-r([a,b,b],X). X=[], Yes a saı́da é vazia (X=[]), significando que toda a entrada foi reconhecida (ou consumida). Caso contrário, o conteúdo do argumento de saı́da é o que sobrou (deixou de ser reconhecido); em ?-r([a,b,a],X). X=[a]; o "a" deixou de ser reconhecido. Podemos testar as produções de maneira isolada, por exemplo, a pergunta ?- b([b,b, b,a,a],X). X=[a,a], Yes é feita para a produção b, que reconhece uma seqüência de b(s), e sobraram dois a(s). CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 14 Exercı́cio 1.5.1 O que é uma DCG? Corresponde a que tipo de gramática na classificação de Chomsky? Solução: Uma DCG (Definite Clause Grammars) é um formalismo gramatical que é embutido na linguagem Prolog. Corresponde a uma Gramática de Atributos que tem o poder computacional de uma linguagem tipo sensı́vel ao contexto. Exercı́cio 1.5.2 Uma gramática é definida por uma tupla (G,N,T,P). Num código DCG como identificamos G,N,T e P? Solução: G é o sı́mbolo inicial, normalmente corresponde a primeira produção. P é o conjunto das produções, normalmente codifica-se cada produção em uma linha. No lado direito da produção os elementos são separados por uma conjunção lógica (,) vı́rgula. Cada terminal (T) é codificados entre colchetes, no lado direito de uma regra. Todos os não terminais (N) são codificados como cabeças de regras, com pelo menos uma regra, podendo ter várias regras alternativas (normalmente, uma em cada linha terminada por ponto). Duas regras podem ser codificadas com uma mesma cabeça, neste caso os dois corpos da regra são ligados por uma disjunção lógica (;). 1.5.2 Gramática livre de contexto Podemos codificar a gramática L = an bn , como segue. 1 2 l --> [a],l,[b]. l --> []. Seguem alguns testes para esta gramática L. ?- l([a,b],X). X=[], Yes ?- l([a,a,a,b,b,b],[]). Yes ?- r([a,b,a],[]). NO Podemos promover uma gramática regular (tipo 3) para livre de contexto (tipo 2) usando equações e atributos de GAs. A gramática regular R = a∗ b∗ , apresentada anteriormente, é codificada em GA, na notação DCG como: 1 2 3 4 5 r a a b b --> --> --> --> --> a, b. [a],a. []. [b],b. []. Se adicionarmos alguns parâmetros nas produções da gramática regular R podemos ter uma nova gramática do tipo livre de contexto, equivalente a gramática l-->[a],l,[b]|[]. CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 1 2 3 4 5 15 r --> a(X), b(Y),{X=Y}. a(N+1) --> [a],a(N). a( 0 ) --> []. b(N+1) --> [b],b(N). b( 0 ) --> []. 1.5.3 Gramática sensı́vel ao contexto A gramática sensı́vel ao contexto, S = an bm cn dm , não pode ser programada por produções do tipo livre do contexto. No entanto, em regras DCG é fácil descrever esta linguagem S como segue. 1 2 3 4 5 6 7 8 9 s --> a(X),b(Y),c(Z),d(W), {X=Z, Y=W}. a(N+1)-->[a],a(N). a( 1)-->[a]. b(N+1)-->[b],b(N). b( 1)-->[b]. c(N+1)-->[c],c(N). c( 1)-->[c]. d(N+1)-->[d],d(N). d( 1)-->[d]. Nesta codificação cada uma das regras a,b,c,d conta o número de ocorrências dos caracteres (tokens) e a equação semântica em s, {X=Z, Y=W}, força a ocorrência do mesmo número de acs e de bds. Seguem alguns testes para a gramática S: ?- s([a,b,c,d],X). X=[], Yes ?- s([a,b,b,c,d,d],[]). Yes ?- s([a,b,a],[]). NO Exercı́cio 1.5.3 Escreva uma GA pra a linguagem {w w | w ∈ (0|1)∗ }. Solução: 1 2 3 4 5 GLC: S --> X --> X --> X --> X X. 0 X. 1 X. []. GA na notaç~ ao DCG: s --> x(A), x(B),{A=B}. x([0|N]) --> [0], x(N). x([1|N]) --> [1], x(N). x( 0 ) --> []. Compare esta solução em GA com a versão sensı́vel ao contexto apresentada abaixo (é a mesma da seção 1.3.2). Lendo as produções abaixo é difı́cil de se entender que representa a linguagem {w w | w ∈ (0|1)∗ }. Além disso, são usadas 13 produções contra 4 da solução GA. CAPÍTULO 1. FUNDAMENTOS DE LINGUAGENS FORMAIS 1 2 3 4 5 6 7 8 S --> ABC AB --> 0AD DC --> B0C EC --> B1C C --> [] D0 --> 0D, D1 --> 1D, 0B --> B0, | 1AD | [] E0 --> 0E E1 --> 1E 1B --> B1 16 %1 %2,3,4 %5 %6 %7 %8,9 %10,11 %12,13 A notação para GA é vista como um formalismo para especificar semântica. Falamos especificar, pois uma gramática define o que e não o como. Na implementação de uma GA devemos escolher uma linguagem de programação ou uma ferramenta especializada. A linguagem mais próxima da notação GA é o formalismo DCG do Prolog, porém nos próximos capı́tulos veremos como programar GAs também em linguagens imperativas como C, C++, Pascal e Java. 1.6 Exercı́cios avançados Exercı́cio 1.6.1 De exemplos em DCG, de codificação e de uso (como testar), para cada um dos tipos de gramáticas: 0, 1, 2 e 3. • regular (tipo 3) • livre de contexto (tipo 2) • sensı́vel ao contexto (tipo 1) • irrestrita (tipo 0) Capı́tulo 2 Fundamentos para GRs, GLCs e GAs Neste capı́tulo enunciamos os principais resultados de Linguagens Formais relacionados a gramáticas regulares (GRs), livres de contexto (GLCs) e gramáticas de atributos (GAs). Inicialmente revisamos as noções e fundamentos sobre as linguagens regulares (GRs e expressões regulares) e o formalismo computacional associado (autômatos). O principal tópico da primeira seção é a transformação de uma gramática regular para um autômato e vice-versa. GLCs são processadas por autômatos de pilha. Quando usamos um autômato de pilha para processar uma GLC, estamos implementando um método de análise sintática que constrói uma árvore sintática para uma string de entrada. Existem duas principais subclasses de gramáticas livres de contexto (GLCs), uma é chamada LL(Left to right, Left most derivation) e a outra é chamada LR(Left to right, Right most derivation). A grande diferença entre elas é que a classe LL é adequada para a análise descendente (top-down) e a classe LR é adequada para a análise ascendente (bottom up). Na segunda seção, enquanto apresentamos os fundamentos da análise sintática, falamos de conceitos e técnicas usados na preparação de uma gramática para programação: ambigüidade, remover recursividade à esquerda, precedência e associatividade de operadores. Na terceira seção apresentamos as GAs. Sobre GAs falamos sobre classificação dos atributos como herdados e sintetizados. Introduzimos duas subclasses de GAs: S-GA e L-GA. A primeira só com atributos sintetizados e a segunda também com atributos herdados, porém com a restrição que as equações possam ser calculadas junto com a análise sintática de uma gramática do tipo LL. Por fim, numa seção especial, e opcional para uma primeira leitura, falamos de gramáticas reversı́veis, que podem ser usadas para reconhecimento e/ou geração de sentenças. 17 CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 2.1 18 Gramáticas Regulares e Autômatos Existem diferentes métodos sistemáticos para se programar as gramáticas regulares e os seus formalismos equivalentes, as expressões regulares e os autômatos finitos. Aqui, nós veremos três principais métodos para programação de gramáticas regulares, cada um mais adequado a um tipo de formalismo: • com goto, mais adequado à codificação de autômatos; • iterativo, mais adequado à codificação de expressões regulares; • recursivo, mais adequado à codificação de regras de produção (gramáticas regulares). Para a escolha do método, num projeto de programação de uma gramática, devemos considerar os resultados da disciplina de Linguagens Formais, bem como a dificuldade técnica de se passar de um formalismo para outro. Estes temas são apresentados na seqüência. 2.1.1 Transformação de expressão regular para autômato Temos um resultado que diz que uma expressão regular pode ser reconhecida por um autômato. No entanto, obter um autômato resultante bom (com poucos estados e determinı́stico) pode ser um tanto trabalhoso. Resultado 2.1.1 Toda linguagem gerada por uma expressão regular é também gerada por uma gramática regular (e vice versa). Resultado 2.1.2 Toda linguagem regular é reconhecida por um autômato finito determinı́stico (e vice versa). Uma expressão regular é definida por quatro construtores básicos como segue (às vezes são utilizados outros construtores, como por exemplo, os colchetes para denotar uma construção opcional): • seqüência: ab denota a seguido de b; • repetição: a* denota {a, aa, aaa, ...}; • alternativa: a|b denota um a ou um b; • agrupamento (abstração): x(a|b)*y a expressão entre parênteses denota o agrupamento, a repetição é aplicada ao agrupamento. Inicialmente ilustramos o uso do algoritmo de Thompson [1] para obter um autômato com transições vazias a partir de uma expressão regular, com as regras abaixo: CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 19 • uma palavra a : cria-se um subautômato com dois estados e a na transição entre eles; • uma seqüência ab : cria-se uma transição vazia entre os dois subautômatos que implementam a e b; • uma repetição a* : cria-se um arco, de transição vazia, do para o inı́cio do subautômato a; • uma alternativa a|b : cria-se dois arcos alternativos, unidos no inı́cio e no fim, um para o subautômato a e outro para o subautômato b; equivale a ligar os dois inı́cios e os dois fins; • um agrupamento (abstração) x(a|b)*y: cria-se um novo subautômato, com a entrada de x que precede e com a saı́da em y que sucede; • por fim: remove-se as transições vazias sem utilidade, que não afetam o comportamento do autômato gerado (passo opcional – só para otimização). A Figura 2.1 apresenta uma versão para a expressão regulara*b*, onde o autômato já está simplificado, removemos algumas transições vazias. O estado sa repete o a e o estado sb repete o b. Os dois estados são ligados por uma transição vazia. Outro exemplo é mostrado abaixo, Figura 2.2, para a expressão regular (a|b)*abb. Este exemplo mostra uma construção alternativa dentro de uma repetição e também seqüências não opcionais abb. A Figura 2.3 mostra uma versão do autômato simplificado, sem as transições vazias. Figura 2.1: Autômato finito correspondente a a expressão regular a*b*; obtido pelo algoritmo de Thompson. Abaixo apresentaremos outras versões destes autômatos, gerados de outras formas. 2.1.2 Transformação de gramática regular para autômato Resultado 2.1.3 Toda gramática regular na forma GLUD é equivalente a um autômato, onde cada produção equivale a uma transição; e cada não terminal a um estado. Se o léxico é especificado como uma gramática regular, e queremos programa-lo como um autômato, devemos fazer a conversão entre as produções da gramática regular e o autômato. O resultado acima diz que a conversão é direta se a gramática esta na forma GLUD. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 20 Figura 2.2: Autômato finito não determinı́stico, obtido pelo método de Thompson, a partir da expressão regular (a|b)*abb. Figura 2.3: Autômato finito não determinı́stico para a expressão regular (a|b)*abb, sem as transições vazias Abaixo exemplificamos o processo de conversão, para uma gramática regular que define a linguagem gerada pela expressão regular a*b*. Usamos a notação DCG para escrever diferentes versões da gramática regular para efeito de estudo. Segue uma versão inicial onde renomeamos os sı́mbolos não terminais com o prefixo (s) para representar estados (sr, sa, sb). 1 2 3 4 5 6 %% vers~ ao inicial R --> A B A --> a A A --> [] B --> b B B --> [] %% sr sa sa sb sb vers~ ao DCG --> sa,sb. --> [a],sa. --> []. --> [b],sb. --> []. Um método para codificar uma gramática regular num autômato é transforma-la numa gramática equivalente na forma GLUD (linear unitária recursiva à direita); onde as produções tem a forma A-->wB ou A-->w (na notação DCG, a-->[w],b ou a-->[w]). Portanto, iniciamos removendo a produção sr --> sa, sb. 1 2 3 sr --> sa. sa --> [a],sa. sa --> sb. %% 1ro passo CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 4 5 21 sb --> [b],sb. sb --> []. Para se chegar a GLUD, removemos também as duas produções do tipo A-->B. Segue a versão final, onde o sı́mbolo inicial é o sa. 1 2 3 4 5 sa sa sa sb sb --> --> --> --> --> [a],sa. [b],sb. []. [b],sb. []. %% 2do passo %% vers~ ao GLUD Uma gramática regular na forma GLUD é facilmente transformada num autômato, pelas regras que seguem: • Cada não terminal é mapeado num estado do autômato; • Cada produção do tipo, X --> [t] Y, é mapeada numa transição do estado X para o estado Y; onde a transição é marcada com [t]; • Cada produção do tipo X --> [t] é mapeada numa transição do estado X para um estado final; onde a transição é marcada com [t]; • Cada produção do tipo X --> [] é mapeada numa transição para um estado final; pode-se simplesmente marcar o estado como final. Usando estas regras a Figura 2.4 apresentam o autômato gerado para a versão GLUD da gramática. Definimos um único estado final do autômato, combinando os dois estados finais. Compare-a com a versão gerada a partir da expressão regular a*b* dada na Figura 2.1. Ambas as versões são determinı́sticas. Temos, também, um resultado que garante a existência de uma gramática regular para cada autômato, portanto, podemos usar estas regras numa forma invertida para obter uma versão GLUD da gramática. 2.1.3 Transformado uma expressão regular numa gramática regular Vimos como transformar uma expressão regular num autômato e, também, como transformar uma gramática regular GLUD num autômato. Veremos ainda como transformar uma expressão regular numa gramática regular. Uma expressão regular é definida por quatro construtores básicos, seqüência, repetição, alternativa e agrupamento (abstração). Para cada uma destas construções existe uma notação gramatical equivalente definida em termos das regras: • seqüência: ab regra A --> ab. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 22 Figura 2.4: Autômato finito determinı́stico associado a versão GLUD da gramática sr. • repetição: a* regras A --> aA | []. • alternativa: a|b regras A --> a|b. • agrupamento(abstração): x(a|b)y regras G --> xAy; A-->a|b. Usando estas regras, é relativamente fácil traduzir uma expressão regular num conjunto de produções. Vamos exemplificar o processo para a expressão regular (a|b)*abb. Inicialmente é necessário desmembrar a expressão regular em termos destas primitivas, como segue: 1 2 3 4 5 R1 R2 R3 R4 R = = = = = a | b (R1)* a R4 b b R2 R3 Nesta representação cada R, R1, R2, R3 e R4 corresponde a um construtor elementar de seqüência, repetição ou alternativa. O R compreende a expressão toda. Para obter a gramática basta aplicar as regras enunciadas acima; segue o resultado. R1 R2 R3 R4 R --> --> --> --> --> a | b R1 R2 | [] a R4 b b R2 R4 Esta gramática é regular mas não esta numa forma GLUD. Para obter a forma GLUD, inicialmente, juntamos as produções R1 e R2, como segue. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 23 R2 --> a R2 R2 --> b R2 R2 --> [] Depois reescrevemos a R3 e R4: R3 --> a R4 R4 --> b R5 R5 --> b Por fim ligamos as produções R2 e R3, obtendo a forma GLUD. Ainda temos um problema pois ela é não determinı́stica. R2 R2 R2 R4 R5 --> --> --> --> --> b a a b b R2 R2 R4 R5 Podemos facilmente transformar esta gramática regular num autômato não determinı́stico, com 4 estados: s0, s1, s2 e s3(fim). S0 S0 S0 s1 s2 --> --> --> --> --> b a a b b s0 s0 s1 s2 (s3=fim) A Figura 2.3 apresenta o autômato para esta versão não determinı́stica da gramática GLUD. Abaixo vamos mostrar como obter uma versão determinı́stica para esta mesma linguagem. Resultado 2.1.4 Todo autômato finito não determinı́stico pode ser rescrito como um autômato finito determinı́stico. Existe um método de conversão de um autômato não determinı́stico para determinı́stico. Ele cria subconjuntos de estados alcançáveis a partir do estado inicial [1]. Vamos ilustrar este processo. Começa-se com o estado inicial A={s0}, que é a semente para gerar os próximos estados – s0 mais todos os estados alcançáveis com uma transição vazia (que neste caso não temos); depois, cria-se o conjunto transiç~ oes(A,a)={s0,s1}=B e o conjunto transiç~ oes(A,b)={s0}=A. Seguindo este processo criamos os conjuntos de A até D, como segue. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS inicial= transiç~ oes(A,a)= transiç~ oes(A,b)= transiç~ oes(B,a}= transiç~ oes(B,b)= transiç~ oes(C,a)= transiç~ oes(C,b)= transiç~ oes(D,a)= transiç~ oes(D,b)= {s0 } {s0,s1} A A {s0,s2} A {s0,s3} A A 24 = A = B = C = D Com estas transições e os estados A, B, C e D criamos uma versão determinı́stica do autômato. O estado final é o D. Esta versão equivale a uma gramática GLUD, determinı́stica, como vemos na Figura 2.5. A-->a A-->b B-->a B-->b C-->a C-->b D-->a D-->b B A A D A D A A Exercı́cio 2.1.1 Compare os autômatos, da Figura 2.5, versão determinı́stica, com a versão não determinı́stica, da Figura 2.3. Compare quanto ao número de estados e número de transições? Mostramos como remover o não determinismo de um autômato, no entanto este método é difı́cil de ser manualmente aplicado para autômatos grandes. Neste caso devemos dispor de uma ferramenta automática que faça este processo. Figura 2.5: Autômato finito determinı́stico, para a expressão regular (a|b)*abb, obtido pelo método de subconjuntos de estados alcançáveis, a partir da versão não determinı́stica do autômato, da Figura 2.3. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 2.1.4 25 Removendo não determinismo, com fatoração Outra forma de eliminar o não determinismo numa gramática, que é aplicável em certos casos, é a fatoração das produções. Para exemplificarmos este processo definimos duas produções não determinı́sticas que geram os inteiros: um inteiro é uma seqüência de dı́gitos, não vazia. 1 2 int --> dig int int --> dig Figura 2.6: Autômato finito para valores inteiros; versões: não determinı́stica e determinı́stica. Estas duas produções int equivalem a um autômato finito não determinı́stico. Elas podem ser facilmente reescritas para uma versão determinı́stica, fatorando-se o termo comum, dig, como segue: 1 2 3 int --> dig rint rint --> dig rint rint --> [] Estas duas versões da mesma gramática representam os dois autômatos da Figura 2.6, sendo que o segundo é determinı́stico. Na primeira versão temos duas transições com dig que partem do mesmo estado int com o valor dig. Na versão fatorada temos dois estados: int, rint. A transição entre os dois garante a presença de pelo menos um dı́gito. O estado rint(resto int) opcionalmente lê uma seqüência de dı́gitos. A vantagem do autômato determinı́stico é a sua eficiência e maior simplicidade de implementação. Além disso, ás vezes o mecanismo usado na implementação não permite a codificação de regras não determinı́sticas. Existe um método para obter a versão mı́nima de um autômato (ver [1]), neste texto ele não é apresentado, pois, manualmente, ele só se aplica a pequenos autômatos. Quando necessário devemos utilizar uma ferramenta automatizada para obter versões minimizadas de autômatos. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 26 Resultado 2.1.5 Para um autômato finito determinı́stico existe um outro equivalente mı́nimo, com um número mı́nimo de estados. Existe um compromisso entre eficiência e tamanho do autômato [1]. Um autômato finito determinı́stico, associado a uma expressão regular r, ocupa um espaço na ordem de 2|r| , onde |r| é o comprimento da expressão r; este autômato permite o reconhecimento de uma palavra x numa ordem linear em função do comprimento de x, |x|. Por outro lado, um autômato não determinı́stico para a mesma linguagem ocupa um espaço na ordem linear ao comprimento de r e precisa de um tempo, no pior caso, na ordem de |r| × |x|. Obter um autômato determinı́stico é importante para se conseguir a máxima eficiência. E, obter uma versão com o número mı́nimo de estados é importante para reduzir o espaço de memória ocupado pelo autômato. Como já comentamos estas otimizações para autômatos grandes devem ser executadas com auxilio de ferramentas especializadas. Exercı́cio 2.1.2 O que é uma expressão regular? Exercı́cio 2.1.3 Qual é a relação entre uma expressão regular e uma gramática regular? Exercı́cio 2.1.4 O que é um autômato finito? Exercı́cio 2.1.5 Qual é a relação entre um autômato finito e uma gramática regular? Exercı́cio 2.1.6 Qual é a utilidade de se transformar uma gramática regular para a forma GLUD? Exercı́cio 2.1.7 Qual a diferença entre um autômato finito determinı́stico e não determinı́stico? 2.2 Gramáticas Livres de Contexto (GLC) Existem duas principais subclasses de gramáticas livres de contexto (GLC), uma é chamada LL(Left to right, Left most derivation) e a outra é chamada LR(Left to right, Right most derivation). Estas duas classes são implementáveis por autômatos de pilha. A grande diferença delas é que a classe LL é adequada para a análise descendente (top-down) e a classe LR é adequada para a análise ascendente (bottom up). Dentro de cada uma destas subclasses podemos ter variações quanto ao número de tokens de ”lookahead” (que temos que olhar na fita de entrada para tomarmos uma decisão sobre qual alternativa de uma produção deve ser utilizada). As mais usadas são a LL(1) e a LR(1), onde é lido apenas um token de lookahead. Resultado 2.2.1 Para cada gramática livre de contexto existe um autômato de pilha que a reconhece. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 2.2.1 27 Análise ascendente LR(k) e descendente LL(k) Inicialmente enunciamos alguns resultados sobre as duas principais subclasses de GLCs. Resultado 2.2.2 Para toda gramática LR(k) existe uma gramática equivalente LR(1). Resultado 2.2.3 Toda gramática LL(k) é também LR(k). Resultado 2.2.4 Existem gramáticas LR(k) para as quais não existem gramáticas LL(k’) equivalentes, para qualquer k’ inteiro finito. Resultado 2.2.5 Dada uma gramática LR(k), existe um algoritmo que num número finito de passos diz se existe ou não uma gramática LL(k’) equivalente. O primeiro resultado 2.2.2 diz que uma gramática LR com (k) sı́mbolos de lookahead pode ser rescrita como uma gramática de um sı́mbolo de lookahead - equivale ao resultado sobre fatoração de regras. Os resultados 2.2.3, 2.2.4 e 2.2.5 dizem que a classe de gramáticas LL(k) é um subconjunto da classe LR(k). O resultado 2.2.5 diz que dada uma gramática LR(k) podemos testar se existe uma gramática equivalente do tipo LL(k’) ou não. Para efeito de especificação de linguagens de programação as duas famı́lias são bem próximas. É muito difı́cil encontramos uma construção sintática de uma linguagem de programação que possa ser especificada numa gramática LR(k) e não possa ser especificada numa gramática LL(k’). Neste texto para efeito de programação de GLCs apresentaremos apenas métodos de programação para a classe LL(k). O problema com a programação de gramáticas LR(k) é que elas dependem de tabelas que são difı́ceis de serem obtidas manualmente [1]. Em contraste produções de gramáticas LL(k) são diretamente traduzidas para procedimentos imperativos. Antes de implementarmos uma ferramenta com base numa GLC definida por uma gramática LL(k), devemos examinar as produções da gramática a fim de fazer alguns ajustes necessários, entre eles: • remoção de ambigüidade; • fatoração; • remoção de recursividade à esquerda (ou à direita); • tratamento para precedência e associatividade de operadores. Estes temas são abordados a seguir. Exercı́cio 2.2.1 Quais são as duas principais classes de GLC? Exercı́cio 2.2.2 Qual das duas classes é mais geral? Exercı́cio 2.2.3 Quais os passos (de ajustes) que devemos seguir para programar uma gramática do tipo LL(k)? Exercı́cio 2.2.4 Numa gramática LL(k) o que significa o k? CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 2.2.2 28 Recursividade à esquerda ou à direita Uma gramática para expressões do tipo inteiro, por exemplo, 1+2*3 que é 7; (20+4)*4/8 que é 12, pode ser definida como segue. 1 2 3 E --> E+E | E-E E --> E*E | E/E %% ambı́gua E --> (E) | 1|2|3... Para se construir analisadores sintáticos esta gramática apresenta alguns problemas: • é ambı́gua; • possui recursividade à esquerda (problema para LL(k)); • não expressa o conhecimento relacionado com a precedência dos operadores (*/) sobre os operadores (+-). Uma gramática é ambı́gua se podemos construir duas derivações para uma mesma sentença. Que é o mesmo que construir duas árvores sintáticas para uma mesma sentença, como segue. 1 2 3 4 5 6 7 8 E /|\ E + E / /|\ 1 E * E / \ 2 3 (a) E /|\ E * E /|\ \ E + E 3 / \ 1 2 (b) O conhecimento da precedência dos operadores possibilita a construção de uma árvore abstrata para se avaliar uma expressão: com as operações de menor precedência mais próximas do topo. + / \ 1 * / \ 2 3 %% 1+2*3 = 1+(2*3) Esta árvore abstrata corresponde a uma estrutura similar ao exemplo (a) das árvores sintáticas descritas acima. No exemplo (a) o operador de soma está no topo. Na transformação de uma árvore sintática para uma árvore abstrata, removemos os não terminais criando uma estrutura mais abstrata, porém similar à árvore sintática. Representamos somente os operadores e os valores. Até mesmo os parênteses são removidos (estão implı́citos na estrutura da árvore). CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 29 Resultado 2.2.6 (RESTRIÇÃO) Uma gramática livre do contexto LL(k) não pode ter produções recursivas à esquerda. Resultado 2.2.7 Para cada gramática livre do contexto com produções recursivas à esquerda existe outra gramática livre do contexto equivalente sem recursividade à esquerda. Com base no resultado 2.2.7 pode-se reescrever a gramática apresentada numa versão sem recursividade à esquerda. Por outro lado, não existe um resultado sobre remoção de ambigüidade. Algumas GLCs são inerentemente ambı́guas - i.e., não é possı́vel remover a ambigüidade delas. Ainda assim, é difı́cil encontramos uma gramática inerentemente ambı́gua para problemas práticos em linguagens de programação. Para a gramática de expressões, removendo a recursividade à esquerda removemos também a ambigüidade. Segue abaixo uma nova gramática equivalente à gramática anterior para as expressões aritméticas. 1 2 3 4 E T F F --> --> --> --> T+E | T-E | T F*T | F/T | F ( E ) 1|2| ... %% recursiva à direita Esta nova versão recursiva à direita está livre dos problemas citados, veja abaixo a única árvore sintática possı́vel de ser construı́da para a expressão: 1+2*3. 1 2 3 4 5 6 7 8 9 10 11 E /|\ T + E / /|\ F F * E | | | 1 2 T | F | 3 Também foi resolvido o problema da precedência dos operadores. Foram introduzidos diferentes nomes para cada nı́vel de precedência operadores: um T(ermo) é associado aos operadores soma (+,-); um F(ator) é associado aos operadores multiplicação (*,/). Na árvore construı́da o * tem precedência sobre o +, será executado antes. Quanto à recursividade podemos escolher se queremos uma versão recursiva à esquerda ou recursiva à direita. Segue a versão recursiva à direita. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 1 2 3 4 E T F F --> --> --> --> E+T | E-T | T T*F | T/F | F ( E ) 1|2| ... 30 %% recursiva à esquerda A recursividade à esquerda ou à direita determina a associatividade dos operadores. Abaixo examinamos a expressão 1+2+3: recursiva à esquerda e à direita. 1 2 3 4 5 6 7 8 9 10 11 12 13 E /|\ T + E + / /|\ / \ F T + E 1 + | | | / \ 1 F T 2 3 | | 2 F (1+(2+3)) | 3 (a) Associativa à direita Gramática recursiva à direita E /|\ E + T + /|\ \ / \ E + T F + 3 | | | / \ T F 3 1 2 | | F 2 ((1+2)+3) | 1 (b) Associativa à esquerda Gramática recursiva à esquerda As árvores sintáticas das versões não ambı́guas da gramática apresentam vários nı́veis de profundidade a mais que a versão ambı́gua. Exercı́cio 2.2.5 Como removemos a recursividade à esquerda? (2 linhas) Exercı́cio 2.2.6 Sempre é possı́vel remover a recursividade à esquerda? Exercı́cio 2.2.7 Quando uma gramática é ambı́gua? Exercı́cio 2.2.8 Sempre é possı́vel remover a ambigüidade? 2.2.3 Fatoração As versões não ambı́guas da gramática E ainda não estão fatoradas. Segue uma versão fatorada. 1 2 3 4 5 6 E --> T Tr Tr --> +E | -E | [] T --> F Fr Fr --> *T | /T | [] F --> ( E ) F --> 1|2| ... %% recursiva à direita %% e fatorada CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 31 O processo de fatoração introduziu mais dois sı́mbolos novos Tr e Fr (resto do Termo e resto do Fator). Estes dois sı́mbolos não terminais aumentam ainda mais a profundidade das árvores sintáticas. Exercı́cio 2.2.9 Construa uma árvore sintática usando a gramática fatorada para a expressão 1+(2*3). Temos um resultado associado ao problema da fatoração, que diz que podemos eliminar (ou acrescentar) produções vazias numa gramática. Resultado 2.2.8 Para cada gramática LL(k) com produções vazias existe uma gramática LL(k+1) sem produções vazias (e vice-versa). As gramáticas são equivalentes exceto que a livre de produções vazias não pode gerar a sentença vazia. Exercı́cio 2.2.10 Reescreva a gramática abaixo sem produções vazias (não poderá mais gerar a sentença vazia). Esta gramática passara de LL(1) para LL(2). 1 2 A --> a A A --> [] Solução: 1 2 A --> a A A --> a Note que agora não temos mais a geração da palavra vazia. Para processar esta gramática não fatorada (sem produções vazias) é necessário olhar um sı́mbolo a mais à frente na fita de entrada. Exercı́cio 2.2.11 Sempre podemos escrever uma gramática sem produções vazias? Exercı́cio 2.2.12 Para que serve a fatoração? Exercı́cio 2.2.13 O que é precedência e associatividade de operadores? Qual a relação destes conceitos com uma gramática que descreve uma linguagem de operadores e operandos? (3 linhas) 2.2.4 Análise sintática descendente Na análise sintática descendente utiliza-se uma gramática LL(k), que não pode ser recursiva à esquerda. 1 2 3 4 E T F F --> --> --> --> T+E | T-E | T F*T | F/T | F ( E ) 1|2| ... %% recursiva à direita CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 32 A análise descendente, é baseada num autômato de pilha. Parte-se empilhando o sı́mbolo inicial da gramática, e segue-se com uma seqüência de ações (uma para cada transição): • derivação (reescrita) usando uma produção – se no topo da pilha tem um não terminal, procura-se uma produção para reescreve-lo; observa-se (k) tokens na fita da entrada para escolher uma das produções; • consumo de token – se no topo temos um terminal, que é o mesmo da fita de entrada, então avançamos o ponteiro da fita de entrada e desempilhamos o terminal; • parar – se a pilha está vazia, para-se e verifica-se se toda a fita foi consumida; se sim a sentença foi reconhecida. Se não temos nenhum destes três estados então temos uma situação de erro. Segue um exemplo de análise sintática para a expressão 1+2*3. Foram usados (a-o) passos, terminando com sucesso. entrada: pilha: ação: .1+2*3@ E (sı́mbolo inicial) .1+2*3@ T+E E-->T+E deriva .1+2*3@ F+E T-->F deriva .1+2*3@ 1+E F-->1 deriva 1.+2*3@ +E (consome 1) 1+.2*3@ E (consome +) 1+.2*3@ F*E E-->F*E deriva 1+.2*3@ 2*E F-->2 deriva 1+2.*3@ *E (consome 2) 1+2*.3@ E (consome *) 1+2*.3@ T E-->T deriva 1+2*.3@ F T-->F deriva 1+2*.3@ 3 F-->3 deriva 1+2*3.@ [] (consome o 3) 1+2*3.@ [] (para) passo: (a (b (c (d (e (f (g (h (i (j (k (l (m (n (o Na escolha de cada produção a ser usada, temos que olhar (k) sı́mbolos da fita de entrada. Por exemplo, no passo (b) temos um E no topo da pilha e temos três alternativas para a produção E->T+E|T-E|T. Como saber qual das três devemos usar? Se olhamos dois sı́mbolos na fita de entrada vemos um +, assim devemos escolher a produção E-->T+E. Portanto, nesta gramática temos que usar um k=2. Se trabalharmos com a versão fatorada desta gramática teremos um k=1. Abaixo temos uma representação da análise sintática a partir dos passos usados na construção da árvore sintática. A árvore é construı́da de cima para baixo da esquerda para a direita. A fronteira da árvore representa a sentença sendo analisada; como uma fita ela vai sendo consumida da esquerda para a direita. 33 CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 1 2 3 E /|\ T + E E /|\ T + E / 4 F 5 6 7 E /|\ T + E / F | 1 E /|\ T + E / /|\ F F * E | 1 E /|\ T + E / /|\ F F * E | | 1 2 8 9 10 .1+2*3@ a,b)E-->T+E .1+2*3@ c)T-->F 1+2*3@ d)F-->1 1+.2*3@ e,f) 1+.2*3@ g)E-->F*E 1+.2*3@ h)F-->2 11 E /|\ T + E / /|\ F F * E | | | 1 2 T 12 13 14 15 16 17 18 19 20 E /|\ T + E / /|\ F F * E | | | 1 2 T | F 21 22 23 24 1+2*.3@ i,j) 1+2*.3@ k)E-->T 1+2*.3@ l)T-->F E /|\ T + E / /|\ F F * E | | | 1 2 T | F | 3 1+2*.3@ 1+2*3.@ m)F-->3 n) Compare a seqüência de passos no autômato de pilha com a construção da árvore sintática da gramática LL (Left to right, Left most derivation). Algumas transições no autômato não modificam a árvore: quando no topo da pilha temos um terminal, ele é apenas consumido. A programação do reconhecedor como autômato de pilha é bem econômica, pois não guarda a árvore sintática, mas apenas pequenos pedaços relativos à forma sentencial que vem sendo processada. 2.2.5 Análise sintática ascendente A análise ascendente faz uso de gramáticas LR(k), sem recursividade à direita. Este método de análise ascendente compreende certas etapas complexas, como a geração de tabelas para definir as transições do autômato de pilha. Não será apresentada aqui a construção destas tabelas. Detalhes deste método devem ser buscados na literatura especializada, listada na bibliografia. Vamos apenas ilustrar o funcionamento do método para um autômato de pilha com o objetivo de entendermos intuitivamente como funciona a análise ascendente. Será usada a versão da gramática que segue. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 1 2 3 E --> E+T | T T --> T*F | F F --> 1|2|3 34 %% recursiva à esquerda Este método faz uso de um autômato de pilha com os três tipos de ações que seguem: 1. reduzir usando uma produção – operação contrária a uma derivação; ocorre no topo da pilha: substituir o corpo de uma produção pelo não terminal; 2. consumir (empilhar) token - avançar o ponteiro da fita, empilhando o token; 3. parar – se a fita está vazia, parar e verificar se na pilha está só o sı́mbolo inicial; neste caso a sentença foi reconhecida. Para decidir qual a ação a ser executada num próximo passo, em termos do topo da pilha e token na fita de entrada, é necessária a tabela auxiliar, que para a gramática do exemplo é definida abaixo. fita: topo pilha: ação: + F|T reduzir * F reduzir fita vazia F|T reduzir qualquer terminal 1|2|3 reduzir qualquer terminal +|* consumir + E consumir * T consumir fita vazia E parar Na tabela abaixo listamos os passos necessários para reconhecer a sentença 1+2*3. No inı́cio do processamento empilha-se o primeiro token da fita de entrada; ainda no passo (a) consulta-se a tabela para saber qual é a ação do passo (b); pilha=1 para qualquer valor da fita a ação é reduzir; utilizando-se a produção F-->1. Nos passos (b-d) acontece a construção, de baixo para cima, do primeiro galho da árvore sintática para o token 1; parte-se do token 1 e chega-se ao não terminal E: E-->T-->F-->1. No passo (e) consultado a tabela, com fita=2 e pilha=+ que resulta na ação de consumir o 2 no passo (f); sempre o token consumido é empilhado. Este processo segue até que a fita fica vazia e não tem mais reduções a serem feitas; neste caso, se na pilha temos o sı́mbolo inicial então a sentença foi reconhecida, caso contrário é uma situação de erro. Como mostram os passos (m-n) à sentença 1+2*3 foi reconhecida. Se construirmos uma árvore seguindo os passos descritos na tabela abaixo, podemos verificar que tentamos substituir sempre o não terminal mais à direita da subárvore sendo construı́da, daı́ o nome LR (Left to right, Right most derivation). CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS entrada: 1.+2*3@ 1.+2*3@ 1.+2*3@ 1.+2*3@ 1+.2*3@ 1+2.*3@ 1+2.*3@ 1+2.*3@ 1+2*.3@ 1+2*3.@ 1+2*3.@ 1+2*3.@ 1+2*3.@ 1+2*3.@ pilha: 1. F. T. E. E+. E+2. E+F. E+T. E+T*. E+T*3. E+T*F. E+T. E. ação: consumir 1 F-->1 T-->F E-->T (consumir +) (consumir 2) F-->2 T-->F (consumir *) (consumir 3) F-->3 T-->T*F E-->E+T (sucesso) 35 passo: (a (b (c (d (e (f (g (h (i (j (k (l (m (n Nós não apresentaremos métodos de programação para gramáticas LR(k). Uma gramática LR(k) tem que ser implementada como um autômato de pilha associado a uma ou mais tabelas para codificar as transições. Neste texto não abordamos métodos baseados em tabelas, pois é difı́cil associar especificações de semântica com transições codificadas em tabelas. Caso for necessário indicamos utilizar uma ferramenta que permite gerar o analisador diretamente a partir de uma especificação gramatical. Exercı́cio 2.2.14 Quais as duas estratégias de análise sintática? Qual a relação delas com os tipos de GLCs? Exercı́cio 2.2.15 Como é executa a análise sintática descendente para a sentença ”aabb” para a gramática L-> a L b | a b ? Mostre todos os passos? Exercı́cio 2.2.16 Qual a diferença principal entre a análise sintática ascendente e descendente: em termos da máquina de pilha? e em termos da construção da árvore? 2.3 Gramáticas de Atributos Desde o trabalho de Knuth (1968), que formalizou as GAs, inúmeros novos trabalhos foram publicados sobre novas subclasses das GAs. Aqui nós mencionamos apenas duas subclasses de GAs. Inicialmente devemos saber que as DCGs implementam uma subclasse bem geral de GAs e que em linguagens imperativas podemos codificar GAs com um poder suficiente para implementar processadores de linguagens de programação (ou de linguagens naturais). A grande diferença entre uma GA e uma DCG é que na primeira as equações semânticas são avaliadas como atribuições, enquanto que na segunda elas são avaliadas com unificação (que é bi-direcional - uma atribuição nos dois sentidos). CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 36 Resultado 2.3.1 Uma GA com apenas atributos sintetizados S-GA tem o poder computacional de uma máquina de Turing. Este resultado pode ser interpretado de várias formas. Sabemos que uma gramática irrestrita, na classificação de Chomsky, também equivale a uma máquina de Turing. Portanto uma GA tem o poder expressivo para especificar qualquer gramática irrestrita. A classe de GAs com atributos herdados e sintetizados é mais geral que a classe que contem somente atributos sintetizados. No entanto, noutra perspectiva, tudo o que queremos computar pode ser computado por uma máquina de Turing. Então dada uma GA com atributos herdados e sintetizados, definindo uma computação com valor prático, podemos encontrar uma GA só com atributos do tipo sintetizado que processa a mesma computação. Na pratica este resultado não é tão útil porque podemos programar GAs com atributos herdados e sintetizados em qualquer linguagem de programação moderna, como veremos mais adiante. Atributos herdados e sintetizados Uma GA estende uma gramática livre de contexto associando atributos aos sı́mbolos não terminais da gramática, que em DCG são os predicados. Resultado 2.3.2 Uma DCG com parâmetros equivale a uma GA. 1 2 3 %% GLC A --> a A A --> [] B --> b B B --> [] 4 5 6 7 %%DCG a(N+1) -->[a], a(N). a( 0 ) -->[]. %% S-GA em DCG a(M) --> [a], a(N), {M := N+1}. a(M) --> [], {M := 0}. Acima temos um S-GA para contar o número de as da fita de entrada usando apenas um atributo do tipo sintetizado. Esta versão usa o atributo M, que recebe o valor de N+1. Note que os atributos são passados do corpo (RHS) para a cabeça das produções (LHS). Na árvore sintática decorada com os atributos, vemos que estes atributos sobem pela estrutura da árvore, por isso são chamados de sintetizados, ver Figura 2.7. 1 2 3 4 5 %% DCG b0( M )--> b(0,M). b(AC,M )--> [b], b(AC+1,M). b(AC,AC)--> []. %% AC é um ACumulador %% L-GA em DCG b0( M )--> b(0,M). b(AC,M )--> [b], {AC1:=AC+1}, b(AC1,M). b(AC,AC)--> []. Já definimos a subclasse S-GA, onde temos somente atributos do tipo sintetizado. Outra subclasse importante é a L-GA (Left to right transversal GA). Uma gramática CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 37 Figura 2.7: Árvore de uma sentença ”aaa”, para uma gramática S-GA, com atributos só sintetizados (sobem) para contar os (a)s. de atributos é L-GA se suas equações podem ser calculadas durante a análise sintática LL(k). Ou também durante um caminho em profundidade da esquerda para a direita. Acima mostramos um exemplo. Para este exemplo, os atributos são melhor visualizados em uma árvore sintática da gramática: um atributo herdado desce pela árvore e um sintetizado sobe, ver Figura 2.8. Nesta L-GA temos atributos sintetizados e herdados. O sintetizado é o M e os herdados são o AC e AC1. EXEMPLO 2.3.1 (Gramática de atributos não L-GA) Abaixo temos um exemplo de uma gramática que não é L-GA. Os atributos do corpo da produção x devem ser calculados da direita para esquerda. 1 2 3 4 %% GLC x --> a, b. a --> [a],a | []. b --> [b],b | []. . 5 6 7 8 9 10 11 %% nao L-GA x(N) --> {N := Na+3},a(Na,AC),{AC:=Nb+4},b(Nb). a(N, AC) --> [a], a(N, AC+1). a(AC,AC) --> []. b(N+1 ) --> [b], b(N). b(0 ) --> []. Numa linguagem imperativa a produção x codificada como um procedimento de uma gramática do tipo LL(k) tem a forma ilustrada abaixo: um atributo herdado equivale CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 38 Figura 2.8: Árvore para a sentença ”bbb”, da gramática L-GA, com atributos herdados (descem) e sintetizados (sobem), contando os (b)s. a um parâmetro de entrada num procedimento (Ac :int); e, um atributo sintetizado equivale a um parâmetro de saı́da (var Na:int), em Pascal. Se executarmos o corpo de produção x da esquerda para a direita (no procedimento imperativo de cima para baixo) não temos os valores nas variáveis para calcular as expressões para fazer as atribuições: em {N := Na+3} ainda não temos o valor do Na que é retornado da procedure a. Isto causa um erro de execução. O mesmo acontece com a atribuição AC:=Nb+4. 1 2 3 4 5 6 7 8 procedure x(var N:int); begin {N := Na+3}; a(Na,AC); {AC:=Nb+4}; b(Nb); end; procedure a(var Na:int; Ac:int); begin ... end; Conhecer a classe de um atributo auxilia na codificação das equações. Se o atributo desce deve ser calculado antes de se chamar o predicado, no corpo da produção, mais à esquerda da chamada. Se ele sobe deve ser calculado no final da regra, mais à direita. Isto é necessário sempre que usamos uma atribuição, porque se uma das variáveis não estiver instanciada ela falha. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 39 Por outro lado, apesar da GA exemplificada acima não poder ser programado numa linguagem imperativa ela pode ser programa em linguagens que permitem o uso de variáveis livres (sem conteúdo) em equações que são avaliadas por demanda. Um exemplo de uma destas linguagens é o Prolog. Se o sistema ainda não conhece o valor da variável, ele gera a expressão com a variável livre, que fica ”esperando” até o valor ser encontrado. Portanto, no Prolog, se usamos a unificação (=) uma equação pode ser colocada em qualquer posição na regra. Abaixo reescrevemos a produção x, onde substituı́mos a atribuição por uma igualdade (unificação). Isto é suficiente para esta regra ser executável em Prolog. Portanto em DCG podemos escrever GAs de várias classes, inclusive algumas não L-AG. Segue a L-AG em Prolog, que é executável: retorna o N que é o total do número de as mais 3 e o número de bs mais 4. 1 2 3 4 5 6 7 8 x(N) --> {N = Na+3},a(Na,AC),{AC=Nb+4},b(Nb). a(N, AC) --> [a], a(N, AC+1). a(AC,AC) --> []. b(N+1 ) --> [b], b(N). b(0 ) -->[]. % %?- x(N, [a,b,b],[]). % N = 0+1+1+4+1+3 EXEMPLO 2.3.2 (Uma linguagem para robôs) Exercı́cio 2.3.1 Faça uma DCG para uma linguagem de robôs, onde o robô pode se mover apenas em quatro direções: traz, frente, esq, dir. Usando dois parâmetros gere uma soma para cada um dos sentidos de deslocamento (traz/frente) e (esq/dir). Por exemplo: ?-move(F,D,[esq,dir,esq,frente,frente,dir,dir,pare],[]). F= 0+0+0+1+1+0+0+0, D=-1+1-1+0+0+1+1+0 Yes Solução: 1 2 3 4 5 6 7 move(0,0) --> [pare]. move(D,F) --> passo(D,F). move(D+D1,F+F1)--> passo(D,F), move(D1,F1). passo( 1, 0) --> [dir]. passo(-1, 0) --> [esq]. passo( 0, 1) --> [frente]. passo( 0,-1) --> [traz] Exercı́cio 2.3.2 A gramática do robô aceita sentenças como [frente, frente, pare] mas também [frente, frente]. Reescreva-a para que aceite somente sentenças terminadas por [pare]. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 40 Exercı́cio 2.3.3 Calcule os valores, para D, F, acrescentando uma regra cmove, que chama move, sem modificar o move. Exercı́cio 2.3.4 Acrescente a dimensão Z, desce/sobe. Pense num braço mecânico se movimentando em 3D. Exercı́cio 2.3.5 Com base nos exemplos de GA apresentados, faça um conjunto de equações associadas à gramática abaixo de modo que retornem três informações: (1) o número de palavras (P), (2) o número de vogais (V) e o (3) o número de espaços em branco (B). Comece pela DCG dada abaixo. 1 2 3 4 5 6 7 lista --> palavra. lista --> palavra, br, lista. br --> [32], br. br --> [32]. palavra --> letra, palavra. palavra --> letra. letra --> [X], {64<X,X<91,!; 96<X,X<123,!}. ?-lista(P,V,B,"abc xyz P=3, V=10, B=3 2.4 aaaeeeiii",[]). Calculando o valor de um número binário Uma abordagem para se calcular o valor decimal para um número binário é varrendo a seqüência de dı́gitos da esquerda para a direita usando a fórmula que segue: %% %% %% %% ?- N é o valor acumulado B é um dı́gito binário N := B+N*2 1101 = 13 n1(N,[1,1,0,1],_), X is N. N = 1+2* (0+2* (1+2* (1+2*0))) X = 13 Esta fórmula é codificada no programa que segue n1(R)-->bs(0,R). bs(N,R)-->b(B),{N1 = 2*N+B}, bs(N1,R). bs(N,N)-->[]. b(0) -->[0]. b(1) -->[1]. Na notação original de Knuth escreve-se a gramática n1 como segue: CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 41 Figura 2.9: Árvore com atributos herdados (descem) e sintetizados (sobem). n bs1 bs b b --> --> --> --> --> bs b bs2 [] 0 1 {bs.N {bs2.N {bs.R {b.B {b.B := := := := := 0; n.R := bs.R} 2*bs1.N+b.B; bs1.R := bs2.R} bs.N} 0} 1} Aqui os sı́mbolos não terminais das produções são indexados (bs1 bs2). No programa Prolog as variáveis são indexadas (N N1). Estas duas variáveis implementam o mesmo atributo N. Aqui vemos que o atributo N é associado ao não terminal bs2 que está no corpo da regras, e que ele vem de bs1 que é seu pai. O fluxo destes atributos é melhor ilustrado na Figura 2.9, onde podemos ver o valor sendo calculado na descida; na subida o valor já calculado é passado de volta. Exercı́cio 2.4.1 Uma segunda forma para calcular o valor decimal usa apenas atributos sintetizados. Parte-se da direita para a esquerda, usando dois atributos, um armazena o total, e a outro armazena a potência de 2 que é multiplicada pelo digito corrente, como exemplificado abaixo. Programe-a? 1 2 3 4 5 6 %% N :=P*B+N %% P :=P*2 %% 1101 = 13 ?- n3(N,[1,1,0,1],_), X is N. N = 1*2*2*2*1+ (1*2*2*1+ (1*2*0+ (1*1+0))) X = 13 Solução: Esta segunda versão é codificada abaixo. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 1 2 3 4 5 42 n3(N)-->bs3(_,N). bs3(P1,N1)-->b(B),!,bs3(P,N),{P1 = P*2, N1 = P*B+N}. bs3(1,0)-->[]. b(0) -->[0]. b(1) -->[1]. Exercı́cio 2.4.2 Faça uma GA para traduzir um número binário para hexadecimal e vice-versa. Assuma as GLCs dadas abaixo; um hexadecimal equivale a quatro dı́gitos binários. 1 2 3 h --> d h h --> d d --> [0]...[9]|[a]..[f] 4 hb --> b4 hb hb --> b4 b4 --> b b b b b --> [0] | [1] Segue dois exemplos de como deve funcionar a gramática. ?- h(BIN,[f,0,2],[]). BIN=[1,1,1,1, 0,0,0,0, 0,0,1,0] ?- hb(H, [1,1,1,1, 0,0,0,1], []). H="F1" EXEMPLO 2.4.1 ((*opcional) Calculando a parte fracionária) Knuth (1968), para apresentar o formalismo GA, usou como exemplo uma gramática que converte números binários em números decimais: ”1101.0100b = 13.25d”. Para este mesmo problema, iniciamos apresentando uma gramática livre de contexto. 1 2 3 4 5 6 7 s -->n. n -->bs,[’.’],bs. n -->bs. bs-->b,bs. bs-->b. b -->[0]. b -->[1]. Esta gramática pode ser testada com perguntas como: ?-n([1,0,’.’,1,0],L). L=[] Yes. Nosso objetivo é calcular também os valores fracionários, como está ilustrado abaixo: CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 43 %% 1101.01 = 13.25 ?-n(V,[1,1,0,1,’.’,0,1],L). V = 13.25 L = [] ?- n(V,[’.’,0,1,1,1,0],[]). V = 0.4375 O predicado bs calcula a parte inteira. A idéia é usa-lo também para a parte decimal, que é o valor inteiro dividido por (2 elevado ao número de dı́gitos): .01b = 1/22 = 0.25 ou .0100b = 4/28 = 0.25 . Para isso é necessário retornar, em bs2, também o número de dı́gitos; o que é feito com a variável Ls. A solução é codificada no programa abaixo. 1 2 3 4 5 n(V) -->bs2(0,Vi,_),[’.’],!,bs2(0,Vd,L), {V is Vi+Vd/2**L}. n(V) -->bs2(0,Vi,_),{V is Vi}. bs2(N,R,Ls+1)-->b(B),!,bs2(N1,R,Ls),{N1 = B+2*N}. bs2(N,N,0)-->[]. b(0) -->[0]. 6 7 b(1) -->[1]. Exercı́cio 2.4.3 Para esta gramática n, desenhe a árvore sintática com atributos, similar a Figura 2.9 para a sentença 101.11 que gera o valor 5.75. Exercı́cio 2.4.4 Classifique todas as variáveis que aparecem no programa acima como atributos herdados ou sintetizados? 2.5 Avaliar expressões aritméticas Quando falamos sobre análise sintática apresentamos a gramática abaixo para expressões aritméticas. 1 2 3 4 E T F F --> --> --> --> T+E | T-E | T F*T | F/T | F ( E ) 1|2| ... Com esta gramática podemos gerar árvores abstratas, similares com as árvores sintáticas, que tratam da precedência dos operadores, onde as operações de menor precedência estão no topo (ver abaixo). CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 44 + / \ 1 * / \ 2 3 No nı́vel semântico, podemos definir uma GA para avaliar estas expressões. Abaixo é definida uma gramática de atributos, com equações semânticas para calcular o valor de cada expressão da linguagem, por exemplo, calcular o valor de (20+4)*4/8 que é 12. E1 --> E1 --> T1 --> T1 --> F --> F --> F --> ... T+E2 T-E2 F*T2 F/T2 (E) 1 2 {E1 .val:=T.val+E2 {E1 .val:=T.val-E2 {T1 .val:=F.val*T2 {T1 .val:=F.val/T2 {F.val := E.val} {F.val := 1} {F.val := 2} ... .val} .val} .val} .val} Esta gramática de atributos define uma semântica para o cálculo do valor das expressões geradas pela linguagem. Uma equação é definida para cada produção (unidade sintática), por exemplo, a equação {F.val = 1} associada à produção F-->1 é lida como, o atributo val do F recebe 1. De forma similar a equação {E1 .val=T.val+E2 .val} associada à produção E1 --> T+E2 é lida como o atributo E1 .val recebe a soma dos atributos T.val e E2 .val. Note que aqui, pelas equações podemos identificar que o atributo val é sintetizado, porque flui em direção pai (se for examinado numa árvore sintática). Exercı́cio 2.5.1 Desenhe uma árvore sintática para a sentença 1+2*3, decorada com os atributos, para a gramática de atributos, definida acima. 2.5.1 Programando a GLC como DCG Segue a gramática livre de contexto, codificada em DCG, para a linguagem de expressões; usamos a codificação E=expr, T=Termo e F=Fator: 1 2 3 4 5 6 7 8 expr --> termo,[+],expr. expr --> termo,[-],expr. expr --> termo. termo--> fator,[*],termo. termo--> fator,[/],termo. termo--> fator. fator --> [X],{integer(X)}. fator --> [’(’], expr, [’)’]. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 45 A produção fator --> [X],{integer(X)} define uma regra válida para todos os inteiros da linguagem Prolog. O predicado integer/1 testa se uma constante é do tipo inteiro como exemplificado: ?- integer(10). Yes ?- integer(a). No A gramática com sı́mbolo inicial expr é um programa executável em Prolog. Podemos perguntar se uma expressão é reconhecida por ela: ?- expr([1,+,2,*,3],X). X=[], Yes 2.5.2 Calculando o valor com equações semânticas Abaixo apresentamos a versão em Prolog desta gramática, estendida com atributos e equações semânticas que calculam o valor da expressão aritmética. A sintaxe do Prolog difere um pouco da notação de GA: numa GA um atributo é associado a um sı́mbolo não terminal. Dois sı́mbolos com mesmo nome numa produção são indexados com um dı́gito, em Prolog este dı́gito indexador é associado a uma variável, por exemplo E1. De qualquer modo, a semântica das equações é a mesma. 1 2 3 4 5 6 7 8 expr(E)--> expr(E)--> expr(T)--> termo(T)--> termo(T)--> termo(F)--> fator(X)--> fator(E)--> termo(T),[+],expr(E1),{E is T+E1}. termo(T),[-],expr(E1),{E is T-E1}. termo(T) fator(F),[*],termo(T1),{T is F*T1}. fator(F),[/],termo(T1),{T is F/T1}. fator(F). [X],{integer(X)}. [’(’], expr(E), [’)’]. Seguem algumas perguntas onde é retornado o valor da expressão. ?-expr(V,[1,+,2,*, 3],X). V=7, X=[] ?- expr(V,[1,+,2,*, ’(’,3,+,1, ’)’],X). V=9, X=[], ?- expr(V,[’(’,20,+,4,’)’, *, 4, /, 8],X). V=12, X=[] Exercı́cio 2.5.2 O que é um atributo herdado e o que é um atributo sintetizado? Como podemos diferenciar eles só olhando para as produções? Exercı́cio 2.5.3 Qual a diferença entre as classes de GAs: S-GA e L-GA? Exercı́cio 2.5.4 Reescreva a solução do exercı́cio sobre a linguagem para robôs na notação de GA original de Knuth? CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 2.5.3 46 O problema da associatividade à esquerda para LL(k) Esta implementação apresenta um problema de associatividade. Este problema aparece em operações não associativas, por exemplo, em seqüências de divisões e em seqüências de somas e/ou subtrações, como vemos abaixo. ?- expr(V,[1,/,2,/,4,*,3],[]), display(V), X is V. /(1, /(2, *(4, 3))) X = 6 ?- expr(V,[1,-,2,+,4,-,3],[]), display(V), X is V. -(1, +(2, -(4, 3))) X = -2 A primeira expressão 1/2/4*3 foi parentizada como (1/(2/(4*3)))=6; o certo é parentiza-la como (((1/2)/4)*3)=0.375. Este problema de associatividade acontece com gramáticas recursivas à direita, as LL(k). Estas gramáticas geram (naturalmente) árvores abstratas associativas à direita. Mas este problema tem solução: isto é, numa gramática do tipo LL(k) podemos gerar uma árvore para avaliar uma expressão com operadores associativos à esquerda. Segue o esboço de uma solução, aqui exemplificada, para o operador +. Esta mesma solução deve ser adotada para os outros operadores. 1 2 3 4 eLeft( To )--> tLeft(T),[+], eLeft( T/To). eLeft(Ti/To )--> tLeft(T),[+], eLeft((Ti+T)/To). eLeft(Ti/(Ti+T))--> tLeft(T). tLeft(X)--> [X],{integer(X)}. 5 A idéia é, em cada produção, passar um termo Ti adiante, para ser parentizado junto com seu irmão imediato à direita. Este termo Ti é um atributo herdado. O resultado em cada produção é uma expressão parentizada que é retornada pelo atributo sintetizado To ou (Ti+T). Segue alguns exemplos de execução, onde mostramos a parentização correta. ?- eLeft(0/V,[1,+,2,+,4,+,3],[]), display(V). +(+(+(+(0, 1), 2), 4), 3) Yes ?- eLeft(V,[1,+,2,+,4,+,3],[]), display(V). +(+(+(1, 2), 4), 3) Yes Devemos agora incluir na gramática das expressões o processo descrito acima ( a solução para a associatividade à esquerda). Para isso devemos tratar dois problemas: (1) quando temos várias operações temos que passar junto qual a operação que está associada 47 CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS ao atributo herdado; e, (2) devemos formar as diferentes combinações para cada regra (por exemplo, [Ti,+] [Ti,-]). Para denotar que vamos iniciar uma nova expressão ou um novo termo, usamos prefixo x(). 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 xexpr( x(E))--> xtermo(x(T)),[+],xexpr([ T ,+]/Eo),{E = xexpr( x(E))--> xtermo(x(T)),[-],xexpr([ T ,-]/Eo),{E = xexpr( x(T))--> xtermo(x(T)). xexpr([Ti,+]/E)--> xtermo(x(T)),[+],xexpr([(Ti+T),+]/Eo),{E = xexpr([Ti,+]/E)--> xtermo(x(T)),[-],xexpr([(Ti+T),-]/Eo),{E = xexpr([Ti,-]/E)--> xtermo(x(T)),[+],xexpr([(Ti-T),+]/Eo),{E = xexpr([Ti,-]/E)--> xtermo(x(T)),[-],xexpr([(Ti-T),-]/Eo),{E = xexpr([Ti,+]/(Ti+T))--> xtermo(x(T)). xexpr([Ti,-]/(Ti-T))--> xtermo(x(T)). %% xtermo( x(T) )--> xfator(F),[*],xtermo([F,*]/Ti),{T = Ti}. xtermo( x(T) )--> xfator(F),[/],xtermo([F,/]/Ti),{T = Ti}. xtermo( x(F) )--> xfator(F). xtermo([Fi,*]/T)--> xfator(F),[*],xtermo([(Fi*F),*] /Ti),{T = xtermo([Fi,*]/T)--> xfator(F),[/],xtermo([(Fi*F),/] /Ti),{T = xtermo([Fi,/]/T)--> xfator(F),[*],xtermo([(Fi/F),*] /Ti),{T = xtermo([Fi,/]/T)--> xfator(F),[/],xtermo([(Fi/F),/] /Ti),{T = xtermo([Fi,*]/(Fi*F))--> xfator(F). xtermo([Fi,/]/(Fi/F))--> xfator(F). %% xfator(X)--> [X],{integer(X)}. xfator(E)--> [’(’], xexpr(x(E)), [’)’]. Eo}. Eo}. Eo}. Eo}. Eo}. Eo}. Ti}. Ti}. Ti}. Ti}. Segue uma lista de testes para a gramática com o problema da associatividade resolvido. Aqui podemos combinar diferentes operadores com ou sem parênteses. ?-xexpr(x(V),[1,/,2,/,4,*,3],[]), display(V), X is V. *(/(/(1, 2), 4), 3) X = 0.375 ?-xexpr(x(V),[1,+,2,*, ’(’,3,+,1, ’)’],[]), display(V), X is V. +(1, *(2, +(3, 1))) X = 9 ?- xexpr(x(V),[1,+,2,*,3,+,1],[]), display(V), X is V. +(+(1, *(2, 3)), 1) X = 8 ?- xexpr(x(V),[1,-,2,*,3,+,1],[]), display(V), X is V. +(-(1, *(2, 3)), 1) X = -4 ?- xexpr(x(V),[1,-,2,+,4,-,3],[]), display(V), X is V. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 48 -(+(-(1, 2), 4), 3) X = 0 A gramática não esta calculando o valor das subexpressões – somente montamos a árvore sintática parentizada. No final o termo é calculado com o comando de atribuição is. Podemos substituir a unificação por atribuição no final de cada produção para calcular as subexpressões. O número de regras da gramática podem ser reduzido se usamos outros recursos do Prolog. A regra abaixo equivale a quatro regras da gramática acima, além de ser mais eficiente, pois não precisa tentar todas as combinações. 1 2 3 xexpr([Ti,Oi]/E)--> xtermo(x(T)),[O],{O=’+’;O=’-’}, {TiOiT =..[Oi,Ti,T]} xexpr([TiOiT,O]/Eo),{E = Eo}. Exercı́cio 2.5.5 PROJETO: Reescreva a gramática dada acima, com as sugestões dadas na produção acima, usando disjunções (;) e o operador (=..). 2.5.4 Gerando notação polonesa com ações semânticas Abaixo segue outra versão da gramática de expressões com ações semânticas de escrita: escreve-se na saı́da o código em notação polonesa para a expressão. Normalmente diferenciamos equações semânticas de ações semânticas. Equações definem relações entre atributos, locais a uma regra gramatical, são mais formais e mais declarativas; como as usadas na gramática de atributos para cálculo do valor da expressão. Por outro lado, as ações semânticas são mais procedurais, tipicamente envolvem entrada e saı́da. Assim elas possuem efeito colateral, uma vez que escrevemos um valor a escrita não pode ser desfeita. Portanto, programas com ações semânticas necessariamente devem ser fatorados para não se ter retrocesso. Por causa disso, fatoramos as produções da gramática de expressões: cada produção começa com a parte comum (termo), seguida de uma produção com várias alternativas para a parte diferenciada (rtermo) – resto do termo. 1 2 3 4 5 6 7 8 9 10 expr --> termo,rexpr. rexpr --> [+],expr, {write(some),nl}. rexpr --> [-],expr, {write(subt),nl}. rexpr --> []. termo--> fator,rtermo. rtermo--> [*],termo, {write(mult),nl}. rtermo--> [/],termo, {write(divi),nl}. rtermo--> []. fator --> [X],{integer(X)},{write(X), write(’ enter’), nl}. fator --> [’(’], expr, [’)’]. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 49 O efeito das ações semânticas é escrever uma seqüência de passos a serem executados numa calculadora do tipo HP para se calcular a expressão. Esta notação para representar expressões sem parênteses é também chamada de notação polonesa. Como segue: ?- expr([10,+,20,*,33],[]). 10 enter 20 enter 33 enter mult some ?- expr([1,-,2,+,4,-,3],[]). 1 enter 2 enter 4 enter 3 enter subt some subt Exercı́cio 2.5.6 Faça uma árvore sintática decorada com as ações semânticas, para a gramática versão fatorada que gera a notação polonesa, para a sentença 1+2*3. Exercı́cio 2.5.7 Qual a diferença entre uma equação semântica e uma ação semântica? Exercı́cio 2.5.8 PROJETO: A gramática que gera notação polonesa não é associativa à esquerda. Reveja a solução proposta acima para parentizar uma expressão com associatividade à esquerda e utilize o método para fazer a geração do código em notação polonesa da forma correta. Exercı́cio 2.5.9 PROJETO: Fatore a versão da gramática que calcula o valor da expressão, com o problema da associatividade resolvido. Note que fatorar uma gramática de atributos, implica na rescrita das equações semânticas. Exercı́cio 2.5.10 PROJETO: Abaixo temos uma gramática para expressões booleanas. Definimos uma ordem de precedência (maior) - ^ v -> = (menor). Para avaliarmos uma expressão corretamente devemos também trabalhar com a associatividade à esquerda. Implemente uma DCG para parentizar expressões booleanas, considerando à associatividade à esquerda. 1 2 3 4 5 E4 E3 E2 E1 E0 --> --> --> --> --> t | f | Q ... | (-E0) E4 ^ E3 | E4 E3 v E2 | E3 E2 -> E1 |E2 E1 = E0 | E1 CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 2.6 50 Regras gramaticais reversı́veis: geração x reconhecimento Regras DCG podem ou não ser reversı́veis. Abaixo apresentamos um programa que traduz uma lista de dı́gitos no valor por extenso e vice versa. Por exemplo, se perguntarmos quanto é por extenso o valor ”123” o sistema responde ”cento e vinte e três”. E, se perguntarmos qual é o valor para ”cento e vinte e três”, ele responde 123. Portanto, esta versão da gramática pode ser utilizada tanto para reconhecimento como a geração; de valores ou de valores por extenso. ddd(C,[1,2,3],[]). C = [cento, e, vinte, e, tres] Yes ?- ddd([cento, e, trinta, e, um],V,[]). V = [1, 3, 1] Yes Nesta gramática DCG o número e a sentença gerada são representados por listas; numa implementação, numa linguagem imperativas podem ser representados por string de caracteres. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 d([um])-->[1]. d([dois])-->[2]. d([tres])-->[3]. %%... dd([dez])-->[1,0]. dd([onze])-->[1,1]. %%... dd([vinte])-->[2,0]. dd([vinte,e|D])-->[2],d(D). dd([trinta])-->[3,0]. dd([trinta,e|D] )-->[3],d(D). %%... ddd([cem])-->[1,0,0]. ddd([cento,e|DD])-->[1],dd(DD). Esta gramática codifica um programa reversı́vel, em DCG. Para uma gramática DCG ser reversı́vel deve satisfazer três requisitos: • não usar funções aritméticas, nem operadores de corte; ou outras construções com efeito colateral; • toda regra deve consumir algum token; quando usada nos dois sentidos; • não deve ter produções vazias (similar ao anterior). CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS 51 O programa do exemplo, consome tokens nos dois sentidos: quando gera um valor consome as palavras; e, quando gera as palavras consome os dı́gitos. O programa que segue, quando reconhece uma lista de z(s), consome letras ?- z([z,z,a],X), X=[a]; Mas quando é usado ao contrário ?-z(X,[]) ele não consome nada, assim sendo, o resultado é um erro de execução. 1 2 z -> [z], z. z -> []. ?- z([z,z,a],X). X = [a] Yes ?- z(X,[]). ERROR: Out of local stack Exception: (31,742) z(_G95224, []) ? Exercı́cio 2.6.1 Qual a diferença entre geração e reconhecimento? Exercı́cio 2.6.2 Dê um exemplo de uma gramática reversı́vel em DCG, diferente exemplificada, e que funcione? EXEMPLO 2.6.1 (Outro exemplo de geração) Vimos uma gramática para a linguagem an bm cn dm que apenas testava se uma dada sentença pertencia a sua linguagem. Se, a partir daquela versão, modificamos a produção inicial s como segue abaixo, podemos também utiliza-la para gerar todas as sentenças válidas, para os valores de M e N variando de 1 até 3. 1 2 3 4 5 6 7 8 9 10 s --> {X=[1,1+1,1+1+1],member(N,X),member(M,X)}, a(N),b(M),c(N),d(M). a(N+1)-->[a],a(N). a( 1)-->[a]. b(N+1)-->[b],b(N). b( 1)-->[b]. c(N+1)-->[c],c(N). c( 1)-->[c]. d(N+1)-->[d],d(N). d( 1)-->[d]. Aqui o X é uma lista com o conjunto dos valores 1, 2, 3 e a função member é usada para selecionar um dos valores do conjunto para M e N. Com isso, quando a(N) é executado ele traz uma seqüência de as de comprimento N. CAPÍTULO 2. FUNDAMENTOS PARA GRS, GLCS E GAS ?- s(X,[]). X = [a, b, c, X = [a, b, b, X = [a, b, b, X = [a, a, b, X = [a, a, b, X = [a, a, b, d] c, b, c, b, b, ; d, c, c, c, b, d] d, d] c, c, 52 ; d, d] ; ; d, d] ; c, d, d|...] ; Exercı́cio 2.6.3 Modifique a gramática para que seja feito o cálculo dos valores para M e N. Assim X=[1,2,...]. Pense somente numa gramática para geração, pois, os operadores aritméticos não são reversı́veis. Acrescente no final de cada produção recursiva uma ação semântica tipo {N is N1-1}. Nesta nova versão é necessário também acrescentar operadores de corte, visando tornar o programa determinı́stico. EXEMPLO 2.6.2 ((*opcional)Regras gramaticais vs cláusulas Prolog) As regras DCGs (escritas com o sı́mbolo gramatical -->) são automaticamente traduzidas para cláusulas Prolog. Cada regra equivale a uma cláusula estendida com dois argumentos. Com o comando listing podemos ver como o Prolog traduz as regras DCG para cláusulas (OBS: diferentes sistemas Prolog, geram este código com pequenas variações). ?-listing([r,a,b]). r(A, B) :- a(A, C),b(C, B). a(A, B) :-’C’(A, a, C),a(C, B). a(A, A). b(A, B) :-’C’(A, b, C),b(C, B). b(A, A). %% %% %% %% %% r a a b b --> --> --> --> --> a, b. [a],a. []. [b],b. []. O predicado ’C’/2 simplesmente extrai (ou come) da lista de entrada um terminal. Abaixo temos a sua definição. Este predicado nas primeiras implementações do Prolog Edimburgo era chamado de ”connectors”, daı́ o ’C’/2. ?-listing(’C’). ’C’([A|B], A, B). Exercı́cio 2.6.4 Teste o seu sistema Prolog. Consulte o programa da gramática r, acima. Depois liste as cláusulas e compare o código com o código apresentado acima. Exercı́cio 2.6.5 Mostre a equivalência entre as cláusulas s1 e s2 abaixo, que traduzem a regra s1 -->[a] para cláusulas. Usando substituições, reescreva s2 até chegar em s1? 1 2 3 s1([a|A], A). s2(A, B) :- ’C’(A, a, B). ’C’([A|B], A, B). Capı́tulo 3 Técnicas para Programação de Gramáticas Métodos de programação de gramáticas Aqui apresentamos a codificação de algumas mini-gramáticas, visando introduzir os métodos básicos sobre programação de gramáticas em linguagens imperativas. Gramáticas são formalismos descritos por produções. Devemos aprender, num primeiro momento, a usar um método para programar cada produção; para num segundo momento aplica-lo para a gramática toda. Nosso objetivo é que cada produção de uma gramática resulte numa linha(s) de código de um programa que implementa a gramática. Com isso podemos facilmente alterar a especificação do problema (a gramática) e em seguida modificar o código da implementação. Um método pode ser aplicado a qualquer gramática desde que satisfeitas as restrições que são imposta para o seu uso. Inicialmente trabalharemos com pequenas gramáticas ilustrativas. Nos próximos capı́tulos, usamos estes métodos em estudos de casos de linguagens de programação mais realistas (subconjuntos de) e até de linguagens naturais (subconjuntos de). Mostraremos como programar os três tipos de linguagens formais (regulares, livres e sensı́veis ao contexto). Os exemplos são ilustrados inicialmente na linguagem Pascal. Nos próximos capı́tulos, estudos de casos utilizando os métodos expostos são também apresentados para as linguagens C++ e Java. NOTA: Nos códigos fontes apresentados nesta seção, em linguagens imperativas, utilizamos uma técnica de avaliação de expressões lógicas que se chama short-circuit (curto-circuito). Na linguagem Pascal, dependendo do compilador, é necessário informar com uma diretiva de compilação dizendo que queremos esta forma de avaliar expressões. Nas linguagens C, C++ e Java, os operandos AND e OR comuns (&& e ||) são avaliadas em curto-circuito. 53 CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 3.1 54 Medidas de tempo Abaixo temos uma tabela que compara os tempos de diferentes métodos de codificação de gramáticas em diferentes linguagens. No testes do tempo são utilizadas fitas de entrada com comprimento de 100 palavras. E, a fim de se obter tempos significativos a execução é repetida 10 mil vezes. Os tempos foram medidos em milésimos de segundos. Na última coluna temos dois tempos para a linguagem C, no primeiro é utilizada uma função para avançar o ponteiro na fita de entrada, enquanto que na segunda, a codificação usa macros (#define) para melhorar a eficiência. Gramática Método Pascal Java C++ Regular Autômato com goto 205 54 Iterativo 220 1780 60 Recursivo 350 1920 110 Livre DRSR 330 1950 55 DRCR salvando o próximo 495 3650 105 DRCR com costura 520 4845 169 Atributos DRCR com costura 600 2080 168 DRSR = Descendente recursivo sem retrocesso DRCR = Descendente recursivo com retrocesso #define 50 55 68 50 60 114 115 Comparando-se as diferentes linguagens vemos que a linguagem C(C++) é a mais eficiente para a codificação de gramáticas. Em segundo lugar esta o Pascal (4x mais lento) e por fim Java. Pelos testes Java é 20 a 30 vezes mais lento. Nas gramáticas regulares o método autômato com GOTO é o mais eficiente, pois não utiliza chamadas para funções. O método recursivo é mais lento devido a sucessivas chamadas de funções realizadas durante o processo; porém, não é muito mais lento (nem 2x). Nas gramáticas livres de contexto os métodos recursivos com retrocesso apresentam uma demora em relação ao método sem retrocesso porque estes têm a preocupação de salvar a posição atual na string de entrada antes de efetuarem uma derivação. Por fim, as gramáticas de atributos são praticamente equivalentes às versões livres de contexto mais complexas. Em particular a linguagem Java tem bom desempenho. Esta tabela comparativa traz uma visão abrangente da performance de diferentes métodos de codificação de gramáticas, permitindo uma criteriosa escolha da linguagem principalmente em termos de performance. Por exemplo, se desejamos codificar uma pequena gramática onde a performance não é tão crı́tica podemos fazê-lo em Java. Por outro lado, a linguagem C(C++) se mostrou mais eficiente na codificação destas gramáticas. Num projeto de ferramentas para processadores de linguagens, vemos que a codificação de um método declarativo como o DRCR (com costura) é tão eficiente como uma versão similar mais procedural, o DRCR (salvando o ponteiro). Esta informação é útil na escolha de um método: devemos procurar utilizar um método mais declarativo. A razão pela qual o uso de macros #define no C++ acelera o desempenho no processamento das gramáticas é que as funções x(c) e n() são substituı́das pelas macros CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 55 (s[p]==c) e (++p) respectivamente, assim não realizando nenhuma chamada de função (evita a criação de um registro de ativação de função para avançar o ponteiro sobre a fita de entrada), tornando o programa mais eficiente. Ver abaixo um exemplo de gramática codificada em C++, com contagem de tempo. 1 2 3 4 #include #include #include #include <iostream.h> <stdio.h> <string.h> <windows.h> 5 6 7 8 9 const max=10000; int i; DWORD start, tempo; void savetime(); void showtime(); void le_palavra(); char s1[]="aaaaaaaaaaaaaaaaa...bbbbbbbbb...cccccccccccc@"; /* 100 chars */ int p[] = {0}; 10 11 12 13 void savetime() {start = ::GetTickCount(); } void showtime() { tempo = ::GetTickCount()-start; cout << "Tempo gasto 1/1000 seg:" << tempo;} 14 15 16 17 18 void bool bool bool le_palavra() { p[0]=0; } x(char c) {return(s1[p[0]]==c);} n() { p[0]++; return(true); } attr(int v[],int exp) {v[0] = exp; return(true);} 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 bool A(int n1[]) { return(x(’a’) && n() && A(n1) && attr(n1,n1[0]+1) || true && attr(n1,0));} bool B(int n1[]) { return(x(’b’) && n() && B(n1) && attr(n1,n1[0]+1) || true && attr(n1,0));} bool C(int n1[]) { return(x(’c’) && n() && C(n1) && attr(n1,n1[0]+1) || true && attr(n1,0)); } bool S() {int na[] = {0}; int nb[] = {0}; int nc[] = {0}; return(A(na) && B(nb) && C(nc) && (na[0]==nb[0]) && (nb[0]==nc[0])); } void main() { le_palavra(); savetime(); for (int j=0;j<=max;j++) { p[0]=0; if (S() && x(’@’)) /*cout << "Reconheceu"*/ ; else if (x(’@’)) cout << "Erro nos valores de n"; else cout << "Erro na posicao: " << p[0];} showtime();} CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 3.2 56 Programação de gramáticas regulares Método 3.2.1 (Autômato com GOTO) Aplica-se para os autômatos finitos determinı́sticos e as gramáticas regulares do tipo GLUD. Para os diversos programas apresentados aqui precisamos de um ”preâmbulo” onde são definidos os tipos de dados, um procedimento básico de inicialização e outras funções úteis. No preâmbulo uma string s guarda uma palavra lida para ser testada. No final da palavra lida acrescentamos um caractere fim que pode ser qualquer caractere desde não pertença ao alfabeto da gramática sendo programada (por exemplo, um @). Uma variável p implementa um ponteiro (inteiro) que aponta para o caractere corrente na fita de entrada. Duas funções com resultado boolean são codificadas para auxiliar na programação das gramáticas: • x(c:char):bool – a função x testa se o caractere c passado como parâmetro é o caractere corrente na entrada, sendo apontado por p; em caso afirmativo o resultado é true. • np:bool – a função np avança o ponteiro p uma posição sobre a fita de entrada; sempre retorna true (o importante desta função é o seu efeito colateral). A função np é definida para ser utilizada dentro de uma expressão booleana, que será avaliadas com curto-circuito. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 {- preambulo -} type bool=boolean; const fim = ’@’; var s:string; p:integer; procedure le_palavra; begin p:=1; writeln(’Digite uma palavra:’); readln(S); S:=S+fim; end; {--} function x(c: char):bool; begin x:= S[p]=c; end; function np :bool; begin p:= p+1; np:=true; end; Com as funções x e np, pelo método do Autômato com GOTO, podemos programar a versão GLUD da gramática da linguagem a*b*, descrita abaixo. 1 2 R --> A B A --> a A | [] %% vers~ ao original CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 3 57 B --> b B | [] 4 5 6 A --> aA | bB | [] B --> bB | [] %% vers~ ao GLUD No programa Pascal, abaixo, temos a tradução da gramática GLUD onde cada produção corresponde a uma linha do código e vice versa: • cada não terminal é definido como um label (um estado do autômato); • acrescentamos também um estado final, para verificar se o reconhecedor chegou no fim da palavra, emitindo a mensagem RECONHECEU ou ERRO na posição indicada por p; • cada produção na forma A--> w B é implementado por um comando if x(w) and np then goto B; • cada produção na forma A--> [] é implementado por um comando goto final; • duas alternativas de uma mesma produção são ligadas pelo comando else Para um autômato o método é ainda mais direto: cada estado é um label e cada transição é um comando goto. No programa abaixo é necessário incluir o ”preâmbulo” que define os tipos de dados, constantes e as funções x e np. Antes de entrarmos no estado inicial devemos chamar o procedimento le_palavra. 1 2 3 4 5 6 7 8 9 10 11 12 13 {- incluir o pre^ ambulo -} label a, b, final; begin le_palavra; goto a; a: if x(’a’) and np then goto a else if x(’b’) and np then goto b else goto final; b: if x(’b’) and np then goto b else goto final; final: if x(fim) then writeln(’Reconheceu’) else writeln(’Erro na posiç~ ao: ’,p); end. Segue abaixo um teste para uma palavra válida e para uma palavra inválida. ’Digite uma palavra:’ aaaabbb<enter> Reconheceu CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 58 ’Digite uma palavra:’ aaaacbbb<enter> Erro na posiç~ ao: 5 Método 3.2.2 (Autômato com IF-WHILE) Aplica-se principalmente para expressões regulares (representando autômatos determinı́sticos). É melhor que as produções da gramática estejam agrupadas nos quatro tipos de construções de expressões regulares: seqüência, alternativa; repetição vazia e não vazia. Este método pode também ser usado em gramáticas regulares, mas neste caso devemos identificar e agrupar as produções nos três tipos de construções básicas. Vamos apresentar este método numa linguagem regular definida pela expressão a∗ (b|c)+ d+ . Esta linguagem tem os quatro tipos de construções das linguagens regulares: • repetição com um obrigatório, (b|c)+ e d+ ; • repetição com vazio, a∗ ; • alternativas (b|c) • seqüência, entre elas; Para cada uma destas construções temos um esquema de programação, usando a combinação dos comandos (;) (if) (while), como segue: • repetições, com pelo menos um, são programadas com a combinação de if-while; • repetições vazias são programadas com while; • alternativas são programadas com or ou com if-then-else; • seqüências são codificadas pela ordem dos comandos ligados por (;) – que é o comando de seqüência no Pascal; Quando um elemento obrigatório falha, devemos emitir uma mensagem de erro, como segue: if x(d) and np then begin ...PROX DA SEQUENCIA...end else writeln(’Esperado (d) na posiç~ ao: ’,p); Segue o código para a expressão regular: {a∗ (b|c)+ d+ }. 1 2 3 4 5 {- incluir o pre^ ambulo -} begin le_palavra; {-A-} while x(a) and np do begin end; {- a* -} CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 6 7 8 9 10 11 12 13 14 15 16 17 18 59 if x(b) and np or x(c) and np {- b|c (1ro) -} then begin {-(b|c)* -} while x(b) and np or x(c) and np do begin end; if x(d) and np {- d (1ro) -} then begin while x(d) and np do begin end; {- d* -} if x(fim) then writeln(’Reconheceu’) else writeln(’Erro na posiç~ ao: ’,p); end else writeln(’Esperado (d) na posiç~ ao: ’,p); end else writeln(’Esperado (b) ou (c) na posiç~ ao: ’,p); end. Este método não funciona para expressões regulares não determinı́sticas, como por exemplo, (ab|ac). O não determinismo exige retrocesso no processamento, assunto a ser discutido mais adiante. Método 3.2.3 (Descendente recursivo sem retrocesso (DRSR)) É um método eficiente. Aplica-se a gramáticas regulares e livres de contexto, desde que (1) não sejam recursivas à esquerda e também que (2) estejam fatoradas (da classe LL(1)). Devemos ter cuidado pois a recursividade à esquerda pode aparecer de forma indireta, como exemplificado abaixo. 1 2 3 E-->K*K E-->0|1 K-->E+E A linguagem regular a*b*, que foi programada com GOTO, pode também ser programada pelo método de análise descendente recursiva. Vamos programar com este método a versão original (não GLUD), que segue. 1 2 3 R --> A B A --> a A | [] B --> b B | [] O método descendente recursivo consiste em codificar uma função recursiva para cada não terminal: • duas alternativas de uma mesma produção são ligadas por um OR; • dois elementos no lado direito da produção são ligados por AND; • um elemento vazio é programado como true; CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 60 • um terminal t é programado com "x(t) and np"; Para facilitar a programação, inicialmente definimos todos os não terminais como protótipos de funções booleanas, com a diretiva forward do Pascal. A seguir definimos estas funções na mesma ordem em que são listadas na gramática. Por exemplo, o não terminal A --> a A | [] gera a expressão A:= X(’a’) and np and A or true. No programa principal chamamos a função associada ao sı́mbolo inicial da gramática e testamos se toda a palavra foi consumida (se o próximo sı́mbolo é o fim da fita); neste caso a palavra foi reconhecida. 1 2 3 {- incluir preambulo -} function A: bool; forward; function B: bool; forward; 4 5 6 7 8 9 10 11 12 13 14 function R: bool; forward; {--} function R; begin R:= A and B; end; function A; begin A:= x(’a’) and np and A or true; end; function B; begin B:= x(’b’) and np and B or true; end; begin {- principal -} le_palavra; if R and x(fim) then writeln(’Reconheceu’) else writeln(’Erro na posiç~ ao:’, p); end. Os testes indicados para a versão programada com GOTO, acima, também se aplicam a esta versão DRSR da gramática. Numa linguagem imperativa, um método simples e rápido de codificar um autômato é programa-lo com GOTO. Apesar dos livros didáticos não recomendarem o uso de GOTO, eles deixam o programa com a ”cara” da especificação; bem dentro das normas da Engenharia de Software: cada linha do programa é uma transição do autômato e vice-versa. Assim, se for mudada a especificação será direta a mudança do código. 3.3 Programação de gramáticas livres de contexto O método DRSR, apresentado acima, se aplica também para gramáticas livres de contexto. Porém, ele funciona somente para gramáticas já fatoradas. Por exemplo, a gramática abaixo não é fatorada. 1 2 L--> a L b L--> a b Podemos fatorar esta gramática acrescentando uma produção auxiliar L1 como é dada abaixo. CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 1 2 3 61 L --> a L1 L1 --> L b L1 --> b Agora utilizando o método já exposto programamos a gramática fatorada, como segue. 1 2 3 4 5 6 7 8 9 10 11 {- incluir preambulo -} function L : bool; forward; function L1: bool; forward; {--} function L; begin L := x(’a’) and np and L1; end; function L1; begin L1 := (L and x(’b’) and np) or (x(’b’) and np) end; begin {- principal -} le_palavra; if R and x(fim) then writeln(’Reconheceu’) else writeln(’Erro na posiç~ ao:’, p); end. Para testa-la utilizamos três palavras: aaabbb é uma palavra válida; aabbbb sobram bs na fita de entrada – erro na posição 5; e a palavra aaabb faltam bs, erro na posição 6, que é o caractere de fim da fita. ’Digite uma palavra:’ aaabbb<enter> Reconheceu ’Digite uma palavra:’ aabbbb<enter> Erro na posiç~ ao: 5 ’Digite uma palavra:’ aaabb<enter> Erro na posiç~ ao: 6 Podemos também programar uma gramática não fatorada, neste caso usa-se o método de análise descendente recursivo com retrocesso. Método 3.3.1 (Descendente recursivo com retrocesso (DRCR)) Aplica-se a gramáticas regulares e livres de contexto, desde que (1) não sejam recursivas à esquerda. Usa-se quando uma gramática não está fatorada (possui produções não determinı́sticas). Existem dois submétodos de implementação: no primeiro, salvamos explicitamente o ponteiro da fita p e o restauramos nos pontos de alternativas se for necessário; e no segundo, fazemos uso da um ponteiro local a cada produção. CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 62 Método 3.3.2 (Descendente recursivo com retrocesso (DRCR-salva-p)) É um submétodo do DRCR – muda apenas na forma de retroceder: salvando o ponteiro p. Aqui o retrocesso é implementado sobre a fita de entrada. Antes de entrar numa alternativa de uma produção salva-se o p; e se durante o reconhecimento descobrimos que a alternativa da produção não devia ser utilizada então retrocedemos o p e tentamos a próxima alternativa. Segue o código para esta versão. 1 2 3 4 5 6 7 8 9 10 function L: bool; var salvap: integer; begin salvap:= p; if (X(’a’) and np and X(’b’) and np) then L:= true else begin p:= salvap; if (X(’a’) and np and L and X(’b’) and np) then L:= true else L:= false; end; 11 12 13 14 15 16 17 end; begin {- principal -} le_palavra; if L and x(fim) then writeln(’Reconheceu’) else writeln(’Erro na posiç~ ao:’, p); end. Como vemos o código fica com uma cara procedimental, pois somos obrigados a usar os comandos (if), (:=) e (begin end) para implementar o retrocesso. Os mesmos testes feitos para o método DRSR se aplicam nesta versão. Método 3.3.3 (Descendente recursivo com retrocesso(DRCR-com costura)) É um submétodo do DRCR - varia apenas na forma de retroceder: implementa o retrocesso numa forma declarativa. Este método é mais geral e limpo para implementar analisadores descendentes recursivos com retrocesso; ele é inspirado no mecanismo DCG do Prolog. Vamos exemplificar na gramática L não fatorada. 1 2 3 % regras n~ ao fatoradas L--> a L b L--> a b 4 5 6 L(i,o) --> x(a,i) L(i+1,i1) x(b,i1+1) {o:=i1+1} L(i,o) --> x(a,i) x(b,i+1) {o:=i+2} %% vers~ ao (a) CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 63 7 8 9 L(i,o) --> x1(a,i,i1) L(i1,i2) x1(b,i2,o) L(i,o) --> x1(a,i,i1) x1(b,i1,o) %% vers~ ao (b) Neste método é necessário programar uma gramática de atributos GA para simular o p. Simular o p consiste em contar os tokens consumidos da fita de entrada. Usa-se dois atributos, um i (input) e um o (output). Após consumir um token a incrementamos o i; o L retornará um i1 com um valor do p na saı́da. Por exemplo, na palavra aaabbb = aLb. o L retorna um i1=6 apontando para o último b; após consumido este b, o i1 é incrementado e devolve-se o=7, apontando para o token fim. Para esta GA funcionar temos uma nova função x(Token,p), onde passamos o token e a posição do p. Para não modificarmos a função x preferimos a versão (b) da GA onde temos uma nova função x1(Token, i, o) que ao mesmo tempo testa pelo Token e incrementa o p. • c:char – idem versão inicial – testa o token; • i:int – indicando o valor de p na entrada (atributo herdado); e; • var o:int – indicando o valor de p na saı́da (atributo sintetizado). Na versão (b), em cada alternativa de produção o valor do i de entrada é passada adiante, no corpo da regra, para o próximo elemento depois de incrementado, até no último elemento da alternativa, que corresponde ao o, que é retornado da função. Por isso também é chamado de método da costura. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 {- include preambulo -} {- vers~ ao (b) -} {- ---------------------------} {- gramática L --> a L b -} {L --> a b -} {- ---------------------------} var pe:int; {posiç~ ao do erro} function x(c:char i:int; var o:int): bool; begin x:=S[i]=c; o:=i+1; pe:=o;end; function L(i:int; var o:int): bool; forward; {--} function L(i:int; var o:int):bool; var i1,i2:int; begin L := x(’a’,i,i1) and L(i1,i2) and x(’b’,i2,o) or x(’a’,i,i1) and x(’b’,i1,o); end; var o,o1:int; begin {- principal -} le_palavra; if L(1,o) and x(fim,o,o1) then writeln(’Reconheceu’) else writeln(’Erro na posiç~ ao:’, pe); end. CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 64 Neste método não temos um p global, ele é implementado localmente em cada função (ou produção). Por isso, caso uma produção falha o p não precisa ser retrocedido. A função np também não é necessária pois é codificada dentro da função x1. Aqui temos uma variável global pe que guarda a posição do último token manipulado, como a posição de erro. Uma melhoria no diagnóstico de erros para o método DRCR é salvar em pe a posição mais avançada sobre a fita de entrada: i.e., o pe não deveria retroceder. Pode ser programado testando-se se o valor a ser atribuı́do ao pe é maior que o valor corrente. Se sim modifica-lo, senão não fazer nada. Ver exercı́cio abaixo. Este método de implementação de analisadores descendentes recursivos com retrocesso é geral, porém ele deve ser utilizado somente se uma gramática não é fatorada, pois usa mais recursos computacionais: dois parâmetros em cada função mais as variáveis locais (em L: i1, i2) para implementar o p. Exercı́cio 3.3.1 Programe a gramática livre de contexto abaixo, com o método DRCR; programe as produções na mesma ordem que acontecem. 1 2 3 4 5 6 7 W A A A A B B --> --> --> --> --> --> --> A B a a a A a a A a A a b B [] Teste o programa para as palavras; abb – o sistema retrocede nas alternativas 2 e 3; aabb – o sistema retrocede na alternativa 2; aaabb não é necessário o retrocesso; – aacb – erro na posição 3; aaac – erro na posição 4. Veja a dica acima para dar este diagnóstico de erro. Uma GA tem o poder computacional de processar gramáticas sensı́veis ao contexto. Aqui nós estamos usando uma GA para implementar um mecanismo de retrocesso para uma gramática livre de contexto. Sempre podemos usar um formalismo de uma classe superior de linguagens para processar uma classe inferior. Na próxima seção mostraremos como codificar gramáticas sensı́veis ao contexto como GAs. Exercı́cio 3.3.2 Programe os comentários da linguagem C++, de forma que possam vir aninhados. Use o método DRSR. É necessária uma gramática livre de contexto. Eles podem ser de dois tipos: (1) dupla // até o fim de linha e (2) múltiplas linhas com abre e fecha /* */ como exemplificado abaixo. Um abre comentário sem um fecha correspondente é erro. 1 2 3 void f() { cout << 10; /* este /* eh um */ comentario */ // funcao vazia CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS /* comentario // de duas linhas esta eh /* a segunda */ */ // este tambem eh 4 5 6 }; 3.4 65 Programação de Gramáticas de Atributos (GAs) Para concluir a visão geral de codificação de gramáticas em linguagens imperativas vamos mostrar a programação de uma GA. Método 3.4.1 (Gramáticas de atributos)) GAs são baseadas em GLCs, portanto usa-se os mesmos métodos para codificação. Os atributos sintetizados são programados com parâmetros de retorno; e, os herdados como parâmetros só de entrada. A implementação da análise DRCR foi exemplificada para a gramática L, na seção anterior, como uma solução baseada em GA: foram usados dois atributos (i-input, ooutput) um herdado e outro sintetizado. Segue um exemplo simples com um atributo sintetizado (n). Considere a GA para a linguagem an bn cn . 1 2 3 4 5 6 7 S A(1+n) A( 0 ) B(1+n) B( 0 ) C(1+n) C( 0 ) --> --> --> --> --> --> --> A(n) B(n) C(n). a A(n). [] b B(n). %% n: atributo sintetizado [] c C(n). [] As produções desta gramática livre de contexto são fatoradas e sem recursividade à esquerda. Portanto pode ser programada pelo método DRSR - descendente recursivo sem retrocesso. Para fazer a contagem do número de as, bs e cs definimos um predicado attr(V,EXP) que simula uma atribuição V:=EXP do Pascal – o valor da expressão é calculado e atribuı́do a variável V. Para garantir que teremos o mesmo número de as, bs e cs, usamos os predicados na=nb and nb=nc no final da produção s. 1 2 3 4 5 6 7 8 {- incluir pre^ ambulo-} function attr(var v:int; exp:int):bool;begin v:=exp; attr:=true; end; {--} function S :bool; forward; function A(var n:int):bool; forward; function B(var n:int):bool; forward; function C(var n:int):bool; forward; {--} CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS 9 10 11 12 13 14 66 function S; var na, nb, nc:int;begin S:= A(na) and B(nb) and C(nc) and na=nb and nb=nc; end; function A(var n:int):bool; begin A:= x(’a’) and np and A(n1) and attr(n,n1+1) or true and attr(n,0); end; function B(var n:int):bool; begin 15 16 17 18 19 20 21 22 23 24 B:= x(’b’) and np and B(n1) and attr(n,n1+1) or true and attr(n,0); end; function C(var n:int):bool; begin C:= x(’b’) and np and C(n1) and attr(n,n1+1) or true and attr(n,0); end; begin {- principal -} le_palavra; if S and x(fim) then writeln(’Reconheceu’) else if x(fim) writeln(’Erro nos valores de n’ 25 26 27 else end. 3.4.1 writeln(’Erro na posiç~ ao:’, p); Método da costura com atributos Abaixo mostramos que podemos combinar o método da costura com o método que codifica gramáticas de atributos; segue um exemplo. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 {- include preambulo -} {- vers~ ao (b) -} {- -----------------------------------} {- gramática L(N+1) --> a L(N) b -} {L( 1) --> a b -} {- ---------------------------=-------} var pe:int; {posiç~ ao do erro} function x(c:char i:int; var o:int): bool; begin x:=S[i]=c; o:=i+1; pe:=o;end; function attr(var v:int; exp:int):bool;begin v:=exp; attr:=true; end; function L(var No:int i:int; var o:int): bool; forward; {--} function L(var No:int; i:int; var o:int):bool; var i1,i2, N:int; begin L := x(’a’,i,i1) and L(N, i1,i2) and x(’b’,i2,o) and attr(No,N+1) or x(’a’,i,i1) and x(’b’,i1,o) and attr(No, 1); end; var o,o1, N:int; begin {- principal -} le_palavra; if L(N,1,o) and x(fim,o,o1) then writeln(’Reconheceu, nı́veis =’,N) CAPÍTULO 3. TÉCNICAS PARA PROGRAMAÇÃO DE GRAMÁTICAS else writeln(’Erro na posiç~ ao:’, pe); 19 20 67 end. 3.4.2 Exercı́cios de Revisão Segue uma relação de exercı́cios simples, com base em versões de GA já discutidas. Exercı́cio 3.4.1 Programe a GA dada abaixo, com atributos herdados e sintetizados, numa linguagem imperativa. 1 2 3 4 5 %% L-GA em DCG b0( M )--> {AC:=0}, b(AC,M). b(AC, M )--> [b], {AC1:=AC+1}, b(AC1,M). b(ACi,ACo)--> [], {ACo:=ACi}. %% AC é um ACumulador Exercı́cio 3.4.2 Programe a GA dada abaixo, na notação de Knuth, com atributos herdados e sintetizados, numa linguagem imperativa. n bs1 bs b b --> --> --> --> --> bs b bs2 [] 0 1 {bs.N {bs2.N {bs.R {b.B {b.B := := := := := 0; n.R := bs.R} 2*bs1.N+b.B; bs1.R := bs2.R} bs.N} 0} 1} Exercı́cio 3.4.3 Programe a DCG abaixo como uma GA numa linguagem imperativa. ?-move(F,D,[esq,dir,esq,frente,frente,dir,dir,pare],[]). F= 0+0+0+1+1+0+0+0, D=-1+1-1+0+0+1+1+0 Yes 1 2 3 4 5 6 7 move(0,0) --> [pare]. move(D,F) --> passo(D,F). move(D+D1,F+F1)--> passo(D,F), move(D1,F1). passo( 1, 0) --> [dir]. passo(-1, 0) --> [esq]. passo( 0, 1) --> [frente]. passo( 0,-1) --> [traz] Capı́tulo 4 Programação de Gramáticas em Prolog Este capı́tulo apresenta o uso prático das técnicas de análise léxica, sintática e semântica, usando os recursos de programação do Prolog. Este capı́tulo apresenta duas principais seções: (1) análise sintática e semântica e (2) análise léxica. Agrupamos o conteúdo sintático e semântico na mesma seção porque é difı́cil separamos a sintaxe da semântica em aplicações práticas. Na análise léxica são explorada duas abordagens. A primeira é o uso direto de DCG que tem o poder de codificar regras com retrocesso (regras não fatoradas). A segunda faz uso de predicados recursivos. Ela é ilustrada num exemplo que trabalha com um arquivo de entrada (como uma fita de caracteres) fazendo uso das operações de entrada e saı́da do Prolog. Nesta segunda abordagem a gramática deve estar fatorada. 68 CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 4.1 69 Análise sintática e semântica Método 4.1.1 (DCG para análise sintática e semântica) DCGs em Prolog implementam o método de análise sintática descendente recursiva com retrocesso (gramáticas LL(k)). Para eliminar o retrocesso basta fatorar a gramática. Quanto estendida por parâmetros, uma DCG implementa uma Gramática de Atributos, que é uma formalismo para especificar semântica de linguagens. Nesta seção temos dois estudos de casos para problemas de análise sintática e semântica: o primeiro é para uma mini linguagem chamada LET para expressões aritméticas; o segundo é para um tradutor que traduz alguns tipos de comandos SQL para álgebra relacional. Estes exemplos ilustrar o poder expressivo de DCG para codificar processadores de linguagens de programação. 4.1.1 Calcular expressões aritméticas com variáveis A linguagem LET, descrita aqui, é uma mini linguagem interessante para ser estudada, pois exige uma tabela de sı́mbolos com contexto para armazenar as variáveis parciais usadas numa expressão aritmética. Ela permite calcular expressões LET aninhadas como as que seguem: let a=4+5, b=8+2 in a + b VALOR=(4+5)+(8+2) = 19 let c= (let a=4+5, b=8+2 %% aqui o par^ entese é opcional in a + b), %% porém facilita a leitura d=8+2 in (c+d)*c+d VALOR=(4+5+ (8+2)+ (8+2))* (4+5+ (8+2))+ (8+2)= 561 Abaixo temos uma gramática para estas expressões. Primeiro codificamos dois predicados para implementar uma tabela de sı́mbolos, como uma lista de pares par(VAR,VAL): • lookUp/2 — retorna o valor para uma variável; • insert/2 — insere um par(VAR,VAL) na tabela de sı́mbolos. Como temos expressões aritméticas simples, do tipo (c+d)*c+d, a idéia é reusar uma versão simplificada da gramática de expressões já discutida. Não trabalharemos com as operações de soma e divisão pelo problema de associatividade já discutido. Agora todas as produções recebem um atributo herdado, que é a tabela de sı́mbolos. Ela é necessária pois agora um fator pode ser uma variável: neste caso o seu valor está registrado na tabela de sı́mbolos (para um expressão bem formada). Na gramática LET são usadas três novas produções let, decVar e decVars A produção let define uma expressão composta de declaração e o corpo da expressão onde as CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 70 declarações serão usadas. A produção decVar declara uma variável associada a uma expressão - no final a variável e a expressão são incluı́das na tabela de sı́mbolos. A produção decVars declara um ou mais pares Var=Exp separados por vı́rgula. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 lookUp(X,T):-member(X,T). insert(X,Ti/To):-To=[X|Ti], write((tab:To)),nl. isLetra(X):-member(X,[a,b,c,d,e,f,g,h,i,x,y,z]). %% let(Ti,V) --> [let], decVars(Ti/T1), [in], expr(T1,V). decVars(Ti/To) --> decVar(Ti/T1), [’,’], decVars(T1/To). decVars(Ti/To) --> decVar(Ti/To). decVar(Ti/To) --> [L],{isLetra(L)}, [=], expr(Ti,E), {insert(par(L,E),Ti/To)}. %% expr(TAB,E)--> let(TAB,E). expr(TAB,E)--> termo(TAB,T),[+],expr(TAB,Eo),{E = (T+Eo)}. expr(TAB,E)--> termo(TAB,E). termo(TAB,T)--> fator(TAB,F),[*],termo(TAB,To),{T = (F*To)}. termo(TAB,F)--> fator(TAB,F). fator(TAB,X)--> [X],{integer(X)}. fator(TAB,E)--> [’(’],expr(TAB,E), [’)’]. fator(TAB,V)--> [X],{member(X,[a,b,c,d,e,f,g,h,i,x,y,z])}, {lookUp(par(X,V),TAB), write((look:X:V)),nl}. %% vars Com é difı́cil digitar corretamente uma destas expressões para teste, codificamos dois testes como predicados, como segue. 1 2 3 4 5 6 7 %% ?- teste(1,LET),let([],V,LET,RESTO), VAL is V. teste(1, [let, a,=,4,+,5,’,’,b,=,8,+,2, in, a, +, b]). teste(2, [let, c,=,let, a,=,4,+,5,’,’,b,=,8,+,2, in, a, +, b,’,’, d,=,8,+,2, in, ’(’, c, +, d,’)’, *, c, +, d ]). Abaixo segue a execução dos testes. Incluı́mos dois write(s) para depurar o programa. Aqui vemos que este programa trabalha com retrocesso: em alguns casos ele inclui na tabela de sı́mbolos resultados que ainda não são definitivos; ao mesmo tempo ele acessa a tabela varias vezes desnecessariamente. Estes problemas são resolvidos com a fatoração do programa, deixado como exercı́cio. ?- teste(1,LET),let([],V,LET,RESTO),VX is V. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG tab:[par(a, tab:[par(b, tab:[par(b, tab:[par(b, look:a:4+5 look:a:4+5 look:b:8+2 look:b:8+2 look:b:8+2 look:b:8+2 71 4+5)] 8+2), par(a, 4+5)] 8), par(a, 4+5)] 8+2), par(a, 4+5)] LET = [let, a, =, 4, +, 5, (’,’), b, =|...] V = 4+5+ (8+2) RESTO = [] VX = 19 ?- teste(2,LET),let([],V,LET,RESTO), VX is V. tab:[par(d, 8+2), par(c, 4+5+ (8+2))] look:c:4+5+ (8+2) look:d:8+2 LET = [let, c, =, let, a, =, 4, +, 5|...] V = (4+5+ (8+2)+ (8+2))* (4+5+ (8+2))+ (8+2) RESTO = [] VX = 561 Exercı́cio 4.1.1 Fatore o programa da gramática. Lembre que ao fatorar deve-se ajustar as equações semânticas. Inclua corte onde for necessário para que ele trabalhe de forma determinı́stica. Exercı́cio 4.1.2 Implemente uma técnica de diagnóstico para erro sintático. Por exemplo, na gramática fatorada, escrevendo até onde o programa reconheceu. Exercı́cio 4.1.3 Implemente um diagnóstico para erros semânticos. As variáveis declaradas em vars numa expressão let vars in expr só podem ser usadas num contexto mais interno in expr. Seguem abaixo dicas para diagnóstico de erros semânticos. let a=b+5, b=8-2 /** em a=b+5 a variável b ainda n~ ao foi declarada **/ in let c=a+b, d=a+a+3 in (c+d)*(c+d)/2 let a=b1+5, b=let k=2+3 in k+k in (b+c+k) /** em b+c+k a variável k já n~ ao existe **/ 72 CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG /** ela é local ao outro let let a=5, b=8-2 in let a=a+1 in a+b **/ /** esta express~ ao é válida e o aqui o a=6 **/ /** vale a declaraç~ ao mais interna, isto já funciona **/ Modifique o predicado do lookUp para dar o diagnóstico dizendo quando ele não encontra a variável na tabela de sı́mbolos. Exercı́cio 4.1.4 Estenda esta gramática para trabalhar com qualquer tipo de expressões aritméticas, e avaliar corretamente. 4.1.2 Traduzir SQL para álgebra relacional Outro exemplo interessante de análise sintática e semântica é apresentado aqui. Ele é da área de BD: é a tradução de um comando SQL para um seqüência de comandos de álgebra relacional. O objetivo aqui não é fazer um interpretador de comandos SQL mas sim ilustrar como os comandos SQL podem ser traduzidos para álgebra relacional. Fazer um interpretador de SQL é um trabalho bem mais complexo devido a necessidade de tratar temas tais como otimização de consultas. Supomos o seguinte esquema de BD e as primitivas da álgebra relacional dadas abaixo: Compra ( cNoItem {numero do item}, cNoForn {numero do fornecedor}, cQuantidade ) Fornecedor ( fNoForn {fornecedor numero}, fNome {fornecedor nome } ) PROJECT (lista-de-campos, tabela) SELECT (condicao, tabela) JOIN (condicao, tabela1, tabela2) Uma consulta de SQL pode ser traduzida para Álgebra Relacional como exemplificado abaixo: SELECT cNoItem, cQuantidade FROM Compra WHERE cNoForn IN (SELECT fNoForn FROM Fornecedor WHERE fNome = ’joaozinho’) PROJECT( [cNoItem, cQuantidade], JOIN( [cNoForn=fNoForn], Compra, PROJECT( [fNoForn], SELECT( [fName = ’joaozinho’], Fornecedor)))) CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 73 São usadas quatro produções principais: sql – define uma construção de projeção; relation – define uma construção de seleção; condition – complementa a seleção com um caso para a operação de join e attrList – define uma lista de atributos para uma relação. Os sı́mbolos terminais são os operadores relacionais, os identificadores para nomes de relações e de atributos; e, as constantes numéricas ou alfanuméricas. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 sql(REL,ATTR)-->[select], attrList(LIST), [from], relation(REL2), {REL =[’\n PROJECT ( [’, LIST, ’]’, REL2, ’)’], ATTR=LIST }. relation(REL) --> id(ID), [where], condition(OP, COND, NESTED), {OP=select, REL= [’\n SELECT ( [’, ’[’, COND, ’,’ , ID, ’]’| NESTED],! ;OP=join, REL= [’\n JOIN ( [’, ’[’, COND, ’,’ , ID, ’]’| NESTED],!}. condition(select, COND, []) --> id(ID), relOper(OPER), const(CONST), {COND = [’(’, ID, OPER, CONST, ’)’ ]}. condition(join, COND, NESTED) --> id(ID), [in],[’(’], sql(REL,ATTR), [’)’], {COND = [’{[’,ID, ’=’, ATTR, ’]’ ], NESTED = [’,’, REL, ’}’]}. attrList(LIST) --> id(ID), [’,’], attrList(LIST1), {LIST = [ID,’,’|LIST1]}. attrList(ID)--> id(ID). 15 16 17 18 relOper(X) --> [X],{relOper(X)}. id(X) --> [X],{field(X) ; relation(X)}. const(X) --> [X],{const(X)}. 19 20 21 \* base de fatos *\ relOper(’=’). relOper(’<’). 22 23 24 field(cNoItem). field(cQuantidade). field(cNoForn). field(fNoForn). field(fNome). 25 26 relation(’Compra’). relation(’Fornecedor’). 27 28 const(joao). const(10). Segue abaixo um teste, que gera código para o exemplo dado acima. 1 2 3 4 5 p:-sql(REL,ATTR, [select, cNoItem, ’,’, cQuantidade, from , ’Compra’, where , cNoForn, in, ’(’, select, fNoForn, from, ’Fornecedor’, CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG where, fNome, ’=’ ,joao, ’)’ ], 6 7 8 9 74 RESTO), wL(REL),nl. wL([X|L]):-!,wL(X),wL(L). wL([]):-!. wL(X):-write(X). ?-p. PROJECT ( [cNoItem,cQuantidade] JOIN ( [[{[cNoForn=fNoForn],Compra], PROJECT ( [fNoForn] SELECT ( [[(fNome=joao),Fornecedor])}) [] Yes Esta gramática pode ser um ponto de partida para se fazer algum trabalho de uma disciplina avançada de BD, que estuda a tradução de SQL para álgebra relacional. 4.2 Análise léxica e Autômatos Em Prolog existem dois métodos de programação de gramáticas regulares, um usando DCG e o outro usando cláusulas. Este segundo método será exemplificado trabalhando com arquivos. Iniciaremos com o estudo de gramáticas na notação DCG para um léxico para expressões aritméticas. 4.2.1 DCGs para análise léxica Podemos trabalhar com analisadores léxicos usando apenas programação por cláusulas, especialmente se a gramática regular estiver fatorada. Porém para certas aplicações pode ser útil trabalharmos com DCG que permite fazer uso de retrocesso. Método 4.2.1 (DCG para análise léxica) DCGs em Prolog implementam o método de análise sintática descendente recursiva com retrocesso (gramáticas LL(k)). Para eliminar o retrocesso basta fatorar a gramática. Tokens para expressões aritméticas Para processadores de linguagens uma tarefa comum é traduzir uma linha de texto numa lista de tokens. Por exemplo, abaixo temos um predicado tokens que retorna uma lista de tokens para um string de uma expressão aritmética: ?- tokens(L,"11 +3*(23)",[]). L = [int(11), operador(+), int(3), operador(*), delimitador(’(’), int(23), delimitador(’)’ ] CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 75 Queremos programar este predicado tokens, que recebe uma lista de caracteres e devolve uma lista de tokens. Aqui temos dois tipos de tokens e um separador: • inteiros, seqüências de dı́gitos (e.g., 11, 3 e 23); • sı́mbolos gráficos, são os operadores +, * e os parênteses; • separadores, são os caracteres não visı́veis; no exemplo, são os espaços em branco, mas poderia ser também caracteres de tabulação e quebra de linha. Diferenciamos o termo token de lexema: um token é uma classe para os lexemas; os lexemas são os elementos de uma classe de tokens. Por exemplo, acima temos os lexemas (11, 3, e 23), todos do token tipo inteiro. Uma gramática para tokens aritméticos deve considerar dois tipos de tokens: inteiros e sı́mbolos e um tipo de separadores (brancos). Um inteiro é uma seqüências de dı́gitos – (int). Os sı́mbolos gráficos (operadores e delimitadores) são representados por caracteres únicos entre eles: "+-*/()". Um token ((tok)) é um inteiro ou um sı́mbolo. Uma fita de tokens é uma seqüência de tokens, que podem estar separados por uma seqüência de brancos((br)).: os tokens são escritos na saı́da e os brancos são desprezados. Ver a gramática abaixo. Figura 4.1: Autômato finito para tokens de expressões aritméticas. A gramática resultante possui 7 não terminais, com 13 produções. Na Figura 4.1 temos um autômato equivalente. Note que na passagem da gramática para o autômato alguns não terminais não são representados como estados: por exemplo, temos 7 não terminais e apenas 4 estados – não temos os estados int, br e tok. Estes estados podem ser criados com uma transição vazia partindo do estado toks – portanto eles também podem ser eliminados, eliminando-se três transições vazias. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 1 2 3 4 5 6 7 8 9 10 11 12 13 14 76 int --> dig, rint. rint --> dig, rint. rint --> []. br --> branco, rbr. rbr --> branco, rbr. rbr --> []. %% tokens --> tok, rtokens. tokens --> br, rtokens. rtokens --> tok, rtokens. rtokens --> br, rtokens. rtokens --> []. tok --> int. tok --> simbolo. Esta gramática na notação DCG do Prolog, é executável; porém ela deve ser completada pela descrição das produções dig, simbolo e branco. Abaixo temos a definição destas produções. 1 2 3 branco --> [C],{C<33}. %% [32]; [8]; [10]; [13]. dig( C ) --> [C],{C>47, C<58}. simbolo([D]) --> [D],{member(D, "*+/-()")}. Na definição destas produções, assumimos que vamos trabalhar com uma cadeia de códigos Ascii dos caracteres. A maneira mais econômica de escrever cadeias de códigos Ascii é usando aspas como segue: ?- ”0123 89”=X. X= [48, 49, 50, 51, 32, 56, 57] Assim, "0"=[48] e "9"=[57], o que significa que o valor ASCII do zero é 48. O Prolog ISO tem outras notações para representar os valores Ascii, por exemplo, 0’0=48 e 0’9=57. Todos os dı́gitos estão entre estes dois valores, o que resultou na produção: dig(C)-->[C],{C>47, C<58} Note que as equações semânticas em DCG são codificadas entre chaves { }. Assim a produção dig é válida somente se o valor de C estiver dentro do intervalo especificado. Os separadores (ou brancos) foram definidos como os caracteres que inclui os códigos menores que 32, o valor Ascii de um espaço em branco. Este conjunto inclui, entre outros, o caracter de tabulação (8) e os caracteres de fim de linha (13 e 10). Estes separadores são reconhecidos na produção branco pela restrição {C<33}. Os sı́mbolos gráficos (operadores e delimitadores) são definidos por uma lista "+-*/()" que implementa um conjunto usando-se o predicado member. Abaixo temos uma versão em DCG da gramática para expressões aritméticas. Foram acrescentados parâmetros para se acumular uma seqüência de dı́gitos para se gerar um CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 77 token inteiro; de forma similar guarda-se uma seqüência de tokens dentro de uma lista, para ser exibida no final do processamento. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int([C|W]) --> dig(C),!, rint(W). rint([C|W]) --> dig(C),!, rint(W). rint( []) --> []. br --> branco,!, rbr. rbr --> branco,!, rbr. rbr --> []. %% tokens([H|T]) --> tok(H),!,rtokens(T). tokens( T ) --> br, !,rtokens(T). rtokens([H|T]) --> tok(H),!,rtokens(T). rtokens( T ) --> br, !,rtokens(T). rtokens( []) --> []. tok(int(T)) --> int(L),!,{name(T,L)}. tok(simb(T)) --> simbolo(L),!,{name(T,L)}. %% branco --> [C],{C<33}. %% [32]; [8]; [10]; [13]. dig( C ) --> [C],{C>47, C<58}. simbolo([D]) --> [D],{member(D, "*+/-()")}. Na prática sempre que implementamos uma gramática regular são necessárias algumas ações semânticas para gerar os tokens. Ações semânticas são naturalmente integradas nas regras DCG do Prolog. Na produção tok o predicado name é usado para transformar uma lista de códigos Ascii num sı́mbolo. Por exemplo, ?-name([48,49],X). X=01. O operador de corte foi introduzido em todas as produções tornando o programa deterministico. Testando o programa Para testar uma gramática devemos começar com com produções isoladas, por exemplo, int, br, simbolo. Depois testa-se o conjunto como um todo. ?- int(V,"12",D). V = [49, 50] D = [] Yes ?- br(" ",G). G = [] Yes ?- simbolo(D,"((",H). D = [40] H = [40] Yes Finalmente podemos testar o gerador de tokens com a uma expressão aritmética. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 78 ?- tokens(L,"11 +3*(23)",[]). L = [int(11), simb(+), int(3), simb(*), simb(’(’), int(23), simb(’)’)] Yes ?- tokens(L,"11 +3*(23+32*(3/45)-1)",[]). L=[int(11),simb(+),int(3),simb(*),simb(’(’),int(23),...] Yes L = [11, +, 3, *, ’(’, 23, ’)’] Yes Exercı́cio 4.2.1 Abaixo temos uma saı́da para léxico em que separamos os operadores (*,+) dos delimitadores (’(’, ’)’). Modifique o programa DCG para gerar esta saı́da. ?- tokens(L,"11 +3*(23)",[]). L = [int(11), operador(+), int(3), operador(*), delimitador(’(’), int(23), delimitador(’)’ ] Programando não determinismos O mecanismo DCG do Prolog permite a codificação de regras não determinı́sticas que olham vários sı́mbolos a frente. Caso uma das alternativas do predicado falha ele automaticamente retrocede e tenta a próxima alternativa. Por exemplo, podemos codificar quatro regras diferentes para os tokens: ==!, ==:, ==, =; como ilustrado no exercı́cio abaixo. Exercı́cio 4.2.2 Codifique em Prolog regras não terministicas para reconhecer os tokens: ==!, ==:, ==, =. Solução: 1 2 3 4 5 6 igual(’==!’) --> [0’=],[0’=],[0’!],!. igual(’==:’) --> [0’=],[0’=],[0’:],!. igual(’==’ ) --> [0’=],[0’=],!. igual(’=’ ) --> [0’=],!. %% ?- igual(SIMB, "==: ",[]). %% O operador de corte no final das regras visa tornar o programa deterministico. Segue um teste. 1 2 ?- igual(SIMB, "==: ",[]). SIMB = ’==:’ Exercı́cio 4.2.3 Apesar de podermos trabalhar com regras não fatoradas, aconselha-se fatorar as produções antes de codifica-las, evitando o retrocesso desnecessário. Defina regras fatoradas pare reconhecer os quatro sı́mbolos da questão anterior (==!, ==:, ==, =); codifique-as e teste-as. Exercı́cio 4.2.4 Desenhe um autômato para as duas versões das regras, fatoradas e não fatoradas. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 4.2.2 79 Autômatos trabalhando com arquivos O processamento ao nı́vel léxico consiste em transformar seqüências de caracteres em tokens ou palavras. Vimos como usar o formalismo gramatical DCG para codificar gramáticas regulares. Uma alternativa para implementação é usar cláusulas lógicas e primitivas de entrada e saı́da. Autômatos codificados com regras recursivas. Para implementarmos autômatos não precisamos de regras recursivas, basta usar construtores de iteração tipo o comando while. No Prolog uma iteração é programada com predicados recursivos. Nesta seção estudaremos a codificação de autômatos finitos determinı́sticos, com predicados recursivas. Método 4.2.2 (Gramáticas LL(1) como regras recursivas) Dada uma gramática LL(1) codifica-se cada produção como uma regra recursiva. Para ser LL(1) a gramática deve estar fatorada. Este método é particularmente válido para Gramáticas regulares do tipo Linear Unitárias com recursividade à Direita (GLUD). Gramáticas LL(1) (que inclui a classe GLUD) não exigem retrocesso. Dada uma grámatica LL(1) fazemos o seguinte codificação: • cada terminal, no corpo de uma produção, é codificado por um predicado que testa se ele esta presente na fita de entrada e caso positivo avança o ponteiro da fita; • cada não terminal é codificado como um predicado; • cada corpo de produção é codificado imitando o corpo da produção pelas duas regras já enunciadas; Por exemplo, seja a especificação abaixo para gerar tokens de dı́gitos como números inteiros. 1 2 3 4 5 %% LL(1) ou GLUD digitos --> digito, rdigitos. rdigitos --> digito,rdigitos. rdigitos --> []. digito --> 1|2| ... 6 7 8 9 10 11 %% em DCG, com aç~ oes sem^ anticas para gerar o token digitos(D)--> digito(L),rdigitos(Ls),{atom_codes(D,[L|Ls])},!. rdigitos([L|Ls]) --> digito(L),rdigitos(Ls),!. rdigitos( [] ) --> [],!. digito(L) --> [L],{isDigito(L)}. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 80 Assumindo que vamos trabalhar com arquivos do Prolog, ele já possui as primitivas que fazer o lookahead de um caracter sem avançar o ponteiro da fita sobre o arquivo: • peek_code — retorna o caracter corrente da fita de entrada sem avançar o ponteiro; • get_code — avança o ponteiro da fita de entrada (consome o caracter). Usando estas duas primitivas segue a codificação da gramática com e sem ações semânticas. Note que as ações semânticas não estão entre chaves pois aqui não estamos trabalhando com DCG. Mesmo na versão sem ações semânticas precisamos pegar o caracter da fita de entrada e testa-lo para ver se é um dı́gito. Na desigualdade X>=0’0,X=<0’9 a notação 0’9 denota o valor ascii do caracter ”9”. 1 2 3 4 5 %% sem aç~ oes semanticas digitos :- digito, rdigitos,!. rdigitos :- digito, rdigitos,!. rdigitos . digito :- peek_code(X),X>=0’0,X=<0’9,get_code(L),!. 6 7 8 9 10 11 12 %% codificada com cláusulas recursivas digitos(D) :- digito(L),rdigitos(Ls),atom_codes(D,[L|Ls]),!. rdigitos([L|Ls]) :- digito(L),rdigitos(Ls),!. rdigitos( [] ) . digito(D) :- peek_code(D),D>=0’0,D=<0’9,get_code(D),!. Na próxima seção mostramos esta técnica aplicada a um léxico para uma mini linguagem. Um léxico trabalhando com um arquivo A seguir apresentamos um gerador de tokens que trabalha com arquivos texto. Entra um ”programa” representado num arquivo texto, como uma fita de entrada, e sai uma lista (fita) de tokens, ver Figura 4.2. Figura 4.2: Integração entre os componentes Léxico e Sintático. A saı́da do Léxico é entrada para um componente sintático. Usualmente o componente léxico devolve as palavras da linguagem e o componente sintático monta construções como frases e comando a partir das palavras. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 81 Estudo de caso: Gerando tokens para uma mini linguagem Vamos implementar um léxico para uma mini linguagem tipo Pascal, onde podemos escrever programas como o exemplificado abaixo. Assumimos que este código Pascal está em um arquivo, o qual será processado pelo léxico para gerar um lista de tokens. 1 2 3 4 5 6 7 8 9 program a1; function pos(i:int):bool; begin pos:=i>=0; end; begin readln(X1); writeln(pos(X1)); XX := X1*X1; writeln(XX); end; Na saı́da do léxico teremos uma lista de tokens. Vamos fazer três versões do léxico: • um léxico simples, com propósitos didáticos - ele gera uma lista de tokens; • um léxico que classifica as palavras em reservadas e que informa a linha e coluna onde o token foi encontrado; Gerando uma lista de tokens A saı́da da primeira versão é mostrada abaixo. Os tokens são escritos (para efeito de visualização e depuração do léxico) e ao mesmo tempo devolvidos em uma lista com os tokens (para serem usados pelo componente sintático). ?- [tokens]. %% swi-prolog % tokens compiled 0.00 sec, -632 bytes Yes ?- ftokens(N). program a1 ; function pos ’(’ i : int ’)’ : bool ; begin pos := i = ’0’ ; end ; begin readln ’(’ ’X1’ ’)’ ; writeln ’(’ pos ’(’ ’X1’ ’)’ ’)’ ; ’XX’ := ’X1’ * ’X1’ ; writeln ’(’ ’XX’ ’)’ ; end ; N = [program, a1, (;), function, pos, ’(’, i, :, int|...] Yes Para trabalhar com um arquivo de entrada usamos as primitivas, do Prolog ISO, que manipulam códigos de caracteres: peek_code/1, get_code/1. Com isso, definimos que queremos trabalhar com os códigos Ascii dos caracteres1 . 1 Uma abordagem alternativa seria trabalhar diretamente com os caracteres usando as primitivas peek char e get char. O problema é que para lermos de um arquivo devemos tratar também os caracteres que não são visı́veis. Portanto para não mesclar caracteres com códigos preferimos trabalhar só com códigos. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 82 O predicado principal ftokens/1 abre o arquivo de entrada e informa que ele será a fita de entrada set_input. Após, chama-se o léxico propriamente dito, tokensN/1, que gera uma lista de tokens que é escrita pelo predicado wList. 1 2 3 4 5 6 ftokens(N) :- open(’teste.pl’,read,_,[type(text),alias(file_in)]), set_input(file_in), tokensN(N), wList(N),!, close(file_in). wList([L|Ls]):-!,writeq(L),write(’ ’),wList(Ls). wList([]) :-!. O predicado principal do léxico tokensN foi programado para trabalhar até o fim de arquivo ser encontrado at_end_of_stream. Este predicado, tokensN, chama repetidamente o predicado separadores seguido do predicado token/1 que retorna um token. É necessário sempre verificar se entre um token e outro existe ou não um separador. Separadores (isSeparador) são os códigos menores que 32 e maiores que -1. O valor -1 é retornado pelas primitivas de leitura de códigos quando é encontrado o fim de arquivo. Segue o programa que gera os tokens. Abaixo, comentaremos os pontos mais importantes deste programa. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 tokensN( [] ) tokensN([N|Ns]) token( N ) separador separadores palavra( Po ) ::::::- at_end_of_stream,!. separadores, token(N),tokensN(Ns),!. digitos(N),!; palavra(N),!; simbolo(N),!. peek_code(C), isSeparador(C), get_code(C),!. separador, separadores,!; true. palavra0(N), atom_codes(P,N), (pal_res(P),Po=res(P),!;Po=id(P),!). palavra0([L|Ls]) :- letra(L), letOuDigs(Ls),!. letOuDigs([L|Ls]) :- peek_code(L),isLetOuDig(L),get_code(L),letOuDigs(Ls),!. letOuDigs( [] ) . digito(L) :- peek_code(L),isDigito(L),get_code(L),!. letra(L) :- peek_code(L),isLetra(L),get_code(L),!. digitos(D) :- digito(L),rdigitos(Ls),atom_codes(D,[L|Ls]),!. rdigitos([L|Ls]) :- digito(L),rdigitos(Ls),!. rdigitos( [] ) . %% simbolo(S) :- peek_code(C1),isSimbolo(C1),get_code(C1), peek_code(C2),simbolo2(C1,C2,S),!. simbolo2(C1,C2,S) :- table2(C1,C2,S),get_code(C2),!;atom_codes(S,[C1]),!. %% table2( 0’:, 0’=, ’:=’ ). table2( 0’<, 0’>, ’<>’ ). table2( 0’<, 0’=, ’<=’ ). CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 24 25 26 27 28 29 %% isLetOuDig(C) isLetra(C) isDigito(C) isSimbolo(C) isSeparador(C) :::::- 83 isLetra(C),!; isDigito(C),!. C>=0’a,C=<0’z,!; C>=0’A,C=<0’Z,!; C=0’_,!. C>=0’0,C=<0’9,!. C>32, \+ isLetOuDig(C),!. C> -1,C<33,!. %% -1 = eof Temos cinco predicados para classificar códigos em tipos de caracteres: • isSeparador — valores menores ou iguais a 32. • isSimbolo — valores maiores que 32, mas que não são letras e nem dı́gitos. • isDigito — valores compreendendo os dı́gitos, sabemos que os dı́gitos estão no intervalo entre 0’0 e 0’9. Esta notação zero com apóstrofo seguido de um caracter representa o código do caracter. • isLetra — define o conjunto das letras minúsculas, maiúsculas mais o ’underscore’. • isLetOuDig — define o conjunto união das letras e dı́gitos. A partir destes predicados que classificam os códigos são definidos três tipos de tokens: inteiros, palavras e sı́mbolos. A leitura de um código de caracter na fita de entrada é feita em dois passos: (1) primeiro o peek_code verifica qual é o código sem avançar o cursor da fita; se o código pertence ao conjunto testado, por exemplo, é um dı́gito, então a primitiva get_code consome o caracter. digito(L) :- peek_code(L),isDigito(L),get_code(L),!. Esta técnica implementa um léxico que olha um caracter a frente (”scanner” com um sı́mbolo de ”Lookahead”). O predicado digitos chama repetidas vezes o digito, pelo menos uma. Ele pode ser testado com uma pergunta como a segue. ?- digitos(N). | 1222 ff <enter> N = ’1222’ ?- token(T). | aaaa2222 ooo <enter> T = aaaa2222 ?- token(T). | := <enter> T = := CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 84 Na verdade todos os predicados que não fazem referencia direta as operações de arquivo (e.g., at_end_of_stream ) podem ser testados diretamente com perguntas que lêem do teclado: por exemplo, token, palavra, simbolo, etc. (tokensN usa o predicado at_end_of_stream que faz referência a um arquivo). Outro predicado que merece uma apresentação é o sı́mbolo, porque no Pascal alguns sı́mbolos são formados pela concatenação de dois caracteres simbólicos, por exemplo: >=, <> e :=. Assim, quando é reconhecido um sı́mbolo, ele é lido (get_char) em seguida consultamos uma tabela, olhando o próximo caractere da fita com peek_code: • caso o par (o lido, mais o lookahead) estejam em table2 então o lookahead também é consumido (lido); a table2 devolve a concatenação dos dois caracteres; • caso contrário, devolvemos o sı́mbolo lido, sem avançar o cursor (sem consumir o próximo caracter). Segue o código para reconhecer sı́mbolos de um ou dois caracteres. table2 codifica as combinações válidas de sı́mbolos formados por dois caracteres. simbolo(S) :- peek_code(C1),isSimbolo(C1),get_code(C1), peek_code(C2),simbolo2(C1,C2,S),!. simbolo2(C1,C2,S) :- table2(C1,C2,S),get_code(C2),!;atom_codes(S,[C1]),!. 4.2.3 Gerando palavras reservadas e números de linhas Aqui apresentamos a segunda versão deste léxico. Agora, queremos algumas melhorias no programa anterior. Segue abaixo a execução da nova versão. Note que cada token é definido pelo functor tok/3. O token tok(1,1,res(program)) diz que na linha 1 e coluna 1 foi encontrada a palavra reservada program; o token tok(6, 16, id(pos)) diz que na linha 6 coluna 16 foi encontrado o id(entificador) pos, e assim por diante. ftokens(N). tok(1, 1, res(program)) tok(1, 9, id(a1)) tok(1, 11, (;)) tok(2, 4, res(function)) tok(2,13,id(pos)) tok(2,16, ’(’) tok(2,17, id(i)) tok(2,18,:) tok(2,19, res(int)) tok(2,22, ’)’) tok(2,23,:) ... tok(6, 8, id(writeln)) tok(6, 15, ’(’) tok(6, 16, id(pos)) tok(6,19,’(’) tok(6, 20, id(’X1’)) tok(6, 22, ’)’) N = [tok(1, 1, res(program)), tok(1, 9, id(a1)), tok(1, 11, (;)), tok(..., ..., ...)|...] Para gerar os números de linha e coluna usamos dois predicados existentes no SWI-PROLOG, respectivamente line_count/2 e line_position/2. tokensN([tok(LC,LP,N)|Ns]) :- delims, line_count(file_in,LC),line_position(file_in,LP), token(N),tokensN(Ns),!. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 85 Estes predicados que retornam o número da linha e coluna podem ser facilmente programados a partir dos predicados de leitura de códigos de caracteres. Toda vez que a seqüência (13) (10) for encontrada é uma nova linha: então o valor de line_count deve ser incrementado e o valor de line_position zerado. Para contar os caracteres cria-se uma primitiva a partir da get_code para incrementar um contador de caracteres line_position, toda vez que é chamada. Por outro lado, para classificar as palavras, entre reservadas e identificadores, basta criar uma tabela de palavras reservadas. Para cada linguagem existe apenas um número finito e pequeno de palavras. Porém, o número de identificadores é potencialmente infinito (ou muito grande). Após o reconhecimento de uma palavra (P) perguntamos se ela é reservada, em caso afirmativo retornamos res(P) em caso negativo id(P). palavra( Po ) :- palavra0(N), atom_codes(P,N), (pal_res(P),Po=res(P),!;Po=id(P),!). Segue abaixo a parte do programa que foi modificada, conforme descrito acima. No inı́cio do código está a tabela de palavras reservadas. Note que para introduzir as melhorias desta versão modificamos apenas duas regras (o código que não é mostrada aqui, é o mesmo da versão anterior). 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 pal_res(program). pal_res(function). pal_res(int). pal_res(begin). pal_res(end). %% tokensN( [] ) :- at_end_of_stream,!. tokensN([tok(LC,LP,N)|Ns]) :- separadores,line_count(file_in,LC), line_position(file_in,LP), token(N),tokensN(Ns),!. token( N ) :- digitos(N),!; palavra(N),!; simbolo(N),!. separador :- peek_code(C), isSeparador(C), get_code(C),!. separadores :- separador, separadores,!; true. palavra( Po ) :- palavra0(N), atom_codes(P,N), (pal_res(P),Po=res(P),!;Po=id(P),!) palavra0([L|Ls]) :- letra(L), letOuDigs(Ls),!. letOuDigs([L|Ls]) :- peek_code(L),isLetOuDig(L),get_code(L),letOuDigs(Ls),!. letOuDigs( [] ) . digito(L) :- peek_code(L),isDigito(L),get_code(L),!. letra(L) :- peek_code(L),isLetra(L),get_code(L),!. digitos(D) :- digito(L),rdigitos(Ls),atom_codes(D,[L|Ls]),!. rdigitos([L|Ls]) :- digito(L),rdigitos(Ls),!. rdigitos( [] ) . simbolo(S) :- peek_code(C1),isSimbolo(C1),get_code(C1), peek_code(C2),simbolo2(C1,C2,S),!. simbolo2(C1,C2,S) :- table2(C1,C2,S),get_code(C2),!;atom_codes(S,[C1]),!. CAPÍTULO 4. PROGRAMAÇÃO DE GRAMÁTICAS EM PROLOG 86 Com esta abordagem podemos programar léxicos para qualquer tipo de processadores de linguagem, tais como compiladores, como solicitado no exercı́cio que segue. Exercı́cio 4.2.5 Usando a técnica apresentada, nesta seção, escreva um léxico para a linguagem Pascal. Procure um manual de uma versão da linguagem que contenha a descrição das palavras reservadas, dos operadores (+ − ∗/ :=<> ...) e dos tokens (identificadores, inteiros, reais e outras representações para números). Capı́tulo 5 Programação de autômatos 5.1 Métodos de codificação de reconhecedores Neste capı́tulo implementamos os métodos de codificação de gramáticas regulares(com goto, iterativo, e recursivo), ou autômatos, nas linguagens Pascal, Java e C++. Em Java só podemos codificar de duas formas pois não existe um comando goto. As linguagens testes são {a∗ b∗ } ou {a∗ b+ }, conforme for conveniente. Para codificar a versão com goto o ideal é usarmos uma gramática escrita na forma GLUD (ver capı́tulo sobre gramáticas regulares), como segue: 1 2 a --> [a] a | [b] b | [] b --> [b] b | [] A versão iterativa, basicamente, codifica a expressão regular {a∗ b+ }; o uso desta gramática, onde pelo menos um b é obrigatório, mostra como codificar um terminal obrigatório. E, a versão recursiva codifica a gramática r abaixo; também, poderia ser a gramática GLUD descrita acima. 1 2 3 r --> a b a --> [a] a | [] b --> [b] b | [] Abaixo implementamos estes três métodos para cada uma das linguagens iniciando por C++. Por fim, na última seção deste capı́tulo mostramos como contabilizar os tempos (usando funções de leitura do relógio da máquina) para fazer comparações de desempenho entre os diferentes métodos combinados com as diferentes linguagens formais nas diferentes linguagens de programação. 5.1.1 Versão em C++ Na versão em C(C++) podemos usar uma macro #define visando otimizar o código, pois a função xp pode ser escrita como uma macro com corpo (s[p]==c) e, de forma 87 CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 88 similar, a função np como uma macro de corpo (++p). Em C++, todo valor acima de zero equivale a um valor verdadeiro – primeiro incrementamos o p, pois ele é inicialmente zero (um string em C++ inicia na posição zero). 1 2 3 4 5 // auto_goto.cpp #include <iostream.h> #include <stdio.h> #include <string.h> #include <windows.h> 6 7 8 9 10 11 12 13 14 15 // ***begin PRIMITIVAS char s[]=""; int p; void le_palavra() {p=0; cout << "digite uma palavra:\n"; s << cin << "@"; } bool xp(char c) {return(s[p]==c);} bool np() {p++; return(true);} // ou {return(++p)} // // #define xp(c) (s[p]==c) // #define np() (++p) // ***end PRIMITIVAS 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void main() // simula uma gramática GLUD { le_palavra(); { p=0; goto a; a: if (xp(’a’) && np()) goto a; else if (xp(’b’) && np()) goto b; else goto final; b: if (xp(’b’) && np()) goto b; else goto final; final: if (xp(’@’)) cout << "Reconheceu até: " << p; else cout << "Erro na posiç~ ao: " << p; }} No programa abaixo temos a mesma gramática codificada como um autômato iterativo. Com o objetivo de economizar espaço, nos programas que seguem deixamos de repetir o preâmbulo que inclui as assertivas #include e as funções primitivas que implementam a fita de entrada. 1 2 3 4 // auto_iter.cpp // simula uma express~ ao regular a* b+ = a* bb* // includes & PRIMITIVAS CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 5 6 7 8 9 10 89 void main() { le_palavra(); { p=0; while xp(’a’) && np(); if (xp(’b’) && np()) { while xp(’b’) && np(); 11 if (xp(’@’)) cout << "Reconheceu até: " << p; else cout << "Erro na posiç~ ao: " << p; 12 13 14 } else cout << "Erro na posiç~ ao: " << p; 15 16 17 }} Por fim temos a versão do autômato com regras recursivas. Normalmente, por questão de eficiência as versões não recursivas são preferidas, porém, como veremos numa análise comparativas destes três métodos, o tempo não é tão diferente (tipicamente o dobro) portanto o programador pode escolher qualquer uma das abordagens, ciente das suas limitações. 1 2 3 // auto_recur.cpp // linguagem a*b* // includes & PRIMITIVAS 4 5 6 7 bool A() { return(xp(’a’) && np() && A() || true); } bool B() { return(xp(’b’) && np() && B() || true); } bool R() { return(A() && B()); } 8 9 10 11 12 13 14 15 void main() { le_palavra(); { p=0; if (R() && xp(’@’)) cout << "Reconheceu até: " << p; else cout << "Erro na posiç~ ao: " << p; }} O método recursivo é o mais próximo da notação gramatical. Por isso, podemos afirmar que ele é o mais declarativo. O método do goto é bem próximo da notação gráfica do automato. E o método iterativo é mais próximo das espressões regulares. Exercı́cio 5.1.1 Modifique uma ou mais linhas da versão C++ do programa com goto, para implementar a linguagem a∗ b+ . Exercı́cio 5.1.2 Modifique uma ou mais linhas da versão C++ do programa iterativo, para implementar a linguagem a∗ b∗ . CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 90 Exercı́cio 5.1.3 Modifique uma ou mais linhas da versão C++ do programa recursivo, para implementar a linguagem a∗ b+ . 5.1.2 Versão em Pascal Segue abaixo os códigos em Pascal. Note que um string em Pascal inicia na posição um. 1 2 program auto_goto; uses crt,WinDos; 3 4 5 6 7 8 9 10 11 12 13 14 {--begin PRIMITIVAS -----} type bool=boolean; const fim = ’@’; var s:string; s := ’’; var p:integer; procedure le_palavra; begin clrscr; p:=1; write(’Digite uma palavra:’); readln(s); s:=s+fim; end; function xp(c: char):bool; begin x:= s[p]=c; end; function np :bool; begin p:= p+1; n:=true; end; {--end PRIMITIVAS---} 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 label a, b, final; begin le_palavra; begin p:=1; goto a; a: if xp(’a’) and np then goto a else if xp(’b’) and np then goto b else goto final; b: if xp(’b’) and np then goto b else goto final; final: if xp(fim) then writeln(’Reconheceu até: ’, p) else writeln(’Erro na posicao: ’, p); end; end. As versões em Pascal em C++ são quase idênticas, pois as duas linguagens possuem as mesmas construções, com pequenas variações na sintaxe. Inclusive o número de linhas no corpo principal do programa é o mesmo. CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 1 2 3 4 91 program auto_iter; {- linguagem a*b+ -} uses crt,WinDos; {- include PRIMITIVAS -} 5 6 7 8 9 10 11 12 13 14 15 16 17 18 begin le_palavra; begin p:=1; while xp(’a’) and np do; if xp(’b’) and np then begin while xp(’b’) and np do if xp(fim) then writeln(’Reconheceu até:’, p) else writeln(’Erro na posiç~ ao:’, p); end else writeln(’Erro na posiç~ ao: ’,p); 19 20 21 end; end. Num programa recursivo sobre gramáticas, em Pascal, é bom declarar todas funções recursivas com a diretiva forward (significando que a função será definida mais adiante no texto). Isto facilita a definição das funções em qualquer ordem dentro do texto do programa, sem causar erros de sintaxe. 1 2 program auto_recur; {- include PRIMITIVAS -} 3 4 5 6 7 8 9 function function function function function function R: A: B: R: A: B: bool; bool; bool; bool; bool; bool; forward; forward; forward; begin R:= A and B; end; begin A:= xp(’a’) and np and A or true; end; begin B:= xp(’b’) and np and B or true; end; 10 11 12 13 14 15 16 begin le_palavra; begin p:=1; if R and xp(fim) then writeln(’Reconheceu até:’, p) else writeln(’Erro na posiç~ ao:’, p); CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 17 18 92 end; end. Exercı́cio 5.1.4 Modifique uma ou mais linhas da versão Pascal do programa com goto, para implementar a linguagem a∗ b+ . Exercı́cio 5.1.5 Modifique uma ou mais linhas da versão Pascal do programa iterativo, para implementar a linguagem a∗ b∗ . Exercı́cio 5.1.6 Modifique uma ou mais linhas da versão Pascal do programa recursivo, para implementar a linguajem a∗ b+ . 5.1.3 Versão em Java Segue abaixo as versões em Java. Java não tem o comando goto. Portanto, temos só duas implementações: iterativo e recursivo. 1 2 3 4 5 6 7 8 9 10 import java.util.*; public class IfWhile { // ---begin PRIMITIVAS private static String s; private static int p; // private static void le_palavra() {p=0;s="aaaaaaabbbbbbbbbb@";} private static boolean xp(String c){return(s.substring(p,p+1).equals(c));} private static boolean np() {p++;return(true);} // ---end PRIMITIVAS 11 12 13 14 15 16 17 18 19 20 21 22 public static void main(String args[]) { String fim = "@"; le_palavra(); { p=0; while (xp("a") && np()); if (xp("b") && np() ) { while (xp("b") && np()); if (xp(fim)) System.out.println("Reconheceu até: " + (p+1)); else System.out.println("Erro na posiç~ ao: " + (p+1)); }}}} Esta versão do método recursivo em Java usa as mesmas funções primitivas do método iterativo. CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 93 1 2 3 4 5 6 import java.util.*; public class Recursao {private static boolean R() {return(A() && B()); } private static boolean A() {return(xp("a") && np() && A() || true);} private static boolean B() {return(xp("b") && np() && B() || true);} 7 8 9 10 11 12 13 14 15 public static void main(String args[]) { String fim = "@"; le_palavra(); { p=0; if (R() && xp(fim)) System.out.println("Reconheceu até: ", p); else System.out.println("Erro na posiç~ ao: " + (p+1)); }}} Exercı́cio 5.1.7 Modifique uma ou mais linhas da versão Java do programa com iterativo, para implementar a linguagem a∗ b∗ . Exercı́cio 5.1.8 Modifique uma ou mais linhas da versão Java do programa com recursiva, para implementar a linguajem a∗ b+ . 5.2 Contagem de tempo O programa abaixo implementa uma versão de autômato com goto em C++, contanto o tempo de execução. A constante max = 10000 define o número de vezes que devemos rodar o programa principal para podermos ter um valor de tempo em milisegundos visı́vel (diferente de zero). Dois procedimentos savetime e showtime são usados respectivamente antes e depois do corpo principal do programa. É importante salvar o tempo somente após a leitura ou inicialização da fita de entrada. Para comparar dois algoritmos, em termos de performance, devemos usar a mesma fita de entrada além de codificar as produções da gramática na mesma ordem, nas diferentes linguagens. Portanto, é melhor inicializar uma fita de comprimento 100. Porém, para testar se o algoritmo está funcionando, é melhor poder ler fitas menores digitadas diretamente no teclado. Na versão em C(C++) podemos usar a macro #define para otimizar a performance, como comentamos anteriormente. Além disso, devemos comentar a linha que escreve se reconheceu ou não, pois sabemos que a fita será reconhecida. Uma escrita na tela anularia totalmente a leitura do tempo de processamento. CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 5.2.1 94 Versão em C++ Segue uma versão com contagem de tempo em C++. No final da fita de entrada acrescentamos um carácter "@" (como token) sinalizando fim da fita. Após o reconhecimento da sentença testamos se é o fim da fita. Se ainda não estamos no fim da fita é uma situação de erro. 1 2 // auto_goto.cpp // gramática regular a* b* 3 4 5 6 7 #include #include #include #include <iostream.h> <stdio.h> <string.h> <windows.h> 8 9 10 11 12 DWORD start, tempo; void savetime() {start = ::GetTickCount();} void showtime() {tempo = ::GetTickCount() - start; cout << "Tempo gasto 1/1000 seg:" << tempo;} 13 14 15 // ***begin PRIMITIVAS int p; char s[]="aaaaaaaaaaaaaaaaaaaaa...bbbbbbbbbbbbbb@"; // 50as e 50bs 16 17 18 19 void le_palavra() { p=0; } bool xp(char c) {return(s[p]==c);} bool np() {p++; return(true);} // ou {return(++p)} pois p começa em 0 20 21 22 // #define xp(c) (s[p]==c) // #define np() (++p) 23 24 // ***end PRIMITIVAS 25 26 const max=10000; int i; 27 28 void main() 29 30 31 32 33 34 35 36 37 { le_palavra(); savetime(); for(int i=0;i<=max;i++) { p=0; goto a; a: if (xp(’a’) && np()) goto a; else if (xp(’b’) && np()) goto b; else goto final; CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS b: if (xp(’b’) && np()) goto b; else goto final; final: if (xp(’@’)) ;// cout << "Reconheceu até: " << p; else cout << "Erro na posicao: " << p; 38 39 40 41 42 } showtime(); 43 44 45 95 } 5.2.2 Versão em Pascal Em Pascal o tempo é medido a partir da função GetTime(th,tm,ts,tc) onde estas variáveis trazem os tempos em hora, minuto, segundo e centésimo de segundo. Assim quando é feita a contagem do tempo devemos multiplicar os centésimos de segundos por 10; os segundos por 1000; e, os minutos por 60000; com isso obtemos um único valor normalizado. 1 2 program auto_goto; uses crt,WinDos; 3 4 5 6 7 8 9 10 11 12 {--begin TEMPO-----------} var th,tm2,tm,ts2,ts,tc2,tc: Word; procedure saveTime; begin GetTime(th,tm, ts, tc); end; procedure showTime; begin GetTime(th,tm2,ts2,tc2); writeln( ’Tempo 1/1000 seg:’, (tm2-tm)*60000+(ts2-ts)*1000+(tc2-tc)*10); Repeat until keypressed; end; {--end TEMPO------------} 13 14 15 16 17 18 {--begin PRIMITIVAS ----} type bool=boolean; const fim = ’@’; var s:string; var p:integer; 19 20 21 22 23 procedure le_palavra; begin p :=1; s := ’aaaaaaaaaaaaaaaaaaaaaa....bbbbbbbbbbbbbbbbbbbbbbbbbb@’; {50a 50b} end; 24 25 26 27 function xp(c: char):bool; begin x:= s[p]=c; end; function np :bool; begin p:= p+1; n:=true; end; {--end PRIMITIVAS ----} CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 96 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 begin const max=10000; var i :integer; label a, b, final; le_palavra; saveTime; for i := 0 to max do begin p:=1; goto a; a: if xp(’a’) and np then goto a else if xp(’b’) and np then goto b else goto final; b: if xp(’b’) and np then goto b else goto final; final: if xp(fim) then {writeln(’Reconheceu até: ’, p)} else writeln(’Erro na posicao: ’, p); end; showTime; end. 5.2.3 Versão em Java A linguagem Java possui, como o C++, uma função que lê o relógio em milisegundos, currentTimeMillis; isto facilita a programação da contagem do tempo. 1 import java.util.*; 2 3 4 5 6 7 public class IfWhile { private static long start, tempo; private static void savetime() { start = System.currentTimeMillis(); } 8 9 10 11 private static void showtime() { tempo = System.currentTimeMillis() - start; System.out.println("Tempo gasto 1/1000 segundos: " + tempo);} 12 13 14 15 // ---begin PRIMITIVAS private static String s; private static int p; 16 17 private static void le_palavra() CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS 18 19 { 97 p=0; s="aaaaaaaaaaaaaaaaaaaaaaaaaaaaa...bbbbbbbbbbbbbbbbbbbbbbbb@"; } 20 21 22 private static boolean xp(String c) { return(s.substring(p,p+1).equals(c)); } 23 24 25 26 private static boolean np() { p++;return(true); } // ---end PRIMITIVAS 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public static void main(String args[]) { int max = 10000; le_palavra(); savetime(); for (int i=0;i<=max;i++) { p=0; while (xp("a")) np(); if (xp("b")) { while (xp("b")) np(); if (xp("@")) ;// System.out.println("Reconheceu até:", p); else System.out.println("Erro na posicao: " + (p+1)); }} showtime(); }} Vimos um exemplo de programa de contagem de tempo para cada linguagem. Alguns destas funções dependem de certas bibliotecas (ou unidades) de certos compiladores. Caso elas não estejam disponı́veis, o leitor deverá buscar no seu compilador outras funções de leitura do tempo que sejam similares. Segue abaixo um mini projeto para fazer uma análise comparativa entre os diferentes métodos para as diferentes linguagens. Exercı́cio 5.2.1 Projeto: Faça um programa que gera na saı́da uma tabela com um comparativo para os tempos das diferentes versões de programas apresentados. Como está esquematizado abaixo (os valores de tempos são apenas ilustrativos). Após obter os tempos em seu computador, e com a sua experiência, responda: • Qual o método (goto, iterativo ou recursivo) mais eficiente independente de linguagem de programação? • Qual é a linguagem de programação mais eficiente para a codificação destes métodos? • Qual é o método mais simples de ser codificado ? CAPÍTULO 5. PROGRAMAÇÃO DE AUTÔMATOS Num Pentium VII 5Gmhz, para fitas de 100 caracteres, para um ciclo de 10000 vezes, tempos em milisegundos: em C++ com goto : 300 ms & com #define : 260 ms em C++ iterativo : 270 ms & com #define : 240 ms em C++ recursivo : 340 ms & com #define : 280 ms em Pascal com goto : 400 ms em Pascal iterativo : 470 ms em Pascal recursivo : 440 ms em Java iterativo : 10470 ms em Java recursivo : 10440 ms 98 Capı́tulo 6 Programação de Gramáticas Livres de Contexto Neste capı́tulo apresentamos a programação de gramáticas livres de contexto nas três linguagens alvo: Pascal, Java e C++. São apresentados três métodos de programação: o primeiro para uma gramática LL(1) fatorada, implementando um reconhecedor descendente recursivo sem retrocesso (DRSR). Os outros dois métodos implementam um reconhecedor descendente recursivo com retrocesso: (1) de modo procedural salvando o ponteiro sobre a fita de entrada e (2) simulando a implementação de uma gramática de cláusulas definidas (DCG) – também chamado de método da costura. 6.1 Versão em Java A linguagem para testes é {an bn }. Segue a implementação da versão fatorada pela gramática abaixo. 1 2 l --> [a], l1. l1 --> l, [b] | [b]. Aqui utilizaremos as duas variantes do método descendente recursivo (com e sem recursividade), que já foi apresentado no capı́tulo anterior. Segue a versão DRSR (sem retrocesso). 1 2 3 4 5 6 7 import java.util.*; public class DRSR { // include PRIMITIVAS // private static boolean L() {return(xp("a") && np() && L1());} private static boolean L1() 99 CAPÍTULO 6. PROGRAMAÇÃO DE GRAMÁTICAS LIVRES DE CONTEXTO 100 {return((L() && xp("b")) && np()) || (xp("b") && np());} 8 // public static void main(String args[]) { le_palavra(); { p=0; if (L() && xp("@")) System.out.println("Reconheceu até: " + p ); else System.out.println("Erro na posicao: " + (p+1)); }}} 9 10 11 12 13 14 15 16 Para o método DRCR, usamos a versão, dada abaixo, não fatorada da gramática. 1 2 l --> [a], l, [b]. l --> [a], [b]. Na implementação desta versão da gramática, devemos nos preocupar em retroceder o ponteiro sobre a fita de entrada toda vez que a primeira alternativa falha, quando é tentada a próxima alternativa. O ideal é sempre fatorarmos a gramática. Porém, existem situações que a fatoração é difı́cil especialmente para gramáticas de atributos, onde durante a fatoração somos obrigados a reescrever as equações semânticas. 1 2 import java.util.*; public class DRCR_salvando_p 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { // includes & PRIMITIVAS private static boolean L() { int salvap = p; if(xp("a") && np() && xp("b") && np()) return(true); else { p = salvap; if(xp("a") && np() && L() && xp("b") && np()) return(true); else return(false); }} public static void main(String args[]) { le_palavra(); { p=0; if (L() && xp("@")) System.out.println("Reconheceu até: " + p); else System.out.println("Erro na posicao: " + (p+1)); }}} CAPÍTULO 6. PROGRAMAÇÃO DE GRAMÁTICAS LIVRES DE CONTEXTO 101 Em Java, o método da costura não tem uma codificação limpa como em Pascal e C++, pois Java não permite através de um parâmetro retornar um valor inteiro (por exemplo, i, i1); termos que retornar um objeto (int[] i), onde o valor inteiro está na primeira posição do vetor, i[0]. Uma valor inteiro i pode entrar na função; apesar disso, preferimos trabalhar com outro vetor para facilitar as atribuições. No método da costura não é utilizado um ponteiro global, sobre a fita de entrada. O ponteiro é implementado como um parâmetro nas funções. Assim, ele pode retroceder automaticamente. Para isso a função xp, substituindo a função x, é utilizada. Se o valor casa então ela aumenta uma posição no valor local do ponteiro sobre a fita de entrada. Quando acontece um retrocesso, temos um novo problema que é como indicar a posição de um erro. O ideal é mostar a posição mais avançada sobre a fita de entrada que já foi utilizada. Para isso é utilizada uma variável global pe (posição do erro) que é atualizada sempre que avançamos sobre um token da fita de entrada. Quando ocorre um retrocesso pe se mantém, com o uso do comando: if (pe[0] < o[0]) {pe = o}; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import java.util.*; public class DRCR_com_costura {// include PRIMITIVAS private static int[] pe={o}; // private static boolean xp1(String c, int[] i, int[] o) { o[0] = i[0] + 1; if (pe[0] < o[0]) {pe = o}; return(s.substring(i[0],i[0]+1).equals(c[0]));} private static boolean L(int[] i, int[] o) { int[] i1 = {0}; int[] i2 = {0}; // return( xp1("a",i,i1) && L(i1,i2) && xp1("b",i2,o) || xp1("a",i,i1) && xp1("b",i1,o));} // public static void main(String args[]) { int[] o = {0}; int[] o1 = {0}; le_palavra(); { pe[0]=0; int[] num = {0}; if (L(num,o) && xp1("@",o,o1)) System.out.println("Reconheceu até: " + o); else System.out.println("Erro na posicao: " + (pe[0]+1)); }}} 6.2 Versão em C++ Segue a versão em C++ da gramática sem retrocesso (DRSR). A codificação segue os mesmos princı́pios da versão em Java. CAPÍTULO 6. PROGRAMAÇÃO DE GRAMÁTICAS LIVRES DE CONTEXTO 102 1 2 3 4 5 6 7 8 9 10 11 12 // DRSR // includes & PRIMITIVAS // bool L() { return(xp(’a’) && np() && L1()); bool L1(){ return((L() && xp(’b’)) && np()) void main() { le_palavra(); { p=0; if (L() && xp(’@’)) cout << "Reconheceu até: " << else cout << "Erro na posicao: " << }} } || (xp(’b’) && np()); } p ; (p+1); Segue a solução em C++ da versão que salva o ponteiro para executar retrocesso (DRCR/salvando). 1 2 // DRCR salva P // includes & PRIMITIVAS 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 bool L() { int salvap = p; if(xp(’a’) && np() && xp(’b’) && np()) return(true); else { p = salvap; if(xp(’a’) && np() && L() && xp(’b’) && np()) return(true); else return(false); }} void main() { le_palavra(); { p=0; if (L() && xp(’@’)) cout << "Reconheceu ate: " << p ; else cout << "Erro na posicao: " << (p+1); }} Exercı́cio 6.2.1 Modifique a solução que salva o p em C++, para indicar de forma correta a posição do erro, utilizando uma variável pe. Teste para algumas palavras inválidas. A solução abaixo, do método DRCR, possui uma variável global pe indicando a posição do erro. CAPÍTULO 6. PROGRAMAÇÃO DE GRAMÁTICAS LIVRES DE CONTEXTO 103 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // DRCR com costura int pe = 0; bool xp1(char c, int i, int &o) { o = i + 1; if (pe < o){pe = o}; return(s[i]==c); } // bool L(int i, int &o) { int i1, i2; return( xp1(’a’,i,i1) && L(i1,i2) && xp1(’b’,i2,o) || xp1(’ba,i,i1) && xp1(’b’,i1,o) ); } void main() { int o, o1 = 0; le_palavra(); { pe =0; if (L(1,o) && xp1(fim,o,o1)) cout << "Reconheceu ate: " << p; else cout << "Erro na posicao: " << pe; }} 6.3 Versão em Pascal Nesta seção temos os três métodos codificados no Pascal. Segue o método DRSR. 1 2 3 4 5 program DRSR; uses crt,WinDos; ... function L:bool; begin L:=(xp(’a’) and np and L1()); end; function L1:bool; begin L1:=(L() and xp(’b’) and np()) or (xp(’b’) and np()); end; 6 7 8 9 10 11 12 13 14 15 begin le_palavra; begin p:=1; if xp(L and xp(fim)) then writeln(’Reconheceu ate: ’, p) else writeln(’Erro na posicao: ’, p); end; end. Segue abaixo o método salva ponteiro. Este método salvando p é procedural, enquanto que o método da costura é declarativo (similar as produções). CAPÍTULO 6. PROGRAMAÇÃO DE GRAMÁTICAS LIVRES DE CONTEXTO 104 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 program DRCR_salvando_p; uses crt,WinDos; ... function L:bool; var salvap: integer; begin salvap:=p; if (xp(’a’) and np and xp(’b’) and n) then L:=true else begin p:=salvap; if (xp(’a’) and np and L and xp(’b’) and n) then L:=true else L:=false; end; end; begin le_palavra; begin p:=1; if (L and xp(fim)) then {writeln(’Reconheceu’)} else writeln(’Erro na posicao: ’,p); end; end. Segue abaixo o método da costura (DRCR). Aqui implementamos o pe, para salvar a posição do erro. 1 2 3 4 5 6 program DRCR_com_costura; uses crt,WinDos; ... var pe:integer; function xp1(c: char; i:int; var o:int):bool; begin o:=i+1; if pe<o then pe:=o; x1:=(s[o]=c); end; 7 8 9 10 11 12 13 function L(i:int; var o:int):bool; var i1: int; i2: int; begin L:= xp1(’a’,i,i1) and L(i1,i2) and xp1(’b’,i2,o) or xp1(’a’,i,i1) and xp1(’b’,i2,o); end; 14 15 16 begin le_palavra; CAPÍTULO 6. PROGRAMAÇÃO DE GRAMÁTICAS LIVRES DE CONTEXTO 105 17 18 19 20 21 22 23 begin pe := 0; if (L(num,o) and xp1(fim,o,o1)) then {writeln(’Reconheceu até: ’,pe)} else writeln(’Erro na posicao: ’,pe); end; end. Exercı́cio 6.3.1 Modifique a solução que salva o ponteiro em Pascal para indicar de forma correta a posição do erro, utilizando uma variável pe. Teste para algumas palavras inválidas, tais como: aa@, bb@, abb@, aab@, etc. Teste estas palavras também com a versão sem o pe. Qual é a importância do pe. Exercı́cio 6.3.2 Projeto: Faça um programa que gera na saı́da uma tabela com um comparativo para os tempos das diferentes versões de programas apresentados. Como está esquematizado abaixo (os valores de tempos são apenas ilustrativos). Após obter os tempos em seu computador, e com a sua experiência, responda: • Qual o método mais eficiente independente de linguagem de programação? • Qual é a linguagem de programação mais eficiente para a codificação destes métodos para gramáticas? • Qual é o método mais simples de ser codificado ? Num Pentium VII 5Gmhz, variacoes do metodo descendente recursivo (DR) para fitas de 100 caracteres, para um ciclo de 10000 vezes, tempos em milisegundos: em C++ DRSR : 300 ms & com #define : em C++ DRCR (salva p) : 270 ms & com #define : em C++ DRCR (costura) : 340 ms & com #define : em Pascal DRSR : 400 ms em Pascal DRCR (salva p) : 470 ms em Pascal DRCR (costura) : 440 ms em Java DRSR : 10470 ms em Java DRCR (salva p) : 10440 ms em Java DRCR (costura) : 10440 ms 260 ms 240 ms 280 ms Capı́tulo 7 Programação de Gramáticas de Atributos Como exemplo de programação de gramáticas de atributos, codificamos inicialmente a gramática {an bn cn } a partir da gramática livre de contexto {a∗ b∗ c∗ }. Um atributo é usado para contar o número de as, bs e de cs. Uma equação é usada para forçar a igualdade. Segue a DCG correspondente ao programa. 1 2 3 4 5 6 7 r --> a(N1) a( 0) b(N1) b( 0) c(N1) c( 0) a(X), b(Y), c(Z), {X=Y, Y=Z}. --> [a],a(N), {N1:=N+1}. --> []. --> [b],b(N), {N1:=N+1}. --> []. --> [c],c(N), {N1:=N+1}. --> []. O atributo N1 é do tipo sintetizado, os valor do nó N filho somado com um é atribuı́do ao nó pai N1 (se construirmos a árvore sintática). 7.1 Versão em Pascal O atributo sintetizado é codificado, em cada função, pela variável n. Como em Pascal podemos escrever sobrepor o valor de uma variável, por exemplo, em n:=n+1 , então não é necessário utilizar uma variável n1. A atribuição dentro da expressão lógica é implementada pela função attr, que avalia uma expressão devolvendo o valor resultante. Uma gramática de atributos é implementada sobre uma gramática livre de contexto. Abaixo, usamos o método DRSR é usado para codificar a gramática livre de contexto. 106 CAPÍTULO 7. 1 PROGRAMAÇÃO DE GRAMÁTICAS DE ATRIBUTOS 107 program DRSR_GA; 2 3 uses crt, WinDos, fita; 4 5 6 function attr(var v:integer;exp:integer):bool; begin v:=exp; attr:=true; end; 7 8 9 10 function A(var n: integer):bool; function B(var n: integer):bool; function C(var n: integer):bool; 11 12 13 14 15 16 17 function A; begin A:=(x(’a’) and np and A(n) and attr(n,n+1) or true and attr(n,0)); end; function B; begin B:=(x(’b’) and np and B(n) and attr(n,n+1) or true and attr(n,0)); end; function C; begin C:=(x(’c’) and np and C(n) and attr(n,n+1) or true and attr(n,0)); end; 18 19 20 21 22 23 24 function S:bool; var na,nb,nc: integer; begin na :=0; nb:=0; nc:=0; S:=(A(na) and B(nb) and C(nc) and (na=nb) and (nb=nc)); end; 25 26 27 28 29 30 31 32 33 34 35 begin le_palavra; begin p[0]:=0; if S and x(fim) then write(’Reconheceu ate:’, p) else if x(fim) then writeln(’Provavel erro nos valores de n’) else writeln(’Erro na posicao: ’,p+1); end; end. No corpo principal do programa temos dois comandos condicionais: o primeiro testa se a palavra foi reconhecida, enquanto que o segundo comando testa se sintaticamente chegamos até o fim, porém falhou uma das equações. 7.2 Versão em C(C++) Segue o mesma gramática de atributos em C++. CAPÍTULO 7. 1 2 PROGRAMAÇÃO DE GRAMÁTICAS DE ATRIBUTOS // DRSR GA // includes & PRIMITIVAS 3 4 5 char s[]="aaaaaaaa...bbbbbbbb...ccccccc@"; int p[] = {0}; 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 bool attr(int &v,int exp) { v= exp; return(true);} bool A(int &n) { return(x(’a’) && np() && A(n) && attr(n,n+1) || true && attr(n,0)); } bool B(int &n) { return(x(’b’) && np() && B(n) && attr(n,n+1) || true && attr(n,0)); } bool C(int &n) { return(x(’c’) && np() && C(n) && attr(n,n+1) || true && attr(n,0)); } bool S() { int na, nb, nc; return(A(na) && B(nb) && C(nc) && (na==nb) && (nb==nc)); } void main() { le_palavra(); { p[0]=0; if (S() && x(’@’)) cout << "Reconheceu ate:", p; else if (x(fim)) cout << "Erro nos valores de n" << p; else cout << "Erro na posicao: " << p; } 7.3 Versão em Java Idem, agora, em Java. 1 import java.util.*; 2 3 4 5 public class DRSR_GA { // includes & PRIMITIVAS 6 7 8 private static String s; private static int[] p = {0}; 9 10 private static void le_palavra() 108 CAPÍTULO 7. PROGRAMAÇÃO DE GRAMÁTICAS DE ATRIBUTOS 109 { s = "aaaaaaaaaaaaaaaaaaaaaa..bbbbbbbbb...ccccccccccccccccccc@"; } 11 12 private static boolean x(String c) { return(s.substring(p[0],p[0]+1).equals(c)); } 13 14 15 private static boolean np() { p[0]++; return(true); } 16 17 18 private static boolean attr(int[] v,int exp) { v[0] = exp; return(true); } 19 20 21 private static boolean A(int[] n) { return(x("a") && np() && A(n) && attr(n,n[0]+1) || true && attr(n,0)); } 22 23 24 25 private static boolean B(int[] n) { return(x("b") && np() && B(n) && attr(n,n[0]+1) || true && attr(n,0)); } 26 27 28 29 private static boolean C(int[] n) { return(x("c") && np() && C(n) && attr(n,n[0]+1) || true && attr(n,0)); } 30 31 32 33 private static boolean S() { int[] na = {0}; int[] nb = {0}; int[] nc = {0}; return(A(na) && B(nb) && C(nc) && (na[0]==nb[0]) && (nb[0]==nc[0])); } public static void main(String args[]) { le_palavra(); { p[0]=0; if (S() && x("@")) System.out.println("Reconheceu ate:", p); else if (x(fim)) System.out.println("Provavel erro nos valores de n"); else System.out.println("Erro na posicao: " + (p[0]+1)); 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 }} Exercı́cio 7.3.1 Programe uma versão da gramática de atributos que segue, para a linguagem {an bn }. Use o método da costura; portanto, são utilizados dois parâmetros para o método da costura mais um parâmetro para contar o número de as e bs. Faça nas três linguagens de programação. CAPÍTULO 7. 1 2 3 4 5 r --> a(N1) a(N1) b(N1) b(N1) PROGRAMAÇÃO DE GRAMÁTICAS DE ATRIBUTOS a(N),b(M), --> [a],a(N), --> [a], --> [b],b(N), --> [b], 110 {N=M}. %equaç~ ao {N1:=N+1}. {N1:=1 }. {N1:=N+1}. {N1:=1 }. Exercı́cio 7.3.2 Projeto: Faça um programa que gera na saı́da uma tabela com um comparativo para os tempos das diferentes versões de programas apresentados. Como está esquematizado abaixo (os valores de tempos são apenas ilustrativos). Após obter os tempos em seu computador, e com a sua experiência, responda: • Qual é a linguagem de programação mais eficiente para a codificação de uma GA? • Qual é a versão mais simples e mais complicada? Num Pentium VII 5Gmhz, variacoes do metodo descendente recursivo (DR) para fitas de 100 caracteres, para um ciclo de 10000 vezes, tempos em milisegundos: em C++ GA : 300 ms em Pascal GA : 400 ms em Java GA : 10440 ms Capı́tulo 8 Exerı́cios e Projetos de Programação Introdução Aqui apresentamos alguns exercı́cios e mini-projetos para exercitar as técnicas apresentadas nos capı́tulos anteriores. O ideal é apresentar uma especificação para cada exercı́cio ou projeto: cada produção de uma gramática deve resultar numa linha de um programa que a implementa. Com isso podemos facilmente alterar a especificação do problema (a gramática) e em seguida modificar o código da implementação. 8.1 Programação de Gramáticas Exercı́cio 8.1.1 (MINI-PROJETO) Abaixo temos uma descrição para identificadores, inteiros (também na notação hexadecimal) e reais. Com base nesta descrição responda as perguntas abaixo. 1. Faça as produções para descrever os números haxadecimais; 2. Faça expressões regulares para: (1) identificadores; (2) inteiros; (3) reais; (4) hexadecimais; 3. Faça um autômato que possa reconhecer todos os tokens; 4. Programe o autômato, com o método GOTO; 5. Programe as expressões regulares com o método IF-WHILE; integre todos os fragmentos de programa num código único; 6. Programe a gramática regular com o método DRSR – note que as produções devem estar fatoradas; 1 2 3 %% aaa123e3 aaaa | 122 | 0x23FF 0xaaaa | 123.3 10.20e23 token --> ident | inteiro | inteiroHexa | real ident --> letra letraOuDigito 111 CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 4 5 6 7 8 9 10 11 12 112 letra --> a..z A..Z digito --> 0..9 letraOuDigito --> letra | digito inteiro --> 0,1, 2, 3 ... inteiroHexa --> 0x00, 0x01, ...0xFF..0xFFFFFF... real --> inteiro [.] inteiro real --> inteiro [.] inteiro exp sinal inteiro exp --> e | E sinal --> + | Exercı́cio 8.1.2 (MINI-PROJETO) Especifique como autômato ou como expressões regulares e depois programe (usando o método com GOTO ou IF-WHILE) o reconhecimento de comentários em C++ que são de dois tipos: (1) dupla // até o fim de linha e (2) múltiplas linhas com abre e fecha /* */ como exemplificado abaixo indicado abaixo. 1 2 3 void f() { cout << 10; 4 5 6 }; /* este eh um comentario */ // funcao vazia /* comentario de duas linhas esta eh a segunda */ // este tambem eh Exercı́cio 8.1.3 Programe os comentários da linguagem C++, de forma que possam vir aninhados. Use o método DRSR. É necessária uma gramática livre de contexto. Eles podem ser de dois tipos: (1) dupla // até o fim de linha e (2) múltiplas linhas com abre e fecha /* */ como exemplificado abaixo. Um abre comentário sem um fecha correspondente é erro. 1 2 3 void f() { cout << 10; 4 5 6 }; /* este /* eh um */ comentario */ // funcao vazia /* comentario // de duas linhas esta eh /* a segunda */ */ // este tambem eh Exercı́cio 8.1.4 Programe a GA dada abaixo, com atributos herdados e sintetizados, numa linguagem imperativa. 1 2 3 4 5 %% L-GA em DCG b0( M )--> {AC:=0}, b(AC,M). b(AC, M )--> [b], {AC1:=AC+1}, b(AC1,M). b(ACi,ACo)--> [], {ACo:=ACi}. %% AC é um ACumulador CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 113 Exercı́cio 8.1.5 Programe a GA dada abaixo, na notação de Knuth, com atributos herdados e sintetizados, numa linguagem imperativa. n bs1 bs b b --> --> --> --> --> bs b bs2 [] 0 1 {bs.N {bs2.N {bs.R {b.B {b.B := := := := := 0; n.R := bs.R} 2*bs1.N+b.B; bs1.R := bs2.R} bs.N} 0} 1} Exercı́cio 8.1.6 Programe a DCG abaixo como uma GA numa linguagem imperativa. ?-move(F,D,[esq,dir,esq,frente,frente,dir,dir,pare],[]). F= 0+0+0+1+1+0+0+0, D=-1+1-1+0+0+1+1+0 Yes 1 2 3 4 5 6 7 move(0,0) --> [pare]. move(D,F) --> passo(D,F). move(D+D1,F+F1)--> passo(D,F), move(D1,F1). passo( 1, 0) --> [dir]. passo(-1, 0) --> [esq]. passo( 0, 1) --> [frente]. passo( 0,-1) --> [traz] 8.2 Integrando Léxico e Sintático A Figura 8.1 mostra a conexão via pipe entre um componente léxico e um componente sintático. Na integração de componentes léxicos e sintáticos existem alguns assuntos. Devemos trabalhar com arquivos? Como será o pipe entre os componentes? Palavra por palavra ou fita completa? No nı́vel sintático, devemos informar os erros apontando a linha e a coluna? Vamos comentar a solução de algumas destas questões nos exercı́cios que seguem. Figura 8.1: Integração entre os componentes Léxico e Sintático. Na apresentação dos métodos, nos capı́tulos anteriores, consideramos um simples string com a entrada do componente léxico, porém num programa maior pode ser um arquivo de texto. Devemos saber como adaptar os métodos apresentados para trabalhar com arquivos. Junte-se a isso existem duas abordagens para se trabalhar com arquivos: 1. trabalhar por demanda; palavra por palavra; CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 114 2. ler o arquivo todo e gerar uma fita de tokens . Um léxico trabalha por demanda quando é codificado como uma função que retorna o próximo token (proxToken). Isto é, o token deve ser criado para o processamento desde o estado inicial até o estado final. Por exemplo, para um léxico de números inteiros, cada vez que é chamada a função proxToken um valor inteiro é devolvido. Em outras palavras, o léxico implementa a função x, xp, dos exemplos implementados em linguagens imperativas, para uma gramática livre de contexto. Na integração de um componente léxico com um componente sintático, se é necessário mostrar os erros de sintaxe na posição linha e coluna, deve-se retornar estes atributos para cada token. Exercı́cio 8.2.1 Programe uma GA para os comentários da linguagem C++, de forma que possam vir aninhados. Retorne para cada comentário três informações: posição que começa (linha e coluna), posição que termina e número de caracteres que possui. Eles pode ser de dois tipos: (1) dupla // até o fim de linha e (2) múltiplas linhas com abre e fecha /* */ como exemplificado abaixo indicado abaixo. Um abre comentário sem um fecha correspondente é erro. 8.3 Gramática fatorada: sem retrocesso Exercı́cio 8.3.1 Programe a gramática abaixo numa linguagem imperativa. Assuma a fita de entrada como um string, de caracteres: valores inteiros só entre 0 e 9. 1 2 3 4 5 6 7 8 9 10 expr --> termo,rexpr. rexpr --> [+],expr, {write(some),nl}. rexpr --> [-],expr, {write(subt),nl}. rexpr --> []. termo--> fator,rtermo. rtermo--> [*],termo, {write(mult),nl}. rtermo--> [/],termo, {write(divi),nl}. rtermo--> []. fator --> [X],{integer(X)},{write(X), write(’ enter’), nl}. fator --> [’(’], expr, [’)’]. O efeito das ações semânticas é escrever uma seqüência de passos a serem executados numa calculadora do tipo HP para se calcular a expressão. Esta notação para representar expressões sem parênteses é também chamada de notação polonesa. Como segue: ?- expr([1,+,2,*,3],[]). 10 enter 20 enter 33 enter mult CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 115 some ?- expr([1,-,2,+,4,-,3],[]). 1 enter 2 enter 4 enter 3 enter subt some subt Exercı́cio 8.3.2 PROJETO: A gramática que gera notação polonesa não é associativa à esquerda. Reveja a solução proposta acima para parentizar uma expressão com associatividade à esquerda e utilize o método para fazer a geração do código em notação polonesa da forma correta. Exercı́cio 8.3.3 PROJETO: Fatore a versão da gramática que calcula o valor da expressão, com o problema da associatividade resolvido. Note que fatorar uma gramática de atributos, implica na rescrita das equações semânticas. Exercı́cio 8.3.4 PROJETO: Abaixo temos uma gramática para expressões booleanas. Definimos uma ordem de precedência (maior) - ^ v -> = (menor). Para avaliarmos uma expressão corretamente devemos também trabalhar com a associatividade à esquerda. Implemente uma DCG para parentizar expressões booleanas, considerando à associatividade à esquerda. 1 2 3 4 5 E4 E3 E2 E1 E0 --> --> --> --> --> 8.4 t | f | Q ... | (-E0) E4 ^ E3 | E4 E3 v E2 | E3 E2 -> E1 |E2 E1 = E0 | E1 Gramática não fatorada: método da costura Abaixo apresentamos uma gramática (em Prolog) que traduz uma lista de dı́gitos no valor por extenso e vice versa. Por exemplo, se perguntarmos quanto é por extenso o valor ”123” o sistema responde ”cento e vinte e três”. E, se perguntarmos qual é o valor para ”cento e vinte e três”, ele responde 123. Portanto, esta versão da gramática pode ser utilizada tanto para reconhecimento como a geração; de valores ou de valores por extenso. ddd(C,[1,2,3],[]). C = [cento, e, vinte, e, tres] Yes CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 116 ?- ddd([cento, e, trinta, e, um],V,[]). V = [1, 3, 1] Yes Nesta gramática DCG o número e a sentença gerada são representados por listas; numa implementação, numa linguagem imperativas podem ser representados por string de caracteres. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 d([um])-->[1]. d([dois])-->[2]. d([tres])-->[3]. %%... dd([dez])-->[1,0]. dd([onze])-->[1,1]. %%... dd([vinte])-->[2,0]. dd([vinte,e|D])-->[2],d(D). dd([trinta])-->[3,0]. dd([trinta,e|D] )-->[3],d(D). %%... ddd([cem])-->[1,0,0]. ddd([cento,e|DD])-->[1],dd(DD). Note que nesta gramática se temos dois valores, 100 e 1000 só podemos decidir no inicio da sentença se o um é lido como cem ou como mil após lermos todo o valor. Portanto esta gramática exige olhar para frente k posições LL(k). Neste caso é mais fácil programar uma GA com retrocesso. Exercı́cio 8.4.1 PROJETO: Faça duas versões de um programa imperativo, usando o método da costura: um para gerar o extenso e outro para reconhecer o extenso e gerar o valor, conforme orientação abaixo. Faça um comparativo de tempo de execução repetindo a entrada: 10 mil vezes. Na versão que gera por extenso, assuma na entrada um string de dı́gitos consecutivo, por exemplo, 9999#, 123#. Limpe os zeros à esquerda. Não é necessário controlar os erros. Na versão que reconhece o valor por extenso. Reconheça erros na entrada indicando a posição. O léxico substitui o valores por extenso numa nova fita como mostra abaixo. O valor deve ser devolvido num atributo e escrito no final. 1 2 string: nove mil e novecentos e noventa e oito# fita: 9, 1000,-1, 900, -1, 90, -1, 8# (vetor de inteiros) 3 4 5 6 string: nove mil ou novecentos e noventa e oito# 1234567890123456789012345678901234567890 ^---ERRO NA COLUNA (11) CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 8.4.1 117 Calcular expressões aritméticas com variáveis A linguagem LET, já apresentada no capı́tulo 4, é uma mini linguagem interessante para ser estudada, pois exige uma tabela de sı́mbolos com contexto para armazenar as variáveis parciais usadas numa expressão aritmética. Ela permite calcular expressões LET aninhadas como as que seguem: let a=4+5, b=8+2 in a + b VALOR=(4+5)+(8+2) = 19 let c= (let a=4+5, b=8+2 %% aqui o par^ entese é opcional in a + b), %% porém facilita a leitura d=8+2 in (c+d)*c+d VALOR=(4+5+ (8+2)+ (8+2))* (4+5+ (8+2))+ (8+2)= 561 Abaixo temos uma gramática em Prolog para estas expressões. Primeiro codificamos dois predicados para implementar uma tabela de sı́mbolos, como uma lista de pares par(VAR,VAL): • lookUp/2 — retorna o valor para uma variável; • insert/2 — insere um par(VAR,VAL) na tabela de sı́mbolos. Como temos expressões aritméticas simples, do tipo (c+d)*c+d, a idéia é reusar uma versão simplificada da gramática de expressões já discutida. Não trabalharemos com as operações de soma e divisão pelo problema de associatividade à esquerda discutido no capı́tulo 2. Agora todas as produções recebem um atributo herdado, que é a tabela de sı́mbolos. Ela é necessária pois agora um fator pode ser uma variável: neste caso o seu valor está registrado na tabela de sı́mbolos (para um expressão bem formada). Na gramática LET são usadas três novas produções let, decVar e decVars. A produção let define uma expressão composta de declaração e o corpo da expressão onde as declarações serão usadas. A produção decVar declara uma variável associada a uma expressão - no final a variável e a expressão são incluı́das na tabela de sı́mbolos. A produção decVars declara um ou mais pares Var=Exp separados por vı́rgula. 1 2 3 4 5 6 7 8 lookUp(X,T):-member(X,T). insert(X,Ti/To):-To=[X|Ti], write((tab:To)),nl. isLetra(X):-member(X,[a,b,c,d,e,f,g,h,i,x,y,z]). %% let(Ti,V) --> [let], decVars(Ti/T1), [in], expr(T1,V). decVars(Ti/To) --> decVar(Ti/T1), [’,’], decVars(T1/To). decVars(Ti/To) --> decVar(Ti/To). decVar(Ti/To) --> [L],{isLetra(L)}, [=], expr(Ti,E), CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO {insert(par(L,E),Ti/To)}. 9 10 11 12 13 14 15 16 17 18 118 %% expr(TAB,E)--> expr(TAB,E)--> expr(TAB,E)--> termo(TAB,T)--> termo(TAB,F)--> fator(TAB,X)--> fator(TAB,E)--> fator(TAB,V)--> 19 let(TAB,E). termo(TAB,T),[+],expr(TAB,Eo),{E = (T+Eo)}. termo(TAB,E). fator(TAB,F),[*],termo(TAB,To),{T = (F*To)}. fator(TAB,F). [X],{integer(X)}. [’(’],expr(TAB,E), [’)’]. [X],{member(X,[a,b,c,d,e,f,g,h,i,x,y,z])}, {lookUp(par(X,V),TAB), write((look:X:V)),nl}. %% vars Os testes devem ser digitados num arquivo. Podem ser comentados. Podem existir várias expressões no mesmo arquivo; separadas por ponto e vı́rgula. Podem existir linhas em branco. /* exemplos de testes: para calculadora LET */ let a=4+5, b=8+2 // comentário de final linha in a + b; // VALOR=(4+5)+(8+2) = 19 let c= (let a=4+5, b=8+2 // aqui o par^ entese é opcional in a + b), // porém facilita a leitura d=8+2 in (c+d)*c+d; // VALOR=(4+5+ (8+2)+ (8+2))* (4+5+ (8+2))+ (8+2)= 561 Abaixo segue a execução dos testes da versão em Prolog. Incluı́mos dois write(s) para depurar o programa. Aqui vemos que este programa trabalha com retrocesso: em alguns casos ele inclui na tabela de sı́mbolos resultados que ainda não são definitivos; ao mesmo tempo ele acessa a tabela varias vezes desnecessariamente. ?- teste(1,LET),let([],V,LET,RESTO),VX is V. tab:[par(a, tab:[par(b, tab:[par(b, tab:[par(b, look:a:4+5 look:a:4+5 look:b:8+2 look:b:8+2 4+5)] 8+2), par(a, 4+5)] 8), par(a, 4+5)] 8+2), par(a, 4+5)] CAPÍTULO 8. EXERÍCIOS E PROJETOS DE PROGRAMAÇÃO 119 look:b:8+2 look:b:8+2 LET = [let, a, =, 4, +, 5, (’,’), b, =|...] V = 4+5+ (8+2) RESTO = [] VX = 19 ?- teste(2,LET),let([],V,LET,RESTO), VX is V. tab:[par(d, 8+2), par(c, 4+5+ (8+2))] look:c:4+5+ (8+2) look:d:8+2 LET = [let, c, =, let, a, =, 4, +, 5|...] V = (4+5+ (8+2)+ (8+2))* (4+5+ (8+2))+ (8+2) RESTO = [] VX = 561 Exercı́cio 8.4.2 Implemente uma técnica de diagnóstico para erros léxicos e sintáticos indicando a linha e coluna. Exercı́cio 8.4.3 Implemente um diagnóstico para erros semânticos. As variáveis declaradas em vars numa expressão let vars in expr só podem ser usadas num contexto mais interno in expr. Seguem abaixo dicas para diagnóstico de erros semânticos. let a=b+5, b=8-2 /** em a=b+5 a variável b ainda n~ ao foi declarada **/ in let c=a+b, d=a+a+3 in (c+d)*(c+d)/2; let a=b1+5, b=let k=2+3 in k+k in (b+c+k); /** em b+c+k a /** ela é local let a=5, b=8-2 in let a=a+1 in a+b; /** esta express~ ao é /** vale a declaraç~ ao variável k já n~ ao existe **/ ao outro let **/ válida e o aqui o a=6 **/ mais interna **/ Modifique o predicado do lookUp para dar o diagnóstico dizendo quando ele não encontra a variável na tabela de sı́mbolos. Exercı́cio 8.4.4 Integre com o léxico de duas formas: a) trabalhando com uma fita toda; b) trabalhando por demanda. Faça um comparativo de tempos de execução com um arquivo de entrada grande: 100 linhas. Mostre uma comparação num gráfico. Referências Bibliográficas [1] A. Aho, R. Seti. e J. Ulmman. Compilers: Principles, Techniques, and Tools. Addison-Wesley, Reading, MA, 1986. (ver versão traduzida) [2] H. Ait-Kaci, Warren’s Abstract Machine: A Tutorial Reconstruction, MIT Press, Cambridge, 1991, (also in the Web). [3] I. Brakto, Prolog Programming for Artificial Intelligence, Second Edition, AddisonWesley Publishing Company. 1990. [4] M. A. Casanova, F. A. C. Giorno e A. L. Furtado, Programação em Lógica e a Linguagem Prolog Edgar Blücher Ltda, Rio de Janeiro, 1987. [5] W. F. Clocksin e C. S. Mellish, Programming in Prolog Springer-Verlag, 4th edition, 1994. [6] A. Comerauer, H. Hanoui, P. Roussel e R. Pasero. ”Un systeme de communication Homme-Machine en Français”, Groupe d’Intelligence Artificielle, Université d’AixMarseille, France, 1973. [7] M. A. Covington, D. Nute e A. Velino, Prolog Programming in Depth, Prentice Hall, New Jersey, 1997. [8] M. A. Covington, Natural Language Processing for Prolog Programmers, Prentice Hall, New Jersey, 1994. [9] P. Deransart, A. Ed-Dbali e L. Cervoni, Prolog: The Standard – Reference Manual Springer, Berlin, 1996. [10] R. Duncan, Programação eficaz com Microsoft macro Assembler, Rio de Janeiro : Campus, 1993 [11] R. A. Kowalski, The predicate calculus as a programming language, International symposium and summer school on Mathematical Foundation of Computer Science Jablona, Poland, 1972. [12] P.B. Menezes, Linguagens Formais e Automatos. Porto Alegre: Sagra-Luzzato Instituto de Informática UFRGS, 2000. (2 ed. Série Livros Didáticos, 3). 120 REFERÊNCIAS BIBLIOGRÁFICAS 121 [13] C. J. Hooger, Essentials of Logic Programming, Oxford University Press, Oxford, 1990. [14] A.M.A. Price, e S.S. Toscani, Implementação de linguagens de Programação: Compiladores. Porto Alegre: Sagra-Luzzato - Instituto de Informática UFRGS, 2000. (2 ed. Série Livros Didáticos, 9). [15] S. J. Russell e P. Norvig, Artificial Intelligence: A modern approach, Prentice Hall, New Jersey, 1995. [16] P. Van Roy, 1983-1993: The Wonder Years of Sequential Prolog Implementation, Journal of Logic Programming, 1993. [17] L. Sterling e E. Shapiro, The Art of Prolog, The MIT Press, Cambridge, 1986. [18] D.H.D Warren, WARPLAN: a system for generate plans, Memo 76, Department of Artificial Intelligence, University of Edinburgh, Scotland, 1974. Índice Remissivo at end of stream, 81 close, 80 digitos, 82 get code, 82 isDigito, 82 isLetOuDig, 82 isLetra, 82 isSeparador, 81 isSimbolo, 82 open, 80 peek code, 82 set input, 81 tokensN, 81 esquema de Banco de Dados, 71 estados (autômato), 21 fatoração de produções, 48 geração de código, 47 gramática, 4 gramática de cláusulas definidas, 2, 35 gramática livre de contexto, 2 gramática regular, 2, 21 gramática sensı́vel ao contexto, 2, 35 Irons, 2 Kleene, 2 Knuth, 2 ações semânticas, 47 álgebra relacional, 71 análise léxica, 73 árvore de derivação, 5 árvore sintática, 5 atributos herdados, 36 atributos sintetizados, 36 autômato determinı́stico, 25 automato, 73 avaliar expressões, 43 léxico (arquivos), 80 linguagem para expressões, 49 linguagens (classificação), 12 Lookahead, 82 mini-linguagem (léxico), 79 mini-linguagem (Pascal), 79 Naur, 2 notação polonesa, 48 números binários, 42 números de colunas, 83 números de linhas, 83 Backus, 2 Backus Normal Form, 2 BNF, 2 Chomsky, 2 DCG, 2, 35 Definite clause grammar, 2, 35 derivação, 4 derivações, 13 palavras reservadas, 83 parte fracionária, 41 precedência de operadores, 43 primitivas ISO, 79 produção, 4 equações semânticas, 38 equivalência de gramáticas, 29, 43 recursividade à esquerda, 43 regras reversı́veis, 49 122 ÍNDICE REMISSIVO scanner, 82 sentenças, 13 sı́mbolo não terminal, 5 sı́mbolo terminal, 5 SQL, 71 token (automaton), 21 tokens (inteiros), 73 tokens (reais), 73 transições, 21 valor decimal, 40 valor por extenso, 49 123