Setelah terbiasa menulis lambda expression dan method reference pada Section Functional Programming, kita memegang satu set alat baru untuk memproses data: Stream API. Stream menggeser cara kita berpikir dari bagaimana memproses data menjadi apa yang ingin kita dapatkan dari data — kita cukup menyatakan saring, ubah, urutkan, lalu kumpulkan, dan JVM yang mengeksekusi langkah-langkahnya. Hadir sejak Java 8, stream kini menjadi gaya baku memproses collection di kode modern. Pada Chapter ini kita membongkar anatomi pipeline, membandingkannya dengan perulangan imperatif yang sudah kita kuasai, lalu membuktikan aturan yang paling sering membingungkan pemula: stream hanya boleh dipakai sekali.

Stream adalah Pipeline, Bukan Struktur Data #

Kesalahpahaman paling umum adalah menganggap stream sebagai jenis collection yang baru. Stream tidak menyimpan elemen apa pun; stream adalah aliran pemrosesan yang mengambil elemen dari sumber — List, array, berkas, generator — lalu mengalirkannya melewati rangkaian operasi. Stream juga tidak mengubah sumbernya: operasi filter tidak menghapus elemen dari List asli, dan sorted tidak menyusun ulang List asli. Setiap operasi menghasilkan stream baru; hasil akhir ditampung secara eksplisit, misalnya lewat toList().

Stream adalah pipeline: data mengalir dari sumber melalui serangkaian operasi, lalu keluar sebagai hasil di ujungnya — alur yang dapat digambar begini:

Pipeline terdiri dari tiga bagian: sumber data, operasi antara (intermediate operations) yang selalu mengembalikan Stream baru, serta operasi akhir (terminal operation) yang memicu eksekusi. Program berikut memperlihatkan ketiganya secara eksplisit — satu variabel per tahap:

import java.util.List;
import java.util.stream.Stream;

public class AnatomiPipeline {

    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("Roti Gandum", "makanan", 30000),
                new Produk("Madu Hutan", "makanan", 120000)
        );

        // Tahap 1: sumber data -- List berubah menjadi Stream
        Stream<Produk> sumber = produk.stream();

        // Tahap 2: operasi antara -- menyaring elemen
        Stream<Produk> yangMahal = sumber.filter(p -> p.harga() >= 50000);

        // Tahap 3: operasi antara lagi -- memetakan Produk menjadi String
        Stream<String> namaMahal = yangMahal.map(Produk::nama);

        // Tahap 4: operasi akhir -- memicu eksekusi dan menghasilkan List
        List<String> hasil = namaMahal.toList();

        System.out.println("Hasil pipeline : " + hasil);
        System.out.println("List asli      : " + produk.size() + " produk, tidak berubah");
    }
}

Output yang Dihasilkan:

text
Hasil pipeline : [Kopi Arabika, Madu Hutan]
List asli      : 3 produk, tidak berubah

Mari kita bedah pipeline di atas tahap demi tahap:

  1. Baris 6 (record Produk(...)): model data contoh di seluruh Section ini — nama, kategori, dan harga dalam satu tipe ringkas dengan equals, hashCode, dan toString otomatis.
  2. Baris 16 (produk.stream()): sumber pipeline — List menjadi Stream<Produk> yang membaca elemen satu per satu saat dibutuhkan.
  3. Baris 19 (sumber.filter(...)): operasi antara pertama — filter mengembalikan Stream<Produk> baru yang hanya melewatkan produk bermaharga minimal 50000.
  4. Baris 22 (yangMahal.map(Produk::nama)): operasi antara kedua — map mengubah tiap Produk menjadi String; tipe pipeline berganti dari Stream<Produk> ke Stream<String>.
  5. Baris 25 (namaMahal.toList()): operasi akhir — sebelum baris ini belum ada elemen yang diproses; toList() memicu eksekusi pipeline dan menampung hasilnya.
  6. Baris 27-28: cetak hasil — List asli tetap utuh tiga produk; stream memproses aliran, bukan mengubah sumber.

