Comparator & Sorting

Comparator & Sorting #

Pengurutan adalah kebutuhan yang hampir selalu muncul — daftar produk diurutkan harga, karyawan diurutkan nama, transaksi diurutkan tanggal terbaru, dan yang paling rumit: data diurutkan berdasarkan beberapa kriteria sekaligus (departemen dulu, lalu nama, lalu gaji). Kotlin menyediakan API pengurutan yang sangat ekspresif: sortedBy untuk kasus sederhana, compareBy untuk multi-kriteria, dan Comparator penuh untuk kontrol maksimal. Memahami perbedaan antara Comparable (objek yang tahu cara membandingkan dirinya sendiri) dan Comparator (objek terpisah yang tahu cara membandingkan dua objek lain) adalah kunci untuk memilih pendekatan yang tepat. Artikel ini membahas seluruh ekosistem sorting di Kotlin dari yang paling sederhana hingga yang paling kompleks, beserta pola idiomatik yang membuat kode pengurutan mudah dibaca dan dimodifikasi.

Pengurutan Dasar #

Kotlin menyediakan empat fungsi utama untuk pengurutan collection:

FungsiIn-placeHasilKeterangan
sort()✓ YaUnitHanya untuk MutableList
sortBy { }✓ YaUnitIn-place dengan key selector
sorted()✗ TidakList baruUntuk tipe Comparable
sortedBy { }✗ TidakList baruDengan key selector
// Tipe yang mengimplementasikan Comparable secara bawaan
val angka = mutableListOf(5, 2, 8, 1, 9, 3)
angka.sort()                    // in-place: [1, 2, 3, 5, 8, 9]
angka.sortDescending()          // in-place: [9, 8, 5, 3, 2, 1]

val kata = listOf("jeruk", "apel", "mangga", "durian")
val terurut = kata.sorted()           // List baru: [apel, durian, jeruk, mangga]
val terbalik = kata.sortedDescending() // List baru: [mangga, jeruk, durian, apel]

// sortedBy — urutkan berdasarkan key selector
data class Produk(val nama: String, val harga: Double, val stok: Int)

val produk = listOf(
    Produk("Laptop", 15_000_000.0, 10),
    Produk("Mouse", 250_000.0, 50),
    Produk("Keyboard", 800_000.0, 30),
    Produk("Monitor", 3_500_000.0, 15),
    Produk("Webcam", 750_000.0, 20)
)

val urutHarga = produk.sortedBy { it.harga }
// [Mouse, Webcam, Keyboard, Monitor, Laptop]

val urutHargaTurun = produk.sortedByDescending { it.harga }
// [Laptop, Monitor, Keyboard, Webcam, Mouse]

val urutNama = produk.sortedBy { it.nama }
// [Keyboard, Laptop, Monitor, Mouse, Webcam]

// ANTI-PATTERN: in-place sort pada read-only list
// produk.sort()  // ERROR — produk adalah List (read-only)

// Untuk in-place sort: perlu MutableList
val produkMutable = produk.toMutableList()
produkMutable.sortBy { it.harga }   // in-place OK

Comparable — Objek yang Bisa Dibandingkan #

Comparable<T> adalah interface yang membuat objek tahu bagaimana membandingkan dirinya dengan objek lain sejenis. Implementasikan ini ketika ada urutan “natural” yang jelas untuk tipe tersebut.

// Mengimplementasikan Comparable
data class Versi(val major: Int, val minor: Int, val patch: Int) : Comparable<Versi> {

    override fun compareTo(other: Versi): Int {
        // compareTo harus mengembalikan:
        // negatif  → this < other
        // 0        → this == other
        // positif  → this > other

        if (major != other.major) return major - other.major
        if (minor != other.minor) return minor - other.minor
        return patch - other.patch
    }

    override fun toString() = "$major.$minor.$patch"
}

val versi = listOf(
    Versi(2, 0, 0),
    Versi(1, 9, 5),
    Versi(2, 1, 0),
    Versi(1, 0, 0),
    Versi(2, 0, 1)
)

val terurut = versi.sorted()
// [1.0.0, 1.9.5, 2.0.0, 2.0.1, 2.1.0]

