Tampilan
Kenapa penting
Hampir setiap layanan backend bertanya "apakah dua data ini sama?": apakah SKU ini sudah ada di keranjang, apakah webhook pembayaran ini sudah pernah diproses, apakah pelanggan ini sudah terdaftar. HashSet, HashMap, dan List.contains menjawab pertanyaan itu memakai equals dan hashCode milik object-mu. Kalau keduanya ditulis salah, collection tidak melempar error — ia diam-diam menyimpan duplikat atau "kehilangan" data. Bug semacam ini lolos dari compiler, sering lolos dari test sederhana, lalu muncul di production sebagai pesanan ganda atau stok yang tidak cocok.
Konsep inti
== vs equals: identitas vs isi
Di bab Operator & Ekspresi kamu sudah tahu bahwa == pada tipe referensi membandingkan identitas — apakah dua variabel menunjuk object yang sama. equals dimaksudkan untuk membandingkan isi. Masalahnya, equals bawaan dari class Object sama saja dengan ==. Selama tidak di-override, dua object dengan isi sama tetap dianggap berbeda:
java
import java.util.List;
class ProductCode {
private final String value;
ProductCode(String value) {
this.value = value;
}
}
public class DefaultEquals {
public static void main(String[] args) {
ProductCode a = new ProductCode("KOPI-250");
ProductCode b = new ProductCode("KOPI-250");
System.out.println(a == b);
System.out.println(a.equals(b));
System.out.println(List.of(a).contains(b));
String s1 = new String("KOPI-250");
String s2 = new String("KOPI-250");
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}Output: false, false, false, false, true. String sudah meng-override equals, sedangkan ProductCode belum — sehingga List.contains (yang memakai equals) tidak menemukan b.
Kontrak equals
Override equals bukan sekadar membandingkan field; ada kontrak yang diandalkan seluruh collection Java. Untuk object x, y, z yang tidak null:
| Sifat | Artinya |
|---|---|
| Refleksif | x.equals(x) selalu true |
| Simetris | x.equals(y) sama hasilnya dengan y.equals(x) |
| Transitif | bila x.equals(y) dan y.equals(z), maka x.equals(z) |
| Konsisten | dipanggil berulang memberi hasil sama selama object tidak berubah |
null | x.equals(null) selalu false — bukan melempar exception |
Kontrak hashCode
HashSet dan HashMap tidak membandingkan elemen satu per satu. Mereka menghitung hashCode() untuk menentukan "laci" penyimpanan, lalu memakai equals hanya untuk elemen di laci yang sama. Agar itu bekerja:
- Dua object yang
equalswajib punyahashCodeyang sama. - Dua object dengan
hashCodesama tidak wajibequals— itu disebut collision dan sah. Contohnya"Aa"dan"BB"sama-sama punya hash2112. hashCodeharus konsisten selama field yang dipakaiequalstidak berubah.
Aturan praktisnya: override equals berarti wajib override hashCode, memakai field yang sama.
Kalau hanya equals yang di-override
java
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class ProductCode {
private final String value;
ProductCode(String value) {
this.value = value;
}
@Override
public boolean equals(Object other) {
return other instanceof ProductCode that && value.equals(that.value);
}
// hashCode() TIDAK di-override — masih memakai versi bawaan Object
}
public class OnlyEquals {
public static void main(String[] args) {
ProductCode a = new ProductCode("KOPI-250");
ProductCode b = new ProductCode("KOPI-250");
System.out.println(a.equals(b));
System.out.println(List.of(a).contains(b));
Set<ProductCode> codes = new HashSet<>();
codes.add(a);
codes.add(b);
System.out.println(codes.size());
System.out.println(codes.contains(new ProductCode("KOPI-250")));
}
}Output: true, true, 2, false. List tampak benar karena hanya memakai equals, tetapi HashSet menyimpan "duplikat" dan gagal menemukan elemen yang jelas ada. Hash bawaan Object hampir pasti berbeda untuk dua object, sehingga set mencari di laci yang salah dan equals tidak pernah dipanggil.
Menulis equals dan hashCode yang benar
Resep modern: cek identitas, pakai pattern instanceof untuk tipe dan null sekaligus, bandingkan field dengan Objects.equals (aman untuk field null), dan hitung hash dari field yang sama dengan Objects.hash.
java
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class ProductVariant {
private final String sku;
private final String size;
ProductVariant(String sku, String size) {
this.sku = sku;
this.size = size;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true; // object yang sama pasti equal
}
if (!(other instanceof ProductVariant that)) {
return false; // null atau tipe lain
}
return Objects.equals(sku, that.sku) && Objects.equals(size, that.size);
}
@Override
public int hashCode() {
return Objects.hash(sku, size); // field yang sama dengan equals
}
@Override
public String toString() {
return sku + "/" + size;
}
}
public class ProductVariantDemo {
public static void main(String[] args) {
ProductVariant a = new ProductVariant("KAOS-01", "M");
ProductVariant b = new ProductVariant("KAOS-01", "M");
ProductVariant c = new ProductVariant("KAOS-01", "L");
System.out.println(a.equals(b) + " " + b.equals(a) + " " + a.equals(c) + " " + a.equals(null));
System.out.println(a.hashCode() == b.hashCode());
Set<ProductVariant> variants = new HashSet<>();
variants.add(a);
variants.add(b);
variants.add(c);
System.out.println(variants.size());
Map<ProductVariant, Integer> stock = new HashMap<>();
stock.put(a, 10);
stock.put(c, 4);
System.out.println(stock.get(new ProductVariant("KAOS-01", "M")));
}
}Output: true true false false, true, 2, 10. Beberapa catatan:
- Parameter
equalsharusObject. Menulisequals(ProductVariant other)hanya membuat overload yang tidak dipakai collection; dengan@Override, compiler langsung menolaknya:method does not override or implement a method from a supertype. - Class dibuat
final. Kesamaan dengan patterninstanceofbisa melanggar sifat simetris bila subclass menambah field dan meng-overrideequalslagi — classfinalmenutup kemungkinan itu. - IDE bisa membuatkan
equals/hashCode, tetapi kamu tetap harus memutuskan field mana yang menentukan kesamaan.
Kunci yang berubah = data "hilang"
Hash dihitung saat object masuk ke HashMap/HashSet. Bila field yang dipakai hashCode diubah setelahnya, entri itu tersesat di laci lama:
java
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
class CartKey {
String userId; // field bisa diubah — sumber masalah
CartKey(String userId) {
this.userId = userId;
}
@Override
public boolean equals(Object other) {
return other instanceof CartKey that && Objects.equals(userId, that.userId);
}
@Override
public int hashCode() {
return Objects.hashCode(userId);
}
}
public class MutableKey {
public static void main(String[] args) {
Map<CartKey, Integer> carts = new HashMap<>();
CartKey key = new CartKey("budi");
carts.put(key, 3);
key.userId = "sari"; // kunci diubah SETELAH masuk map
System.out.println(carts.size());
System.out.println(carts.get(key));
System.out.println(carts.get(new CartKey("budi")));
System.out.println(carts.containsKey(key));
}
}Output: 1, null, null, false. Map masih berisi satu entri, tetapi tidak ada cara menemukannya — dicari dengan kunci baru, hash-nya beda; dicari dengan kunci lama, equals-nya gagal. Pelajaran dari bab Static & Final berlaku di sini: kunci map dan elemen set sebaiknya immutable.
Record: kesamaan berbasis isi secara otomatis
Untuk data yang identitasnya memang ditentukan isinya, kamu tidak perlu menulis apa pun. Record yang kamu pelajari di M02 otomatis mendapat equals, hashCode, dan toString berbasis semua komponennya — dan field-nya final:
java
import java.util.HashSet;
import java.util.List;
import java.util.Set;
record ProductVariant(String sku, String size) {}
public class RecordKey {
public static void main(String[] args) {
ProductVariant a = new ProductVariant("KAOS-01", "M");
ProductVariant b = new ProductVariant("KAOS-01", "M");
System.out.println(a.equals(b) + " " + (a.hashCode() == b.hashCode()));
Set<ProductVariant> set = new HashSet<>(List.of(a, b, new ProductVariant("KAOS-01", "L")));
System.out.println(set.size());
System.out.println(a);
}
}Output: true true, 2, ProductVariant[sku=KAOS-01, size=M]. Pakai record untuk value object, kunci map, dan DTO. Tulis equals/hashCode manual hanya bila aturannya khusus — misalnya mengabaikan huruf besar/kecil seperti di latihan bab ini.
Entity dengan ID
Object yang mewakili baris database (entity), seperti Customer atau Order, punya identitas berupa ID, bukan isi field lain. Dua object Customer dengan nama sama tetapi ID berbeda adalah pelanggan berbeda. Karena itu entity biasanya membandingkan berdasarkan ID saja. Detailnya — termasuk jebakan ID yang baru diisi database setelah disimpan — akan kamu temui saat memakai JPA di level Spring Boot.
Contoh di dunia backend
Payment gateway sering mengirim webhook yang sama lebih dari sekali (misalnya karena timeout jaringan). Bila setiap kiriman diproses, status order bisa berubah dua kali atau email "pembayaran diterima" terkirim ganda. Solusinya: simpan kunci event yang sudah diproses di sebuah Set dan lewati duplikat. Kunci itu harus punya equals/hashCode yang benar — record membuatnya mudah.
java
import java.util.HashSet;
import java.util.List;
import java.util.Set;
record WebhookKey(String provider, String eventId) {}
record WebhookEvent(String provider, String eventId, String orderId, String status) {
WebhookKey key() {
return new WebhookKey(provider, eventId);
}
}
class PaymentWebhookHandler {
private final Set<WebhookKey> processed = new HashSet<>();
void handle(WebhookEvent event) {
if (!processed.add(event.key())) { // add() → false bila kunci sudah ada
System.out.println("Lewati duplikat " + event.eventId());
return;
}
System.out.println("Order " + event.orderId() + " → " + event.status());
}
}
public class WebhookDedup {
public static void main(String[] args) {
PaymentWebhookHandler handler = new PaymentWebhookHandler();
List<WebhookEvent> incoming = List.of(
new WebhookEvent("midpay", "evt-101", "INV-001", "PAID"),
new WebhookEvent("midpay", "evt-102", "INV-002", "PAID"),
new WebhookEvent("midpay", "evt-101", "INV-001", "PAID"), // dikirim ulang
new WebhookEvent("xenpay", "evt-101", "INV-003", "FAILED")); // eventId sama, provider beda
for (WebhookEvent event : incoming) {
handler.handle(event);
}
}
}Output:
text
Order INV-001 → PAID
Order INV-002 → PAID
Lewati duplikat evt-101
Order INV-003 → FAILEDPerhatikan kuncinya gabungan provider + eventId: dua provider berbeda bisa saja memakai ID event yang sama. Di aplikasi nyata, daftar event yang sudah diproses disimpan di database dengan unique constraint (agar tetap benar saat ada banyak server), tetapi cara berpikirnya sama: tentukan dengan tepat apa yang membuat dua data "sama".
Pertanyaan interview
1. Apa kontrak antara equals dan hashCode?
Bila a.equals(b) bernilai true, maka a.hashCode() wajib sama dengan b.hashCode(). Sebaliknya tidak berlaku: hash yang sama boleh dimiliki object berbeda (collision). equals juga harus refleksif, simetris, transitif, konsisten, dan mengembalikan false untuk null.
2. Apa yang terjadi bila kamu meng-override equals tetapi lupa hashCode?
Collection berbasis hash rusak secara diam-diam: HashSet bisa menyimpan dua object yang equals, dan HashMap.get dengan kunci yang isinya sama mengembalikan null, karena pencarian dimulai dari hash yang berbeda. List tetap tampak benar karena hanya memakai equals, sehingga bug ini sering lolos test sederhana.
3. Kenapa kunci HashMap sebaiknya immutable?
Lokasi entri ditentukan dari hash kunci saat put. Bila field yang memengaruhi hash berubah, entri tidak bisa ditemukan lagi walau masih ada di map (kebocoran memori kecil sekaligus bug logika). Pakai kunci immutable seperti String, wrapper, enum, atau record dengan komponen immutable.
Jebakan umum
- Override
equalstanpahashCode. Selalu override keduanya dengan field yang sama. - Parameter
equalsbukanObject.equals(Sku other)hanya overload; selalu tulis@Overrideagar compiler menangkapnya. - Melempar exception untuk
nullatau tipe lain.equalsharus mengembalikanfalse, bukanNullPointerExceptionatauClassCastException— patterninstanceofmenangani keduanya. - Field mutable di kunci map/elemen set. Mengubahnya setelah dimasukkan membuat entri tersesat.
hashCodedari field yang tidak ikutequals(atau sebaliknya). Dua object yangequalsbisa mendapat hash berbeda.- Menulis manual padahal record cukup. Untuk data yang kesamaannya berbasis semua isinya, record lebih ringkas dan bebas salah.
Ringkasan
==membandingkan identitas;equalsmembandingkan isi — tetapiequalsbawaanObjectsama dengan==.- Kontrak
equals: refleksif, simetris, transitif, konsisten,falseuntuknull. - Object yang
equalswajib punyahashCodesama; hash sama tidak berartiequals. - Override keduanya bersama, dengan field yang sama, memakai pattern
instanceof,Objects.equals, danObjects.hash. - Kunci
HashMapdan elemenHashSetharus immutable; record memberikanequals/hashCodeotomatis.