Total omzet, stok gabungan, produk termahal, satu paragraf dari banyak kalimat — semuanya adalah bentuk pertanyaan yang sama: banyak nilai masuk, satu nilai keluar. Operasi reduce adalah jawaban paling umum di Stream API untuk pola tersebut. Kita memberikan dua bahan — sebuah nilai awal (identity) dan sebuah fungsi penggabung (accumulator) — lalu JVM melipat seluruh elemen menjadi satu hasil, persis seperti melipat kertas berisi angka menjadi satu baris jumlah. Pada Chapter ini kita akan membedah dua bentuk reduce, membuktikan pentingnya identity yang benar, dan menimbang kapan reduce layak dipakai dibanding operasi agregasi khusus seperti sum dan count.

Melipat Elemen menjadi Satu Nilai #

Bentuk pertama reduce menerima dua argumen: reduce(identity, accumulator). Identity adalah nilai netral — titik awal pelipatan yang tidak mengubah hasil jika stream kosong. Accumulator bertipe BinaryOperator<T>: fungsi yang menggabungkan hasil sementara dengan satu elemen, dipanggil berulang dari elemen pertama sampai terakhir. Untuk penjumlahan, identity yang benar adalah 0 karena 0 + x = x; untuk penggabungan teks, identity yang benar adalah "" karena teks kosong digabung apa adanya. Program berikut mengumpulkan empat variasi sekaligus — termasuk satu contoh sengaja salah agar kita melihat akibatnya:

import java.util.List;

public class AgregasiHarga {

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

    public static void main(String[] args) {
        List<Produk> produk = List.of(
                new Produk("Kopi Arabika", "minuman", 85000),
                new Produk("Teh Hijau", "minuman", 45000),
                new Produk("Roti Gandum", "makanan", 30000),
                new Produk("Madu Hutan", "makanan", 120000)
        );

        // reduce(identity, accumulator): identitas 0 netral bagi penjumlahan
        int total = produk.stream()
                .map(Produk::harga)
                .reduce(0, (a, b) -> a + b);
        System.out.println("Total    : " + total);

        // Identitas yang SALAH: 1 bukan elemen netral penjumlahan
        int keliru = produk.stream()
                .map(Produk::harga)
                .reduce(1, (a, b) -> a + b);
        System.out.println("Keliru   : " + keliru + " (tergeser +1)");

        // Nilai terbesar dengan reduce tanpa identitas -> Optional
        int termahal = produk.stream()
                .map(Produk::harga)
                .reduce(Integer::max)
                .orElse(0);
        System.out.println("Termahal : " + termahal);

        // Concat teks: identitas "" netral bagi penggabungan
        String gabungan = produk.stream()
                .map(Produk::nama)
                .reduce((a, b) -> a + " + " + b)
                .orElse("(tidak ada produk)");
        System.out.println("Gabungan : " + gabungan);
    }
}

Output yang Dihasilkan:

text
Total    : 280000
Keliru   : 280001 (tergeser +1)
Termahal : 120000
Gabungan : Kopi Arabika + Teh Hijau + Roti Gandum + Madu Hutan

Mari kita bedah keempat pelipatan tersebut:

  1. Baris 16-18 (reduce(0, (a, b) -> a + b)): pelipatan berjalan seperti ((0+85000)+45000)+30000)+120000 hingga menghasilkan 280000. Parameter a selalu membawa hasil sementara, b adalah elemen berikutnya yang baru dibaca.
  2. Baris 22-24 (reduce(1, ...)): identity diganti 1 dan hasilnya tergeser menjadi 280001 — compiler tidak protes, hasilnya saja yang salah diam-diam. Identity wajib memenuhi accumulator.apply(identity, x) sama dengan x; angka 1 melanggar syarat itu untuk penjumlahan.
  3. Baris 28-31 (reduce(Integer::max)): bentuk tanpa identity menghasilkan Optional<Integer> — JVM tidak punya nilai awal untuk dikembalikan jika stream kosong. orElse(0) membuka bungkusnya dengan nilai cadangan; hasilnya 120000 dari Madu Hutan.
  4. Baris 35-38 (reduce((a, b) -> a + " + " + b)): concat teks menyisipkan pemisah " + " di antara hasil sementara dan elemen berikutnya, membentuk satu baris daftar produk. Varian Optional dipilih agar stream kosong terwakili "(tidak ada produk)".

Hasil yang Mungkin Tidak Ada #