Imperatif vs Stream #

Cara paling cepat memahami nilai stream adalah membandingkan dua gaya yang menyelesaikan tugas identik: mengambil nama semua produk bermaharga minimal 50000, mengurutkannya, lalu menggabungkannya menjadi satu baris teks. Versi imperatif memakai loop, List sementara, Collections.sort, dan StringBuilder; versi stream menyatakan maksudnya sebagai rantai operasi:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class RingkasanToko {

    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),
                new Produk("Air Mineral", "minuman", 8000)
        );

        // Gaya imperatif: kita menuliskan BAGAIMANA, langkah demi langkah
        List<String> mahal = new ArrayList<>();
        for (Produk p : produk) {
            if (p.harga() >= 50000) {
                mahal.add(p.nama());
            }
        }
        Collections.sort(mahal);
        StringBuilder teks = new StringBuilder();
        for (int i = 0; i < mahal.size(); i++) {
            teks.append(mahal.get(i));
            if (i < mahal.size() - 1) {
                teks.append(", ");
            }
        }
        System.out.println("Imperatif : " + teks);

        // Gaya stream: kita menyatakan APA yang ingin dicapai
        String hasil = produk.stream()
                .filter(p -> p.harga() >= 50000)
                .map(Produk::nama)
                .sorted()
                .collect(Collectors.joining(", "));
        System.out.println("Stream    : " + hasil);
    }
}

Output yang Dihasilkan:

text
Imperatif : Kopi Arabika, Madu Hutan
Stream    : Kopi Arabika, Madu Hutan

Kedua gaya mencetak baris yang persis sama, dan itu memang intinya — stream bukan fitur yang menghasilkan sesuatu yang mustahil dilakukan loop, melainkan cara menuliskan niat dengan lebih ringkas dan aman. Mari kita amati perbedaan strukturnya:

  1. Baris 19-33 (blok imperatif): empat langkah manual — sisihkan, urutkan, gabung dengan koma, cetak. ArrayList sementara, indeks i, dan kondisi batas koma dikelola tangan; setiap detail bagaimana tertulis di kode.
  2. Baris 37-41 (pipeline stream): maksudnya terbaca seperti daftar keinginan — saring yang mahal, ambil namanya, urutkan, gabung dengan ", ". Variabel sementara, indeks, dan mutasi state tidak ada lagi.
  3. Baris 41 (Collectors.joining(", ")): operasi akhir yang menghasilkan String jadi — pengganti baris-baris StringBuilder di atasnya.

Gaya imperatif tetap sah untuk kasus sederhana. Stream unggul saat pipeline bertingkat: satu rantai deklaratif menggantikan loop bersarang beserta variabel pembantunya, terbaca dari atas ke bawah.

Stream Hanya Bisa Dipakai Sekali #

Aturan kedua yang wajib diingat: satu objek Stream hanya boleh dijalankan satu operasi akhir. Begitu operasi akhir selesai, stream ditandai terpakai dan tertutup; pipeline lain yang memakai objek Stream yang sama ditolak JVM dengan IllegalStateException. Program berikut sengaja memicu penolakan tersebut agar kita mengenali pesannya:

import java.util.List;
import java.util.stream.Stream;

public class StreamSekaliPakai {

    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)
        );

        Stream<Produk> aliran = produk.stream();

        long jumlahPertama = aliran.filter(p -> p.harga() >= 50000).count();
        System.out.println("Eksekusi pertama, produk mahal: " + jumlahPertama);

        try {
            long jumlahKedua = aliran.filter(p -> p.kategori().equals("minuman")).count();
            System.out.println("Eksekusi kedua: " + jumlahKedua);
        } catch (IllegalStateException e) {
            System.out.println("Eksekusi kedua ditolak: " + e.getMessage());
        }
    }
}

Output yang Dihasilkan:

text
Eksekusi pertama, produk mahal: 1
Eksekusi kedua ditolak: stream has already been operated upon or closed

