Terminal operation reduce melipat stream menjadi satu nilai, tetapi pekerjaan nyata sering meminta bentuk hasil yang lebih kaya: teks gabungan, statistik angka, Map nama-ke-harga, hingga laporan ringkas yang menggabungkan beberapa angka sekaligus. Untuk kebutuhan tersebut, Stream API menyediakan method collect yang bekerja sama dengan kelas java.util.stream.Collectors — pabrik Collector siap pakai. Pada Chapter ini kita akan memakai Collector bawaan untuk joining, counting, summingInt, averagingInt, maxBy, minBy, toMap, mapping, hingga teeing, semuanya tanpa satu pun perulangan manual.

Method collect dan Kelas Collectors #

collect menjalankan pola mutable reduction: alih-alih membuat nilai baru pada setiap langkah seperti reduce, collect menumbuhkan satu wadah yang bisa diubah — StringBuilder, ArrayList, atau HashMap — lalu mengembalikan wadah itu sebagai hasil akhir. Implementasinya diatur oleh interface Collector, yang menyimpan empat komponen: pabrik wadah (supplier), cara menambah satu elemen (accumulator), cara menggabungkan dua wadah saat stream berjalan paralel (combiner), dan transformasi akhir opsional (finisher).

Kita hampir tidak pernah menulis Collector sendiri dari nol. Cukup panggil method statis pada Collectors dan biarkan pustaka menyusun keempat komponen tersebut. Berikut Collector yang akan kita pakai di chapter ini:

CollectorHasilKeterangan
joining(d, p, s)Stringgabungkan teks dengan pemisah, prefiks, sufiks
counting()Longjumlah elemen
summingInt(...)Integertotal nilai fungsi int
averagingInt(...)Doublerata-rata nilai fungsi int
maxBy(k) / minBy(k)Optional<T>elemen terbesar/terkecil menurut pembanding
toMap(k, v, merge)Map<K,V>bangun Map; merge menyelesaikan kunci ganda
mapping(f, lain)bervariasiubah elemen sebelum dikumpulkan downstream
teeing(c1, c2, gabung)bervariasijalankan dua Collector lalu gabungkan hasilnya

Laporan Nilai dengan joining dan Statistik #

Bayangkan kita menyimpan nilai ulangan satu rombongan dan harus mencetak laporan singkat: daftar nama, jumlah siswa, total nilai, rata-rata, serta pemilik nilai tertinggi dan terendah. Program berikut menyelesaikan seluruh tugas itu dengan satu class Siswa berbentuk record dan serangkaian pemanggilan collect:

import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class LaporanNilai {

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

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "10A", 78),
            new Siswa("Siti", "10A", 92),
            new Siswa("Dewi", "10A", 85),
            new Siswa("Ani", "10A", 74)
        );

        // joining: gabungkan nama dengan pemisah, prefiks, dan sufiks
        String daftarNama = rombongan.stream()
            .map(Siswa::nama)
            .collect(Collectors.joining(", ", "Siswa: [", "]"));
        System.out.println(daftarNama);

        // counting: jumlah elemen sebagai Long
        Long jumlah = rombongan.stream().collect(Collectors.counting());
        System.out.println("Jumlah siswa: " + jumlah);

        // summingInt dan averagingInt
        Integer total = rombongan.stream().collect(Collectors.summingInt(Siswa::nilai));
        Double rataRata = rombongan.stream().collect(Collectors.averagingInt(Siswa::nilai));
        System.out.println("Total nilai: " + total);
        System.out.printf("Rata-rata: %.2f%n", rataRata);

        // maxBy dan minBy menghasilkan Optional<Siswa>
        Comparator<Siswa> byNilai = Comparator.comparingInt(Siswa::nilai);
        Optional<Siswa> tertinggi = rombongan.stream().collect(Collectors.maxBy(byNilai));
        Optional<Siswa> terendah = rombongan.stream().collect(Collectors.minBy(byNilai));
        tertinggi.ifPresent(s -> System.out.println("Nilai tertinggi: " + s.nama() + " (" + s.nilai() + ")"));
        System.out.println("Nilai terendah: " + terendah.map(Siswa::nama).orElse("tidak ada data"));
    }
}

Output yang Dihasilkan:

text
Siswa: [Budi, Siti, Dewi, Ani]
Jumlah siswa: 4
Total nilai: 329
Rata-rata: 82.25
Nilai tertinggi: Siti (92)
Nilai terendah: Ani

