Pengelompokan adalah salah satu operasi paling berguna dalam analisis data. Dari daftar transaksi, kita mungkin ingin tahu total pengeluaran per kategori, jumlah transaksi per hari, atau nilai tertinggi per produk. Pada chapter ini kita akan mempelajari groupBy, groupingBy, eachCount, fold, reduce, dan aggregate — operasi yang memetakan koleksi menjadi ringkasan per kunci.

groupBy: Klasik #

groupBy { key } menghasilkan Map<K, List<T>>. Tiap kunci memetakan ke list elemen-elemen yang berbagi kunci tersebut.

kotlin Contoh: groupBy sederhana
val buah = listOf("Apel", "Anggur", "Mangga", "Alpukat")
val perHuruf = buah.groupBy { it.first() }
// {A=[Apel, Anggur, Alpukat], M=[Mangga]}

groupBy mengiterasi seluruh collection sekaligus dan menyimpan list elemen per kunci. Bila kita hanya ingin agregat ringkasan (jumlah, total, rata-rata), groupingBy lebih efisien karena tidak menyimpan list per grup.

groupingBy: Lazy Group #

groupingBy { key } mengembalikan objek Grouping<K, T> yang belum mengiterasi apa pun. Agregasi baru dijalankan saat kita memanggil salah satu operasi terminal seperti eachCount, fold, reduce, atau aggregate.

OperasiMengembalikanKegunaan
eachCount()Map<K, Int>Hitung elemen per grup
fold(initial) { acc, x -> ... }Map<K, R>Akumulasi dengan nilai awal
reduce { key, acc, x -> ... }Map<K, R>Akumulasi tanpa nilai awal
aggregate { key, acc, x, first -> ... }Map<K, R>Paling fleksibel: lambda tahu apakah elemen pertama

fold vs reduce #

fold dan reduce adalah dua operasi akumulasi paling mendasar:

OperasiMenerimaMengembalikanTipe hasil
reduce { acc, x -> ... }(acc, x) -> accSama dengan tipe elemenT
fold(initial) { acc, x -> ... }(acc, x) -> accSama dengan tipe initialR (bebas)
reduceRight / foldRightSama, dari kananSamaT atau R
foldIndexed / reduceIndexedMenerima indeksSamaT atau R

Perbedaan utama: reduce mengambil elemen pertama sebagai nilai awal dan tipe hasil harus sama dengan tipe elemen. fold menerima nilai awal eksplisit dan tipe hasil bebas — bahkan bisa berbeda dari tipe elemen.

Contoh Lengkap #

kotlin
data class Transaksi(val kategori: String, val jumlah: Int)

fun main() {
    val transaksi = listOf(
        Transaksi("Makanan", 25000),
        Transaksi("Transport", 15000),
        Transaksi("Makanan", 30000),
        Transaksi("Hiburan", 50000),
        Transaksi("Transport", 20000),
        Transaksi("Makanan", 10000)
    )

    // groupBy: kelompokkan per kategori
    val perKategori = transaksi.groupBy { it.kategori }
    println("GroupBy kategori:")
    perKategori.forEach { (k, v) -> println("  $k: $v") }

    // groupingBy + eachCount: hitung jumlah per kategori
    val jumlahPerKategori = transaksi.groupingBy { it.kategori }.eachCount()
    println("Jumlah transaksi per kategori: $jumlahPerKategori")

    // groupingBy + fold: jumlahkan total per kategori
    val totalPerKategori = transaksi
        .groupingBy { it.kategori }
        .fold(0) { acc, t -> acc + t.jumlah }
    println("Total per kategori: $totalPerKategori")

    // groupingBy + reduce: max per kategori
    val maxPerKategori = transaksi
        .groupingBy { it.kategori }
        .reduce { _, acc, t -> if (acc.jumlah > t.jumlah) acc else t }
    println("Transaksi terbesar per kategori:")
    maxPerKategori.forEach { (k, v) -> println("  $k: $v") }

    // fold vs reduce
    val deret = listOf(1, 2, 3, 4, 5)
    val jumlahReduce = deret.reduce { acc, x -> acc + x }       // mulai dari elemen pertama
    val jumlahFold = deret.fold(0) { acc, x -> acc + x }        // mulai dari 0
    println("reduce: $jumlahReduce, fold: $jumlahFold")

    // fold dengan tipe berbeda
    val ringkasan = deret.fold("Deret: ") { acc, x -> "$acc$x " }
    println("fold dengan String: $ringkasan")

    // foldRight dan reduceRight: dari kanan
    val dariKanan = deret.foldRight(0) { x, acc -> acc + x }
    println("foldRight: $dariKanan")

    // foldIndexed
    val denganIdx = deret.foldIndexed("") { i, acc, x -> "$acc[$i:$x] " }
    println("foldIndexed: $denganIdx")

    // aggregate: kombinasi banyak nilai sekaligus
    val statistik = transaksi
        .groupingBy { it.kategori }
        .aggregate { _, acc: Transaksi?, t, first ->
            if (first) t else if (acc!!.jumlah > t.jumlah) acc else t
        }
    println("Aggregate (max per kategori):")
    statistik.forEach { (k, v) -> println("  $k: $v") }
}

Output yang Dihasilkan:

text
GroupBy kategori:
  Makanan: [Transaksi(kategori=Makanan, jumlah=25000), Transaksi(kategori=Makanan, jumlah=30000), Transaksi(kategori=Makanan, jumlah=10000)]
  Transport: [Transaksi(kategori=Transport, jumlah=15000), Transaksi(kategori=Transport, jumlah=20000)]
  Hiburan: [Transaksi(kategori=Hiburan, jumlah=50000)]
Jumlah transaksi per kategori: {Makanan=3, Transport=2, Hiburan=1}
Total per kategori: {Makanan=65000, Transport=35000, Hiburan=50000}
Transaksi terbesar per kategori:
  Makanan: Transaksi(kategori=Makanan, jumlah=30000)
  Transport: Transaksi(kategori=Transport, jumlah=20000)
  Hiburan: Transaksi(kategori=Hiburan, jumlah=50000)
reduce: 15, fold: 15
fold dengan String: Deret: 1 2 3 4 5 
foldRight: 15
foldIndexed: [0:1] [1:2] [2:3] [3:4] [4:5] 
Aggregate (max per kategori):
  Makanan: Transaksi(kategori=Makanan, jumlah=30000)
  Transport: Transaksi(kategori=Transport, jumlah=20000)
  Hiburan: Transaksi(kategori=Hiburan, jumlah=50000)

Mari kita bedah baris-baris penting di atas:

  1. transaksi.groupBy { it.kategori }: mengiterasi seluruh list dan menyimpan tiap elemen ke dalam list per kategori. Hasilnya Map<String, List<Transaksi>> lengkap dengan semua elemen asli.
  2. transaksi.groupingBy { it.kategori }.eachCount(): groupingBy hanya membentuk group source; eachCount adalah operasi terminal yang mengiterasi dan menghitung jumlah elemen per kategori. Tidak menyimpan list elemen.
  3. groupingBy { ... }.fold(0) { acc, t -> acc + t.jumlah }: fold pada grouping menerima nilai awal 0 per grup dan akumulator lambda. Hasilnya Map<String, Int> berisi total jumlah per kategori.
  4. groupingBy { ... }.reduce { _, acc, t -> ... }: reduce pada grouping tidak memiliki nilai awal — akumulator dimulai dari elemen pertama tiap grup. Lambda menerima key, acc, dan elemen saat ini.
  5. deret.reduce { acc, x -> acc + x }: reduce mulai dari elemen pertama (1) dan menambahkan elemen berikutnya. Hasilnya 15 bertipe Int sama seperti elemen.
  6. deret.fold(0) { acc, x -> acc + x }: fold mulai dari nilai awal 0. Hasilnya juga 15, tetapi nilai awal eksplisit memungkinkan tipe hasil berbeda.
  7. deret.fold("Deret: ") { acc, x -> "$acc$x " }: tipe hasil String — berbeda dari tipe elemen Int. reduce tidak bisa melakukan ini karena tipe hasil harus sama dengan tipe elemen.
  8. deret.foldRight(0) { x, acc -> acc + x }: urutan lambda terbalik — (elemen, akumulator) bukan (akumulator, elemen). Iterasi dari elemen terakhir ke pertama.
  9. deret.foldIndexed("") { i, acc, x -> ... }: lambda menerima (indeks, akumulator, elemen). Berguna ketika posisi elemen memengaruhi logika.
  10. groupingBy { ... }.aggregate { _, acc, t, first -> ... }: lambda menerima key, acc (akumulator atau null), elemen saat ini, dan Boolean apakah elemen ini adalah yang pertama untuk grup tersebut. Paling fleksibel — bisa menggabungkan beberapa statistik sekaligus.

Referensi Lanjutan #

Setelah memahami agregasi per grup, kita akan beralih ke operasi penyortiran yang mengatur urutan elemen sesuai kriteria yang kita tentukan.