Iguais ou o Mesmo? — equals, hashCode e Identidade

Iguais ou o Mesmo? — equals, hashCode e Identidade

Identidade e igualdade em Java: o contrato do equals, Double.compare, hashCode com os mesmos campos, o objeto que some de um HashSet, toString, herança contra simetria, o cache de Integer e o string pool, e quando uma classe deve ou não sobrescrever equals.
Java

• • 17 min de leitura

A diferença entre dois objetos serem iguais e serem o mesmo objeto, como ensinar isso às suas classes, e o contrato que o equals e o hashCode precisam cumprir juntos.

Esta pergunta vem se acumulando desde "Operadores, Texto e Conversa com o Usuário", quando dois textos idênticos deram false com ==. Em "Muitos Valores sob um Nome", o mesmo aconteceu com arrays. E em "Do Geral ao Específico" apareceu o método equals herdado de Object, com a promessa de uma aula própria. É esta. Você vai ver que a igualdade entre objetos é uma decisão de projeto, que o Java a expressa por um contrato de dois métodos, equals e hashCode, e que respeitar só metade desse contrato produz alguns dos erros mais difíceis de diagnosticar da linguagem.

Identidade e igualdade

Há duas perguntas diferentes que se pode fazer sobre dois objetos:

  • São o mesmo objeto? Essa é a pergunta da identidade, respondida pelo ==, que compara referências.
  • Representam a mesma coisa? Essa é a pergunta da igualdade, respondida pelo equals, conforme o que a classe definir.

Duas notas de 10 reais são iguais: têm o mesmo valor, e ninguém se importa com qual delas recebe de troco. Mas não são a mesma nota. Cada uma tem seu número de série. Para a maioria dos objetos que representam valores, como pontos, datas, quantias e textos, o que importa é a igualdade. A identidade é um detalhe de onde o objeto está na memória.

O equals herdado de Object responde, por padrão, a pergunta da identidade:

public boolean equals(Object obj) {
    return (this == obj);
}

Por isso, uma classe que não sobrescreve equals considera iguais apenas objetos que são o mesmo. Para a classe Ponto de "O que Fica Dentro, o que Fica Fora", dois pontos com as mesmas coordenadas são diferentes, o que não faz sentido para um valor. Vamos corrigir isso.

O contrato do equals

A documentação de Object exige que toda sobrescrita de equals seja:

  • Reflexiva: x.equals(x) é sempre verdadeiro.
  • Simétrica: se x.equals(y), então y.equals(x).
  • Transitiva: se x.equals(y) e y.equals(z), então x.equals(z).
  • Consistente: chamadas repetidas dão o mesmo resultado, enquanto os objetos não mudam.
  • Segura com null: x.equals(null) é sempre falso, e nunca lança exceção.

Parecem óbvias, e é fácil violá-las sem perceber, como você vai ver adiante. A importância do contrato está no fato de que todo o resto da biblioteca, como as coleções, as buscas e as remoções, depende dele. Uma coleção não tem como funcionar direito com objetos cujo equals é inconsistente.

Implementando equals

A versão do Ponto com equals:

@Override
public boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (!(o instanceof Ponto outro)) {
        return false;
    }
    return Double.compare(x, outro.x) == 0
        && Double.compare(y, outro.y) == 0;
}

Linha por linha:

  1. O parâmetro é Object, e não Ponto. É a assinatura de Object.equals, e só com ela há sobrescrita. Um equals(Ponto outro) seria uma sobrecarga, que as coleções nunca chamam, porque elas enxergam os elementos como Object. É exatamente o problema da sobrecarga decidida pelo tipo estático, visto em "Um Tipo, Várias Formas". O @Override pega esse erro.
  2. O teste de identidade é um atalho: um objeto é sempre igual a si mesmo, e não é preciso comparar campos.
  3. O instanceof com padrão faz duas coisas: devolve falso para null, porque null instanceof Qualquer é sempre falso, e para objetos de outros tipos. Se passar, a variável outro já é um Ponto.
  4. Os campos são comparados. Para double, usa-se Double.compare, e não ==, por causa de dois casos especiais do ponto flutuante de "Cada Valor em sua Caixa":