Mari kita telusuri apa yang sebenarnya terjadi:

  1. Baris 15 (produk.stream()): satu objek Stream<Produk> dibuat dan disimpan ke variabel aliran. Data tetap hidup di produk; aliran hanya pegangan pipeline.
  2. Baris 17: pipeline pertama — filter lalu count() — berjalan mulus dan mencetak 1: hanya Kopi Arabika yang bermaharga minimal 50000. count() menandai aliran sebagai terpakai.
  3. Baris 21: pipeline kedua mencoba memakai aliran yang sama. JVM menolak sebelum satu elemen pun dibaca.
  4. Baris 23-25 (catch IllegalStateException): penolakan ditangkap dan pesan verbatim dari eksekusi nyata tercetak: stream has already been operated upon or closed.

Solusinya sederhana: panggil produk.stream() lagi, atau tampung hasil pertama ke List lalu buat stream dari hasil itu. Membuat stream murah — yang mahal adalah datanya, dan data tetap aman di collection.

Dengan anatomi pipeline, kontras imperatif-deklaratif, dan aturan sekali pakai di tangan, kita siap memperluas sumber data. Chapter Creating Streams menunjukkan cara membangun stream dari nilai lepas, array, collection kosong, rentang angka, hingga generator iterate dan generate.

Referensi #

Berkas Sumber

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

AnatomiPipeline.java

Unduh berkas
java
import java.util.List;
import java.util.stream.Stream;

public class AnatomiPipeline {

    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("Roti Gandum", "makanan", 30000),
                new Produk("Madu Hutan", "makanan", 120000)
        );

        // Tahap 1: sumber data -- List berubah menjadi Stream
        Stream<Produk> sumber = produk.stream();

        // Tahap 2: operasi antara -- menyaring elemen
        Stream<Produk> yangMahal = sumber.filter(p -> p.harga() >= 50000);

        // Tahap 3: operasi antara lagi -- memetakan Produk menjadi String
        Stream<String> namaMahal = yangMahal.map(Produk::nama);

        // Tahap 4: operasi akhir -- memicu eksekusi dan menghasilkan List
        List<String> hasil = namaMahal.toList();

        System.out.println("Hasil pipeline : " + hasil);
        System.out.println("List asli      : " + produk.size() + " produk, tidak berubah");
    }
}

RingkasanToko.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class RingkasanToko {

    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),
                new Produk("Air Mineral", "minuman", 8000)
        );

        // Gaya imperatif: kita menuliskan BAGAIMANA, langkah demi langkah
        List<String> mahal = new ArrayList<>();
        for (Produk p : produk) {
            if (p.harga() >= 50000) {
                mahal.add(p.nama());
            }
        }
        Collections.sort(mahal);
        StringBuilder teks = new StringBuilder();
        for (int i = 0; i < mahal.size(); i++) {
            teks.append(mahal.get(i));
            if (i < mahal.size() - 1) {
                teks.append(", ");
            }
        }
        System.out.println("Imperatif : " + teks);

        // Gaya stream: kita menyatakan APA yang ingin dicapai
        String hasil = produk.stream()
                .filter(p -> p.harga() >= 50000)
                .map(Produk::nama)
                .sorted()
                .collect(Collectors.joining(", "));
        System.out.println("Stream    : " + hasil);
    }
}

StreamSekaliPakai.java

Unduh berkas
java
import java.util.List;
import java.util.stream.Stream;

public class StreamSekaliPakai {

    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)
        );

        Stream<Produk> aliran = produk.stream();

        long jumlahPertama = aliran.filter(p -> p.harga() >= 50000).count();
        System.out.println("Eksekusi pertama, produk mahal: " + jumlahPertama);

        try {
            long jumlahKedua = aliran.filter(p -> p.kategori().equals("minuman")).count();
            System.out.println("Eksekusi kedua: " + jumlahKedua);
        } catch (IllegalStateException e) {
            System.out.println("Eksekusi kedua ditolak: " + e.getMessage());
        }
    }
}