Kontrak equals dan hashCode

Menerapkan equals dan hashCode yang simetris dan konsisten karena collections bergantung pada keduanya.

Daftar isi

Metode equals dan hashCode bekerja sebagai satu pasangan yang aturan mainnya ditetapkan sebuah kontrak. Collections seperti HashSet dan HashMap menitipkan kebenaran pencarian dan keunikan datanya pada keduanya, sehingga pelanggaran kontrak tidak berhenti sebagai perdebatan teori, melainkan berubah menjadi entri yang terduplikasi atau tidak dapat ditemukan. Topik ini mengurai aturan kontraknya, memperlihatkan cara simetri patah karena subclass, dan menutup dengan dampaknya pada collections.

Kontrak yang mengikat equals#

Metode equals bawaan Object membandingkan identitas, sama seperti operator ==, sehingga dua objek dengan isi sama tetap dianggap berbeda. Mengoverride equals mengganti makna itu menjadi kesetaraan logis, misalnya dua Titik dengan koordinat sama dianggap setara. Setelah maknanya diganti, aturan mainnya ikut berlaku, dan seluruh aturan itu tercantum pada dokumentasi Object.equals. Kebutuhannya berupa lima janji berikut.

  • Refleksivitas, yaitu x.equals(x) selalu true.
  • Simetri, yaitu x.equals(y) dan y.equals(x) harus senilai.
  • Transitivitas, yaitu bila x.equals(y) dan y.equals(z), maka x.equals(z).
  • Konsistensi, yaitu hasil antarpemanggilan tetap selama keadaan objeknya tidak berubah.
  • x.equals(null) selalu false, bukan error.

Tiga janji pertama menyangkut struktur kesetaraan, sedangkan dua terakhir menyangkut keandalannya. Collections hampir selalu menyentuh lebih dari satu janji sekaligus pada satu operasi, sehingga satu pelanggaran kecil sudah cukup merusak perilaku set dan map. Contoh pada bagian berikut memilih satu janji, yaitu simetri, lalu mematahkannya secara sengaja agar akibatnya terlihat jelas.

Simetri yang patah karena subclass#

Patah simetri paling sering lahir dari pewarisan, yaitu ketika kelas turunan mengoverride equals dengan syarat yang lebih ketat daripada induknya. Induk membandingkan koordinat saja, sedangkan turunan menambahkan warna ke pemeriksaannya. Program berikut menyandingkan keduanya, lalu memanggil equals dari dua arah atas pasangan objek yang sama.

java UjiSimetri.java
public class UjiSimetri {
    public static void main(String[] args) {
        Titik a = new Titik(2, 3);
        TitikWarna c = new TitikWarna(2, 3, "merah");

        System.out.println("a.equals(c) : " + a.equals(c));
        System.out.println("c.equals(a) : " + c.equals(a));
    }
}

class Titik {
    int x;
    int y;

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

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

    @Override
    public int hashCode() {
        return 31 * x + y;
    }
}

class TitikWarna extends Titik {
    String warna;

    TitikWarna(int x, int y, String warna) {
        super(x, y);
        this.warna = warna;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof TitikWarna)) {
            return false;
        }
        TitikWarna lain = (TitikWarna) obj;
        return super.equals(lain) && warna.equals(lain.warna);
    }
}
text Keluaran
a.equals(c) : true
c.equals(a) : false

Arah pertama diterima oleh Titik, karena TitikWarna juga instance dari Titik dan koordinatnya sama. Arah kedua ditolak oleh TitikWarna, karena objek yang dibandingkan bukan instance TitikWarna, sehingga warnanya tidak dapat diperiksa. Dua pemanggilan atas pasangan objek yang sama kini menghasilkan nilai berbeda, dan janji simetri patah. Perbaikan yang sehat tidak berada di satu baris kode, melainkan di rancangan. Pilihannya berupa tidak mewariskan kelas yang sudah mendefinisikan equals bernilai, memakai komposisi untuk menambah field, atau menyetujui bahwa equals induk mengabaikan field tambahan dan turunan tidak mengoverride-nya sama sekali.

Janji hashCode kepada equals#

Kontrak hashCode berisi tiga janji yang saling mengunci. Pertama, kode hash bersifat konsisten dalam satu eksekusi, yaitu antarpemanggilan mengembalikan nilai yang sama selama field yang dipakainya tidak berubah. Kedua, objek yang sama menurut equals wajib menghasilkan kode hash yang sama. Ketiga, objek yang berbeda sebaiknya menghasilkan kode hash berbeda, tetapi tabrakan diperbolehkan dan tidak melanggar kontrak.

Janji kedua adalah yang mengikat kedua metode menjadi satu pasangan, dan alasan duduknya di sisi hashCode adalah mekanisme collections. HashSet dan HashMap menempatkan entri ke dalam keranjang (bucket) berdasarkan kode hash, lalu baru membandingkan kandidat di dalam keranjang itu memakai equals. Diagram berikut merangkum rantai ketergantungan itu dari kesetaraan logis sampai koleksinya.