IO.println(0.0 == -0.0);                        // true
IO.println(Double.compare(0.0, -0.0) == 0);     // false
double nan = 0.0 / 0;
IO.println(nan == nan);                         // false
IO.println(Double.compare(nan, nan) == 0);      // true

Com ==, um NaN não é igual a si mesmo, e isso violaria a reflexividade. Double.compare trata NaN como igual a NaN e distingue 0.0 de -0.0, que é o comportamento coerente para o equals. Para campos int, long, char e boolean, o == basta. Para campos que são objetos, usa-se o equals deles, e, quando o campo pode ser null, Objects.equals(a, b), que trata o null com segurança.

Com isso:

Ponto a = new Ponto(1, 2);
Ponto b = new Ponto(1, 2);
IO.println(a == b);        // false: são dois objetos
IO.println(a.equals(b));   // true: representam o mesmo ponto

hashCode: a outra metade

O contrato tem uma segunda parte, e é ela que costuma ser esquecida: se dois objetos são iguais pelo equals, eles devem ter o mesmo hashCode.

O hashCode devolve um inteiro calculado a partir do conteúdo do objeto. Estruturas de dados como o HashSet e o HashMap, que você vai conhecer a fundo em "Listas, Conjuntos e Mapas", usam esse número para decidir em que "gaveta" guardar cada objeto. Numa busca, elas calculam o hashCode do objeto procurado, vão direto à gaveta correspondente e só lá usam o equals. É isso que torna a busca quase instantânea, mesmo com milhões de elementos.

Veja o que acontece com uma versão do Ponto que tem equals, mas não hashCode:

HashSet<Ponto> visitados = new HashSet<>();
visitados.add(new Ponto(1, 2));
IO.println(visitados.contains(new Ponto(1, 2)));   // false!

(O <Ponto> indica o tipo dos elementos do conjunto, um recurso explicado em "Escreva uma Vez para Todo Tipo".) O equals diz que os pontos são iguais, mas o conjunto não os encontra. Sem sobrescrita, o hashCode herdado é derivado da identidade do objeto, e dois objetos diferentes quase certamente têm códigos diferentes. O conjunto procura o segundo ponto na gaveta errada e nunca chega a chamar o equals. Nenhuma exceção, nenhum aviso: o programa simplesmente dá a resposta errada.

A implementação correta usa os mesmos campos que o equals:

@Override
public int hashCode() {
    return Objects.hash(x, y);
}

Objects.hash combina os valores dos campos num único inteiro. Agora new Ponto(1, 2).hashCode() é igual para qualquer ponto com essas coordenadas, e o contains devolve true.

O contrato não exige o contrário: dois objetos diferentes podem ter o mesmo hashCode. É uma colisão, e é inevitável, porque há mais objetos possíveis do que inteiros. "Aa".hashCode() e "BB".hashCode() valem ambos 2112. Colisões só deixam as estruturas mais lentas, porque aí o equals desempata. Um bom hashCode espalha os objetos de forma que as colisões sejam raras, e Objects.hash faz isso razoavelmente bem, levando em conta a ordem dos campos: Objects.hash(1.0, 2.0) e Objects.hash(2.0, 1.0) dão valores diferentes.

A regra de ouro: sobrescreveu equals, sobrescreva hashCode, sempre, com os mesmos campos.

toString: completando o trio

O terceiro método de Object que toda classe de valor deveria sobrescrever é o toString, que você já conheceu em "Do Geral ao Específico":

@Override
public String toString() {
    return "Ponto(" + x + ", " + y + ")";
}

Não faz parte do contrato de igualdade, mas completa o conjunto de uma classe que se comporta bem: iguais quando devem ser, encontráveis em coleções e legíveis em mensagens de erro, logs e depuração. Um Ponto@6d06d69c num log às três da manhã não ajuda ninguém.

Quando herança e igualdade se chocam

A simetria parece a propriedade mais fácil de cumprir, e é justamente a que a herança quebra com mais facilidade. Imagine um PontoColorido que estende Ponto (numa versão do Ponto sem o final) e acrescenta uma cor, com um equals que também compara a cor:

