equals() e hashCode(): contrato e armadilhas comuns

1. Visão geral

equals() e hashCode() são dois métodos que parecem simples, mas se implementados incorretamente, HashSet conterá elementos duplicados e HashMap “perderá” chaves. Esses erros geralmente não lançam exceções. O programa continua executando, apenas com a lógica errada.

Este artigo apresenta o contrato (contract) que Java estabelece para esses dois métodos. Depois, percorrer os erros mais comuns, cada um com um exemplo que o reproduz. Após ler, você saberá implementar corretamente os dois métodos, quando usar record e como escrever testes para validar.

2. O contrato do equals()

Por padrão, Object.equals() compara referências, isto é, this == obj. Ao fazer override, você deve preservar as cinco propriedades a seguir para todas as referências não-nulas:

  • Reflexiva: x.equals(x) é sempre true.
  • Simétrica: x.equals(y) é true se e somente se y.equals(x) é true.
  • Transitiva: se x.equals(y) e y.equals(z), então x.equals(z).
  • Consistente: se o objeto não muda, chamadas repetidas devem retornar o mesmo resultado.
  • Com null: x.equals(null) é sempre false.

Uma implementação padrão para uma classe Customer fica assim:

import java.util.Objects;

public final class Customer {
    private final String email;
    private final String name;

    public Customer(String email, String name) {
        this.email = email;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return Objects.equals(email, other.email)
                && Objects.equals(name, other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email, name);
    }
}

Giải thích cada parte:

  • this == o é um atalho quando ambas as referências apontam para o mesmo objeto.
  • instanceof Customer other usa pattern matching (Java 16+). Essa expressão retorna false quando o é null, então a propriedade “com null” é garantida sem necessidade de verificação separada.
  • Objects.equals é seguro com campos null.
  • Objects.hash calcula o hash dos campos exatos usados em equals().

A classe é declarada final. A razão ficará clara na seção 6.

3. O contrato do hashCode()

hashCode() tem três regras:

  1. Chamadas repetidas no mesmo objeto imutável devem retornar o mesmo valor.
  2. Se a.equals(b), então a.hashCode() == b.hashCode(). Esta é a regra mais importante.
  3. Objetos diferentes podem ter o mesmo código hash (colisão). No entanto, um hash bem distribuído ajuda as coleções a executarem mais rapidamente.

O inverso da regra 2 não é verdadeiro. Ter o mesmo hash code não significa ser igual.

Para ver por que a regra 2 é importante, veja como HashMap encontra uma chave. Primeiro usa hashCode() para selecionar um bucket. Depois usa equals() para comparar os elementos naquele bucket. Se dois objetos são “iguais” mas têm hashes diferentes, ficam em buckets diferentes e equals() nunca é chamado.

4. Erro 1: Override de equals() sem hashCode()

Este é o erro clássico. Suponha que você remova hashCode() da classe na seção 2:

public class Customer {
    private final String email;

    public Customer(String email) { this.email = email; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return email.equals(other.email);
    }
    // falta hashCode()
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));

System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false

Dois objetos são iguais de acordo com equals(). Mas hashCode() ainda é o padrão de Object, geralmente diferente para cada instância. O resultado é que HashSet contém dois elementos “duplicados” e contains() retorna false. Este resultado é quase certo de ocorrer. Raramente dois hashes padrão coincidem, então o erro é difícil de reproduzir consistentemente.

Nota: IDEs e ferramentas como SonarQube alertam sobre esse erro. Não desative esse aviso.

5. Erro 2: Overload em vez de override

Este erro é muito mais difícil de notar:

public class Order {
    private final long id;

    public Order(long id) { this.id = id; }

    public boolean equals(Order other) {   // ERRADO: parâmetro é Order, não Object
        return other != null && id == other.id;
    }

    @Override
    public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);

System.out.println(a.equals(b));                  // chama equals(Order)
System.out.println(List.of(a).contains(b));       // chama equals(Object)
true
false

equals(Order) é um novo método (overload). Ele não faz override de equals(Object). Coleções sempre chamam equals(Object), então elas ainda usam a comparação de referência de Object.

A prevenção é muito simples: sempre use @Override. Se você adicionar @Override ao método acima, o compilador relatará o erro imediatamente.

6. Erro 3: Quebrar a simetria ao herdar

Considere a classe Point e a subclasse ColorPoint que adiciona o campo color:

public class Point {
    protected final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }

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

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(cp) && color.equals(cp.color);
    }

    @Override
    public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");

System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false

A simetria foi quebrada. Além disso, p.equals(cp) é true, mas os hashes dos dois objetos são diferentes, então a regra 2 da seção 3 também é violada.

Não há maneira de adicionar um campo significativo a uma subclasse e manter o contrato com instanceof. Você tem três opções:

  • Declare a classe final ou use record. Esta é a forma mais simples.
  • Use getClass() != o.getClass() em vez de instanceof. Assim, Point e ColorPoint nunca serão iguais. Isso preserva o contrato, mas uma subclasse que não adiciona campos (como um proxy) também não será igual à classe pai.
  • Prefira composition: deixe ColorPoint conter um Point em vez de herdar de Point.

7. Erro 4: Campo mutável como chave

hashCode() é calculado quando o objeto é adicionado a HashSet. Se um campo usado no hash mudar depois, o objeto fica no bucket antigo:

public class Product {
    private String sku;
    public Product(String sku) { this.sku = sku; }
    public void setSku(String sku) { this.sku = sku; }

