Aula 5: Tipos Abstratos - mit

advertisement
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
Download