static class PontoColorido extends Ponto {
    final String cor;
    // ...
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof PontoColorido pc)) {
            return false;
        }
        return super.equals(pc) && cor.equals(pc.cor);
    }
}
Ponto p = new Ponto(1, 2);
PontoColorido pc = new PontoColorido(1, 2, "vermelho");
IO.println(p.equals(pc));   // true
IO.println(pc.equals(p));   // false

A simetria foi violada. O equals do Ponto aceita qualquer Ponto, inclusive um colorido, e compara só as coordenadas. O do PontoColorido recusa pontos sem cor. Uma coleção que contenha os dois pode se comportar de formas diferentes conforme a ordem em que os objetos foram inseridos.

Não existe solução perfeita para isso. É um resultado conhecido: não há como estender uma classe instanciável, acrescentar um campo que participa da igualdade e preservar o contrato do equals. As saídas são:

  • Usar composição em vez de herança. O PontoColorido tem um Ponto e uma cor, em vez de ser um Ponto. É a recomendação de "Do Geral ao Específico", agora com um motivo concreto.
  • Comparar com getClass() em vez de instanceof. Com getClass() != o.getClass(), objetos de classes diferentes nunca são iguais. Preserva a simetria, mas um PontoColorido deixa de ser igual a qualquer Ponto, o que quebra a substituição de Liskov.
  • Declarar a classe de valor como final, como fizemos com o Ponto. Sem subclasses, o problema não existe.

Na prática, a terceira opção resolve a grande maioria dos casos. Classes que representam valores quase nunca precisam de subclasses.

Objetos mutáveis e coleções não combinam

O hashCode depende dos campos. O que acontece se um campo mudar enquanto o objeto está dentro de um HashSet?

HashSet<Caixa> conjunto = new HashSet<>();
Caixa caixa = new Caixa(1);      // equals e hashCode baseados em "valor"
conjunto.add(caixa);
caixa.valor = 2;

IO.println(conjunto.contains(caixa));   // false
IO.println(conjunto.size());            // 1

O objeto está no conjunto, que tem tamanho 1, e mesmo assim o conjunto diz que ele não está lá. A caixa foi guardada na gaveta correspondente ao hashCode de quando o valor era 1. Depois da mudança, o hashCode é outro, e a busca vai à gaveta errada. O objeto ficou perdido dentro da coleção: não é encontrado e não pode ser removido pelo remove.

A lição reforça "O que Fica Dentro, o que Fica Fora": objetos usados como elementos de conjuntos ou chaves de mapas devem ser imutáveis, ou, pelo menos, os campos que participam do equals e do hashCode não devem mudar. É mais uma vantagem dos objetos imutáveis, como String, Integer e o nosso Ponto.

Identidade escondida: caches e strings

Algumas surpresas do == vêm da própria biblioteca, que reaproveita objetos por eficiência:

Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
IO.println(a == b);        // true
IO.println(c == d);        // false
IO.println(c.equals(d));   // true

Integer é a classe que embrulha um int num objeto, e a conversão automática entre os dois (o autoboxing) é assunto de "Escreva uma Vez para Todo Tipo". O que importa aqui: a biblioteca mantém prontos os objetos Integer de −128 a 127, e os reaproveita. 127 vem do cache, e as duas variáveis apontam para o mesmo objeto. 128 está fora do cache, e cada atribuição cria um objeto novo. Um código que compara Integer com == funciona em todos os testes com números pequenos e falha em produção com o primeiro valor acima de 127.

Com String acontece algo parecido, e agora dá para explicar o "às vezes funciona" do exercício 2 de "Operadores, Texto e Conversa com o Usuário":

String s1 = "java";
String s2 = "java";
String s3 = new String("java");
String s4 = "ja" + "va";
String parte = "ja";
String s5 = parte + "va";

IO.println(s1 == s2);            // true
IO.println(s1 == s3);            // false
IO.println(s1 == s4);            // true
IO.println(s1 == s5);            // false
IO.println(s1 == s5.intern());   // true