val terbaru = versi.max()   // 2.1.0
val tertua = versi.min()    // 1.0.0

// Operator perbandingan otomatis tersedia setelah implement Comparable
println(Versi(2, 0, 0) > Versi(1, 9, 5))   // true
println(Versi(1, 0, 0) in Versi(1, 0, 0)..Versi(2, 0, 0))  // true

// compareValuesBy — cara lebih ringkas untuk implementasi compareTo
data class Karyawan(val nama: String, val departemen: String, val gaji: Double)
    : Comparable<Karyawan> {

    override fun compareTo(other: Karyawan): Int =
        compareValuesBy(this, other,
            { it.departemen },   // urutkan departemen dulu
            { it.nama }          // lalu nama
        )
}
Implementasikan Comparable hanya jika ada satu urutan “natural” yang jelas dan tidak ambigu untuk tipe tersebut. Untuk versi: urutan natural adalah dari terkecil ke terbesar. Jika ada beberapa cara yang valid untuk mengurutkan (harga vs nama vs stok), gunakan Comparator yang terpisah daripada memaksakan satu urutan ke dalam kelas.

Comparator — Pengurutan Fleksibel #

Comparator<T> adalah objek terpisah yang mendefinisikan cara membandingkan dua objek. Ini lebih fleksibel dari Comparable karena kamu bisa memiliki banyak Comparator berbeda untuk tipe yang sama.

compareBy — Multi-Kriteria #

data class Karyawan(
    val nama: String,
    val departemen: String,
    val gaji: Double,
    val tahunMasuk: Int
)

val karyawan = listOf(
    Karyawan("Clara", "Engineering", 18_000_000.0, 2020),
    Karyawan("Andi", "Marketing", 12_000_000.0, 2019),
    Karyawan("Budi", "Engineering", 15_000_000.0, 2021),
    Karyawan("Dina", "HR", 10_000_000.0, 2018),
    Karyawan("Eva", "Engineering", 18_000_000.0, 2019),
    Karyawan("Fani", "Marketing", 13_500_000.0, 2020)
)

// compareBy — urutkan berdasarkan satu kriteria
val comparatorNama = compareBy<Karyawan> { it.nama }
val urutNama = karyawan.sortedWith(comparatorNama)
// [Andi, Budi, Clara, Dina, Eva, Fani]

// compareBy dengan beberapa kriteria — prioritas dari kiri ke kanan
val comparatorDeptNama = compareBy<Karyawan>({ it.departemen }, { it.nama })
val urutDeptNama = karyawan.sortedWith(comparatorDeptNama)
// Engineering: Budi, Clara, Eva
// HR: Dina
// Marketing: Andi, Fani

// compareByDescending — urutan terbalik
val comparatorGajiTurun = compareByDescending<Karyawan> { it.gaji }
val urutGajiTurun = karyawan.sortedWith(comparatorGajiTurun)
// [Clara, Eva, Budi, Fani, Andi, Dina] — gaji tertinggi dulu

thenBy — Pengurutan Berlapis #

thenBy dan thenByDescending menambahkan kriteria pengurutan berikutnya — digunakan saat kriteria sebelumnya menghasilkan nilai yang sama (tie-breaking).

// Kasus klasik: departemen naik, lalu gaji turun, lalu nama naik
val comparatorKompleks = compareBy<Karyawan> { it.departemen }
    .thenByDescending { it.gaji }
    .thenBy { it.nama }

val hasilKompleks = karyawan.sortedWith(comparatorKompleks)
// Engineering: Eva(18jt), Clara(18jt), Budi(15jt)   — gaji turun, nama naik untuk sama
// HR: Dina(10jt)
// Marketing: Fani(13.5jt), Andi(12jt)

// Lebih banyak level:
val comparatorLengkap = compareBy<Karyawan>
    { it.departemen }
    .thenByDescending { it.gaji }
    .thenBy { it.tahunMasuk }
    .thenBy { it.nama }

// thenComparator — gunakan comparator lain sebagai tie-breaker
val comparatorGajiNama = compareByDescending<Karyawan> { it.gaji }
    .thenComparator { a, b -> a.nama.compareTo(b.nama) }

