Collections.sort, TreeSet, TreeMap, dan PriorityQueue sama-sama menjanjikan isi yang terurut — tetapi tidak satu pun dari struktur itu tahu arti kata “terurut” untuk class buatan kita. Definisi urutan dipasok lewat dua interface yang bekerja berpasangan: Comparable menanamkan urutan alami ke dalam class itu sendiri (nilai siswa dari kecil ke besar, nama dari A ke Z), sedangkan Comparator membawa definisi urutan dari luar sehingga satu class yang sama bisa diurutkan dengan banyak cara. Pada Chapter ini kita implementasikan keduanya pada record, merakit comparator multi-kriteria dengan API modern Comparator.comparing, lalu membuktikan betapa mustahil akibatnya bila kontrak perbandingan dilanggar.
Comparable sebagai Urutan Alami #
Interface Comparable<T> hanya memiliki satu method: int compareTo(T lain). Nilai baliknya dibaca dari tandanya, bukan angkanya — negatif berarti objek ini mendahului lain, positif berarti sebaliknya, dan nol berarti keduanya setara dalam urutan. Integer.compare(a, b) dan String.compareTo mengikuti konvensi yang sama; kelas pustaka seperti Integer, String, dan LocalDate mengimplementasikan Comparable sehingga bisa langsung dipakai Collections.sort maupun menjadi key TreeMap.
Karena Comparable melekat pada class, satu class hanya punya satu urutan alami. Untuk domain yang jelas — tanggal, nomor, nama — satu definisi biasanya memang cukup, dan urutan alami itulah yang dipakai TreeSet/TreeMap ketika konstruktor tidak menerima Comparator.
Kontrak yang Mengikat compareTo #
compareTo bukan sekadar method bebas; kontraknya memiliki tiga aturan matematis:
- Antisimetri:
sgn(x.compareTo(y))adalah kebalikan darisgn(y.compareTo(x)). Jikaxmendahuluiy, makaywajib mengikutix. - Transitivitas: jika
x > ydany > z, makax > zwajib benar. - Konsistensi dengan kesetaraan:
x.compareTo(y) == 0sebaiknya sejalan denganx.equals(y).
Aturan ketiga hanya rekomendasi, tetapi patahnya terasa nyata: TreeSet dan TreeMap memutuskan keunikan elemen lewat hasil compareTo, bukan equals, sehingga dua objek dengan compareTo == 0 dianggap satu entri. Contoh klasiknya BigDecimal: new BigDecimal("1.0") dan new BigDecimal("1.00") tidak setara menurut equals karena equals turut membandingkan jumlah digit di belakang koma, tetapi compareTo keduanya menghasilkan 0 — akibatnya TreeSet<BigDecimal> hanya menyimpan satu dari keduanya, sementara HashSet menyimpan keduanya. Mari kita lihat implementasi yang taat aturan:
import java.util.*;
public class UrutanAlami {
// Urutan alami siswa: berdasarkan nilai. compareTo mengembalikan
// angka negatif, nol, atau positif -- bukan sekadar -1 atau 1.
record Siswa(String nama, int nilai) implements Comparable<Siswa> {
@Override
public int compareTo(Siswa lain) {
return Integer.compare(nilai, lain.nilai);
}
}
public static void main(String[] args) {
List<Siswa> kelas = new ArrayList<>(List.of(
new Siswa("Budi", 78),
new Siswa("Siti", 92),
new Siswa("Dewi", 78),
new Siswa("Anton", 85)));
System.out.println("Sebelum sort : " + kelas);
Collections.sort(kelas);
System.out.println("Setelah sort : " + kelas);
}
}Output yang Dihasilkan:
Sebelum sort : [Siswa[nama=Budi, nilai=78], Siswa[nama=Siti, nilai=92], Siswa[nama=Dewi, nilai=78], Siswa[nama=Anton, nilai=85]]
Setelah sort : [Siswa[nama=Budi, nilai=78], Siswa[nama=Dewi, nilai=78], Siswa[nama=Anton, nilai=85], Siswa[nama=Siti, nilai=92]]Poin bedahnya:
- Baris 5-11 (
record Siswa ... implements Comparable<Siswa>):Siswasendiri yang menyandang urutan alaminya; parameter generikComparable<Siswa>membuatcompareTomenerima tipe yang tepat tanpa pengecoran. - Baris 8-10 (
Integer.compare(nilai, lain.nilai)): pembantuInteger.comparemenghasilkan-1,0, atau1dengan aman untuk semua nilaiint— mengurangi sendiri (nilai - lain.nilai) berisiko luber untuk angka ekstrem, jadi biasakan memakai pembantu ini atauLong.compare. - Baris 21-23 (hasil sort): siswa tampil menaik berdasarkan nilai. Kedua siswa bernilai
78(BudidanDewi) tetap dalam urutan relatif yang sama seperti masukan karenaCollections.sortbersifat stable — elemen yang dianggap setara oleh pembanding tidak saling ditukar.
Comparator untuk Urutan Lain #
Ketika urutan dibutuhkan dalam beberapa bentuk — peringkat nilai tertinggi dulu, atau daftar hadir alfabetis — definisinya dipindah ke luar class. Comparator<T> punya method compare(a, b) dengan kontrak tanda yang sama seperti compareTo, dan sejak Java 8 class ini berisi pabrik comparator yang bisa dirakit berantai. Program berikut mengurutkan daftar siswa yang sama dengan dua urutan berbeda, lalu menangani null:
import java.util.*;
public class ComparatorModern {
record Siswa(String nama, int nilai) {}
public static void main(String[] args) {
List<Siswa> kelas = new ArrayList<>(List.of(
new Siswa("Budi", 78),
new Siswa("Siti", 92),
new Siswa("Dewi", 78),
new Siswa("Anton", 85)));
// Kriteria ganda: nilai tertinggi dulu, nama sebagai pemecah seri.
Comparator<Siswa> peringkat = Comparator
.comparingInt(Siswa::nilai).reversed()
.thenComparing(Siswa::nama);
kelas.sort(peringkat);
System.out.println("Nilai turun : " + kelas);
// Urutan lain dari list yang sama: nama naik, lalu nilai turun.
Comparator<Siswa> namaLaluNilai = Comparator
.comparing(Siswa::nama)
.thenComparing(Comparator.comparingInt(Siswa::nilai).reversed());
kelas.sort(namaLaluNilai);
System.out.println("Nama naik : " + kelas);
// nullsFirst: null diperlakukan paling kecil, sisanya urutan alami.
List<String> tim = new ArrayList<>(Arrays.asList("Siti", null, "Budi", null));
tim.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println("nullsFirst : " + tim);
}
}Output yang Dihasilkan:
Nilai turun : [Siswa[nama=Siti, nilai=92], Siswa[nama=Anton, nilai=85], Siswa[nama=Budi, nilai=78], Siswa[nama=Dewi, nilai=78]]
Nama naik : [Siswa[nama=Anton, nilai=85], Siswa[nama=Budi, nilai=78], Siswa[nama=Dewi, nilai=78], Siswa[nama=Siti, nilai=92]]
nullsFirst : [null, null, Budi, Siti]Mari kita bedah baris-baris pentingnya:
- Baris 13-15 (
comparingInt(...).reversed().thenComparing(...)):comparingInt(Siswa::nilai)mengangkat fieldnilaimenjadi kriteria utama;.reversed()membaliknya menjadi menurun;.thenComparing(Siswa::nama)memasang pemecah seri. HasilnyaSiti (92)memimpin, dan dua siswa bernilai78diurutkan menurut nama —BudilaluDewi. - Baris 18-21 (
comparing(...).thenComparing(Comparator)): varianthenComparingini menerimaComparatorjadi, sehingga tiap kriteria bisa punya arah berbeda — nama naik, nilai turun — dalam satu rangkaian. - Baris 24-26 (
nullsFirst): comparator dasar menolaknull(memanggil method padanullmelemparNullPointerException);nullsFirstmembungkusnya sehingganulldianggap paling kecil dan dikumpulkan di depan. PasangannyanullsLastmeletakkannulldi belakang.
Akibat Kontrak yang Dilanggar #
Kontrak compareTo/compare bukan kesopanan yang boleh diabaikan — algoritma pengurutan dan pohon pencarian biner bergantung padanya untuk mengambil keputusan arah. Program berikut memasang comparator yang melanggar aturan antisimetri dan refleksivitas (setiap pasangan dianggap “lebih kecil”, termasuk elemen terhadap dirinya sendiri) ke dalam TreeSet:
import java.util.*;
public class KontrakDilanggar {
public static void main(String[] args) {
// Comparator rusak: mengklaim "a lebih kecil dari b" dari kedua arah.
Comparator<Integer> rusak = (a, b) -> -1;
System.out.println("compare(2, 3) : " + rusak.compare(2, 3));
System.out.println("compare(3, 2) : " + rusak.compare(3, 2));
System.out.println("compare(2, 2) : " + rusak.compare(2, 2));
TreeSet<Integer> himpunan = new TreeSet<>(rusak);
himpunan.add(1);
himpunan.add(2);
himpunan.add(3);
System.out.println("isi : " + himpunan);
System.out.println("size() : " + himpunan.size());
System.out.println("contains(1) : " + himpunan.contains(1));
System.out.println("contains(2) : " + himpunan.contains(2));
}
}Output yang Dihasilkan:
compare(2, 3) : -1
compare(3, 2) : -1
compare(2, 2) : -1
isi : [3, 2, 1]
size() : 3
contains(1) : false
contains(2) : falsePoin bedahnya:
- Baris 6-10 (tiga pemanggilan
compare):compare(2, 3)dancompare(3, 2)sama-sama-1— dua-duanya saling mengklaim lebih kecil;compare(2, 2)juga-1, padahal kontrak menuntut nol. Dua aturan patah dalam satu baris lambda. - Baris 12-16 (
TreeSetdengan comparator rusak):addberhasil tiga kali dansize()melaporkan3, karena penyisipan hanya mengikuti arah yang ditunjukkan comparator — setiap elemen baru selalu diletakkan di cabang kiri, membentuk rantai1 <- 2 <- 3yang tercetak terbalik sebagai[3, 2, 1]. - Baris 18-19 (
containsberbohong): pencariancontains(1)dancontains(2)mengembalikanfalsemeskipun elemen-elemen itu jelas tersimpan — pohon mencabang ke kiri selamanya sesuai comparator, melewati node yang dicari.removemengalami nasib yang sama, sehingga elemen seperti ini tidak bisa dihapus satu per satu.
Dengan Comparable dan Comparator, seluruh mesin pengurutan Collections Framework kini berada di bawah kendali kita. Topik berikutnya mengubah cara kita membuat collection itu sendiri: Java modern menyediakan pabrik List.of, Set.of, dan Map.of yang menghasilkan collection immutable — isi yang sekali dibentuk tidak bisa diubah lagi, dengan jaminan yang berbeda dari unmodifiable view lawas.
Referensi #
- Java API Specification: java.lang.Comparable
- Java API Specification: java.util.Comparator
- Dev.java: The Java Collections Framework
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ComparatorModern.java
Unduh berkasimport java.util.*;
public class ComparatorModern {
record Siswa(String nama, int nilai) {}
public static void main(String[] args) {
List<Siswa> kelas = new ArrayList<>(List.of(
new Siswa("Budi", 78),
new Siswa("Siti", 92),
new Siswa("Dewi", 78),
new Siswa("Anton", 85)));
// Kriteria ganda: nilai tertinggi dulu, nama sebagai pemecah seri.
Comparator<Siswa> peringkat = Comparator
.comparingInt(Siswa::nilai).reversed()
.thenComparing(Siswa::nama);
kelas.sort(peringkat);
System.out.println("Nilai turun : " + kelas);
// Urutan lain dari list yang sama: nama naik, lalu nilai turun.
Comparator<Siswa> namaLaluNilai = Comparator
.comparing(Siswa::nama)
.thenComparing(Comparator.comparingInt(Siswa::nilai).reversed());
kelas.sort(namaLaluNilai);
System.out.println("Nama naik : " + kelas);
// nullsFirst: null diperlakukan paling kecil, sisanya urutan alami.
List<String> tim = new ArrayList<>(Arrays.asList("Siti", null, "Budi", null));
tim.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println("nullsFirst : " + tim);
}
}
KontrakDilanggar.java
Unduh berkasimport java.util.*;
public class KontrakDilanggar {
public static void main(String[] args) {
// Comparator rusak: mengklaim "a lebih kecil dari b" dari kedua arah.
Comparator<Integer> rusak = (a, b) -> -1;
System.out.println("compare(2, 3) : " + rusak.compare(2, 3));
System.out.println("compare(3, 2) : " + rusak.compare(3, 2));
System.out.println("compare(2, 2) : " + rusak.compare(2, 2));
TreeSet<Integer> himpunan = new TreeSet<>(rusak);
himpunan.add(1);
himpunan.add(2);
himpunan.add(3);
System.out.println("isi : " + himpunan);
System.out.println("size() : " + himpunan.size());
System.out.println("contains(1) : " + himpunan.contains(1));
System.out.println("contains(2) : " + himpunan.contains(2));
}
}
UrutanAlami.java
Unduh berkasimport java.util.*;
public class UrutanAlami {
// Urutan alami siswa: berdasarkan nilai. compareTo mengembalikan
// angka negatif, nol, atau positif -- bukan sekadar -1 atau 1.
record Siswa(String nama, int nilai) implements Comparable<Siswa> {
@Override
public int compareTo(Siswa lain) {
return Integer.compare(nilai, lain.nilai);
}
}
public static void main(String[] args) {
List<Siswa> kelas = new ArrayList<>(List.of(
new Siswa("Budi", 78),
new Siswa("Siti", 92),
new Siswa("Dewi", 78),
new Siswa("Anton", 85)));
System.out.println("Sebelum sort : " + kelas);
Collections.sort(kelas);
System.out.println("Setelah sort : " + kelas);
}
}