Compiladores Análise Semântica versão α 0.001 Simão Melo de Sousa Este documento é uma tradução adaptada do capítulo Analyse Sémantique da sebenta Cours de Compilation de Christine Paulin-Morhing e Marc Pouzet (http://www.lri.fr/~paulin). 1 Introdução A análise semântica trata a entrada sintáctica e transforma-a numa representação mais simples e mais adaptada à geração de código. Esta camada do compilador fica igualmente encarregue de analisar a utilização dos identificadores e de ligar cada uma delas a sua declaração. Nesta situação verificar-se-á que o programa respeita as regras de visibilidade e de porte dos identificadores. É também esperado que esta fase da compilação verifique que cada expressão definida tenha um tipo adequado conforme as regras próprias à linguagem. Neste parte da lição iremos estudar a gestão da tabela dos símbolos que serve para a ligação dos nomes manipulados aos objectos que estes de facto designam. Estudaremos igualmente a tipificação dos programas. Por fim definiremos as noções de gramáticas de atributos que permitam associar valores aos nodos da árvore de derivação sintáctica. 2 Tabela dos símbolos 2.1 Introdução As linguagens de programação manipulam identificadores que são essencialmente símbolos que servem para designar objectos conteúdo dum endereço memória, no caso por exemplo duma variável, pedaços de código no caso de nomes de procedimentos, tipos, etc... A tabela de símbolos arquiva as informações sobre os objectos designados por nomes na linguagem em questão. Esta é actualizada de cada vez que é analisada uma declaração dum novo identificador. De forma semelhante, a tabela é consultada de cada vez que é utilizado um identificador no programa analisado. A tabela de símbolos permite para cada identificador o arquivo das informações associadas ao objecto identificado. Estas podem ser de natureza diversa, como o tipo do 1
objecto, uma posição na lista das variáveis declaradas (com a finalidade de calcular o endereço relativo aquando da geração de código), um valor... É igualmente possível coexistirem várias tabelas de símbolos, por exemplo quando existem vários espaços de nomes, como é o caso para linguagens orientadas a objecto (e.g. os packages do java). Classicamente, encontraremos nestas linguagens uma tabela de símbolos para cada espaço de nome. Por exemplo arquivaremos nos diferentes espaços o nome das classes e o nome dos métodos associados. Em linguagens de tipo ML, os tipos, os módulos e os valores podem ser agrupados em tabelas de símbolos diferentes. Um mesmo identificador pode ser utilizado para representar diferentes objectos. Este objectos podem estar arquivados ou referenciados em diferentes tabelas de símbolos. Este situação obriga conhecer a natureza do objecto para determinar em que tabela procurar os seus dados. Este conhecimento é em geral adquirido. Assim este aparente conflito pode ser facilmente resolvido. Um mesmo identificador (variável, procedimento) pode igualmente estar declarado mais do que uma vez na mesma tabela. Aquando da compilação será necessário conhecer precisamente o objecto referenciado por cada identificador. As regras de porte (scope em inglês) dos identificadores que permitirá resolver os conflitos subjacentes. 2.2 Porte dos identificadores Os programas manipulam vários identificadores. Por razões de eficácia, e com a finalidade de melhorara a robustez do código, as linguagens permitam indicar sintácticamente que a utilização de certas variáveis será confinada a uma parte bem determinada do programa, designado de bloco. Assim, fora deste bloco, não será necessário alocar espaço para as variáveis atribuídas ao bloco em questão. Durante a análise semântica, o compilador assegurar-se-á que todas as variáveis utilizadas foram declaradas de forma adequada e são bem visíveis durante as suas utilizações (i.e. as suas utilizações tem lugar no bloco ao qual pertencem). As regras de porte/alcance dos identificadores são específicas a cada linguagem. Por exemplo em C, uma variável é local ao procedimento em que foi declarada, ou é global a todo o programa. Em Pascal, qualquer identificador tem de ser declarado de forma centralizada antes da sua utilização (daí a necessidade da instrução forward para as funções mutuamente recursivas). O corpo dum procedimento pode utilizar variáveis declaradas em qualquer procedimento que o engloba (ele próprio incluído). Em ML, qualquer identificador deve ter sido previamente declarado numa expressão let id =... in... ou let id =... O porte de tal declaração está restrito a expressão associada a expressão a direita do in no caso da declaração local, ou ao resto do programa/módulo em que está definido no segundo caso (ficheiro = módulo em OCaml). Fora do módulo um identificador exportado (i.e. cuja visibilidade fora do módulo é permitida) pode ser acedido através do que se designa por nome qualificado ou seja Nome_do_módulo.identificador. Se existirem directivas de abertura de módulos (por exemplo open em OCaml, import em Java) a qualificação pode ser omitida. Se um identificador é declarado mais do que uma vez o objecto acedido pelo nome qualificado é o ultimo declarado. É no entanto possível aceder a um objecto via o seu nome completamente qualificado. No caso de um nome completamente qualificado poder 2
referir dois objectos diferentes, então este fica por designar o último declarado. Uma declaração de função em ML não é por defeito recursiva. Para tal é preciso juntar a palavra chave rec ao let. Em Java, o corpo dum método pode utilizar outros métodos da mesma classe mesmo se a definição destes métodos ocorre posteriormente. Isto obriga ao processamento em bloco das definições de classes. Mais, podemos definir numa classe vários métodos com o mesmo nome. O tipo dos parâmetros permite determinar de forma estática (i.e. em tempo de compilação) o método por aplicar. De uma forma similar, várias classes podem redefinir os mesmos métodos. Se as classes são disjuntas, esta ambiguidade de nome pode ser resolvida por uma verificação dos tipos (i.e. type checking). Se um mesmo identificador está definido numa classe e redefinido numa das suas sub-classes então devese impor coerência entre os tipos dos objectos assim definidos, como no caso dos métodos por exemplos. A escolha do método por executar é geralmente feita de forma dinâmica (na altura da execução). Em algumas situações é possível indicar sintácticamente que método se pretende chamar, como no caso do uso das palavras chaves super ou this. No primeiro caso é assim indicado explicitamente que se pretende invocar o método da classe mãe mesmo se essa foi redefinida na classe activa. 2.3 Representação da tabela de símbolos Deve ser possível após a análise dum programa encontrar toda a informação associada a um identificador. Isto pode ser feito enfeitando (analogia feita ao enfeito da árvore de natal que fica após este trabalho com todo o significado a semântica de natal) a árvore de sintaxe abstracta (ASA). É possível associar a cada utilização dum identificador um apontador para parte da ASA que corresponde a declaração do identificador. Esta associação pode (costuma) igualmente incluir outras informações úteis como, por exemplo, o tipo do objecto referenciado. De facto o cuidado por ter aqui é o compromisso entre utilidade da informação arquivada, o seu tamanho e a frequência da sua utilização. Sem cuidado, a gestão duma tabela de símbolo pode se tornar pesada. Uma outra solução consiste em arquivar as informações sobre um objecto numa tabela e associar um endereço para esta tabela a cada utilização do identificador. Este endereço pode ser um apontador, um inteiro ou um nome único. Ao lado desta estrutura persistente, é necessário gerir uma tabela para a verificação do porte de cada identificador. Esta tabela deve poder informar em qualquer instante desta fase de análise dos dados dos identificadores visíveis. deve ser igualmente possível juntar novos identificadores, determinar rapidamente e facilmente se um dado identificador é visível. De forma semelhante deve ser possível retirar identificadores desta estrutura quando o objecto referenciado deixa de existir ou de ser acessível (um identificador de uma variável local quando se atinge o fim do bloco em que está definido). Consideremos a analise do seguinte programa: 1 let x = ( let y = 2 in y*y + 2*y +1) in x+y Quando começamos a analisar esta expressão, construímos uma tabela dos símbolos visíveis T. Primeiro analisa-se a construção let x = e in e que declara o novo identificador x. Para encontrar informação sobre este identificador, analisa-se o corpo e da definição (aqui let y = 2 in y*y + 2*y +1). Nesta situação devemos então analisar o corpo da 3
definição de y ou seja 2. Este processo leva assim a actualização da tabela T em T na qual se acrescentou a entrada y com a informação de que este é, por exemplo, inteiro. Esta declaração torna qualquer informação prévia sobre y em T invisível. Utilizando a tabela T analisamos a expressão y*y + 2*y +1 que é determinada como sendo do tipo inteiro. Ao sair do bloco y*y + 2*y +1, a entrada y na tabela de símbolos deve desaparecer e assim voltamos de T para T. Neste ponto preciso sabe-se que x é inteiro e podemos então juntar esta informação a tabela de símbolos T e proceder a analise de x+y. Com se vê na expressão por analisar e pelas regras de porte em OCaml a ocorrência de y não faz referência à definição interna à definição de x mas sim a uma declaração prévia. No fim desta análise, as diferentes utilizações dos identificadores na ASA devem estar associadas à boa declaração. Estas tabelas devem ser optimizadas porque o número de identificadores pode ser importante e o acesso a informação deve ser rápido. Podem ser implementadas de forma imperativa ou funcional No caso duma representação funcional a analise de visilibilidade poderia se escrever da seguinte forma: function visivel : tabela * asa -> asa_tipada visivel (T,let(x,e,e )) = seja f = visivel(t,e), tip = tipo_de(t,f), T = add ((x,tip),t), f = visivel(t,e ), let(x:tip,f,f ) Este processo funciona se a representação da tabela é funcional, ou seja se a construção de T (junção de (x, tip)) não altere de facto a tabela T. Não é o caso, por exemplo se T for uma tabela de Hash (como é o caso em OCaml). A copia e arquivo da cópia da tabela de hash com o objectivo de a repor caso necessário seria aqui altamente ineficiente. Se optarmos por uma tabela de hash, é assim necessário retirar explicitamente os identificadores dos quais pretendemos apagar o registo. Assim sendo esta tabela por ser global: function visible_imp : asa -> asa_tipada visival_imp (let(x,e,e )) = seja f = visivel_imp(e), tip = tipo_de(f), add x; seja f = visivel_imp(e ), del (x); let(x:tip,f,f ) As tabelas de hash com ligações para listas de entradas (onde os identificadores pertencendo a mesma lista correspondem aos valores com a mesma chave de dispersão (hash key)) são em regra geral boas estruturas para implementar tabelas de símbolos visíveis. De facto quando identificadores com o mesmo nome estão declarados o último esconde naturalmente os anteriores. Isto porque a lista de entradas é gerida como uma pilha. Para retirar um identificador basta eliminar a primeira ocorrência encontrada na tabela (que corresponde ao último inserido). 4
É possível introduzir em cada bloco do programa analisado um número arbitrário de identificadores. Na saída de cada bloco devemos poder identificar e remover todos os identificadores da tabela que nele foram introduzidos (estes deixam de ser visíveis). É assim necessário conservar uma pilha dos blocos abertos com a informação, para cada bloco aberto, dos identificadores que este introduz. Este processo pode ser feito de forma funcional arquivando uma pilha de listas de identificadores ou de forma mais imperativa interligando o conjunto de identificadores introduzidos num mesmo bloco na tabela dos símbolos visíveis e guardando num estrutura (uma pilha por exemplo) o endereço na tabela do último identificador introduzido no bloco activo. 2.4 Representação dos símbolos Quando se analisa os símbolos do programa, é necessário fazer numerosas comparações (igualdades, desigualdades se se utiliza árvores binárias de pesquisa). Para tal é pertinente utilizar uma representação eficiente dos identificadores com por exemplo uma codificação com base em inteiros. Neste caso é interessante dispor duma tabela de dispersão (hash table) onde se arquiva a associação entre inteiro - identificador. Em termos de requisitos, (a) esta associação deve ser única, isto é, um inteiro representa um único identificador; (b) esta tabela deve poder devolver o identificador representado por um inteiro e viceversa; (c) a gestão desta tabela não deve sobrecarregar computacionalmente o processo de analise. 5