equals e hashCode em Java OOP: identidade, igualdade e uso correto em coleções

Capítulo 11

Tempo estimado de leitura: 10 minutos

+ Exercício

Identidade (==) vs igualdade lógica (equals)

Em Java, == compara identidade: se duas referências apontam para o mesmo objeto na memória. Já equals compara igualdade lógica: se dois objetos devem ser considerados equivalentes segundo uma regra de negócio.

String a = new String("abc");
String b = new String("abc");

System.out.println(a == b);      // false (objetos diferentes)
System.out.println(a.equals(b)); // true  (mesmo conteúdo)

Regra prática: use == quando você quer saber se é o mesmo objeto; use equals quando você quer saber se “representam a mesma coisa”.

Quando equals não é sobrescrito

Se uma classe não sobrescreve equals, ela herda a implementação de Object, que é essencialmente baseada em identidade (equivalente a ==). Isso costuma ser incorreto para classes que representam valores (ex.: dinheiro, e-mail, coordenadas) e muitas entidades de domínio.

O contrato de equals: regras que sua implementação deve obedecer

Uma implementação correta de equals deve respeitar o contrato:

  • Reflexivo: x.equals(x) é true.
  • Simétrico: x.equals(y) implica y.equals(x).
  • Transitivo: se x.equals(y) e y.equals(z), então x.equals(z).
  • Consistente: múltiplas chamadas retornam o mesmo resultado enquanto os campos usados na comparação não mudarem.
  • Não-nulo: x.equals(null) é false.

Essas regras não são “teoria”: elas garantem que coleções e algoritmos consigam confiar na comparação.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

A relação obrigatória entre equals e hashCode

Além do contrato de equals, existe uma regra obrigatória envolvendo hashCode:

  • Se a.equals(b) é true, então a.hashCode() == b.hashCode() deve ser true.

O inverso não é obrigatório: dois objetos podem ter o mesmo hashCode e ainda assim não serem iguais (colisões).

Por que isso importa em HashMap/HashSet

HashMap e HashSet usam hashCode para escolher um “balde” (bucket) e depois usam equals para confirmar se é o mesmo elemento/chave. Se equals e hashCode não estiverem alinhados, você terá sintomas como:

  • set.contains(obj) retornando false para algo que “está no set”.
  • map.get(chave) retornando null mesmo com a chave “equivalente”.
  • duplicatas em HashSet quando não deveria.

Passo a passo: implementando equals e hashCode corretamente

A seguir, um roteiro prático que funciona bem na maioria dos casos.

Passo 1: defina o que significa “igual” para a classe

Antes do código, decida quais campos determinam igualdade. Isso depende do papel do objeto:

  • Value Object: igualdade normalmente é por todos os campos relevantes do valor.
  • Entidade: igualdade normalmente é por identidade de negócio (um identificador estável), não por todos os atributos mutáveis.

Passo 2: implemente equals com checagens padrão

Estrutura típica:

  • Se this == o, retorne true.
  • Se o == null ou classes incompatíveis, retorne false.
  • Faça cast e compare os campos escolhidos.

Passo 3: implemente hashCode com os mesmos campos

Use os mesmos campos usados no equals. Uma forma segura e legível é Objects.hash(...) (com custo aceitável na maioria dos domínios). Para cenários de altíssima performance, pode-se usar cálculo manual, mas a prioridade aqui é correção.

Exemplo 1: Value Object (imutável) com equals/hashCode por valor

Um value object como Money deve considerar iguais valores com mesma quantia e moeda.

import java.math.BigDecimal;
import java.util.Objects;