Os literais de texto são guardados numa tabela da JVM (o string pool), e literais iguais apontam para o mesmo objeto. "ja" + "va" é calculado pelo compilador, porque os dois lados são constantes, e vira o literal "java". parte + "va" é calculado na execução e produz um objeto novo, assim como new String(...). O método intern() devolve o objeto da tabela com o mesmo conteúdo. Nada disso é algo em que se deva confiar. É exatamente por isso que a regra é comparar objetos sempre com equals. O == só é correto para tipos primitivos, para enums (que você vai ver na próxima aula) e quando a pergunta é mesmo de identidade.

Toda classe deve sobrescrever equals?

Não. A decisão depende do que a classe representa.

Objetos de valor, definidos pelo conteúdo, como Ponto, uma quantia de dinheiro, uma data ou um CPF, devem sobrescrever equals, hashCode e toString, e de preferência ser imutáveis.

Entidades, que têm identidade própria e estado que muda com o tempo, geralmente não devem. Duas contas bancárias com o mesmo titular e o mesmo saldo não são a mesma conta. E a mesma conta continua sendo ela mesma depois de um saque, quando o saldo muda. Para a ContaBancaria, o equals herdado, de identidade, está correto. Se for preciso comparar contas guardadas e recarregadas de um banco de dados, a comparação é pelo identificador, o número da conta, e nunca pelo saldo. Essa distinção entre entidades e objetos de valor volta com força em "Objetos no Banco", na trilha de persistência.

Escrever equals, hashCode e toString corretamente para cada classe de valor é trabalhoso e repetitivo: são dezenas de linhas para dizer algo óbvio, que o Ponto é igual a outro quando as coordenadas coincidem. E cada linha escrita à mão é uma chance de errar, como esquecer um campo no hashCode. O Java moderno tem uma resposta para isso, capaz de reduzir a classe Ponto inteira a uma única linha.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Sem rodar, preveja cada linha e explique:

Integer x = 100, y = 100, z = 1000, w = 1000;
String a = "curso", b = "cur" + "so", c = new String("curso");
IO.println(x == y);
IO.println(z == w);
IO.println(a == b);
IO.println(a == c);
IO.println(a.equals(c));
Ver resposta

✓ Resposta: true, false, true, false, true. x e y valem 100, dentro da faixa de −128 a 127 que a biblioteca mantém em cache, e apontam para o mesmo objeto. z e w valem 1000, fora do cache, e cada um é um objeto novo. "cur" + "so" é uma concatenação de constantes, calculada pelo compilador, e resulta no mesmo literal da tabela de strings que a. new String("curso") sempre cria um objeto novo. O equals compara conteúdo e dá true. O exercício mostra por que o == com objetos é traiçoeiro: os resultados dependem de detalhes de implementação, como o tamanho do cache e as otimizações do compilador, que não fazem parte da lógica do programa.

Exercício 2

Escreva uma classe imutável Dinheiro com os campos long centavos e String moeda (como "BRL" ou "USD"), com validação no construtor (a moeda não pode ser nula nem vazia) e com equals, hashCode e toString corretos. Por que guardar centavos num long em vez de reais num double?

Ver resposta

✓ Resposta:

import java.util.Objects;

public final class Dinheiro {
    private final long centavos;
    private final String moeda;

    public Dinheiro(long centavos, String moeda) {
        if (moeda == null || moeda.isBlank()) {
            throw new IllegalArgumentException("Moeda é obrigatória");
        }
        this.centavos = centavos;
        this.moeda = moeda;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Dinheiro outro)) {
            return false;
        }
        return centavos == outro.centavos && moeda.equals(outro.moeda);
    }

    @Override
    public int hashCode() {
        return Objects.hash(centavos, moeda);
    }

    @Override
    public String toString() {
        return "%s %d.%02d".formatted(moeda, centavos / 100, Math.abs(centavos % 100));
    }
}

