Aula 5: Tipos Abstratos 5.1. Introdução Nesta aula, iremos analisar um tipo particular de dependência, é a dependência observada entre um cliente de um tipo abstrato de dado para com a representação deste tipo, e ver como esta dependência pode ser evitada. Nós iremos discutir também o conceito de campos de especificação para a definição de tipos abstratos, classificação de operações e benefícios do uso de representações. 5.2. Tipos definidos pelo usuário Nos primeiros dias da computação, uma linguagem de programação vinha com tipos embutidos (como integers, booleans, string, etc) e com procedimentos embutidos (para entrada e saída de dados, por exemplo). Além disso, os usuários podiam definir seus próprios procedimentos: era assim que grandes programas podiam ser construídos. Um dos grandes avanços ocorridos no desenvolvimento de software foi a idéia de tipos abstratos, segundo a qual, linguagens de programação nas quais o usuário pode definir seus próprios tipos, podem ser construídas. A idéia surgiu do trabalho de muitos pesquisadores, notadamente Dahl (o inventor da linguagem Simula), Hoare (que desenvolveu muitas das técnicas que utilizamos para trabalhar com tipos abstratos), Parnas (que cunhou o termo ‘escondendo informações’ e que primeiro articulou a idéia de organizar módulos de programas de acordo com o conteúdo que encapsulavam) e, aqui no MIT, Barbara Liskov e John Guttag, que realizaram um trabalho pioneiro a respeito da especificação de tipos abstratos e a respeito do suporte para tais tipos em linguagens de programação (e que desenvolveram o curso 6170!). A idéia principal da abstração de dados é que um tipo é caracterizado pelas operações que você pode realizar sobre este tipo. Um número é algo que você pode somar e multiplicar; uma string é algo que você pode concatenar e retirar substrings; um tipo booleano é algo que você pode negar e assim por diante. De certa forma, os usuários já podiam definir seus próprios tipos nas primeiras linguagens de programação: era possível criar um tipo date através do recurso de programação record, por exemplo, com campos integer para dia, mês e ano. Mas o que tornou os tipos abstratos novos e diferentes foi o foco dado às operações: o usuário do tipo não precisava se preocupar sobre como seus valores eram armazenados, da mesma forma que um programador pode ignorar como um compilador armazena integers. Tudo o que importa são as operações. Em Java, como em muitas linguagens de programação modernas, a separação entre tipos embutidos e tipos definidos pelo usuário é um pouco confusa. As classes presentes no package java.lang, 54 como Integer e Boolean são embutidas; já a questão de se considerar ou não todas as coleções de classes do package java.util como embutidas não é um assunto tão claro (e de pouca importância de qualquer forma). O Java complica a questão ao possuir tipos primitivos que não são objetos. O conjunto destes tipos, como int e boolean, não pode ser estendido pelo usuário. 5.3. Classificando Tipos e Operações Tipos, sejam embutidos ou definidos pelo usuário, podem ser classificados como mutáveis ou imutáveis. Os objetos de um tipo mutável podem ser alterados: ou seja, eles possuem operações que quando executadas fazem com que os resultados de outras operações sobre o mesmo objeto forneçam diferentes resultados. Portanto, Vector é mutável, pois você pode chamar addElement e observar a alteração com a operação size, que irá fornecer um resultado diferente a cada execução de addElement. Ao passo que String é imutável, pois suas operações criam novos objetos String ao invés de alterar objetos existentes. Algumas vezes um tipo será fornecido de duas formas, uma mutável e outra imutável. StringBuffer, por exemplo, é uma versão mutável de String (embora os dois tipos não sejam, com certeza, o mesmo tipo dentro da linguagem Java, não sendo intercambiáveis portanto). Tipos imutáveis geralmente são mais fáceis para se trabalhar. O fenômeno denominado Aliasing1 não é um problema e, algumas vezes, a utilização de tipos imutáveis é mais eficiente, pois podemos ter mais compartilhamento. Mas muitos problemas são expressos mais naturalmente através de tipos mutáveis, e quando alterações locais são necessárias em grandes estruturas, eles tendem a ser mais eficientes. As operações de um tipo abstrato são classificadas como · Constructors, ou construtores, criam novos objetos de um determinado tipo. Um construtor pode receber um objeto como argumento, mas não um objeto do tipo sendo construído. · Producers, ou produtores, criam novos objetos a partir de objetos já existentes. O método concat de uma String, por exemplo, é um produtor: ele recebe duas strings e produz uma nova representando a concatenação. · Mutators, ou modificadores, alteram o valor dos objetos. O método addElement da classe Vector, por exemplo, altera um vetor ao adicionar um elemento no fim do vetor. · Observers, ou observadores, recebem objetos de um determinado tipo abstrato e retornam objetos de um tipo diferente. O método size da classe Vector, por exemplo, retorna um inteiro. 1 O Aliasing será mais bem explicado na seção 9.7 da aula 9. 55 Podemos resumir estas distinções esquematicamente da seguinte forma: consctrutor (construtores): t -> T producer (produtores): T, t -> T mutator (modificadores): T, t -> void observer (observadores): T, t -> t Este esquema apresenta informalmente o formato das operações nas várias classes. Cada T é um tipo abstrato por si só; cada t representa um outro tipo. Em geral, quando um tipo é mostrado à esquerda, ele pode ocorrer mais de uma vez. Por exemplo, um produtor pode receber dois valores de um determinado tipo abstrato, como o método concat que recebe duas strings. As ocorrências de t do lado esquerdo podem ser omitidas; alguns observadores não recebem nenhum argumento que não seja de algum tipo abstrato (como size, por exemplo), já outros recebem vários. Esta classificação nos fornece uma terminologia bastante útil, mas que não é perfeita. Em tipos de dados complexos, pode haver operações que são, ao mesmo tempo, produtoras e transformadoras, por exemplo. Algumas pessoas usam o termo ‘produtor’ para enfatizar que nenhuma transformação do dado ocorre. Um outro termo que você deve conhecer é iterator, ou iterador. Um iterator normalmente significa um tipo especial de método (não disponível em Java) que retorna uma coleção de objetos retornando um de cada vez dos elementos que estão, por exemplo, em um conjunto. Em Java, um iterator é uma classe que proporciona métodos que podem ser usados para se obter uma coleção de objetos, retornando um de cada vez. A maioria das classes de coleções possui um método com o nome iterator que retorna um objeto do tipo java.util.Iterator para que seus objetos sejam acessados por intermédio de um iterator propriamente dito. 5.4. Exemplo: Lista Vamos analisar um exemplo de um tipo abstrato: a lista. A lista, em Java, é como um array. Ela fornece métodos para extrair o elemento de um determinado índice, e para substituir o elemento de um determinado índice. Mas, diferente de um array, ela também possui métodos para inserir ou remover um elemento em um determinado índice. Em Java o tipo List é uma interface com muitos métodos, mas por enquanto, vamos imaginar que se trata de uma classe simples com os seguintes métodos: public class List { public List (); public void add (int i, Object e); public void set (int i, Object e); public void remove (int i); 56 public int size (); public Object get (int i); } Os métodos add, set e remove são transformadores; os métodos size e get são observadores. É comum que um tipo mutável não tenha produtores (e, óbvio, que um tipo imutável não tenha transformadores). Para especificar estes métodos iremos precisar de um modo através do qual poderemos falar a respeito da lista considerando sua estrutura. Faremos isso através do conceito de campos de especificação. Você pode assumir que um objeto de um determinado tipo possui campos de especificação, mas lembre-se que não é necessário que estes campos sejam, realmente, campos em sua implementação. Também não é exigido que o valor de um campo de especificação possa ser obtido através de algum método. Neste caso, iremos descrever as listas com um único campo de especificação, seq [Object] elems; para uma lista l, a expressão l.elems irá indicar a seqüência de objetos armazenados na lista, que é indexada a partir do zero. Agora, podemos especificar alguns métodos: public void get (int i); // throws // IndexOutOfBoundsException if i < 0 or i > length (this.elems) // returns // this.elems [i] public void add (int i, Object e); // modifies this // effects // throws IndexOutOfBoundsException if i < 0 or i > length (this.elems) // else this.elems’ = this.elems [0..i-1] ^ <e> ^ this.elems [i..] public void set (int i, Object e); // modifies this // effects // throws IndexOutOfBoundsException if i < 0 or i >= length (this.elems) // else this.elems’ [i] = e and this.elems é inalterado em qualquer outro lugar Na pós-condição de add, eu utilizei s[i..j] para indicar a seqüência de s que vai do índice i até o índice j, e s[i..] para indicar a seqüência de elementos a partir do índice i. O acento circunflexo 57 significa concatenação de seqüência. Portanto, a pós-condição diz que, quando o valor do índice passado como argumento está dentro dos limites do array, o novo elemento é colocado no índice passado como argumento. 5.5. Projetando um Tipo Abstrato Projetar um tipo abstrato envolve a escolha de boas operações e a determinação de como elas devem se comportar. Algumas boas práticas são: · É melhor ter algumas poucas operações simples que podem ser combinadas para realizar funções mais complexas, do que ter um monte de operações complexas. · Cada operação deve ter um propósito bem definido, e ter um comportamento coerente ao invés de uma montanha de casos especiais em seu código. · O conjunto de operações deve ser adequado; deve haver o suficiente para se realizar o tipo de computação que os clientes provavelmente irão precisar. Um bom teste é checar que todas as propriedades de um objeto de um determinado tipo podem acessadas. Por exemplo, se não houvesse a operação get, não seríamos capazes de encontrar quais são os elementos da lista. A obtenção de informações básicas não deve ser uma complicação para o cliente. O método size, por exemplo, não é estritamente necessário, pois poderíamos aplicar o método get sobre valores incrementais de índice, o que é ineficiente e inconveniente. · O tipo pode ser genérico: uma lista ou um conjunto, ou um grafo por exemplo. Ou pode ser específico para um dado domínio (domínio-específico): o mapa de uma rua, uma base de dados de empregados, uma agenda de telefones, etc. Mas não deve misturar características específicas com características domínio-específicas. 5.6. A Escolha das Representações Até aqui, focamos na caracterização de tipos abstratos através de suas operações. No código, uma classe que implementa um tipo abstrato possui uma representação: a estrutura de dados propriamente dita que suporta as operações. A representação será uma coleção de campos (campos de fato ou operações) cada um dos quais possuindo algum outro tipo Java; em uma representação recursiva, um campo pode ter um tipo abstrato (uma classe), mas isto é raramente realizado em Java. Listas encadeadas são uma representação comum de listas, por exemplo. O modelo de objeto apresentado abaixo mostra uma implementação de uma lista encadeada semelhante (mas não idêntica) à classe LinkedList da biblioteca padrão do Java: O objeto lista possui um campo header que referencia um objeto Entry (o nome entry relembra 58 um dado fornecido como dado de entrada para a lista). Um objeto Entry é um registro com três campos (ou um objeto com três propriedades): next e prev que podem manter referências para outros objetos Entry (ou podem ser null), e element, que mantém uma referência para um objeto que, de fato, é o elemento armazenado na lista. Os campos next e prev são links que apontam para frente e para trás ao longo da lista. No meio da lista, após uma chamada consecutiva aos métodos next e prev, o ponteiro da lista estará apontado para o objeto que era apontado inicialmente, antes das chamadas aos métodos. Vamos assumir que a lista encadeada não armazena referências nulas como elementos. Haverá sempre um elemento Entry auxiliar no início da lista, cujo campo element é nulo, sendo que este Entry não deve ser considerado um dado de entrada da lista, mas apenas um auxiliar. O diagrama de objetos abaixo mostra uma lista contendo dois elementos: 59 Uma outra representação diferente de listas utiliza um array na representação. O modelo de objetos abaixo mostra como as listas são representadas na classe ArrayList da biblioteca padrão do Java: Aqui temos um exemplo com dois elementos na representação por ArrayList. 60 Estas representações possuem diferentes vantagens. A representação de lista encadeada será mais eficiente quando houver muitas inserções na lista, pois um novo elemento pode ser adicionado à cadeia necessitando apenas que alguns poucos ponteiros sejam modificados. Diferentemente, durante uma inserção, a representação por array tem que promover todos os elementos que estão acima do índice do elemento a ser inserido para uma posição posterior. E, se o array for muito pequeno, pode ser necessário alocar um novo array, maior que o anterior, e copiar todas as referências para a nova lista que foi criada. Se houver muitas operações de get e set, no entanto, a representação da lista por array é melhor, pois provê acesso randômico em tempo constante, enquanto que a lista encadeada tem que realizar uma busca seqüencial. Podemos não saber que operações irão predominar quando estivermos escrevendo código para representação de uma lista. A questão crucial, então, é como podemos ter certeza que será fácil alterar a representação posteriormente. 5.7. Independência de Representação Independência de representação significa que o uso de um determinado tipo abstrato é independente de sua representação, de maneira que alterações em sua representação não causam efeitos no código exterior que utiliza o código do tipo abstrato. Na seqüência, vamos analisar o que pode dar errado caso não exista independência de representação, veremos também alguns mecanismos de linguagem que ajudam a garantir a independência. Suponha que saibamos que nossa lista é implementada como um array de elementos. Estamos tentando utilizar um código que cria uma seqüência de objetos, mas infelizmente este código armazena a seqüência em um objeto Vector e não em um List com representação de array, como queremos. O tipo de dados List que estamos utilizando não oferece um construtor capaz de receber um objeto do tipo Vector e fazer a conversão automaticamente. Descobrimos que Vector possui um método denominado copyInto que copia os elementos do vetor para um array. Escrevemos, então, o seguinte código: 61 List l = new List (); v.copyInto (l.elementData); Onde v representa uma instância de um objeto Vector. Que truque inteligente! Mas como muitos truques, ele funciona por pouco tempo. Suponha que o desenvolvedor da classe List decida alterar a representação da versão que utiliza array para uma versão de lista encadeada. Agora a lista l não terá mais um campo elementData, como havia quando utilizava a representação de array. O compilador irá rejeitar o programa. Esta é uma falha de independência de representação: teremos que alterar todos os lugares do código que utilizam o truque de copiar um Vector para um List. A compilação falhar não é um desastre tão grande. Seria bem pior caso a compilação tivesse tido sucesso. Pois a alteração do objeto List iria, da mesma forma, destroçar o programa que agora está rodando. Veja como isto pode acontecer: Em geral, o tamanho do array deve ser maior do que o número de elementos na lista, do contrário, seria necessário criar um novo array toda vez que um elemento fosse removido ou adicionado. Portanto, deve haver algum meio de marcar o fim do segmento do array no qual os elementos estão contidos. Suponha que o desenvolvedor da lista a projetou assumindo que o fim da lista é marcado por uma única referência nula, que quando encontrada será interpretada como o final da lista de elementos. Ou pelo fim do array propriamente dito, o que for encontrado primeiro. Com sorte (ou, na verdade, com falta de sorte) nosso truquezinho de copiar o Vector para o List funciona sob estas circunstâncias. Neste momento, nosso desenvolvedor percebe que a utilização da referência nula não é uma boa escolha, pois para se determinar o tamanho da lista, é necessário realizar uma busca seqüencial para se encontrar a primeira referência nula. Portanto, ele acrescenta um campo size que é atualizado toda vez que uma operação altera o conteúdo da lista. Esta é uma solução bem melhor, pois acessar o tamanho da lista agora pode ser feito em tempo constante, além de que a lista poderá, naturalmente, manipular referências nulas como elementos da lista (é por conta desta vantagem que a implementação da LinkedList do Java utiliza este artifício). Nesta situação, nosso truque está propenso a produzir algum comportamento de erro cuja causa é difícil de ser encontrada. A lista que criamos possui um campo size com valor zero, mesmo que contenha muitos elementos na lista (pois, durante a cópia, fizemos a atualização apenas do array, e não do campo size). As operações de get e set, aparentemente, irão funcionar, mas a primeira 62 chamada ao campo size irá falhar misteriosamente. Aqui temos um outro exemplo. Suponha que tenhamos a implementação da lista utilizando a representação de lista encadeada, e que acrescentamos uma operação que retorna o objeto Entry correspondente a um dado índice. public Entry getEntry (int i) O método set(i,w) de List utilizando a representação de lista dinâmica é executado em duas etapas: na primeira é feita uma busca seqüencial para se encontrar, e acessar, o elemento Entry de índice i; na segunda, o campo element é atualizado para fazer referência ao objeto w que deve ser inserido na lista. Ao sabermos que a representação utilizada é de lista encadeada, concluímos que, se são realizadas muitas chamadas ao método set no mesmo índice, poderíamos evitar o primeiro passo da execução do método (a busca seqüencial) acessando o elemento do índice onde será realizada a inclusão. Ao invés de l.set (i, x); ... ; l.set (i, y) agora, podemos escrever Entry e = l.getEntry (i); e.element = x; ... e.element = y; uma alternativa baseada no conhecimento da representação do dado abstrato que pode oferecer vantagens em termos de desempenho, já que a busca seqüencial é relativamente custosa. No entanto, esta alternativa também viola a independência de representação, porque quando houver uma alteração na implementação da List para a representação de array, não haverá mais objetos Entry. Podemos ilustrar o problema através de um diagrama de dependência modular: 63 Deveria haver, apenas, uma dependência do tipo Client sobre o tipo List (e sobre o tipo do campo element, que neste caso é Object). A dependência de Client sobre Entry é a razão de nossos problemas. Retornando ao nosso modelo de objeto para esta representação, queremos que a classe Entry e suas associações sejam internas à parte List. Podemos indicar este esquema formalmente através de duas alterações em nosso modelo: primeiro, colorimos as partes que deveriam ser inacessíveis à parte client (a parte denominada Entry em amarelo) e, segundo, adicionamos um campo de especificação denominado elems que esconde a representação: No exemplo de Entry expusemos a representação. Uma exposição mais aceitável, e que é bem comum, surge da implementação de um método que retorna uma coleção. Quando a representação já contém um objeto que mantém a coleção, é comum se tentar retornar, e acessar, o objeto diretamente. 64 Por exemplo, suponha que List possui um método toArray que retorna um array de elementos correspondentes aos elementos da lista. Se tivéssemos implementado a lista como um array, poderíamos apenas retornar o array propriamente dito. Nesta situação, se o campo size fosse baseado no índice onde uma referência nula aparece pela primeira vez, uma modificação neste array poderia impedir o cálculo do tamanho do array. a =l.toArray (); // expõe os dados a[i] = null; // ops! … l.get (i); // agora o comportamento é imprevisívely Uma vez que size é computado erradamente, tudo pode dar errado: operações subseqüentes podem se comportar de formas imprevisíveis. 5.8. Mecanismos de Linguagem Para evitar o acesso à representação, podemos definir os campos como private. O que impede o truque do array que fizemos no exemplo mais acima; Por exemplo, a sentença v.copyInto (l.elementData); seria rejeitada pelo compilador, pois a expressão l.elementData estaria referenciando, ilegalmente, um campo private a partir de um local externo à classe. Já o problema decorrente do campo Entry não é tão facilmente resolvido. Não há acesso direto à representação. Ao invés disso, a classe List retorna um objeto Entry que pertence à representação. Esta ocorrência é denominada exposição de representação, e não pode ser evitada apenas por mecanismos de linguagem. Precisamos garantir que referências a componentes mutáveis internos à representação não sejam passados para os clientes do lado de fora; precisamos garantir também que a representação não é construída a partir de objetos mutáveis passados como argumentos para a representação interna. Na representação por array, por exemplo, não podemos permitir um construtor que recebe um array e o atribui para um campo interno, deliberadamente. As interfaces provêm um outro método para se alcançar independência de representação. Na biblioteca Java padrão, as duas representações de lista que discutimos, na verdade, são classes distintas: ArrayList e LinkedList. Ambas são declaradas estendendo a interface List. A interface quebra a dependência entre o cliente e a outra classe, neste caso a classe de representação: 65 Esta abordagem é muito boa, pois uma interface não pode ter campos (de fato, pode ter apenas campos que sejam estáticos), de maneira que a questão de se acessar a representação nunca é um problema. Mas, pelo fato de que interfaces em Java não podem ter construtores, pode ser ineficaz utilizar este recurso na prática, pois as informações de como são invocados os construtores compartilhados entre as classes de implementação que estendem uma mesma interface, não podem ser expressas através da interface. Além disso, como o código cliente deve, em algum ponto, construir objetos, haverá dependências sobre as classes concretas (que obviamente tentaremos localizar), e não apenas sobre a interface como supõe a prática de desacoplamento. O padrão de projeto denominado Factory, que iremos discutir mais tarde no curso, aborda este problema em particular. 5.9. Resumo Tipos abstratos são caracterizados por suas operações. A independência de representação torna possível alterar a representação de um tipo sem que seus clientes tenham que ser alterados também. Em Java, mecanismos de controle de acesso e interfaces podem ajudar a garantir independência de representação. Não obstante, a exposição de representações pode comprometer a utilização de um tipo abstrato, necessitando ser manipulada dentro de uma cuidadosa disciplina de programação. 66