Bentuk kedua, reduce(accumulator), mengembalikan Optional<T> — bentuk yang jujur terhadap kenyataan bahwa stream kosong tidak punya nilai untuk dikembalikan. Optional memaksa pemanggil menghadapi kemungkinan itu lewat orElse, isPresent, atau ifPresent. Pola ini cocok untuk method utilitas seperti “harga termurah dari sekumpulan produk”:

import java.util.List;
import java.util.Optional;

public class ReduceOpsional {

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

    static Optional<Integer> termurah(List<Produk> sumber) {
        return sumber.stream()
                .map(Produk::harga)
                .reduce(Integer::min);
    }

    public static void main(String[] args) {
        List<Produk> terisi = List.of(
                new Produk("Kopi Arabika", "minuman", 85000),
                new Produk("Air Mineral", "minuman", 8000)
        );
        List<Produk> kosong = List.of();

        System.out.println("Termurah (terisi) : " + termurah(terisi).orElse(-1));
        System.out.println("Termurah (kosong) : " + termurah(kosong).orElse(-1));
        System.out.println("Kosong ada nilai? : " + termurah(kosong).isPresent());
    }
}

Output yang Dihasilkan:

text
Termurah (terisi) : 8000
Termurah (kosong) : -1
Kosong ada nilai? : false

Mari kita amati perilakunya:

  1. Baris 8-12 (method termurah): signature Optional<Integer> menyatakan kontraknya sejak baris pertama — “mungkin tidak ada jawaban”. Pemanggil tidak bisa mengabaikan kasus kosong karena compiler memaksa Optional dibuka lebih dulu.
  2. Baris 21 (termurah(terisi).orElse(-1)): dua harga dilipat dengan Integer::min menjadi 8000 — harga Air Mineral.
  3. Baris 22-23 (termurah(kosong)): stream kosong membuat reduce mengembalikan Optional.empty(), sehingga orElse(-1) menghasilkan -1 dan isPresent() bernilai false.

Sebenarnya masih ada bentuk ketiga: reduce(identity, accumulator, combiner) dengan satu fungsi tambahan bernama combiner untuk menggabungkan hasil antar bagian. Bentuk ini wajib saat stream berjalan paralel dan accumulator tidak cukup untuk menyatukan hasil parsial dari beberapa thread:

java reduce tiga argumen dengan combiner
// Contoh teruji: menghitung total panjang seluruh kata.
int totalPanjang = kata.stream()
        .reduce(0,
                (jumlah, kataSatu) -> jumlah + kataSatu.length(), // akumulator
                Integer::sum);                                     // penggabung

Pada stream berurutan combiner jarang terpakai; panggung utamanya adalah Chapter Parallel Streams. Yang perlu diingat sekarang: accumulator boleh berganti tipe (di contoh di atas String dilipat menjadi Integer), dan itulah alasan bentuk tiga argumen tidak bisa disederhanakan menjadi dua.

Kapan reduce, Kapan Operasi Khusus #

Jumlah elemen, total angka, rata-rata, nilai terbesar — pola-pola ini begitu umum sehingga Stream API menyediakan operasi akhir khusus: count(), sum(), average(), min(), max(). Semua yang bisa dilakukan reduce dengan lambda, operasi khusus lakukan dengan nama yang langsung terbaca. Program berikut membandingkan keduanya pada data yang sama:

import java.util.List;

public class KhususVsReduce {

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

    public static void main(String[] args) {
        List<Produk> produk = List.of(
                new Produk("Kopi Arabika", "minuman", 85000),
                new Produk("Teh Hijau", "minuman", 45000),
                new Produk("Roti Gandum", "makanan", 30000)
        );

        // reduce manual: identitas + accumulator
        int viaReduce = produk.stream()
                .map(Produk::harga)
                .reduce(0, Integer::sum);
        System.out.println("reduce  : " + viaReduce);

        // Operasi khusus IntStream: langsung tanpa lambda
        int viaSum = produk.stream()
                .mapToInt(Produk::harga)
                .sum();
        System.out.println("sum     : " + viaSum);

        long jumlahElemen = produk.stream().count();
        System.out.println("count   : " + jumlahElemen);

        double rataRata = produk.stream()
                .mapToInt(Produk::harga)
                .average()
                .orElse(0);
        System.out.printf("average : %.2f\n", rataRata);
    }
}

Output yang Dihasilkan:

text
reduce  : 160000
sum     : 160000
count   : 3
average : 53333.33

