equals dan hashCode dalam Collections

Mencari tahu mengapa objek menghilang di HashSet tanpa equals dan hashCode yang benar.

Daftar isi

Kelas buatan sendiri yang disimpan di HashSet atau dipakai sebagai kunci HashMap tunduk pada satu syarat yang mudah terlewat, yaitu equals dan hashCode yang dipasangkan dengan benar. Tanpa keduanya, objek yang isinya sama tetap dianggap dua objek yang berbeda, sehingga duplikat lolos, pencarian gagal, dan penghapusan tidak bekerja. Topik ini menunjukkan kegagalannya secara nyata, memperbaikinya, lalu menghubungkan perilaku tersebut dengan mekanisme keranjang yang mendasarinya.

Objek yang hilang di HashSet#

Kelas Titik berikut menyimpan koordinat x dan y, lengkap dengan toString, tetapi belum menimpa equals maupun hashCode. Dua titik berisi (1, 2) lalu dimasukkan ke HashSet, dan hasilnya mengejutkan.

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

public class DemoTitikSalah {

    public static void main(String[] args) {
        Set<Titik> titikUnik = new HashSet<>();
        titikUnik.add(new Titik(1, 2));
        titikUnik.add(new Titik(1, 2));   // isi sama, objek berbeda

        System.out.println("jumlah titik      : " + titikUnik.size());
        System.out.println("ada titik (1, 2)? : " + titikUnik.contains(new Titik(1, 2)));

        titikUnik.remove(new Titik(1, 2));
        System.out.println("sesudah remove    : " + titikUnik.size());
    }
}

class Titik {
    int x;
    int y;

    Titik(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "(" + x + ", " + y + ")";
    }
}
text Keluaran
jumlah titik      : 2
ada titik (1, 2)? : false
sesudah remove    : 2

Ketiga baris keluaran itu salah semua menurut niat programnya. Ukuran set menjadi 2 karena duplikat tidak dikenali, contains mengembalikan false meski titik yang dicari benar-benar ada, dan remove tidak menghapus apa pun. Penyebabnya tunggal, yaitu versi bawaan equals yang membandingkan referensi, sehingga dua objek berisi (1, 2) yang dibuat terpisah memang bukan objek yang sama menurut Java.

Baris contains yang gagal paling sering membingungkan, karena set sudah memuat titik yang koordinatnya persis sama. Yang dicari contains memang ada di dalam set, tetapi sebagai objek lain yang ditempatkan di keranjang tersendiri. Pencarian memakai keadaan objek baru itu, bukan menelusuri isi satu per satu, sehingga kegagalannya terjadi tanpa pesan error apa pun. Perilaku senyap seperti inilah yang membuat bug semacam ini bisa hidup lama di kode produksi.

Memperbaiki dengan equals dan hashCode#

Perbaikannya menimpa kedua metode itu di kelas Titik. equals membandingkan isi koordinatnya, sedangkan hashCode menurunkan kode hash dari field yang sama, dalam contoh ini lewat Objects.hash(x, y).

java DemoTitikBenar.java
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

public class DemoTitikBenar {

    public static void main(String[] args) {
        Set<Titik> titikUnik = new HashSet<>();
        titikUnik.add(new Titik(1, 2));
        titikUnik.add(new Titik(1, 2));

        System.out.println("jumlah titik      : " + titikUnik.size());
        System.out.println("ada titik (1, 2)? : " + titikUnik.contains(new Titik(1, 2)));

        titikUnik.remove(new Titik(1, 2));
        System.out.println("sesudah remove    : " + titikUnik.size());

        Map<Titik, String> labelPeta = new HashMap<>();
        labelPeta.put(new Titik(3, 4), "pos tumpuan");
        System.out.println("label (3, 4)      : " + labelPeta.get(new Titik(3, 4)));
    }
}

class Titik {
    int x;
    int y;

    Titik(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof Titik)) {
            return false;
        }
        Titik lain = (Titik) obj;
        return x == lain.x && y == lain.y;
    }

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

    @Override
    public String toString() {
        return "(" + x + ", " + y + ")";
    }
}
text Keluaran
jumlah titik      : 1
ada titik (1, 2)? : true
sesudah remove    : 0
label (3, 4)      : pos tumpuan

Kini semua operasi berjalan sesuai niatnya. Duplikat ditolak, pencarian menemukan titik yang dibuat terpisah, penghapusan bekerja, dan yang paling terakhir perlu dicermati, get menemukan nilai untuk kunci yang dibuat baru saat pencarian. Kemampuan terakhir itu hanya mungkin karena kunci pencarian dan kunci tersimpan sepakat bahwa “setara” berarti koordinatnya sama.