Mari kita telaah baris-baris kode di atas:

  1. Baris 8 (record Siswa(String nama, String kelas, int nilai) {}): mendefinisikan model data ringkas. Method nama(), kelas(), dan nilai() dibuat otomatis, sehingga method reference seperti Siswa::nama langsung tersedia untuk pipeline.
  2. Baris 19-21 (rombongan.stream().map(Siswa::nama).collect(...)): pipeline mengubah setiap Siswa menjadi String nama, lalu joining merangkai hasilnya. Argumen pertama ", " adalah pemisah antar elemen, argumen kedua "Siswa: [" prefiks, dan argumen ketiga "]" sufiks — ketiganya boleh dikosongkan bila tidak dibutuhkan.
  3. Baris 25 (Long jumlah = ... Collectors.counting()): counting() menghitung elemen dan mengembalikan Long, bukan int — selaras dengan terminal operation count() yang juga mengembalikan long, sehingga kedua jalur hitung memakai tipe yang sama.
  4. Baris 29-30 (summingInt dan averagingInt): kedua Collector menerima ToIntFunction, di sini Siswa::nilai. summingInt menghasilkan Integer bertotal 329, sedangkan averagingInt menghasilkan Double 82.25; pembulatan tampilan dua angka di baris 32 ditangani printf dengan format %.2f.
  5. Baris 35-37 (Comparator lalu maxBy/minBy): maxBy dan minBy menerima Comparator dan menghasilkan Optional<Siswa> karena stream kosong tidak punya elemen terbesar. Wadah Optional memaksa kita menangani kasus kosong secara eksplisit.
  6. Baris 38-39 (ifPresent dan map(...).orElse(...)): kedua baris membuka Optional dengan aman — ifPresent menjalankan aksi hanya bila nilai ada, sedangkan map(Siswa::nama).orElse("tidak ada data") menyiapkan nilai cadangan tanpa pernah memanggil get() tanpa pemeriksaan.

Membangun Map dengan toMap dan Merge Function #

toMap memetakan setiap elemen menjadi satu pasangan kunci-nilai. Masalah klasiknya: dua elemen bisa menghasilkan kunci yang sama. Tanpa penyelesai, toMap melempar IllegalStateException; dengan merge function, kita yang memutuskan nilai mana yang bertahan. Toko contoh kita menyimpan dua entri Pena dengan harga berbeda, dan merge function Math::min memilih harga termurah:

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

public class HargaProduk {

    record Produk(String nama, String kategori, int harga) {}

    public static void main(String[] args) {
        List<Produk> etalase = List.of(
            new Produk("Pena", "alat tulis", 2000),
            new Produk("Pensil", "alat tulis", 1500),
            new Produk("Pena", "alat tulis", 2500),
            new Produk("Bag", "tas", 75000)
        );

        // toMap dengan merge function: kunci ganda diselesaikan, bukan error
        Map<String, Integer> hargaTermurah = etalase.stream()
            .collect(Collectors.toMap(
                Produk::nama,
                Produk::harga,
                (hargaLama, hargaBaru) -> Math.min(hargaLama, hargaBaru)));
        System.out.println("Harga termurah per nama: " + hargaTermurah);

        // mapping sebagai downstream collector pada groupingBy
        Map<String, List<String>> namaPerKategori = etalase.stream()
            .collect(Collectors.groupingBy(
                Produk::kategori,
                Collectors.mapping(Produk::nama, Collectors.toList())));
        System.out.println("Nama per kategori: " + namaPerKategori);

        // teeing: dua collector berjalan pada stream yang sama lalu hasilnya digabung
        String ringkasan = etalase.stream().collect(Collectors.teeing(
            Collectors.counting(),
            Collectors.summingInt(Produk::harga),
            (jumlah, total) -> jumlah + " produk dengan total harga " + total));
        System.out.println(ringkasan);
    }
}

Output yang Dihasilkan:

text
Harga termurah per nama: {Bag=75000, Pensil=1500, Pena=2000}
Nama per kategori: {tas=[Bag], alat tulis=[Pena, Pensil, Pena]}
4 produk dengan total harga 81000

Mari kita telaah bagian-bagian pentingnya:

  1. Baris 18-22 (toMap(Produk::nama, Produk::harga, ...)): argumen pertama menentukan kunci, argumen kedua menentukan nilai, dan lambda ketiga (hargaLama, hargaBaru) -> Math.min(hargaLama, hargaBaru) dipanggil hanya saat kunci Pena muncul kedua kali. Hasil akhirnya Pena=2000, bukan galat.
  2. Baris 26-29 (groupingBy + mapping): mapping(Produk::nama, Collectors.toList()) adalah downstream collector — Collector yang memproses nilai di dalam setiap kelompok alih-alih elemen aslinya. Kelompok "alat tulis" berisi Pena, Pensil, lalu Pena lagi karena sumbernya memang belum dibersihkan dari duplikat; pembahasan penuh groupingBy menanti Chapter berikutnya.
  3. Baris 33-36 (teeing): teeing (hadir sejak Java 12) membelah pekerjaan menjadi dua Collector — counting() dan summingInt — lalu lambda terakhir menggabungkan hasil 4 dan 81000 menjadi satu kalimat. Pola ini menggantikan kebutuhan menjalankan stream dua kali.

Bila merge function sengaja dihapus, program yang sama berhenti dengan galat. Berkas berikut sengaja dibiarkan gagal untuk membuktikannya:

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

public class TanpaMerge {

    record Produk(String nama, String kategori, int harga) {}