Rantai ketergantungan collections terhadap equals dan hashCodea.equals(b) bernilai truekesetaraan logis yang dideklarasikana.hashCode() == b.hashCode()wajib, karena kode hash dari field yang samaHashSet dan HashMapmencari keranjang memakai hashCode,lalu memastikan kandidat memakai equals
Collections mencari lewat hashCode lalu memastikan lewat equals

Bila janji kedua dilanggar, dua objek yang setara mendarat di keranjang yang berbeda, sehingga equals tidak pernah sempat dijalankan dan set menampung duplikatnya. Implementasi yang lazim menurunkan kode hash dari field yang sama dengan equals, misalnya 31 * x + y untuk Titik, sehingga objek setara otomatis berbagi kode hash.

Konsistensi di hadapan collections#

Janji konsistensi terasa paling nyata pada kunci map yang isinya berubah. Setelah entri ditempatkan, koleksi tidak menata ulang keranjangnya setiap kali field kuncinya berubah, sehingga kuncinya tercecer dari keranjang yang seharusnya dihuninya. Program berikut memindahkan koordinat kunci setelah entri dibuat, lalu mencoba mencarinya kembali.

java UjiKonsistensi.java
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class UjiKonsistensi {
    public static void main(String[] args) {
        Map<Titik, String> catatan = new HashMap<>();
        Titik pusat = new Titik(1, 1);
        catatan.put(pusat, "titik pusat");

        System.out.println("Sebelum digeser : " + catatan.get(pusat));

        pusat.x = 9; // field berubah, hashCode ikut berubah
        System.out.println("Sesudah digeser : " + catatan.get(pusat));
        System.out.println("Masih ada       : " + catatan.containsKey(pusat));

        Set<Titik> kumpulan = new HashSet<>();
        kumpulan.add(new Titik(4, 5));
        kumpulan.add(new Titik(4, 5));
        System.out.println("Ukuran set      : " + kumpulan.size());
    }
}

class Titik {
    int x;
    int y;

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

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

    @Override
    public int hashCode() {
        return 31 * x + y;
    }
}
text Keluaran
Sebelum digeser : titik pusat
Sesudah digeser : null
Masih ada       : false
Ukuran set      : 1

Entri dengan nilai titik pusat masih tersimpan di dalam map, tetapi kuncinya tidak lagi dapat ditemukan, karena kode hashnya berpindah keranjang sementara entri tertinggal di keranjang lama. Baris terakhir memperlihatkan sisi sehatnya, yaitu dua Titik yang setara hanya menghasilkan satu elemen set, bukti bahwa hashCode dan equals bekerja bergandengan saat kontraknya dipatuhi. Kunci yang efektif tidak berubah, seperti String, menjauhkan program dari jebakan pertama secara alami.

Mengamati keranjang hash secara langsung#

Coba demo berikut, geser nilai x dan y pada dua titik, lalu perhatikan hasil equals, kode hash, dan keranjang tempat masing-masing mendarat. Rumusnya meniru contoh artikel, yaitu 31 kali x ditambah y, dan 16 keranjangnya meniru kapasitas awal collections berbasis hash.

Nilai default menempatkan titik a di keranjang 1 dan titik b di keranjang 12, sehingga equals bahkan tidak perlu dipanggil untuk membedakan keduanya. Menyamakan koordinat keduanya membuat kode hash jatuh di keranjang yang sama, dan bila kandidatnya berbeda objek, keadaan itu adalah tabrakan yang sah, sebab kontrak hanya menuntut objek setara berbagi keranjang, bukan sebaliknya. Pemahaman ini menjelaskan mengapa mutasi field kunci begitu berbahaya, karena keranjangnya dihitung ulang padahal entri tidak ikut berpindah.

Kesalahan umum#

  • Mengoverride equals tanpa mengoverride hashCode. Dua objek setara menghasilkan kode hash berbeda, sehingga HashSet menampung duplikat dan HashMap.get gagal menemukan entri yang ada. Selimuti keduanya bersama, karena kontraknya satu pasangan.
  • Mendeklarasikan equals(Titik lain) alih-alih equals(Object obj). Metode itu adalah overloading, bukan override, sehingga collections tetap memakai versi Object. Anotasi @Override menolaknya sejak awal dan memaksa parameter yang benar.
  • Memakai == untuk field bertipe referensi di dalam equals. Pembandingan itu menguji identitas, bukan isi, sehingga dua String dengan teks sama dapat dinilai berbeda. Bandingkan isi field dengan metode equals milik field itu, dan tangani kasus null dengan Objects.equals.
  • Menaruh field yang sering berubah ke dalam equals dan hashCode. Nilai hash ikut berpindah setiap mutasi, sementara posisi entri di collections tidak. Pisahkan field identitas dari field keadaan yang boleh berubah.

Ringkasan#

  • Kontrak equals menuntut refleksivitas, simetri, transitivitas, konsistensi, dan penolakan terhadap null.
  • Objek yang sama menurut equals wajib berbagi kode hash, sedangkan objek berbeda boleh bertabrakan.
  • Collections mencari lewat kode hash dan memastikan lewat equals, sehingga kuncinya wajib efektif tidak berubah.

Referensi#