Entendendo o equals e o hashCode

O tema de hoje é referente a dois personagens presentes na API do Java que sempre usamos, mas nem sempre trabalhamos com eles de forma correta: os métodos equals() e hashCode(). Há quem diga que quando implementamos um deles, obrigatoriamente temos que implementar o outro. Isso não é verdade, meu caro! Neste artigo você aprenderá sobre o uso eficiente destes métodos, suas premissas de implementação e principalmente a relação afetiva entre eles.

O método equals

Como o próprio nome deixa claro, este método tem como objetivo comparar o estado de igualdade entre dois objetos. Este método é herdado da classe Object por todas as classes Java criadas, uma vez que estas possuem uma relação IS-A com Object de forma implícita. Vejamos a implementação do equals() fornecida pela classe Object:

Método simples e super intuitivo, não é mesmo? Fazendo uma análise bem simplória já é capaz de percebermos que este método é público, retorna um valor booleano e recebe como parâmetro um Object. Mas o importante é analisarmos o seu comportamento padrão. Observe que a implementação do método compara meramente a referência entre objetos! Vejamos dois exemplos básicos:

Porém, este método tem poderes e capacidades muito maiores do que simplesmente comparar referências entre objetos, como por exemplo fazer uma comparação lógica entre os objetos. Sacou? 🙂 Vamos pegar a classe String como exemplo. Esta classe tem uma implementação própria onde a sequência de caracteres é comparada entre os objetos, não mais a referência, fornecendo assim uma diferenciação lógica entre as instâncias geradas. Vejamos alguns exemplos de comparação de referência, semelhante à implementação padrão do equals(), e o teste utilizando o métodos equals() sobrescrito pela String:

Para garantir a eficiência da implementação deste método, alguns pontos devem ser levados em consideração:

  1. Simetria: para duas referências, x e y, x.equals(y) se e somente se y.equals(x).
  2. Reflexividade: para qualquer referência não nula de x, x.equals(x) deve ser verdadeiro.
  3. Transitividade: se x.equals(y), y.equals(z), então z.equals(x)  e x.equals(z) devem ser verdadeiros.

É muito importante salientar que estas são premissas para o funcionamento eficiente do método equals(). Se uma destas três regras não for cumprida, não haverá erro de compilação, porém, o resultado pode divergir do esperado.

Vamos brincar com um exemplo real tratado pela classe Produto. Esta classe é composta pelos atributos SKU e nome, onde o SKU é um código único para cada produto cadastrado. Sendo assim, nossa comparação lógica para saber se um produto é igual ao outro será definida pela comparação da sequência de caracteres do SKU. Vejamos como a classe ficaria num exemplo de comparação usando o equals() sobrescrito.

Modelo 1 – Implementação da classe Produto

Modelo 2 – Testando os conceitos de transitividade, reflexividade e simetria nas instâncias da classe Produto

É importante que fique claro a possibilidade de sobrescrita e flexibilidade de implementação deste método. Além disto, lembre-se que se o método for implementado de forma incorreta ou incoerente, não haverá erro de compilação!!!!

O método hashCode

Assim como o equals, o hashCode é nativo da classe Object e também pode ser sobrescrito por qualquer classe Java criada. Muitos de nós desenvolvedores de software desconhece a importância da implementação deste método e opta por não implementa-lo em suas classes. Eu não diria que este é um erro grave, mas certamente você deixará de melhorar alguns pontos de desempenho de sua aplicação, principalmente quando o assunto envolver algorítimos de espalhamento. Antes de entrarmos neste assunto, vejamos a implementação nativa do método hashCode:

Este método retorna um primitivo do tipo int o qual é utilizando como índice ou chave de espalhamento (hashing) nos algorítimos baseados em Hash, como por exemplo HashSets, HashMap, entre outros. Confuso não é mesmo? Pra tentar simplificar o tal algorítimo de espalhamento, vamos imaginar uma academia com mais de 1 milhão de alunos matriculados, onde as fichas destes alunos ficam todas soltas dentro de uma caixa organizadora, e que em dado momento eu precise recuperar a ficha do aluno “Raphael”. O jeito seria encarar a caixa com 1 milhão de fichas e rezar 100 Ave Marias pra dar sorte de encontrar a ficha do Raphael no meio dessa confusão. Sacou o problema de desempenho?! Aí que entra o tal algorítimo de espalhamento. Não seria mais fácil criarmos divisórias na caixa organizadora contendo as letras do alfabeto e distribuir as fichas dos alunos de acordo com a letra inicial de seu nome? Desta forma, se eu quiser a ficha do Raphael, provavelmente ela estará na divisória “R”, a do Joaquim na divisória “J”, e assim por diante.

Buckets hashCode

Imagem 1 – Buckets hashCode, Fonte: Blog da Algaworks