Comparator Kustom dengan Lambda #

// Comparator sebagai lambda langsung
val sortTidakBiasa = Comparator<String> { a, b ->
    // Urutkan berdasarkan panjang dulu, lalu alphabetical
    val panjangCmp = a.length.compareTo(b.length)
    if (panjangCmp != 0) panjangCmp else a.compareTo(b)
}

val kata = listOf("kotlin", "is", "a", "great", "language")
println(kata.sortedWith(sortTidakBiasa))
// [a, is, great, kotlin, language]

// Null-safe comparator — tempatkan null di akhir
val comparatorNullAkhir = compareBy<String?>(nullsLast()) { it }
val denganNull = listOf("banana", null, "apple", null, "cherry")
println(denganNull.sortedWith(comparatorNullAkhir))
// [apple, banana, cherry, null, null]

// Null di awal
val comparatorNullAwal = compareBy<String?>(nullsFirst()) { it }
println(denganNull.sortedWith(comparatorNullAwal))
// [null, null, apple, banana, cherry]

naturalOrder dan reverseOrder #

// naturalOrder — urutan natural (Comparable)
val naturalComp: Comparator<Int> = naturalOrder()
val reverseComp: Comparator<Int> = reverseOrder()

listOf(3, 1, 4, 1, 5, 9).sortedWith(naturalComp)  // [1, 1, 3, 4, 5, 9]
listOf(3, 1, 4, 1, 5, 9).sortedWith(reverseComp)  // [9, 5, 4, 3, 1, 1]

// reversed() — balik comparator yang sudah ada
val comparatorHarga = compareBy<Produk> { it.harga }
val comparatorHargaTurun = comparatorHarga.reversed()

// Kombinasi dengan thenBy setelah reversed
val comparatorFinal = compareByDescending<Produk> { it.harga }
    .thenBy { it.nama }

Pengurutan String — Nuansa Penting #

Pengurutan String punya beberapa nuansa yang perlu dipahami, terutama untuk aplikasi Indonesia.

// Pengurutan default — berdasarkan kode Unicode, case-sensitive
val nama = listOf("Zara", "andi", "Budi", "clara", "Dina")
println(nama.sorted())
// [Budi, Dina, Zara, andi, clara] — huruf kapital sebelum kecil!

// Case-insensitive sort
val namaInsensitive = nama.sortedBy { it.lowercase() }
// [andi, Budi, clara, Dina, Zara]

// Locale-aware sort — untuk aplikasi Indonesia
import java.text.Collator
import java.util.Locale

val collatorID = Collator.getInstance(Locale("id", "ID"))
val comparatorLokal = Comparator<String> { a, b -> collatorID.compare(a, b) }

val namaID = listOf("Żurek", "Ąndré", "Budi", "Żaneta", "andi")
println(namaID.sortedWith(comparatorLokal))
// Urutan sesuai aturan bahasa Indonesia

// Sort berdasarkan panjang String, lalu alphabetical
val sortPanjang = compareBy<String>({ it.length }, { it })
listOf("cc", "aaa", "b", "dddd", "ee").sortedWith(sortPanjang)
// [b, cc, ee, aaa, dddd]

// Natural sort — mengurutkan string dengan angka secara natural
// "item2" seharusnya sebelum "item10" (secara natural, bukan lexicographic)
fun naturalSortComparator(): Comparator<String> = Comparator { a, b ->
    val reNumerik = Regex("(\\D+)|(\\d+)")
    val tokenA = reNumerik.findAll(a).map { it.value }.toList()
    val tokenB = reNumerik.findAll(b).map { it.value }.toList()

    for (i in 0 until minOf(tokenA.size, tokenB.size)) {
        val ta = tokenA[i]
        val tb = tokenB[i]
        val cmp = if (ta.first().isDigit() && tb.first().isDigit()) {
            ta.toLong().compareTo(tb.toLong())
        } else {
            ta.compareTo(tb)
        }
        if (cmp != 0) return@Comparator cmp
    }
    tokenA.size.compareTo(tokenB.size)
}

