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:

  1. Antisimetri: sgn(x.compareTo(y)) adalah kebalikan dari sgn(y.compareTo(x)). Jika x mendahului y, maka y wajib mengikuti x.
  2. Transitivitas: jika x > y dan y > z, maka x > z wajib benar.
  3. Konsistensi dengan kesetaraan: x.compareTo(y) == 0 sebaiknya sejalan dengan x.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:

java UrutanAlami.java
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:

text
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:

  1. Baris 5-11 (record Siswa ... implements Comparable<Siswa>): Siswa sendiri yang menyandang urutan alaminya; parameter generik Comparable<Siswa> membuat compareTo menerima tipe yang tepat tanpa pengecoran.
  2. Baris 8-10 (Integer.compare(nilai, lain.nilai)): pembantu Integer.compare menghasilkan -1, 0, atau 1 dengan aman untuk semua nilai int — mengurangi sendiri (nilai - lain.nilai) berisiko luber untuk angka ekstrem, jadi biasakan memakai pembantu ini atau Long.compare.
  3. Baris 21-23 (hasil sort): siswa tampil menaik berdasarkan nilai. Kedua siswa bernilai 78 (Budi dan Dewi) tetap dalam urutan relatif yang sama seperti masukan karena Collections.sort bersifat 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:

text
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:

  1. Baris 13-15 (comparingInt(...).reversed().thenComparing(...)): comparingInt(Siswa::nilai) mengangkat field nilai menjadi kriteria utama; .reversed() membaliknya menjadi menurun; .thenComparing(Siswa::nama) memasang pemecah seri. Hasilnya Siti (92) memimpin, dan dua siswa bernilai 78 diurutkan menurut nama — Budi lalu Dewi.
  2. Baris 18-21 (comparing(...).thenComparing(Comparator)): varian thenComparing ini menerima Comparator jadi, sehingga tiap kriteria bisa punya arah berbeda — nama naik, nilai turun — dalam satu rangkaian.
  3. Baris 24-26 (nullsFirst): comparator dasar menolak null (memanggil method pada null melempar NullPointerException); nullsFirst membungkusnya sehingga null dianggap paling kecil dan dikumpulkan di depan. Pasangannya nullsLast meletakkan null di 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:

text
compare(2, 3) : -1
compare(3, 2) : -1
compare(2, 2) : -1
isi          : [3, 2, 1]
size()       : 3
contains(1)  : false
contains(2)  : false

Poin bedahnya:

  1. Baris 6-10 (tiga pemanggilan compare): compare(2, 3) dan compare(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.
  2. Baris 12-16 (TreeSet dengan comparator rusak): add berhasil tiga kali dan size() melaporkan 3, karena penyisipan hanya mengikuti arah yang ditunjukkan comparator — setiap elemen baru selalu diletakkan di cabang kiri, membentuk rantai 1 <- 2 <- 3 yang tercetak terbalik sebagai [3, 2, 1].
  3. Baris 18-19 (contains berbohong): pencarian contains(1) dan contains(2) mengembalikan false meskipun elemen-elemen itu jelas tersimpan — pohon mencabang ke kiri selamanya sesuai comparator, melewati node yang dicari. remove mengalami 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ComparatorModern.java

Unduh berkas
java
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);
    }
}

KontrakDilanggar.java

Unduh berkas
java
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));
    }
}

UrutanAlami.java

Unduh berkas
java
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);
    }
}