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:
| Fungsi | In-place | Hasil | Keterangan |
|---|---|---|---|
sort() | ✓ Ya | Unit | Hanya untuk MutableList |
sortBy { } | ✓ Ya | Unit | In-place dengan key selector |
sorted() | ✗ Tidak | List baru | Untuk tipe Comparable |
sortedBy { } | ✗ Tidak | List baru | Dengan 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
)
}
ImplementasikanComparablehanya 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), gunakanComparatoryang 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 bersamacompareBy,thenBy,thenByDescending, atauComparatorkustom.compareBy({ a }, { b })untuk multi-kriteria dengan arah yang sama (semua ascending).compareBy { a }.thenByDescending { b }.thenBy { c }untuk arah campuran.Comparablecocok saat ada satu urutan “natural” yang jelas untuk tipe tersebut (Versi,Tanggal,Uang). ImplementasikancompareTodan gunakancompareValuesByuntuk menyederhanakan logika.Comparatorlebih fleksibel — buat beberapa comparator berbeda untuk tipe yang sama sesuai kebutuhan konteks (sort harga untuk halaman produk, sort nama untuk daftar admin).nullsLast()dannullsFirst()untuk menangani nullable dalam pengurutan — selalu tentukan posisi null secara eksplisit daripada membiarkanNullPointerException.- 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 padaMutableList—sorted()selalu menghasilkan List baru dan bisa dipanggil pada List read-only apapun.