    @Override
    public boolean equals(Object o) {
        return o instanceof Product p && Objects.equals(sku, p.sku);
    }

    @Override
    public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);

product.setSku("SKU-2");

System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1

O objeto ainda está no conjunto, mas você não consegue encontrá-lo e também não consegue removê-lo. O resultado é um pequeno vazamento de memória e lógica incorreta.

Nota: Use apenas campos imutáveis (immutable) em equals()/hashCode(). Se você precisa usar um campo mutável, não modifique o objeto enquanto ele é uma chave em HashMap ou elemento em HashSet. A maneira correta é remover o objeto, modificá-lo e depois adicioná-lo novamente.

8. Erro 5: As armadilhas do BigDecimal e arrays

Alguns tipos no JDK têm equals() não como você esperaria.

8.1. BigDecimal compara também a escala

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true

BigDecimal.equals() considera 1.0 e 1.00 diferentes porque têm scales diferentes. Se sua classe Money usa BigDecimal em equals(), normalize a scale primeiro. Por exemplo, chame amount.setScale(2, RoundingMode.HALF_UP) no construtor. Note que stripTrailingZeros() também normaliza, mas pode resultar em scales negativas, por exemplo com 100.

8.2. Arrays comparam referência

Arrays não fazem override de equals(). Portanto, new int[]{1, 2}.equals(new int[]{1, 2}) retorna false. Quando um campo é um array, use Arrays.equals() e Arrays.hashCode(). Essa armadilha também se aplica a record, como você verá na seção seguinte.

9. Use record para evitar a maioria dos erros

A partir do Java 16, record gera automaticamente equals(), hashCode() e toString() com base em todos os componentes:

public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);

System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true

record é uma classe final, os campos são final e os dois métodos são gerados corretamente. Os erros nas seções 4, 5 e 6 desaparecem. O erro na seção 7 também praticamente desaparece, a menos que o componente seja um objeto mutável, como uma List.

Dois pontos que você precisa lembrar ao usar record:

  • Componentes que são arrays ainda são comparados por referência. Use List em vez de arrays.
  • Componentes BigDecimal ainda têm a armadilha de scale. Normalize no constructor compacto:
public record Money(BigDecimal amount, String currency) {
    public Money {
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

Com essa declaração, new Money(new BigDecimal("1.0"), "VND") é igual a new Money(new BigDecimal("1.00"), "VND"), porque ambas são normalizadas para scale 2.

10. Testando equals() e hashCode()

Você deve ter testes para o comportamento principal. Abaixo está um teste com JUnit 5 e AssertJ para a classe Customer da seção 2:

import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class CustomerTest {

    @Test
    void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
        var a = new Customer("an@example.com", "An");
        var b = new Customer("an@example.com", "An");

        assertThat(a).isEqualTo(b);
        assertThat(b).isEqualTo(a);
        assertThat(a.hashCode()).isEqualTo(b.hashCode());

        Set<Customer> set = new HashSet<>(Set.of(a));
        assertThat(set).contains(b);
    }

    @Test
    void customer_shouldNotEqualNullOrOtherType() {
        var a = new Customer("an@example.com", "An");

        assertThat(a).isNotEqualTo(null);
        assertThat(a).isNotEqualTo("an@example.com");
    }
}

O primeiro teste verifica simetria, a regra “se iguais, então mesmo hash” e o comportamento real ao usar em HashSet. O segundo teste verifica o caso de null e o caso de tipo diferente.

Escrever todos os casos manualmente é bastante trabalhoso. A biblioteca EqualsVerifier verifica o contrato inteiro com apenas uma linha:

<dependency>
    <groupId>nl.jqno.equalsverifier</groupId>
    <artifactId>equalsverifier</artifactId>
    <version>3.16.1</version>
    <scope>test</scope>
</dependency>
@Test
void equalsContract() {
    EqualsVerifier.forClass(Customer.class).verify();
}

Se você esquecer um campo em hashCode(), usar um campo não-final, ou uma classe não-final com instanceof, o teste falhará com uma mensagem indicando qual regra foi violada.

11. Quando NÃO fazer override

Nem toda classe precisa fazer override de equals()/hashCode():

  • Classes com identidade natural por instância, como Thread, PaymentService ou beans Spring. Dois serviços são dois objetos diferentes e comparar referências é correto.
  • Entity JPA: evite usar todos os campos. Também não use @Id ingenuamente, pois id é null antes de persistir, causando mudanças de hash após salvar. A forma comum é comparar por chaves de negócio imutáveis. Outra forma é comparar pelo id quando não-nulo e retornar uma constante por classe em hashCode(), como getClass().hashCode().
  • Lombok @Data em entity ou classes com relações bidirecionais: essa anotação gera equals()/hashCode() usando todos os campos. Isso pode causar StackOverflowError quando duas entidades se referenciam, ou ativar lazy loading indesejadamente.

12. Conclusão

O contrato entre equals() e hashCode() gira em torno de uma regra central: dois objetos iguais devem ter o mesmo hash code. A maioria dos erros práticos vem de cinco causas:

  • esquecer de fazer override de hashCode();
  • fazer overload de equals(Order) em vez de override de equals(Object);
  • herança que quebra a simetria;
  • usar campo mutável na chave;
  • as armadilhas do BigDecimal e arrays.

Em código novo, prefira record para objetos de valor. Para classes comuns, declare final, sempre use @Override e use apenas campos imutáveis. Por fim, adicione um teste com EqualsVerifier para a ferramenta verificar o contrato para você.

javaequalshashcodecollectionsrecord