E é exatamente desta forma que funcionam os algorítimos de espalhamento, onde as divisórias da nossa caixa organizadora são chamadas de buckets. O índice destes buckets é gerado exatamente pelo método hashCode dos objetos que entrarão na coleção de Hashes (HashMap, HashSet, entre outros). Vamos adaptar nossa classe Produto para que quando manipulada por um algorítimo de hash ela seja “organizada” pela letra inicial do SKU, já que este atributo representa o código único de um produto no nosso sistema.

Veja que então pegamos o primeiro caractere da String SKU e o retornamos como índice de espalhamento do nosso objeto – chars podem ser convertidos para int. Mas imagine que todos os alunos da academia tenham o nome iniciando com a letra “A”. Lascou novamente! Apesar de termos vários buckets na caixa organizadora, todos as fichas estariam alocadas em somente um bucket, o de letra “A”. Aí o problema de performance no processo de encontrar a ficha voltaria à tona! Logo um ponto importante é apresentado: quanto menos objetos tivermos alocados num bucket, melhor será nosso desempenho.

Você deve ter pensando: tudo bem, encontrei uma divisória de fichas com nomes iniciados por “A”, mas como vou encontrar o que realmente quero dentro dessa bagunça?? Aí que entra a relação afetiva entre o hashCode e o equals, jovem padauã. Veja, o hashCode te diz em qual bucket o objeto está, mas é o equals que te diz qual é o objeto correto!!!! Por este motivo, muita gente afirma de forma até errônea que os dois devem ser implementados de forma obrigatoria. Tendo em vista essa relação entre os dois métodos, é importante saber os dois conceitos abaixo:

  1. Dada duas referências, x e y, se x.equals(y), então o hashCode de x e y deve ser o mesmo.
  2. Objetos diferentes podem ou não ter o mesmo hashCode.

Bom, já vimos que organizar nossos produtos tendo como índice a primeira letra do SKU não é uma boa ideia, certo? Lembra que usamos a implementação do equals() sobrescrita pela classe String no nosso Produto? Podemos fazer o mesmo com o hashCode! Veja como ficaria nossa classe:

Vamos brincar com tudo isso agora? Que tal fazermos uma aplicação simples de cadastro de produtos utilizando coleções? Temos unicamente como requisito não permitir que um mesmo produto seja cadastrado duas vezes. Será que as implementações que fizemos na classe Produto atenderão este requisito? Vejamos!

Cenário 1 – cadastro de produto utilizando ArrayList

Importante ressaltar neste algorítimo que ao chamarmos o método contains(obj), a verificação será feita por meio do método equals do obj do tipo Produto, logo, nossa lista não permitirá dois objetos com o mesmo SKU! Ao tentar cadastrar os produtos: ADM123, ADM12 e ADM12, temos a seguinte saída no console:

Nosso método equals funcionou perfeitamente! Agora vamos testar um cenário contendo algorítimo de espalhamento.

Cenário 2 – cadastro de produtos utilizando HashSet sem a implementação do hashCode

Primeiramente eu te convido a comentar o bloco do método hashCode na classe Produto antes de prosseguirmos a fim de compararmos o comportamento de do algorítimo de hashing sem e com a implementação do método em questão.

A classe Produto ficará da seguinte forma:

Modelo 3 – comentando o método hashCode na classe Produto

Vamos analisar o comportamento da aplicação:

Veja que agora nosso sistema permitiu o cadastro de dois produtos contendo o mesmo SKU!!! No caso do HashSet o algorítimo de espalhamento funciona da seguinte forma quando o método contains é executado:

  1. executa o hashCode do objeto que será comparado e verifica se existe um bucket com o valor do índice retornado.
  2. se já existe um bucket do índice retornado, o método equals é executado para dar fundamento ao contains.
  3. se o hashCode não determina o índice da bucket, esse objeto fica “solto” na tabela de espalhamento e quando o equals é chamado, ele verifica uma referência nula, ou seja, retorna false.

Agora vamos devolver o método hashCode pra classe Produto e ver como a aplicação se comporta:

Cenário 3 – cadastro de produtos utilizando HashSet com a implementação do hashCode

Aqui a execução é a mesma do cenário 2, com exceção que agora existe a implementação do hashCode. Vamos observar o console de saída ao executar a aplicação:

Agora tudo ocorreu como esperávamos, pois nossa estratégia de geração de índice foi eficiente, fazendo com que o algorítimo localize o bucket do objeto e execute o equals baseado nos resultados da cesta.

Espero que tenha ficado claro a relação entre os métodos equals e hashCode, suas premissas/boas práticas e principalmente a importância destes caras nas nossas classes!

Grande abraço e até a próxima.

 

Written by Raphael Oliveira Neves
Engenheiro de software, evangelista de novas tecnologias e apaixonado por arquitetura e desenvolvimento de software utilizando Java.