Perhatikan pula cara kedua metode itu ditulis. equals diawali pemeriksaan identitas untuk memangkas pekerjaan, lalu pemeriksaan tipe dengan instanceof sebelum penurunan tipe, dan baru perbandingan field demi field. hashCode didelegasikan ke Objects.hash(x, y) agar perhitungannya konsisten dengan field yang dibandingkan. Pola dua langkah ini adalah bentuk yang paling sering dipakai pada kelas biasa, dan @Override pada keduanya memastikan kesalahan tanda tangan metode tertangkap saat kompilasi.

Cara keduanya bekerja di balik keranjang#

HashSet dan HashMap menempatkan entri ke keranjang berdasarkan hashCode(), lalu membandingkan kandidat di dalam keranjang itu dengan equals(). Akibatnya, kedua metode membentuk satu pasangan yang tidak boleh dipisahkan. Dua objek yang equals-nya true wajib memiliki kode hash yang sama, sebab bila keduanya mendarat di keranjang berbeda, equals tidak pernah sempat dijalankan dan keunikan serta pencarian langsung kacau. Kebalikannya tidak berlaku, dua objek dengan kode hash sama boleh saja tidak setara, keadaan yang dikenal sebagai tabrakan hash.

Kontrak penuh equals sendiri mencakup kesimetrian, konsistensi, dan perlakuan terhadap null, dan rinciannya diulas pada modul OOP yang membahas kedua metode ini di tingkat kelas. Yang perlu ditambahkan di sini hanya porsi praktisnya, yaitu cukupkan equals dan hashCode pada field yang sama, dan gunakan Objects.hash(...) agar perhitungannya tidak ditulis manual. Ketergantungan yang sama juga diwarisi pabrik Set.of dan Map.of, karena koleksi imutabel pun tetap menilai kesetaraan lewat kedua metode ini.

Kualitas penyebaran kode hash memengaruhi panjang rantai tabrakan, meski tidak pernah merusak kebenaran hasilnya. Rumus yang dikenakan String, yaitu 31 * h + c untuk setiap karakternya, menjadi contoh yang lazim ditiru karena bilangan ganjil itu menyebar bit dengan baik sekaligus murah dihitung. Bila kode hash semua objek selalu bernilai sama, semuanya mendarat di satu keranjang, dan set tetap benar secara kontrak, tetapi setiap add dan contains berubah menjadi penyisiran panjang yang menepikan keunggulan tabel hash.

Kesalahan umum#

  • Menimpa equals saja tanpa hashCode. Objek setara mendarat di keranjang berbeda, sehingga HashSet tetap menampung duplikat dan HashMap gagal menemukan kuncinya. Timpa keduanya selalu sebagai satu paket.
  • Menimpa hashCode dari field yang tidak dipakai equals. Dua objek setara bisa punya kode hash berbeda, dan kontraknya dilanggar tanpa peringatan apa pun. Turunkan keduanya dari field yang sama persis.
  • Menulis equals dengan parameter yang salah, misalnya equals(Titik lain). Metode itu menjadi overload, bukan penimpaan, sehingga versi bawaan tetap dipakai collections. Tandai dengan @Override agar kompilator menolak kesalahan ini.
  • Mengubah field kunci HashMap setelah put. Pasangan tidak berpindah keranjang, sehingga get dengan nilai baru mengembalikan null. Bila kuncinya memang harus berubah, hapus dulu, ubah, lalu simpan kembali.
  • Menyimpan objek dengan equals yang membandingkan field yang bisa berubah. Kesetaraan yang berubah-ubah membuat posisi entri di collections tidak lagi cocok dengan isinya. Pilih field yang stabil sebagai dasar kesetaraan, atau gunakan record yang fieldnya memang tak terganti.

Ringkasan#

  • Collections berbasis hash menempatkan entri lewat hashCode lalu memastikannya lewat equals, sehingga keduanya harus diimplementasikan berpasangan.
  • Tanpa penimpaan yang benar, duplikat lolos, contains gagal, remove tak berdaya, dan get pada HashMap mengembalikan null.
  • Kontrak intinya, objek yang setara wajib berbagi kode hash, sedangkan kode hash sama tidak menuntut keduanya setara.
  • Kunci dan elemen Set sebaiknya imutabel, dan record memberi kedua metode itu secara otomatis untuk pembawa data.

Referensi#