Perbandingannya membaca begini:

  1. Baris 15-17 vs 21-23: reduce(0, Integer::sum) dan mapToInt(...).sum() sama-sama menghasilkan 160000; bedanya sum() hidup di IntStream sehingga bebas dari pembungkusan Integer dan terbaca sebagai satu kata, bukan satu ekspresi.
  2. Baris 26-27 (count()): penghitung elemen paling cepat; pada beberapa sumber JVM menjawab tanpa melipat elemen satu per satu.
  3. Baris 29-33 (average()): menghasilkan OptionalDouble — sekali lagi bentuk jujur untuk stream kosong — dan %.2f merapikan cetakan pecahan.
  4. Kesimpulan praktisnya: untuk agregasi standar, pakai operasi khusus; simpan reduce untuk lipatan yang benar-benar khusus — menggabungkan teks dengan pemisah, menghitung transaksi beruntun, atau melipat record menjadi ringkasan berbentuk record lain.

Ada satu batasan yang perlu kita bawa ke Chapter berikutnya: reduce melipat nilai menjadi nilai baru — hasil antara tidak boleh dimodifikasi, hanya digabung. Bagaimana jika hasil yang kita butuhkan adalah List, Set, atau String yang dirakit bertahap? Di situlah collect mengambil peran dengan container yang bisa diubah, diisi elemen demi elemen — Chapter collect membedah mekanismenya beserta Collectors.

Referensi #

Berkas Sumber

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

AgregasiHarga.java

Unduh berkas
java
import java.util.List;

public class AgregasiHarga {

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

    public static void main(String[] args) {
        List<Produk> produk = List.of(
                new Produk("Kopi Arabika", "minuman", 85000),
                new Produk("Teh Hijau", "minuman", 45000),
                new Produk("Roti Gandum", "makanan", 30000),
                new Produk("Madu Hutan", "makanan", 120000)
        );

        // reduce(identity, accumulator): identitas 0 netral bagi penjumlahan
        int total = produk.stream()
                .map(Produk::harga)
                .reduce(0, (a, b) -> a + b);
        System.out.println("Total    : " + total);

        // Identitas yang SALAH: 1 bukan elemen netral penjumlahan
        int keliru = produk.stream()
                .map(Produk::harga)
                .reduce(1, (a, b) -> a + b);
        System.out.println("Keliru   : " + keliru + " (tergeser +1)");

        // Nilai terbesar dengan reduce tanpa identitas -> Optional
        int termahal = produk.stream()
                .map(Produk::harga)
                .reduce(Integer::max)
                .orElse(0);
        System.out.println("Termahal : " + termahal);

        // Concat teks: identitas "" netral bagi penggabungan
        String gabungan = produk.stream()
                .map(Produk::nama)
                .reduce((a, b) -> a + " + " + b)
                .orElse("(tidak ada produk)");
        System.out.println("Gabungan : " + gabungan);
    }
}

KhususVsReduce.java

Unduh berkas
java
import java.util.List;

public class KhususVsReduce {

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

    public static void main(String[] args) {
        List<Produk> produk = List.of(
                new Produk("Kopi Arabika", "minuman", 85000),
                new Produk("Teh Hijau", "minuman", 45000),
                new Produk("Roti Gandum", "makanan", 30000)
        );

        // reduce manual: identitas + accumulator
        int viaReduce = produk.stream()
                .map(Produk::harga)
                .reduce(0, Integer::sum);
        System.out.println("reduce  : " + viaReduce);

        // Operasi khusus IntStream: langsung tanpa lambda
        int viaSum = produk.stream()
                .mapToInt(Produk::harga)
                .sum();
        System.out.println("sum     : " + viaSum);

        long jumlahElemen = produk.stream().count();
        System.out.println("count   : " + jumlahElemen);

        double rataRata = produk.stream()
                .mapToInt(Produk::harga)
                .average()
                .orElse(0);
        System.out.printf("average : %.2f\n", rataRata);
    }
}

ReduceOpsional.java

Unduh berkas
java
import java.util.List;
import java.util.Optional;

public class ReduceOpsional {

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

    static Optional<Integer> termurah(List<Produk> sumber) {
        return sumber.stream()
                .map(Produk::harga)
                .reduce(Integer::min);
    }

    public static void main(String[] args) {
        List<Produk> terisi = List.of(
                new Produk("Kopi Arabika", "minuman", 85000),
                new Produk("Air Mineral", "minuman", 8000)
        );
        List<Produk> kosong = List.of();

        System.out.println("Termurah (terisi) : " + termurah(terisi).orElse(-1));
        System.out.println("Termurah (kosong) : " + termurah(kosong).orElse(-1));
        System.out.println("Kosong ada nilai? : " + termurah(kosong).isPresent());
    }
}