val file = listOf("item10", "item2", "item1", "item20", "item3")
println(file.sortedWith(naturalSortComparator()))
// [item1, item2, item3, item10, item20] — natural sort!
// bukan: [item1, item10, item2, item20, item3] — lexicographic

Pengurutan Nullable #

Mengurutkan collection yang berisi null memerlukan penanganan khusus.

data class Produk(val nama: String, val diskon: Double?)

val produk = listOf(
    Produk("Laptop", null),
    Produk("Mouse", 0.15),
    Produk("Keyboard", null),
    Produk("Monitor", 0.05),
    Produk("Webcam", 0.20)
)

// ANTI-PATTERN: sortedBy langsung akan error jika ada null
// produk.sortedBy { it.diskon }  // Null cannot be compared

// BENAR: tangani null secara eksplisit
// Null di akhir (produk tanpa diskon di bawah)
val urutDiskonNullAkhir = produk.sortedWith(
    compareBy(nullsLast()) { it.diskon }
)
// [Monitor(0.05), Mouse(0.15), Webcam(0.20), Laptop(null), Keyboard(null)]

// Null di awal
val urutDiskonNullAwal = produk.sortedWith(
    compareBy(nullsFirst()) { it.diskon }
)
// [Laptop(null), Keyboard(null), Monitor(0.05), Mouse(0.15), Webcam(0.20)]

// Null dengan nilai default untuk pengurutan
val urutDiskonDefault = produk.sortedBy { it.diskon ?: -1.0 }
// Null dianggap -1.0: [Laptop, Keyboard, Monitor, Mouse, Webcam]

// Nullable dengan thenBy — multi-kriteria dengan null handling
val urutLengkap = produk.sortedWith(
    compareBy(nullsLast<Double>()) { it.diskon }
        .thenBy { it.nama }
)

Stabilitas Pengurutan #

Kotlin (sejak versi 1.7 untuk JVM, dan semua target lain) menggunakan pengurutan yang stabil — elemen dengan kunci yang sama mempertahankan urutan relatif mereka dari sebelum pengurutan.

data class Nilai(val nama: String, val skor: Int)

val data = listOf(
    Nilai("Zara", 90),
    Nilai("Andi", 85),
    Nilai("Budi", 90),
    Nilai("Clara", 85),
    Nilai("Dina", 90)
)

// Urutkan berdasarkan skor saja
val hasilUrut = data.sortedBy { it.skor }
// [Andi, Clara, Zara, Budi, Dina]
// — urutan relatif Andi-Clara (skor 85) terjaga dari urutan asli
// — urutan relatif Zara-Budi-Dina (skor 90) terjaga dari urutan asli

// Stabilitas penting untuk UI: sort berdasarkan kolom berbeda
// Jika user sort nama dulu, lalu sort skor — urutan nama dalam skor yang sama terjaga

Pola Idiomatik untuk Kasus Nyata #

Sortable Table / Data Grid #

enum class ArahUrut { ASC, DESC }
data class KolomUrut(val kolom: String, val arah: ArahUrut)

fun List<Karyawan>.urutkan(kriteria: List<KolomUrut>): List<Karyawan> {
    if (kriteria.isEmpty()) return this

    var comparator: Comparator<Karyawan>? = null

    for (k in kriteria) {
        val comp: Comparator<Karyawan> = when (k.kolom) {
            "nama" -> compareBy { it.nama }
            "departemen" -> compareBy { it.departemen }
            "gaji" -> compareBy { it.gaji }
            "tahunMasuk" -> compareBy { it.tahunMasuk }
            else -> continue
        }

        val compFinal = if (k.arah == ArahUrut.DESC) comp.reversed() else comp
        comparator = comparator?.then(compFinal) ?: compFinal
    }

    return comparator?.let { sortedWith(it) } ?: this
}

// Penggunaan — misal dari request user
val kriteria = listOf(
    KolomUrut("departemen", ArahUrut.ASC),
    KolomUrut("gaji", ArahUrut.DESC)
)
val hasilUrut = karyawan.urutkan(kriteria)

Top-N dengan Heap (Efisien) #