    public static void main(String[] args) {
        List<Produk> etalase = List.of(
            new Produk("Pena", "alat tulis", 2000),
            new Produk("Pena", "alat tulis", 2500)
        );

        // TANPA merge function: kunci ganda memicu IllegalStateException
        Map<String, Integer> harga = etalase.stream()
            .collect(Collectors.toMap(Produk::nama, Produk::harga));
        System.out.println(harga);
    }
}

Galat tersebut terverifikasi dengan menjalankan berkasnya: JVM mencetak Exception in thread "main" java.lang.IllegalStateException: Duplicate key Pena (attempted merging values 2000 and 2500) — pesan yang menyebut kunci ganda beserta kedua nilai yang bertabrakan. Gunakan merge function setiap kali kunci berpotensi sama, misalnya (a, b) -> a untuk mempertahankan nilai pertama.

Dengan koleksi Collector ini, laporan satu dimensi sudah terasa ringan untuk ditulis. Pertanyaan berikutnya adalah bagaimana mengelompokkan data menjadi kategori — tugas yang dijawab langsung oleh groupingBy dan partitioningBy pada Chapter berikutnya.

Referensi #

Berkas Sumber

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

HargaProduk.java

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

public class HargaProduk {

    record Produk(String nama, String kategori, int harga) {}

    public static void main(String[] args) {
        List<Produk> etalase = List.of(
            new Produk("Pena", "alat tulis", 2000),
            new Produk("Pensil", "alat tulis", 1500),
            new Produk("Pena", "alat tulis", 2500),
            new Produk("Bag", "tas", 75000)
        );

        // toMap dengan merge function: kunci ganda diselesaikan, bukan error
        Map<String, Integer> hargaTermurah = etalase.stream()
            .collect(Collectors.toMap(
                Produk::nama,
                Produk::harga,
                (hargaLama, hargaBaru) -> Math.min(hargaLama, hargaBaru)));
        System.out.println("Harga termurah per nama: " + hargaTermurah);

        // mapping sebagai downstream collector pada groupingBy
        Map<String, List<String>> namaPerKategori = etalase.stream()
            .collect(Collectors.groupingBy(
                Produk::kategori,
                Collectors.mapping(Produk::nama, Collectors.toList())));
        System.out.println("Nama per kategori: " + namaPerKategori);

        // teeing: dua collector berjalan pada stream yang sama lalu hasilnya digabung
        String ringkasan = etalase.stream().collect(Collectors.teeing(
            Collectors.counting(),
            Collectors.summingInt(Produk::harga),
            (jumlah, total) -> jumlah + " produk dengan total harga " + total));
        System.out.println(ringkasan);
    }
}

LaporanNilai.java

Unduh berkas
java
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class LaporanNilai {

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

    public static void main(String[] args) {
        List<Siswa> rombongan = List.of(
            new Siswa("Budi", "10A", 78),
            new Siswa("Siti", "10A", 92),
            new Siswa("Dewi", "10A", 85),
            new Siswa("Ani", "10A", 74)
        );

        // joining: gabungkan nama dengan pemisah, prefiks, dan sufiks
        String daftarNama = rombongan.stream()
            .map(Siswa::nama)
            .collect(Collectors.joining(", ", "Siswa: [", "]"));
        System.out.println(daftarNama);

        // counting: jumlah elemen sebagai Long
        Long jumlah = rombongan.stream().collect(Collectors.counting());
        System.out.println("Jumlah siswa: " + jumlah);

        // summingInt dan averagingInt
        Integer total = rombongan.stream().collect(Collectors.summingInt(Siswa::nilai));
        Double rataRata = rombongan.stream().collect(Collectors.averagingInt(Siswa::nilai));
        System.out.println("Total nilai: " + total);
        System.out.printf("Rata-rata: %.2f%n", rataRata);

        // maxBy dan minBy menghasilkan Optional<Siswa>
        Comparator<Siswa> byNilai = Comparator.comparingInt(Siswa::nilai);
        Optional<Siswa> tertinggi = rombongan.stream().collect(Collectors.maxBy(byNilai));
        Optional<Siswa> terendah = rombongan.stream().collect(Collectors.minBy(byNilai));
        tertinggi.ifPresent(s -> System.out.println("Nilai tertinggi: " + s.nama() + " (" + s.nilai() + ")"));
        System.out.println("Nilai terendah: " + terendah.map(Siswa::nama).orElse("tidak ada data"));
    }
}

TanpaMerge.java

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

public class TanpaMerge {

    record Produk(String nama, String kategori, int harga) {}

    public static void main(String[] args) {
        List<Produk> etalase = List.of(
            new Produk("Pena", "alat tulis", 2000),
            new Produk("Pena", "alat tulis", 2500)
        );

        // TANPA merge function: kunci ganda memicu IllegalStateException
        Map<String, Integer> harga = etalase.stream()
            .collect(Collectors.toMap(Produk::nama, Produk::harga));
        System.out.println(harga);
    }
}