Aula 9: Igualdade entre objetos, Cópia de objetos e - mit

Propaganda
Aula 9: Igualdade entre objetos, Cópia de objetos e
Visões
9.1 O Contrato da Classe Object
Toda classe estende a classe Object e, portanto, herda também todos os seus métodos. Dois destes
métodos são particularmente importantes, pois geram conseqüências em todos os programas. O
método para teste de igualdade:
public boolean equals (Object o)
e o método para geração de código hash:
public int hashCode ()
Como quaisquer outros métodos de uma superclasse, estes métodos podem ser sobrepostos.
Veremos em uma aula próxima, sobre tipagem, que uma subclasse deveria ser um subtipo. Isto
significa que ela deveria se comportar de acordo com a especificação da superclasse, de forma que
um objeto da subclasse possa ser utilizado em um contexto no qual um objeto da superclasse é
esperado, funcionando apropriadamente.
A especificação da classe Object é um tanto abstrata e pode ser considerada confusa. Mas a não
conformidade com sua especificação pode ocasionar terríveis conseqüências, resultando em bugs
complexos e obscuros. Ainda pior, se você não compreender esta especificação e suas ramificações,
você poderá estar introduzindo falhas no seu código, falhas profundas e difíceis de serem
eliminadas sem um trabalho duro. A especificação da classe Object é tão importante que muitas
vezes é referenciada como ‘O Contrato da classe Object’, ou contrato de objeto.
O contrato pode ser encontrado nas especificações dos métodos equals e hashCode na
documentação da API Java. Onde é estabelecido o seguinte:
· equals deve estabelecer uma relação de equivalência - isto é, uma relação que é reflexiva,
simétrica e transitiva;
· equals deve ser consistente: repetidas chamadas ao método devem gerar o mesmo resultado, a
não ser quando os argumentos são modificados;
· para uma referência não nula x, x.equals(null) deve retornar false;
· hashCode deve produzir o mesmo resultado para dois objetos considerados iguais pelo método
equals;
99
9.2 Propriedades de Igualdade
Vamos olhar primeiro nas propriedades do método equals. Ser reflexivo significa que um dado
objeto será sempre igual a si mesmo; simetria significa que quando a equals b, b equals a;
transitividade significa que quando a equals b e b equals c, a equals c.
Estas propriedades podem parecer óbvias, e de fato são. Mas se elas não forem válidas, é difícil
imaginar como o método equals poderia ser utilizado: seria preciso preocupar-se com a correta
escrita do método: a.equals(b) ou b.equals(a), por exemplo, caso a simetria não fosse uma
propriedade válida.
Mas menos óbvio, no entanto, é o quão fácil essas propriedades podem ser quebradas
inadvertidamente. O seguinte exemplo (extraído do excelente Effective Java: Programming
Language Guide, de Joshua Bloch's, um dos textos recomendados pelo curso) mostra como a
simetria e a transitividade podem ser quebradas quando utilizamos herança.
Considere uma simples classe que implementa um ponto bi-dimensional:
public class Point {
private final int x;
private final int y;
public Point (int x, int y) {
this.x = x; this.y = y;
}
public boolean equals (Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
…
}
Agora considere que adicionamos a noção de cor:
public class ColourPoint extends Point {
private Colour colour;
public ColourPoint (int x, int y, Colour colour) {
100
super (x, y);
this.colour = colour;
}
…
}
Como deve ser o método equals da classe ColourPoint? Poderíamos apenas herdar o método equals
de Point, mas dessa forma dois objetos ColourPoint seriam considerados iguais mesmo que
tivessem cores diferentes. Poderíamos sobrepor o método da seguinte forma:
public boolean equals (Object o) {
if (!(o instanceof ColourPoint))
return false;
ColourPoint cp = (ColourPoint) o;
return super.equals (o) && cp.colour.equals(colour);
}
Este método, aparentemente inofensivo, viola a exigência da simetria. Para ver o porquê, considere
um ponto Point e um ponto colorido ColourPoint:
Point p = new Point (1, 2);
ColourPoint cp = new ColourPoint (1, 2, Colour.RED);
Agora p.equals(cp) retorna true, mas cp.equals(p) retorna false! O problema é que estas duas
expressões utilizam diferentes métodos equals: a primeira utiliza o método da classe Point, que
ignora cor, e o segundo utiliza o método da classe ColourPoint.
Poderíamos tentar consertar este problema fazendo com que o método da classe ColourPoint ignore
a cor quando a comparação é realizada com um ponto que não é colorido:
public boolean equals (Object o) {
if (!(o instanceof Point))
return false;
// se o é um Point normal, utilize a comparação sem considerar cor
if (!(o instanceof ColourPoint))
return o.equals (this);
ColourPoint cp = (ColourPoint) o;
return super.equals (o) && cp.colour.equals (colour);
}
101
O que resolve o problema da simetria, mas agora temos um problema de transitividade! Para ver o
porquê, considere a construção destes pontos:
ColourPoint p1 = new ColourPoint (1, 2, Colour.RED);
Point p2 = new Point (1, 2);
ColourPoint p3 = new ColourPoint (1, 2, Colour.BLUE);
As chamadas p1.equals(p2) e p2.equals(p3) irão ambas retornar true, mas p1.equals(p3) irá retornar
false.
Desta forma parece que não há solução para o problema: trata-se de um problema fundamental da
utilização de herança. Não se pode escrever um bom método equals para ColourPoint, caso esta
classe seja herdeira de Point. No entanto, se você implementar ColourPoint utilizando Point em
sua representação, de maneira que um ColourPoint não seja mais tratado como um Point, o
problema desaparece. Veja o livro de Bloch para mais detalhes.
O livro de Bloch também dá boas dicas sobre como escrever um bom método equals, apontando
alguns erros muito comuns. Por exemplo, o que acontece se você escreve alguma coisa parecida
com isto:
public boolean equals (Point p)
Isto é, utilizando um outro tipo que não Object na declaração do método equals?
9.3 Hashing
Para compreender a parte do contrato de objeto relacionada ao método hashCode, você deverá saber
alguma coisa a respeito de como uma tabela hash funciona.
As tabelas hash são uma invenção fantástica - uma das melhores idéias da ciência da computação.
Uma tabela hash é uma representação de um mapeamento: um tipo abstrato de dado que mapeia
chaves para valores. As tabelas hash oferecem tempo constante de busca e, portanto, possuem um
desempenho melhor do que as árvores ou listas. As chaves não precisam estar ordenadas, ou
possuírem qualquer propriedade particular, exceto oferecerem os métodos equals e hashCode.
Agora mostraremos como uma tabela hash funciona. Ela possui um array inicialisado com um
tamanho correspondente ao número de elementos que esperamos inserir. Quando uma chave e um
valor são apresentados para inserção, computamos o valor hash (ou código hash) da chave, e
102
convertermos este código para um índice dentro do intervalo abrangido pelo array (por exemplo,
através da operação módulo de divisão). O valor, então, é inserido naquele índice.
A invariante rep de uma tabela hash inclui a restrição fundamental de que as chaves devem ser
inseridas nas posições determinadas pelos seus respectivos códigos hash. Veremos mais adiante por
que isso é importante.
Os códigos hash são projetados de forma que as chaves sejam distribuídas igualmente ao longo dos
índices. Mas, ocasionalmente ocorrem conflitos, e duas chaves são colocadas no mesmo índice.
Então, ao invés de armazenar um único valor em um índice, a tabela hash armazena em cada
posição de seu índice uma lista de pares chave/valor (normalmente denominados 'hash buckets').
Durante a inserção, insere-se um destes pares na lista correspondente à posição determinada pelo
código hash. Durante a operação de busca, aplica-se a função hash sobre o elemento de busca para
que se recupere seu código hash, encontra-se a posição correta no array, e examinam-se cada um
dos pares alocados na lista daquela posição até que se encontre a chave procurada.
Agora deve estar claro o porquê da exigência de que objetos iguais tenham o mesmo código hash.
Se dois objetos que são iguais possuem códigos diferentes, eles podem ser colocados em posições
diferentes. Então, se tentarmos fazer uma busca utilizando a chave utilizada na operação de inserção
do par chave/valor, a busca pode falhar.
Uma maneira simples e drástica para se garantir que o contrato de objeto é satisfeito pelo método
hashCode, é fazer com que este método retorne sempre um valor constante, de maneira que todo
objeto tenha o mesmo código hash. Esta medida satisfaz o contrato de objeto, mas resultaria em um
desastre de performance, pois toda chave seria armazenada na mesma posição, e toda busca seria
realizada linearmente ao longo de uma lista.
A maneira padrão de se construir um código hash mais adequado, e que ainda satisfaz o contrato, é
computar o código hash para cada componente (propriedade do objeto) utilizado na determinação
de igualdade do objeto. Normalmente, procede-se chamando o método hashCode de cada
componente, combinando cada código conseguido através de algumas operações aritméticas.
Refira-se ao livro de Bloch para mais detalhes.
Ainda mais crucial, perceba que se você não sobrepor o método hashCode, você estará utilizando a
versão do método da classe Object, que é baseada no endereço do objeto. Caso você tenha
sobreposto o método equals, é quase certeza que você violou o contrato. Então, como uma regra
geral:
Sempre sobreponha o método hashCode quando você sobrepor o método equals.
103
Esse é um dos aforismos de Bloch. Todo o livro é uma coleção de aforismos como este, cada um
dos quais explicado e ilustrado.
Ano passado, um estudante dedicou horas tentando descobrir um bug em um projeto que foi escrito
com um pequeno erro: ao invés de hashCode, foi utilizado o termo hashcode. Como resultado, foi
criado um método que não sobrepôs o método hashCode da classe Object, fazendo com que coisas
estranhas acontecessem. (o Java é sensível ao caso).
Esta é uma outra razão para se evitar a utilização do recurso de herança ...
9.4 Copiando Objetos
Muitas vezes surge a necessidade de se fazer a cópia de um objeto. Por exemplo, você pode querer
realizar uma computação que altera um objeto, mas deseja que outros objetos que já fazem
referência a este objeto não sejam afetados. Ou, em um outro caso, você pode ter um objeto
protótipo a partir do qual você quer criar uma coleção de objetos que diferem sutilmente, sendo
conveniente fazer cópias deste protótipo para modificá-las.
As pessoas às vezes falam a respeito de cópias 'rasas' e de cópias 'profundas'. Uma cópia rasa de um
objeto é conseguida criando-se um novo objeto cujos campos apontam para os mesmos objetos para
os quais o objeto original apontava. Uma cópia profunda é conseguida criando-se, também, um
novo objeto para cada um dos objetos apontados pelos campos do objeto original e, talvez, para os
objetos para os quais estes objetos apontam, e assim por diante.
Como a cópia deve ser feita? Se você foi estudioso e consultou a API Java, você pode supor que
será necessário utilizar o método clone da classe Object, junto com a interface Cloneable. Isto é
tentador, pois Cloneable é um tipo especial de interface que adiciona funcionalidades mágicas a
uma classe. Infelizmente, no entanto, o projeto desta parte do Java não está muito correta, sendo
muito difícil utilizá-la corretamente. Portanto, eu recomendo que você não a utilize, a menos que
seja obrigado (por exemplo, quando o código que você está utilizando requer que se implemente
Cloneable, ou porque seu gerente não participou do curso 6170). Consulte o livro de Bloch para
uma interessante discussão dos problemas relacionados.
Você pode achar que é bom declarar um método desta forma:
class Point {
Point copy () {
…
104
}
…
}
Preste atenção ao tipo de retorno: a cópia de um Point deve resultar em um Point. Mas em uma
subclasse, você gostaria que o método copy retornasse um objeto da subclasse:
class ColourPoint extends Point {
ColourPoint copy () {
…
}
…
}
Infelizmente, isto não é permitido em Java. Você não pode alterar o tipo de retorno de um método
quando sobrepô-lo em uma subclasse. E a sobrecarga de nomes de métodos permite apenas a
alteração dos tipos dos argumentos. Portanto, você seria forçado a declarar ambos os métodos da
seguinte forma:
Object copy ()
Isto é um incômodo, pois será necessário fazer uma operação de downcast para que se possa
trabalhar com o tipo correto. Mas este recurso funciona, e algumas vezes é a coisa certa a se fazer.
Existem duas outras formas de se fazer uma cópia. Uma delas é utilizar um método estático
conhecido como método 'factory', ou método de fabricação, pois cria novos objetos:
public static Point newPoint (Point p)
A outra forma é fornecer construtores adicionais, normalmente denominados 'copy constructors', ou
'construtores de cópia'.
public Point (Point p)
Ambas as formas funcionam muito bem, embora não sejam perfeitas. Você não pode colocar
métodos estáticos ou construtores em uma interface, portanto, essas alternativas não são perfeitas
quando você quer promover funcionalidades genéricas. A abordagem do construtor de cópia é
amplamente utilizada na API do Java. Uma característica interessante dessa abordagem é que ela
permite que o cliente escolha a classe do objeto a ser criado. O argumento para o construtor de
105
cópia é muitas vezes declarado como tendo o tipo de uma interface, de maneira que você possa
passar para o construtor qualquer tipo de objeto que implemente a interface. Todas as classes de
coleção do Java, por exemplo, fornecem um construtor de cópia que recebe um argumento do tipo
Collection ou Map. Se você pretende criar um ArrayList a partir de um LinkedList l, por exemplo,
seria preciso apenas chamar
new ArrayList (l)
9.5 Igualdade de Elementos e igualdade de Containers
Containers são objetos que, fundamentalmente, armazenam referências para outros elementos ou
objetos. Por exemplo, os tipos List e derivados são containers. Quando dois objetos do tipo
container são iguais?
Se eles são imutáveis, eles devem ser iguais se contiverem os mesmos elementos. Por exemplo,
duas strings devem ser iguais se elas contêm os mesmos caracteres (na mesma ordem). Se apenas
mantivermos o método padrão de igualdade da classe Object, uma string inserida através do teclado,
por exemplo, nunca seria igual a uma string que está em uma lista ou uma tabela, pois seria um
novo objeto string (uma outra instância) e, portanto, não seria o mesmo objeto. De fato, é
exatamente assim que o método equals é implementado na classe String do Java, e se você quer
saber se duas strings s1 e s2 contêm a mesma seqüência de caracteres, você deverá escrever:
s1.equals (s2)
e não
s1 == s2
A segunda linha de código do exemplo acima irá retornar falso quando s1 e s2 representarem
diferentes objetos string, mesmo que contenham a mesma seqüência de caracteres.
9.5.1 O Problema
Chega de strings – ou seqüências de caracteres. Vamos considerar agora as listas, que são
seqüências de objetos arbitrários. As listas devem ser consideradas da mesma forma que as strings,
de forma que duas listas são iguais se elas contiverem os mesmos elementos na mesma ordem?
106
Suponha que estou planejando uma festa na qual meus amigos irão se sentar em várias mesas
diferentes, e que escrevi um programa para me ajudar a estabelecer o planejamento. Eu represento
cada mesa como uma lista de amigos, e a festa inteira como um conjunto de listas do tipo HashSet.
O programa se inicia criando listas vazias para as mesas e inserindo estas listas ao conjunto de
mesas:
List t1 = new LinkedList ();
List t2 = new LinkedList ();
…
Set s = new HashSet ();
s.add (t1);
s.add (t2);
…
Em algum momento mais tarde, o programa irá adicionar amigos às várias listas; podendo, também,
criar novas listas e substituir listas existentes no conjunto por estas novas listas. Finalmente, o
programa realiza uma iteração sobre o conjunto de mesas imprimindo cada uma das listas.
Este programa irá falhar, pois as inserções iniciais não surtirão o efeito esperado. Mesmo que as
listas vazias representem conceitualmente mesas distintas, de acordo com o método equals de
LinkedList, elas serão iguais. Pois a classe Set aplica o método equals sobre seus elementos afim de
rejeitar duplicatas. Portanto, todas as inserções, menos a primeira, não surtirão nenhum efeito, pois
todas as listas vazias serão encaradas como duplicatas.
Como solucionar este problema? Você pode imaginar que Set deveria ter utilizado == para checar
por duplicatas, ao invés de equals. Desta maneira, um objeto será considerado uma duplicata apenas
se é exatamente aquele objeto (a mesma instância) que já está no conjunto.
É interessante observar que, embora resolva o problema acima, esta abordagem não funciona para
strings; isto significa que após
Set Set = new HashSet ();
String lower = "hello";
String upper = "HELLO";
Set.add (lower.toUpperCase());
…
O teste Set.contains(upper) iria retornar false, pois o método toUpperCase cria uma nova string.
107
9.5.2 A Solução de Liskov
No texto de nosso curso, Liskov apresenta uma solução sistemática para o problema. Você deve
fornecer dois métodos distintos: equals que retorna true quando dois objetos de uma classe possuem
comportamento equivalente e, similar, que retorna true quando dois objetos podem ser observados
como equivalentes.
Aqui está a diferença. Dois objetos possuem comportamento equivalente senão existe uma
seqüência de operações que possa distingüí-los. Nestes termos, as listas vazias t1 e t2 do exemplo
acima não são equivalentes, pois se você inserir um elemento em uma delas, você pode observar
que uma é alterada, mas a outra não. Mas duas strings distintas que contêm a mesma seqüência de
caracteres são equivalentes no comportamento, pois você não pode modificá-las e, portanto, não
pode descobrir que são objetos diferentes (estamos assumindo que você não pode utilizar == neste
experimento).
Dois objetos podem ser observados como equivalentes, ou seja, eles possuem uma equivalência
observacional, se você não puder identificar a diferença entre eles utilizando operações do tipo
observadoras (e não operações do tipo modificadoras). Nestes termos, as listas vazias t1 e t2 do
exemplo acima são equivalentes, pois possuem mesmo tamanho, contêm os mesmos elementos, etc.
Desta forma duas strings que contêm a mesma seqüência de caracteres são, também, equivalentes.
Mostraremos agora como você pode codificar equals e similar. Para um tipo mutável, você
simplesmente herda o método equals da classe Object, e escreve um método similar que realiza
uma comparação campo a campo. Para um tipo imutável, você deve sobrepor o método equals com
um método que realiza a comparação campo a campo, e deve fazer com que similar invoque equals
de maneira que eles sejam o mesmo método (similar se torna um wrapper de equals).
Esta solução, quando aplicada uniformemente, é de fácil compreensão e funciona bem. Mas nem
sempre é ideal. Suponha que você queira escrever um código que utiliza o padrão de projeto
Interning, que será visto na aula 12. Este padrão determina que se você possui referências a objetos
estruturalmente idênticos, deve-se alterar sua estrutura de dados de forma que as referências para
estes objetos apontem para exatamente a mesma instância do objeto, descartando as instâncias de
maneira que reste apenas uma (o representante canônico). Isto é muitas vezes utilizado em
compiladores; os objetos podem ser variáveis de programas, por exemplo, e você deseja que todas
as referências para uma variável particular da árvore de sintaxe abstrata apontem para o mesmo
objeto, assim, qualquer informação que você armazene a respeito desta variável (alterando o objeto)
é efetivamente propagada para todos os locais onde esta variável aparece.
108
Para armazenar e controlar os objetos, você pode tentar utilizar uma tabela hash. Toda vez que você
encontrar um novo objeto na estrutura de dados, você deve procurar este objeto na tabela para
verificar se ele possui um representante canônico. Se possuir o representante, você deve substituir o
objeto pelo seu representante; do contrário, se não houver um representante, você deve inserí-lo na
tabela hash de forma que ele seja, ao mesmo tempo, chave e valor.
Segundo a abordagem de Liskov, esta estratégia iria falhar, pois o teste de igualdade sobre as
chaves nunca encontraria uma correspondência para objetos distintos que são estruturalmente
equivalentes, pois o método equals de um objeto mutável só retorna true quando se trata,
exatamente, do mesmo objeto.
9.5.3 A Abordagem Java
Por razões como esta, o projetista da API de coleções do Java não seguiu esta abordagem. Não
existe um método similar, e o método equals refere-se à equivalência observacional.
Este fato pode causar algumas conseqüências convenientes como, por exemplo, a tabela hash de
armazenagem irá funcionar. Mas também possui algumas conseqüências infortúnias. O programa de
planejamento de festa do exemplo da seção 9.5.1 não irá funcionar, pois duas listas distintas vazias
serão consideradas iguais, como já foi apontado.
Na especificação da classe List do Java, duas listas são iguais não apenas se elas contêm os mesmos
elementos na mesma ordem, mas apenas se elas contêm elementos iguais segundo o método equals,
e na mesma ordem. Em outras palavras, o método equals é chamado recursivamente para todos os
elementos armazenados. Para manter o contrato de objeto, o método hashCode também é chamado
recursivamente sobre os elementos. O que ocasiona uma surpresa desagradável. O seguinte código,
no qual uma lista é inserida em si mesma, irá falhar, pois nunca será concluído, ou seja, nunca irá
terminar!
List l = new LinkedList ();
l.add (l);
int h = l.hashCode ();
Esta é a razão pela qual você irá encontrar avisos na documentação da API Java a respeito da
inserção de objetos containers neles próprios, veja, por exemplo, este comentário da especificação
da classe List:
109
Nota: ao passo que é permitido que as listas contenham a si próprias como elementos, é feito
um aviso para tomada de extrema cautela: os métodos equals e hashCode não mais estão bem
definidos neste tipo de lista.
Existem outras conseqüências, ainda mais delicadas, decorrentes da abordagem Java. São
conseqüências a respeito da exposição de representação. Como será explicado mais adiante.
Este fato lhe deixa com duas opções, ambas as quais são aceitas pelas idéias do curso 6170:
· Você pode seguir a abordagem Java, na qual você terá os benefícios de sua conveniência, mas
terá que lidar com as complicações que podem surgir.
· Alternativamente, você pode seguir a abordagem de Liskov, mas nesse caso você terá que
descobrir como incorporar as classes de coleções do Java no seu código (com LinkedList e
HashSet).
Em geral, quando você tem quem incorporar uma classe da qual o método equals segue uma
abordagem diferente do programa como um todo, você pode escrever um wrapper para a classe a
ser incorporada para substituir o método equals por um mais adequado. O texto do curso dá um
exemplo de como fazer isso.
9.6 Exposição de Rep
Vamos rever o exemplo da exposição de rep com o qual encerramos a última aula. Lá imaginamos
uma variante de LinkedList para representar seqüências sem duplicatas. A operação add foi
definida como possuindo uma especificação que determina que um elemento só é adicionado caso
este elemento não seja uma duplicata, sendo que o código do método add deve realizar esta
checagem.
void add (Object o) {
if (contains (o))
return;
else
// adiciona o elemento
…
}
Devemos registrar, no topo do arquivo, a determinação da invariante rep, isto é, um aviso dizendo
que a lista não contém duplicatas:
110
A lista não contém duplicatas. Ou seja, não existem duas entradas (objetos Entry) distintas e1 e
e2 tais que e1.element.equals (e2.element).
Para verificamos que esta propriedade é preservada, devemos nos certificar que todo método que
adiciona um elemento, primeiro realiza uma checagem.
Infelizmente, esta não é uma boa idéia. Veja o que acontece se construímos uma lista de listas e
alteramos um dos elementos lista:
List x = new LinkedList ();
List y = new LinkedList ();
Object o = new Object ();
x.add (o);
List p = new LinkedList ();
p.add (x);
p.add (y);
x.remove (o);
Após esta seqüência de código, a invariante rep de p é quebrada. O problema é que a alteração em x
o torna igual a y, pois ambos são listas vazias.
O que está acontecendo aqui? O contorno de invariante que passamos ao redor da representação
inclui a classe do elemento, pois a invariante rep depende de uma propriedade do elemento (veja a
figura). Perceba que este problema não teria surgido se a igualdade tivesse sido determinada pela
abordagem de Liskov, pois dois elementos mutáveis seriam iguais apenas se eles fossem
exatamente o mesmo objeto: o contorno se estende apenas até a referência do elemento, e não até o
elemento propriamente dito.
111
9.6.1 Alterando Chaves Hash
Um exemplo mais comum e com maior ocorrência deste fenômeno ocorre com chaves hash. Se
você alterar um objeto depois que ele foi inserido como uma chave de uma tabela hash, pode
ocorrer que seu código hash seja alterado. Como resultado, a invariante rep crucial da tabela hash aquela que determina que as chaves estão armazenadas nas posições especificadas pelos seus
respectivos códigos hash - é quebrada.
Aqui temos um exemplo. Um conjunto hash pode ser entendido como um conjunto implementado
sobre uma tabela hash: imagine este conjunto como uma tabela hash com chaves, mas nenhum
valor. Se inserirmos uma lista vazia no conjunto hash e, então, inserirmos um elemento na lista da
seguinte forma:
Set s = new HashSet ();
List x = new LinkedList ();
s.add (x);
x.add (new Object ());
Uma chamada subseqüente s.contains(x) provavelmente irá retornar false. Se você considera isto
aceitável, considere o fato de que agora não existe nenhum valor x para o qual s.contains(x) retorna
true, mesmo que s.size() retorne 1!
Novamente, o problema é exposição de representação: o contorno ao redor da tabela hash inclui as
chaves.
A lição que tiramos disso é: ou você segue a abordagem de Liskov, que utiliza um wrapper para
sobrepor o método equals da lista do Java, ou certifica-se de que nunca irá alterar a chaves hash. Do
contrário você pode permitir qualquer alteração de um elemento, mas estará correndo o risco de
quebrar a invariante rep do container no qual o elemento está inserido.
Esta é a razão pela qual você verá comentários como este na especificação da API Java:
Nota: muito cuidado deve ser tomado com objetos mutáveis que são utilizados como elementos
de um conjunto. O comportamento de um conjunto torna-se indefinido caso o valor de um objeto
seja alterado de maneira que a alteração afete as comparações de igualdade enquanto este
objeto for um elemento do conjunto. Um caso especial de proibição é: não é permitido que um
conjunto contenha a si mesmo como um elemento.
112
9.7 Visões
Uma prática cada vez mais comum na programação orientada a objetos é ter-se objetos distintos que
oferecem diferentes tipos de acesso para a mesma estrutura de dados subjacente. Tais objetos são
denominados views, ou visões. Normalmente um objeto é chamado de primário, e um outro de
secundário. O objeto primário é chamado de objeto 'underlying', ou subjacente; e o objeto
secundário é chamado de 'view', ou visão.
Estamos acostumados com a utilização de aliasing, isto é, existem duas (ou mais) referências com
nomes distintos para o mesmo objeto, de forma que uma alteração no objeto acessado via uma das
referências aparece como uma alteração para a outra referência:
List x = new LinkedList ();
List y = x;
x.add (o); // altera y também
As visões são problemáticas, pois elas envolvem uma forma delicada de aliasing, na qual os dois
objetos possuem tipos distintos. Nós vimos um exemplo destes com iteradores, cujo método remove
de um iterador remove da coleção subjacente o último elemento inserido:
List x = new LinkedList ();
…
Iterator i = x.iterator ();
while (i.hasNext ()) {
Object o = i.next ();
…
i.remove (); //altera x também
}
Um iterador pode ser considerado como uma visão da coleção subjacente. Aqui temos dois outros
exemplos de visões da API de coleções do Java.
· Para que se consiga um método keySet que retorna um conjunto de chaves de um objeto Map, é
exigido que se implemente a interface Map. Este conjunto é uma visão; pois se o objeto Map
subjacente for alterado, o conjunto retornado por keySet será alterado de forma semelhante.
Diferente de um iterador, esta visão e o objeto subjacente podem, ambos, serem modificados;
deletar-se uma chave do conjunto irá fazer com que a chave e seu valor sejam deletados do
objeto Map. O conjunto não suporta uma operação add, pois não haveria sentido em se
113
adicionar uma chave sem um valor. (isto, por acaso, é a razão pela qual os métodos add e
remove são métodos opcionais da interface Set).
· A classe List possui um método subList que retorna uma visão de parte da lista. A visão
retornada pelo método subList pode ser usada para se acessar a lista com o auxílio de um offset,
eliminando a necessidade de operações explícitas sobre o intervalo de índices. Qualquer
método sobre o intervalo de índices da lista subjacente, e que espera uma lista como
argumento, pode ser utilizado passando-se apenas uma visão na forma de uma sublista, ao
invés da lista toda. Por exemplo, o seguinte código remove um intervalo de elementos de uma
lista:
List.subList(from, to).clear();
Idealmente, uma visão e seu objeto subjacente deveriam ser, ambos, modificáveis com os efeitos
decorrentes das modificações sendo, perfeitamente, propagados entre os dois. Infelizmente, isto não
é sempre possível, muitas visões determinam restrições sobre as quais, alguns tipos de alterações
são impossíveis. Um iterador, por exemplo, se torna inválido se a coleção subjacente for modificada
durante a iteração. Uma sublista é invalidada por certas alterações estruturais ocorridas na lista
subjacente. As coisas ficam ainda mais complicadas quando existem diversas visões do mesmo
objeto. Por exemplo, se você tem dois iteradores simultaneamente sobre a mesma coleção
subjacente, uma modificação sobre um iterador (através de uma chamada ao método remove) irá
invalidar o outro iterador (mas não a coleção).
9.8 Resumo
Os tópicos a respeito de igualdade entre objetos, cópia de objetos e visões demonstram o poder da
programação orientada a objetos, mas também demonstram suas armadilhas. Você deve utilizar um
tratamento sistemático e uniforme durante a utilização de igualdade, durante a utilização de hashing
e durante as operações de cópia em qualquer programa que você escreva. As visões são um
mecanismo muito útil, mas devem ser utilizadas cuidadosamente. A construção de um modelo de
objeto para o seu programa é outra prática útil, pois o modelo irá lembrar você dos locais onde
ocorre compartilhamento fazendo com que você examine cada caso com muito cuidado.
114
Download