public final class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = Objects.requireNonNull(amount);
        this.currency = Objects.requireNonNull(currency);
    }

    public BigDecimal amount() { return amount; }
    public String currency() { return currency; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.equals(money.amount) && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

Como é imutável, o resultado de equals/hashCode não muda com o tempo, o que é ideal para uso em HashSet/HashMap.

Armadilha comum: BigDecimal e igualdade

BigDecimal.equals considera escala. Assim, new BigDecimal("10.0") não é igual a new BigDecimal("10.00"). Se no seu domínio isso deve ser considerado igual, você precisa normalizar (ex.: stripTrailingZeros()) ou comparar via compareTo e ajustar o hashCode de forma consistente. Caso contrário, você terá “duplicatas” inesperadas em sets.

Exemplo 2: Entidade com equals/hashCode por identificador estável

Em entidades, comparar todos os atributos pode ser perigoso, porque muitos mudam ao longo do ciclo de vida. O mais comum é usar um identificador estável (por exemplo, id).

import java.util.Objects;

public class Customer {
    private final String id; // identidade estável
    private String name;
    private String email;

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

    public String id() { return id; }
    public String name() { return name; }
    public String email() { return email; }

    public void changeEmail(String newEmail) {
        this.email = Objects.requireNonNull(newEmail);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Customer customer = (Customer) o;
        return id.equals(customer.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

Note que name e email não entram na igualdade. Isso evita que o objeto “mude de balde” em um HashSet quando o e-mail for alterado.

Armadilha: id nulo ou id que muda

Se o id puder ser null em parte do ciclo de vida (ex.: antes de persistir) ou puder mudar, você pode quebrar coleções baseadas em hash. Estratégias comuns:

  • Gerar o id no construtor (UUID) para ser estável desde o início.
  • Não colocar instâncias “sem id” em HashSet/HashMap como chave.
  • Evitar setters para id.

Uso correto em coleções: cenários práticos

HashSet: evitando duplicatas por igualdade lógica

import java.util.HashSet;
import java.util.Set;

Set<Money> prices = new HashSet<>();
prices.add(new Money(new BigDecimal("10.00"), "BRL"));
prices.add(new Money(new BigDecimal("10.00"), "BRL"));

System.out.println(prices.size()); // 1 (se equals/hashCode corretos)

HashMap: chave lógica e recuperação consistente

import java.util.HashMap;
import java.util.Map;

Map<Customer, String> notes = new HashMap<>();
Customer c1 = new Customer("C-10", "Ana", "ana@ex.com");
notes.put(c1, "VIP");

Customer c2 = new Customer("C-10", "Ana Maria", "ana2@ex.com");
System.out.println(notes.get(c2)); // "VIP" (mesmo id => equals true)

Esse comportamento é desejável quando a identidade da entidade é o id.

Bug clássico: campos mutáveis usados em equals/hashCode

O erro mais comum é incluir no hashCode um campo que pode mudar após o objeto ser inserido em um HashSet ou usado como chave em HashMap.

Exemplo de implementação incorreta

import java.util.Objects;

public class User {
    private String email; // mutável

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

    public void changeEmail(String newEmail) {
        this.email = newEmail;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(email, user.email);
    }

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

Como o bug aparece

import java.util.HashSet;
import java.util.Set;

User u = new User("a@ex.com");
Set<User> set = new HashSet<>();
set.add(u);

u.changeEmail("b@ex.com");

System.out.println(set.contains(u)); // pode ser false
System.out.println(set.remove(u));   // pode ser false

O objeto foi armazenado em um bucket calculado com o hash antigo. Ao mudar o e-mail, o hash muda, e a coleção procura no bucket errado.

Correções possíveis

  • Tornar o campo usado em equals/hashCode imutável.
  • Em entidades, basear igualdade em um id estável e imutável.
  • Evitar usar objetos mutáveis como chave de HashMap ou elemento de HashSet.

Quais campos entram na comparação? Heurísticas úteis

Tipo de objetoCampos típicos em equals/hashCodeEvitar
Value Object (ex.: Money, Email)Todos os campos que definem o valorCampos derivados/redundantes; dados transitórios
Entidade (ex.: Customer, Order)Identificador estável (id de negócio ou técnico)Atributos que mudam (nome, status, timestamps)
DTO/RequestDepende do uso; muitas vezes não precisa sobrescreverImplementar sem necessidade e criar semântica confusa

getClass() vs instanceof em equals

Há duas abordagens comuns:

  • getClass(): só considera iguais objetos da mesma classe exata. Evita problemas de simetria/transitividade com herança.
  • instanceof: permite igualdade entre subclasses, mas pode quebrar o contrato se subclasses adicionarem estado relevante.

Em modelos com herança, é fácil errar. Uma prática segura é preferir getClass() quando você não tem uma estratégia bem definida para igualdade em hierarquias.

Testes: verificando o contrato e prevenindo regressões

Testes automatizados ajudam a detectar violações do contrato e bugs em coleções. Abaixo, exemplos com JUnit 5.

Teste de propriedades básicas do contrato

import static org.junit.jupiter.api.Assertions.*;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class MoneyEqualityTest {

    @Test
    void equalsContract_basicProperties() {
        Money x = new Money(new BigDecimal("10.00"), "BRL");
        Money y = new Money(new BigDecimal("10.00"), "BRL");
        Money z = new Money(new BigDecimal("10.00"), "BRL");

        // reflexivo
        assertEquals(x, x);

        // simétrico
        assertEquals(x.equals(y), y.equals(x));

        // transitivo
        assertTrue(x.equals(y) && y.equals(z) && x.equals(z));

        // não-nulo
        assertNotEquals(x, null);

        // hashCode consistente com equals
        assertEquals(x, y);
        assertEquals(x.hashCode(), y.hashCode());
    }
}

Teste de comportamento em HashSet (detecção de hashCode incorreto)

import static org.junit.jupiter.api.Assertions.*;

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

class CustomerHashSetTest {

    @Test
    void hashSetShouldNotDuplicateSameIdentity() {
        Customer c1 = new Customer("C-10", "Ana", "ana@ex.com");
        Customer c2 = new Customer("C-10", "Ana Maria", "ana2@ex.com");

        Set<Customer> set = new HashSet<>();
        set.add(c1);
        set.add(c2);

        assertEquals(1, set.size());
    }
}

Teste que evidencia bug com campo mutável na chave

import static org.junit.jupiter.api.Assertions.*;

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

class MutableKeyBugTest {

    @Test
    void changingFieldUsedInHashBreaksHashSet() {
        User u = new User("a@ex.com");
        Set<User> set = new HashSet<>();
        set.add(u);

        u.changeEmail("b@ex.com");

        // Este teste pode falhar (e é exatamente o problema)
        assertFalse(set.contains(u));
    }
}

Esse último teste é útil como “teste de demonstração” para a equipe: ele mostra por que chaves/elementos em coleções hash devem ter identidade estável.

Cenário de bug realista: HashMap com chave inconsistente

Um erro frequente é sobrescrever equals e esquecer de sobrescrever hashCode (ou implementá-lo com campos diferentes). O resultado: inserção funciona, mas recuperação falha.

import java.util.HashMap;
import java.util.Map;

class ProductCode {
    private final String code;

    ProductCode(String code) { this.code = code; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ProductCode that = (ProductCode) o;
        return code.equals(that.code);
    }

    // BUG: hashCode não sobrescrito => herda de Object (identidade)
}

Map<ProductCode, Integer> stock = new HashMap<>();
stock.put(new ProductCode("P-1"), 10);
System.out.println(stock.get(new ProductCode("P-1"))); // null (bug)

Correção: sobrescrever hashCode usando o mesmo campo code.

Agora responda o exercício sobre o conteúdo:

Ao implementar equals e hashCode para usar objetos como chave em HashMap ou elemento em HashSet, qual prática ajuda a evitar falhas em contains/get após o objeto ser alterado?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Em coleções baseadas em hash, hashCode escolhe o bucket e equals confirma a igualdade. Se campos usados nesses métodos mudarem após a inserção, o objeto pode ser procurado no bucket errado. Por isso, use campos estáveis/imutáveis (como um id).

Próximo capitúlo

Organização de código Java OOP: pacotes, coesão, acoplamento e princípios aplicados

Arrow Right Icon
Capa do Ebook gratuito Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos
65%

Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos

Novo curso

17 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.