2. Tipos Abstratos de Dados Um Tipo Abstrato de Dados especifica uma classe de dados definida pelo usuário em termos de suas propriedades abstratas Esta especificação do TAD descreve o comportamento de um objeto independentemente de sua implementação; unicamente através dessas propriedades abstratas Não há preocupação de como essas propriedades seriam implementadas numa linguagem de programação A forma usual de se especificar um tipo abstrato de dados é através de uma especificação algébrica, que em geral contém três partes: 1. Especificação sintática: define o nome do tipo, suas operações e o tipo dos argumentos das operações. Dizemos que define a assinatura ou interface do TAD 2. Especificação Semântica: contém um conjunto de equações algébricas, que descreve, independentemente de uma implementação específica, as propriedades das operações 3. Especificação das Restrições: estabelece as condições que devem ser satisfeitas antes e depois da aplicação das operações Vamos agora mostrar a especificação dos número naturais como um TAD. Definiremos os naturais Nat = {0, 1, 2, 3,...} com as operações teste de zero, soma e igualdade através da seguinte especificação 1 2 3 ABSTRACT Nat sintaxe zero() Nat IsZero(Nat) Boolean Succ(Nat) Nat 1
4 Add(Nat, Nat) Nat 5 Eq(Nat, Nat) Boolean 6 semantic x, y Nat 6 IsZero(Zero)= true 7 IsZero(Succ(x))= false 8 Add(Zero, y)= y 9 Add(Succ(x),y)= Succ(Add(x, y)) 10 Eq(x, Zero)= if IsZero(x) then true else false 11 Eq(Zero, Succ(y))= false 12 Eq(Succ(x),Succ(y))= Eq(x, y) 13 restrictions end Zero é a função constante que significa não precisar de argumento de entrada e o seu resultado é o número natural zero, escrito Zero IsZero é a função booleana cujo resultado será verdadeiro ou falso, conforme o número do tipo Nat passado como parâmetro seja zero ou não Succ significa o sucessor do número passado como parâmetro Usando Zero e Succ podemos definir todos os números naturais como: 0 = Zero 1 = Succ(Zero) 2 = Succ(Succ(Zero)) 3 = Succ(Succ(Succ(Zero)))... As regras das linhas 8 e 9 nos mostram exatamente como funciona a operação de adição. Por exemplo, se queremos somar 2 com 3 teremos a seguinte seqüência de expressões: Add(Succ(Succ(Zero)),Succ(Succ(Succ(Zero)))) 2.2
que conforme a linha 9 será igual a Succ(Add(Succ(Zero),Succ(Succ(Succ(Zero))))) novamente, conforme a linha 9 será igual a Succ(Succ(Add(Zero, Succ(Succ(Succ(Zero)))))) e finalmente, conforme a linha 8 será igual a Succ(Succ(Succ(Succ(Succ(Zero))))) que corresponde ao natural 5. Obviamente que esta não é a maneira de implementar a adição e muito menos os naturais são definidos desta maneira numa linguagem de programação Na prática, representamos os naturais usando seqüências de bits que equivalem à estrutura de dados normalmente usada nos computadores (os bytes) O que deve ficar bem claro, contudo, é que a representação que escolhemos para um TAD na nossa linguagem de programação deve obedecer ao comportamento definido através da especificação algébrica 2.1. Representação de Tipos de Dados Vimos no Capítulo 1 um tipo como um conjunto de valores Esta visão é razoável para muitos propósitos, mas pode acarretar problemas quando se define um novo tipo em termos de tipos já existes Por exemplo, suponha que numa aplicação que estamos desenvolvendo precise de uma estrutura de dados com o comportamento de uma pilha. Seria interessante que a linguagem empregada suportasse um tipo predefinido chamado Pilha e que bastasse declarar uma variável deste tipo, do mesmo modo como fazemos quando declaramos um array. 2.3
Entretanto, a maioria das linguagem não oferece estas facilidades Para definir um novo tipo, em geral, usamos uma representação para seus valores, com base em valores de um tipo que já existe Muitas vezes esta representação tem propriedades não desejáveis! Exemplo: suponha que queremos definir um tipo cujos valores serão os números racionais, com operações aritméticas que são exatas. Na linguagem de programação ML, podemos escrever as seguintes declarações datatype rational = rat of (int * int); val zero = rat(0,1) and one = rat(1,1) fun op ++(rat(m 1,n 1 ): rational, rat(m 2,n 2 ): rational) = rat(m 1 *n 2 +m 2 *n 1,n 1 *n 2 ) Cada número racional é representado por um par de inteiros rotulado, onde o primeiro elemento é o numerador e o segundo o denominador 3 2 é representado por rat(3,2), mas também pode ser representado por rat(6,4), rat(9,6), rat(-3,-2), etc Os números racionais que estes pares rotulados supostamente representam são matematicamente iguais, mas os pares em si, são todos distintos Considere a seguinte comparação: if one ++ rat(1,2) = rat(6,4) then... else... mas rat(3,2) rat(6,4) Esta desigualdade é devida a representação escolhida, sendo uma propriedade (comportamento) indesejada, já que os números são matematicamente iguais!!! Outro comportamento indesejado é que não existe nada que previna um par rotulado como rat(0,0) ou rat(1,0), que não correspondem a qualquer número racional 2.4
O conjunto de valores do tipo definido anteriormente é Rational = { rat( m, n) m, n Integer} Idealmente queríamos definir o seguinte conjunto de valores { rat( m, n) m, n Integer; n>0; m e n sem fatores em comun } mas ML não tem um tipo com este conjunto de valores Deste modo, a definição dos números racionais em termos de um tipo existente é insatisfatório A seguir, mostraremos um resumo das dificuldades que podem surgir, em geral, quando nós representamos um tipo abstrato por outro tipo, o tipo concreto ou representacional: O tipo representacional pode ter valores que não correspondem a qualquer valor do tipo abstrato desejado O tipo representacional pode ter vários valores que correspondem a um mesmo valor no tipo abstrato desejado A menos que se use uma declaração de novo tipo, os valores do tipo abstrato desejado pode se confundir com valores do tipo representacional Deste modo, como implementar um TAD numa linguagem de programação, de modo que as propriedades indesejáveis do tipo representacional sejam suprimidas, com a implementação refletindo estritamente o comportamento do TAD? A chave para responder a esta pergunta será vista na seção seguinte 2.5
2.2. Independência de Representação Vimos que uma especificação abstrata (TAD) define o comportamento de um tipo de dado sem se preocupar com sua implementação Uma representação concreta (através de um tipo concreto ou representacional) nos diz como um TAD é implementado, como seus dados são colocados dentro do computador e como estes dados são manipulados por suas operações A chave para se conseguir verdadeiramente implementar tipos abstratos de dados é aplicar o conceito de Independência de Representação: Um programa deveria ser projetado de forma que a representação de um tipo de dado possa ser modificada sem que isto interfira no restante do programa A aplicação deste conceito é possível em linguagens de programação que suportem módulos Um módulo é qualquer unidade nomeada de um programa que pode ser implementada como uma entidade relativamente independente Tipicamente, um módulo é um grupo de vários componentes (tipos, variáveis, constantes, funções, procedimentos, etc) declarados com um propósito específico Dizemos que um módulo encapsula seus componentes Em geral, apenas uma pequena parte dos componentes de um módulo são visíveis externamente, são os componentes exportados pelo módulo Os demais componentes permanecem escondidos dentro do módulo, sendo usados apenas para ajudar na implementação dos componentes exportados Assim, um TAD pode ser implementado usando um módulo da seguinte maneira: 2.6
1. O nome do módulo é o nome do TAD 2. Apenas as operações da especificação abstrata são visíveis externamente ao módulo 3. A representação concreta do TAD e outros componentes auxiliares ficam ocultos dentro do módulo Uma característica importante da aplicação da independência da representação é que qualquer alteração na representação concreta, que não altere as propriedades abstratas do TAD definido, fica restrita à parte do módulo que é oculta, não afetando o resto do programa; De forma similar, uma mudança no programa, que usa as abstrações de dados, não afeta a exatidão do módulo que implementa o TAD 2.3. Exemplo de um TAD Vamos agora implementar os racionais como um TAD em ML abstype rational = rat of (int * int) with val zero = rat(0,1) and one = rat(1,1); fun op //(m: int, n: int) = if n <> 0 then rat(m, n) else (* error *) and op ++(rat(m 1,n 1 ): rational, rat(m 2,n 2 ): rational) = rat(m 1 *n 2 +m 2 *n 1,n 1 *n 2 ) end and op ==(rat(m 1,n 1 ): rational, rat(m 2,n 2 ): rational) = (m 1 *n 2 = m 2 *n 1 ) Esta declaração abstype implementa um TAD chamado rational através de um módulo que define os seguintes componentes: as constantes zero e one; as operações //, ++ e ==, que são os únicos componentes visíveis do módulo. 2.7
A representação concreta (pares de inteiros) escolhida para valores do novo tipo é oculta do usuário do tipo, bem como a implementação das funções O único meio para o usuário gerar valores do tipo rational é pela avaliação de expressões envolvendo as constantes zero e one e as funções //, ++. Estes valores só podem ser comparados chamando a função == Logo, o seguinte código trabalhará como deve: val h = 1//2;... if (one ++ h) == 6//4 then... else... Agora, não importa que um dado valor tenha várias representações possíveis, porque estas representações são ocultadas do usuário O que é importante é que apenas propriedades desejáveis dos valores são observáveis usando as operações associadas com o tipo abstrato Ex.: no exemplo anterior, a diferença entre as representações rat(3,2) e rat(6,4) não são observáveis, porque a função == as trata como iguais Em geral, uma implementação de um TAD deve prover operações de construção para compor (criar) valores do tipo abstrato e operações de destruição, para decompor estes valores No exemplo anterior, a função // é necessária para permitir que valores racionais sejam compostos. zero e one são também exemplos de operações de construção. Um possível destrutor seria fun float(rat(m, n): rational) = m/n; Implementações de TADs são similares aos tipos predefinidos, como os inteiros e booleanos. Por que? 2.8