// Untuk N kecil dari data yang sangat besar — lebih efisien dari sort penuh
fun <T, R : Comparable<R>> List<T>.topN(n: Int, selector: (T) -> R): List<T> {
    // Gunakan minHeap dengan ukuran n
    val heap = java.util.PriorityQueue<T>(n, compareBy(selector))
    for (item in this) {
        if (heap.size < n) {
            heap.offer(item)
        } else if (selector(item) > selector(heap.peek()!!)) {
            heap.poll()
            heap.offer(item)
        }
    }
    return heap.sortedByDescending(selector)
}

// Ambil 5 produk paling mahal dari list jutaan produk
val top5Mahal = semuaProduk.topN(5) { it.harga }

Pengurutan dengan Cache Key #

// Jika key selector mahal (parsing, kalkulasi kompleks) — cache hasilnya
data class Dokumen(val judul: String, val isi: String)

// ANTI-PATTERN: key selector dipanggil berkali-kali saat sort
dokumen.sortedBy { it.isi.split(" ").size }   // hitung kata setiap perbandingan!

// BENAR: hitung sekali, sort berdasarkan cache
dokumen
    .map { doc -> doc to doc.isi.split(" ").size }   // hitung sekali
    .sortedBy { (_, jumlahKata) -> jumlahKata }
    .map { (doc, _) -> doc }                          // ambil kembali dokumennya

Decision Tree — Pilih Pendekatan yang Tepat #

flowchart TD
    A{Berapa kriteria\npengurutan?} --> B["Satu kriteria"]
    A --> C["Beberapa kriteria"]

    B --> D{Ada urutan\nnatural?}
    D -- Ya --> E["Implement Comparable\ndi kelas, gunakan sorted()"]
    D -- Tidak --> F["sortedBy { selector }\natau sortedByDescending { }"]

    C --> G{Arah semua\nkriteria sama?}
    G -- Ya semua ASC --> H["compareBy({ a }, { b }, { c })\n.sortedWith(comparator)"]
    G -- Tidak --> I["compareBy { a }\n.thenByDescending { b }\n.thenBy { c }"]

    A --> J["Ada null\ndalam data?"]
    J --> K["compareBy(nullsLast()) { }\natau compareBy(nullsFirst()) { }"]

Ringkasan #

  • sortedBy { } untuk pengurutan berdasarkan satu kriteria tanpa null — paling sering dipakai dan paling ringkas. sortedByDescending { } untuk urutan terbalik.
  • sortedWith(comparator) untuk pengurutan kompleks — dipakai bersama compareBy, thenBy, thenByDescending, atau Comparator kustom.
  • compareBy({ a }, { b }) untuk multi-kriteria dengan arah yang sama (semua ascending). compareBy { a }.thenByDescending { b }.thenBy { c } untuk arah campuran.
  • Comparable cocok saat ada satu urutan “natural” yang jelas untuk tipe tersebut (Versi, Tanggal, Uang). Implementasikan compareTo dan gunakan compareValuesBy untuk menyederhanakan logika.
  • Comparator lebih fleksibel — buat beberapa comparator berbeda untuk tipe yang sama sesuai kebutuhan konteks (sort harga untuk halaman produk, sort nama untuk daftar admin).
  • nullsLast() dan nullsFirst() untuk menangani nullable dalam pengurutan — selalu tentukan posisi null secara eksplisit daripada membiarkan NullPointerException.
  • Pengurutan String yang benar untuk aplikasi Indonesia membutuhkan Collator.getInstance(Locale("id", "ID")) — pengurutan default Unicode tidak sesuai aturan bahasa Indonesia.
  • Stabilitas pengurutan di Kotlin terjamin — elemen dengan kunci yang sama mempertahankan urutan relatif aslinya. Manfaatkan ini untuk implementasi sort multi-kolom yang interaktif.
  • Hindari key selector yang mahal di sortedBy — fungsi selector dipanggil berulang kali saat perbandingan. Gunakan pola map-sort-map jika selector membutuhkan kalkulasi yang berat.
  • sort() in-place hanya bisa dipanggil pada MutableListsorted() selalu menghasilkan List baru dan bisa dipanggil pada List read-only apapun.

← Sebelumnya: Enum   Berikutnya: Random →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact