Laporan sering kali bukan satu angka, melainkan sekumpulan angka per kategori: jumlah siswa per kelas, total penjualan per kategori barang, atau daftar nama peserta remedial. Chapter sebelumnya memberi kita Collector satu hasil seperti counting dan averagingInt; sekarang kita naik satu tingkat ke Collector yang membangun Map: groupingBy untuk pengelompokan berdasarkan kunci apa pun, dan partitioningBy untuk pembagian dua kelompok berdasarkan kondisi benar-salah. Keduanya setara klausa GROUP BY pada SQL, tetapi bekerja sepenuhnya di dalam pipeline Java.

Cara Kerja groupingBy #

Collectors.groupingBy menerima sebuah classifier function — fungsi yang mengubah setiap elemen menjadi kunci Map. Untuk setiap elemen, classifier dijalankan, kunci hasilnya dicari di Map, dan elemen dimasukkan ke daftar milik kunci tersebut. Bila kunci belum pernah muncul, groupingBy membuat daftar baru lebih dulu; peristilahannya, elemen diakumulasikan ke dalam kelompok.

Tiga overload groupingBy menentukan seberapa jauh kita mengendalikan hasilnya. Overload satu argumen hanya menerima classifier dan menghasilkan Map<K, List<T>> dengan daftar sebagai nilai standar. Overload dua argumen menerima downstream collector — Collector yang memproses isi tiap kelompok, misalnya counting() untuk menghitung anggota atau mapping(...) untuk mengubah isinya. Overload tiga argumen menambahkan supplier Map, sehingga kita bisa memilih TreeMap agar kunci terurut.

groupingBy dengan counting dan mapping #

Kita mulai dari data lima siswa pada tiga kelas berbeda. Program berikut menyusun tiga bentuk laporan dari sumber yang sama: daftar siswa per kelas, jumlah siswa per kelas, dan daftar nama saja per kelas:

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class PengelompokanSiswa {

    record Siswa(String nama, String kelas, int nilai) {}

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "XII-1", 78),
            new Siswa("Siti", "X-2", 92),
            new Siswa("Dewi", "X-2", 70),
            new Siswa("Ani", "XI-3", 85),
            new Siswa("Rahmat", "XII-1", 88)
        );

        // groupingBy dasar: kelas -> daftar Siswa
        Map<String, List<Siswa>> perKelas = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas));
        System.out.println("perKelas = " + perKelas);

        // groupingBy + counting: kelas -> jumlah siswa
        Map<String, Long> jumlahPerKelas = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas, Collectors.counting()));
        System.out.println("jumlahPerKelas = " + jumlahPerKelas);

        // groupingBy + mapping: kelas -> daftar nama saja
        Map<String, List<String>> namaPerKelas = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas,
                Collectors.mapping(Siswa::nama, Collectors.toList())));
        System.out.println("namaPerKelas = " + namaPerKelas);
    }
}

Output yang Dihasilkan:

text
perKelas = {XII-1=[Siswa[nama=Budi, kelas=XII-1, nilai=78], Siswa[nama=Rahmat, kelas=XII-1, nilai=88]], X-2=[Siswa[nama=Siti, kelas=X-2, nilai=92], Siswa[nama=Dewi, kelas=X-2, nilai=70]], XI-3=[Siswa[nama=Ani, kelas=XI-3, nilai=85]]}
jumlahPerKelas = {XII-1=2, X-2=2, XI-3=1}
namaPerKelas = {XII-1=[Budi, Rahmat], X-2=[Siti, Dewi], XI-3=[Ani]}

Mari kita telaah baris-baris kode di atas:

  1. Baris 19-20 (groupingBy(Siswa::kelas)): classifier Siswa::kelas mengembalikan String kelas sebagai kunci. Tanpa downstream, nilai standarnya List<Siswa>, sehingga tipe hasilnya Map<String, List<Siswa>>.
  2. Baris 24-25 (groupingBy(Siswa::kelas, Collectors.counting())): downstream counting() mengganti isi tiap kelompok dengan jumlah anggotanya bertipe Long. Hasilnya Map<String, Long>, bukan lagi daftar siswa.
  3. Baris 29-31 (groupingBy + mapping): downstream mapping(Siswa::nama, Collectors.toList()) memotong setiap anggota kelompok menjadi String nama sebelum dikumpulkan menjadi daftar. Kombinasi groupingBy + mapping adalah resep paling sering dipakai untuk laporan “kunci ke daftar atribut”.
  4. Urutan kunci pada output: groupingBy satu argumen memakai HashMap, sehingga urutan tampilan kunci mengikuti perhitungan hash, bukan urutan alfabet — perhatikan XII-1 tampil sebelum X-2. Urutan ini stabil untuk data yang sama, tetapi bukan janji pengurutan.

groupingBy Berjenjang dan Peta Terurut #

Laporan boleh bertingkat. Dengan menjadikan groupingBy sebagai downstream dari groupingBy lain, kita membangun Map bersarang: kunci tingkat pertama kelas, kunci tingkat kedua status lulus, dan nilainya daftar nama. Overload tiga argumen sekaligus memperkenalkan TreeMap sebagai supplier agar kunci tingkat pertama terurut:

import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class PengelompokanBerjenjang {

    record Siswa(String nama, String kelas, int nilai) {}

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "XII-1", 78),
            new Siswa("Siti", "X-2", 92),
            new Siswa("Dewi", "X-2", 70),
            new Siswa("Ani", "XI-3", 85),
            new Siswa("Rahmat", "XII-1", 60)
        );

        // groupingBy berjenjang: kelas -> (lulus/remedial) -> daftar nama
        Map<String, Map<Boolean, List<String>>> berjenjang = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas,
                Collectors.groupingBy(s -> s.nilai() >= 75,
                    Collectors.mapping(Siswa::nama, Collectors.toList()))));
        System.out.println("berjenjang = " + berjenjang);

        // TreeMap sebagai supplier Map: kunci hasil terurut menurut alfabet
        Map<String, Long> terurut = rombongan.stream()
            .collect(Collectors.groupingBy(
                Siswa::kelas, TreeMap::new, Collectors.counting()));
        System.out.println("terurut = " + terurut);
    }
}

Output yang Dihasilkan:

text
berjenjang = {XII-1={false=[Rahmat], true=[Budi]}, X-2={false=[Dewi], true=[Siti]}, XI-3={true=[Ani]}}
terurut = {X-2=2, XI-3=1, XII-1=2}

Mari kita telaah bagian-bagian pentingnya:

  1. Baris 19-22 (groupingBy bersarang): classifier luar Siswa::kelas membagi siswa per kelas. Downstream-nya bukan toList(), melainkan groupingBy(s -> s.nilai() >= 75, ...) yang membagi lagi tiap kelas menjadi dua kelompok Boolean, lalu mapping mengumpulkan nama-namanya. Tipe hasil akhirnya Map<String, Map<Boolean, List<String>>>.
  2. Baris 20-22 (predikat menjadi kunci): lambda s -> s.nilai() >= 75 menghasilkan true atau false, sehingga peta tingkat kedua berisi kunci true (lulus, nilai minimal 75) dan false (remedial).
  3. Baris 26-28 (groupingBy(kunci, TreeMap::new, counting())): argumen kedua TreeMap::new adalah supplier yang menentukan jenis Map hasilnya. TreeMap menyimpan kunci dalam urutan alami String, terbukti pada output terurut yang menampilkan X-2, XI-3, lalu XII-1 — berbeda dengan urutan hash HashMap pada bagian pertama program.

partitioningBy untuk Dua Kelompok #

Bila pengelompokannya memang biner — lulus/remedial, murah/mahal, aktif/tidak aktif — partitioningBy lebih tepat daripada groupingBy dengan classifier Boolean. Method ini menerima satu Predicate, menghasilkan Map<Boolean, List<T>>, dan selalu menyediakan kedua kunci true serta false meskipun salah satu kelompok kosong:

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class PembagianLulus {

    record Siswa(String nama, String kelas, int nilai) {}

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "XII-1", 78),
            new Siswa("Siti", "X-2", 92),
            new Siswa("Dewi", "X-2", 70),
            new Siswa("Rahmat", "XII-1", 60)
        );

        // partitioningBy dasar: Map<Boolean, List<Siswa>>
        Map<Boolean, List<Siswa>> pembagian = rombongan.stream()
            .collect(Collectors.partitioningBy(s -> s.nilai() >= 75));
        System.out.println("Lulus: " + pembagian.get(true));
        System.out.println("Remedial: " + pembagian.get(false));

        // partitioningBy dengan downstream counting
        Map<Boolean, Long> jumlah = rombongan.stream()
            .collect(Collectors.partitioningBy(s -> s.nilai() >= 75,
                Collectors.counting()));
        System.out.println("jumlah = " + jumlah);

        // Stream kosong tetap menghasilkan kedua kunci
        Map<Boolean, List<Siswa>> kosong = List.<Siswa>of().stream()
            .collect(Collectors.partitioningBy(s -> s.nilai() >= 75));
        System.out.println("kosong = " + kosong);
    }
}

Output yang Dihasilkan:

text
Lulus: [Siswa[nama=Budi, kelas=XII-1, nilai=78], Siswa[nama=Siti, kelas=X-2, nilai=92]]
Remedial: [Siswa[nama=Dewi, kelas=X-2, nilai=70], Siswa[nama=Rahmat, kelas=XII-1, nilai=60]]
jumlah = {false=2, true=2}
kosong = {false=[], true=[]}

Mari kita telaah baris-baris kode di atas:

  1. Baris 18-19 (partitioningBy(s -> s.nilai() >= 75)): Predicate menentukan keanggotaan grup true atau false. Tidak ada classifier bebas seperti pada groupingBy; hanya dua kotak yang tersedia.
  2. Baris 22-23 (pembagian.get(true) dan get(false)): kedua kunci dijamin ada, sehingga pemanggilan get aman tanpa pemeriksaan containsKey. Perbandingannya, groupingBy hanya membuat kelompok yang benar-benar terisi.
  3. Baris 25-27 (downstream counting()): sama seperti groupingBy, partitioningBy menerima downstream collector. Hasilnya Map<Boolean, Long> berisi false=2, true=2.
  4. Baris 31-33 (stream kosong): partitioningBy pada stream kosong tetap menghasilkan {false=[], true=[]}. Perilaku ini berguna saat kode pembaca laporan mengiterasi kedua grup tanpa perlu menangani null.

Pengelompokan dan statistik pada chapter ini masih bekerja dengan Stream<Integer> untuk angka — setiap nilai tersimpan sebagai objek. Chapter berikutnya memperkenalkan primitive streams (IntStream, LongStream, DoubleStream) yang mengolah angka mentah tanpa boxing, lengkap dengan operasi statistik bawaan seperti sum dan summaryStatistics.

Referensi #

Berkas Sumber

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

PembagianLulus.java

Unduh berkas
java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class PembagianLulus {

    record Siswa(String nama, String kelas, int nilai) {}

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "XII-1", 78),
            new Siswa("Siti", "X-2", 92),
            new Siswa("Dewi", "X-2", 70),
            new Siswa("Rahmat", "XII-1", 60)
        );

        // partitioningBy dasar: Map<Boolean, List<Siswa>>
        Map<Boolean, List<Siswa>> pembagian = rombongan.stream()
            .collect(Collectors.partitioningBy(s -> s.nilai() >= 75));
        System.out.println("Lulus: " + pembagian.get(true));
        System.out.println("Remedial: " + pembagian.get(false));

        // partitioningBy dengan downstream counting
        Map<Boolean, Long> jumlah = rombongan.stream()
            .collect(Collectors.partitioningBy(s -> s.nilai() >= 75,
                Collectors.counting()));
        System.out.println("jumlah = " + jumlah);

        // Stream kosong tetap menghasilkan kedua kunci
        Map<Boolean, List<Siswa>> kosong = List.<Siswa>of().stream()
            .collect(Collectors.partitioningBy(s -> s.nilai() >= 75));
        System.out.println("kosong = " + kosong);
    }
}

PengelompokanBerjenjang.java

Unduh berkas
java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class PengelompokanBerjenjang {

    record Siswa(String nama, String kelas, int nilai) {}

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "XII-1", 78),
            new Siswa("Siti", "X-2", 92),
            new Siswa("Dewi", "X-2", 70),
            new Siswa("Ani", "XI-3", 85),
            new Siswa("Rahmat", "XII-1", 60)
        );

        // groupingBy berjenjang: kelas -> (lulus/remedial) -> daftar nama
        Map<String, Map<Boolean, List<String>>> berjenjang = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas,
                Collectors.groupingBy(s -> s.nilai() >= 75,
                    Collectors.mapping(Siswa::nama, Collectors.toList()))));
        System.out.println("berjenjang = " + berjenjang);

        // TreeMap sebagai supplier Map: kunci hasil terurut menurut alfabet
        Map<String, Long> terurut = rombongan.stream()
            .collect(Collectors.groupingBy(
                Siswa::kelas, TreeMap::new, Collectors.counting()));
        System.out.println("terurut = " + terurut);
    }
}

PengelompokanSiswa.java

Unduh berkas
java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class PengelompokanSiswa {

    record Siswa(String nama, String kelas, int nilai) {}

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "XII-1", 78),
            new Siswa("Siti", "X-2", 92),
            new Siswa("Dewi", "X-2", 70),
            new Siswa("Ani", "XI-3", 85),
            new Siswa("Rahmat", "XII-1", 88)
        );

        // groupingBy dasar: kelas -> daftar Siswa
        Map<String, List<Siswa>> perKelas = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas));
        System.out.println("perKelas = " + perKelas);

        // groupingBy + counting: kelas -> jumlah siswa
        Map<String, Long> jumlahPerKelas = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas, Collectors.counting()));
        System.out.println("jumlahPerKelas = " + jumlahPerKelas);

        // groupingBy + mapping: kelas -> daftar nama saja
        Map<String, List<String>> namaPerKelas = rombongan.stream()
            .collect(Collectors.groupingBy(Siswa::kelas,
                Collectors.mapping(Siswa::nama, Collectors.toList())));
        System.out.println("namaPerKelas = " + namaPerKelas);
    }
}