new Dinheiro(1990, "BRL") é igual a outro new Dinheiro(1990, "BRL"), diferente de new Dinheiro(1990, "USD"), e aparece como "BRL 19.90". Como moeda nunca é null, graças à validação, o equals pode chamar moeda.equals diretamente. Centavos num long evitam a imprecisão do double vista em "Cada Valor em sua Caixa": somar e comparar inteiros é exato, e 0,10 + 0,20 dá 30 centavos, e não 0,30000000000000004. A classe ainda é simples e não lida com valores negativos na formatação. A solução completa para dinheiro, com BigDecimal, está em "Tempo, Dinheiro e Padrões".

Exercício 3

Explique por que o PontoColorido da aula quebra a simetria do equals e por que declarar Ponto como final resolve o problema. Qual é o custo dessa solução?

Ver resposta

✓ Resposta: O equals do Ponto usa instanceof Ponto, que aceita qualquer subclasse, e compara só as coordenadas. Assim, p.equals(pc) é verdadeiro. O equals do PontoColorido exige que o outro objeto também seja colorido, então pc.equals(p) é falso. A mesma pergunta, feita nas duas direções, recebe respostas diferentes. Com Ponto declarado final, não pode haver subclasses, e todo objeto que passa no instanceof Ponto é exatamente um Ponto, comparado sempre pelos mesmos campos. O custo é que não se pode mais estender Ponto. Para um ponto colorido, usa-se composição: uma classe PontoColorido com um campo Ponto e um campo cor, que não é um Ponto e não se compara com pontos simples. Para classes de valor, o custo é baixo, porque raramente se quer estendê-las.

Exercício 4

No exemplo da Caixa mutável dentro do HashSet, o objeto "sumiu" da coleção depois de mudar de valor. Explique o mecanismo e diga duas formas de evitar o problema.

Ver resposta

✓ Resposta: O HashSet guarda cada objeto numa posição determinada pelo hashCode no momento da inserção. Quando o campo valor muda, o hashCode passa a ser outro, mas o objeto continua guardado na posição antiga. Um contains ou um remove calcula o hashCode atual e procura na posição nova, onde não há nada, sem nunca chegar a chamar o equals com o objeto certo. O objeto continua ocupando espaço, aparece numa iteração, mas não pode ser encontrado nem removido pelas operações normais. Primeira forma de evitar: tornar a classe imutável, para que o hashCode nunca mude. Segunda: se o objeto precisa ser mutável, basear equals e hashCode só em campos que nunca mudam, como um identificador final. E, se for inevitável alterar um campo relevante, remover o objeto da coleção antes da mudança e reinseri-lo depois.

Exercício 5

Um colega sugere sobrescrever equals e hashCode na ContaBancaria, comparando titular e saldo. Explique por que isso é um erro e o que seria razoável fazer, se alguma comparação fosse necessária.

Ver resposta

✓ Resposta: A conta é uma entidade: tem identidade própria, que persiste enquanto o estado muda. Comparar por titular e saldo cria dois absurdos. Duas contas diferentes da mesma pessoa, que por acaso tenham o mesmo saldo, passariam a ser "iguais", e uma coleção de contas trataria uma delas como duplicata e a descartaria. E como o saldo muda a cada operação, o hashCode mudaria junto, e a conta se perderia dentro de qualquer HashSet, como a Caixa do exercício anterior. O equals herdado, de identidade, é o correto para uma entidade enquanto ela vive na memória. Se for necessário comparar contas carregadas de fontes diferentes, como duas leituras do mesmo registro num banco de dados, a igualdade deve se basear só no identificador imutável, o numero, que é final e único.

Comentários

Mais em Java

O Programa que se Examina — Anotações e Reflexão
O Programa que se Examina — Anotações e Reflexão

Como declarar anotações próprias em Java, escolher a retenção certa e usar a…

Quem Recolhe o Lixo — os Coletores G1 e ZGC
Quem Recolhe o Lixo — os Coletores G1 e ZGC

Como a JVM decide o que é lixo pela alcançabilidade a partir das raízes, a…

Um Milhão de Threads — Virtual Threads
Um Milhão de Threads — Virtual Threads

Threads virtuais esperam quase de graça: dez mil requisições de um segundo…