Tampilan
Kenapa penting
Setelah menguasai lambda, stream, collector, dan Optional, godaan berikutnya adalah menulis semuanya sebagai stream. Hasilnya sering justru lebih sulit dibaca, sulit di-debug, dan kadang salah secara diam-diam. Di tim backend, yang dinilai bukan "seberapa fungsional" kodemu, melainkan apakah rekan kerja bisa memahami dan mengubahnya dengan aman. Bab ini merangkum prinsip gaya fungsional yang benar-benar berguna — fungsi murni, data immutable, lambda tanpa side effect — sekaligus batasnya: kapan loop biasa adalah pilihan yang lebih profesional.
Konsep inti
Fungsi murni dan data immutable
Fungsi murni (pure function) punya dua sifat: hasilnya hanya bergantung pada argumen, dan ia tidak mengubah apa pun di luar dirinya (tidak menulis field, tidak mencetak, tidak menyimpan ke database). Dengan input yang sama, hasilnya selalu sama — sehingga mudah dites dan aman dipanggil dari mana saja.
Immutable artinya object tidak bisa diubah setelah dibuat. "Mengubah" berarti membuat object baru. record sudah memberi field final; tinggal pastikan collection di dalamnya juga tidak bisa diubah dengan List.copyOf.
java
import java.util.ArrayList;
import java.util.List;
public class PureDemo {
static long taxRate = 11; // state global yang bisa berubah
static long priceWithTaxImpure(long price) { // tidak murni: membaca state luar
return price + price * taxRate / 100;
}
static long priceWithTax(long price, long ratePercent) { // murni: hanya bergantung pada argumen
return price + price * ratePercent / 100;
}
record Cart(String customer, List<String> skus) {
Cart {
skus = List.copyOf(skus); // salinan immutable
}
Cart add(String sku) { // tidak mengubah, mengembalikan Cart baru
List<String> next = new ArrayList<>(skus);
next.add(sku);
return new Cart(customer, next);
}
}
public static void main(String[] args) {
System.out.println(priceWithTaxImpure(100_000));
taxRate = 12;
System.out.println(priceWithTaxImpure(100_000)); // input sama, hasil beda
System.out.println(priceWithTax(100_000, 11)); // selalu 111000
List<String> source = new ArrayList<>(List.of("KOPI-250"));
Cart cart = new Cart("budi", source);
source.add("TEH-100"); // tidak memengaruhi cart
Cart bigger = cart.add("GULA-500");
System.out.println(cart.skus() + " " + bigger.skus());
try {
cart.skus().add("SUSU-1L");
} catch (UnsupportedOperationException e) {
System.out.println("Tidak bisa diubah: " + e.getClass().getSimpleName());
}
}
}Output:
text
111000
112000
111000
[KOPI-250] [KOPI-250, GULA-500]
Tidak bisa diubah: UnsupportedOperationExceptionTanpa List.copyOf di compact constructor, source.add("TEH-100") akan ikut mengubah isi cart — record-nya "immutable" hanya di permukaan. Tentu aplikasi tetap butuh efek samping (menyimpan pesanan, mengirim email). Prinsipnya: kumpulkan efek samping di tepi (controller, repository), dan buat logika bisnis di tengahnya murni.
Side effect di lambda: jebakan terbesar
Lambda di filter, map, dan sorted seharusnya murni. Dokumentasi java.util.stream bahkan menyebut stream boleh melewatkan pemanggilan lambda bila hasilnya tidak memengaruhi output. Contoh paling klasik: count() pada stream yang jumlah elemennya sudah diketahui dari sumber.
java
import java.util.ArrayList;
import java.util.List;
public class CountSkip {
public static void main(String[] args) {
List<Integer> seen = new ArrayList<>();
long n = List.of(1, 2, 3).stream()
.map(x -> { seen.add(x); return x * 2; })
.count();
System.out.println(n + " " + seen);
}
}Output: 3 [] — lambda di map tidak pernah dipanggil. Bayangkan kalau di dalamnya ada "kirim notifikasi" atau "kurangi stok". Masalah kedua muncul saat stream berjalan paralel dan lambda menulis ke collection bersama:
java
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
public class SideEffect {
public static void main(String[] args) {
// Salah: lambda menulis ke list di luar stream
List<Integer> evens = new ArrayList<>();
IntStream.rangeClosed(1, 10_000).parallel()
.filter(n -> n % 2 == 0)
.forEach(evens::add); // ArrayList tidak thread-safe
System.out.println("forEach + add : " + evens.size());
// Benar: biarkan stream yang mengumpulkan hasil
List<Integer> collected = IntStream.rangeClosed(1, 10_000).parallel()
.filter(n -> n % 2 == 0)
.boxed()
.toList();
System.out.println("toList() : " + collected.size());
}
}Salah satu output saat dijalankan (angka pertama berubah setiap kali, kadang malah muncul ArrayIndexOutOfBoundsException):
text
forEach + add : 2804
toList() : 5000Aturannya sederhana: hasil stream keluar lewat operasi terminal (toList, collect, sum, reduce), bukan lewat lambda yang menulis ke variabel luar. Java sendiri sudah membantu — variabel lokal yang dipakai di lambda harus effectively final — tetapi aturan itu tidak melindungi isi ArrayList atau field.
Refactor loop → stream langkah demi langkah
Refactor yang aman dilakukan dengan menerjemahkan setiap bagian loop menjadi satu operasi, lalu membuktikan hasilnya sama. Contoh: email unik dan terurut dari pelanggan yang punya pesanan PAID minimal Rp500.000.
java
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
public class Refactor {
enum Status { NEW, PAID, CANCELLED }
record Order(String id, String customer, Status status, long total) {}
// Versi loop: email pelanggan (unik, terurut) yang punya pesanan PAID ≥ 500.000
static List<String> bigSpendersLoop(List<Order> orders) {
TreeSet<String> result = new TreeSet<>();
for (Order order : orders) {
if (order.status() == Status.PAID && order.total() >= 500_000) {
result.add(order.customer().toLowerCase());
}
}
return new ArrayList<>(result);
}
// Versi stream: setiap langkah loop menjadi satu operasi
static List<String> bigSpenders(List<Order> orders) {
return orders.stream()
.filter(order -> order.status() == Status.PAID) // if (...) bagian 1
.filter(order -> order.total() >= 500_000) // if (...) bagian 2
.map(order -> order.customer().toLowerCase()) // nilai yang di-add
.distinct() // peran TreeSet: unik
.sorted() // peran TreeSet: urut
.toList();
}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("O-1", "Sari@Mail.com", Status.PAID, 750_000),
new Order("O-2", "budi@mail.com", Status.NEW, 900_000),
new Order("O-3", "adi@mail.com", Status.PAID, 520_000),
new Order("O-4", "sari@mail.com", Status.PAID, 610_000),
new Order("O-5", "dewi@mail.com", Status.PAID, 120_000),
new Order("O-6", "rina@mail.com", Status.CANCELLED, 800_000));
System.out.println(bigSpendersLoop(orders));
System.out.println(bigSpenders(orders));
System.out.println(bigSpendersLoop(orders).equals(bigSpenders(orders)));
}
}Output:
text
[adi@mail.com, sari@mail.com]
[adi@mail.com, sari@mail.com]
trueLangkahnya: (1) setiap if → filter; (2) ekspresi yang dimasukkan ke hasil → map; (3) sifat struktur penampung (TreeSet = unik + urut) → distinct + sorted; (4) penampung → operasi terminal. Sebelum menghapus versi loop, jalankan keduanya pada data yang sama (idealnya di unit test) dan pastikan hasilnya identik. Perhatikan detail halus seperti toLowerCase() sebelum distinct() — urutan operasi memengaruhi hasil.
Kapan TIDAK memakai stream
| Situasi | Kenapa loop lebih baik |
|---|---|
| Logika bercabang banyak, berhenti di tengah, beberapa akumulator | break/return dan beberapa variabel jauh lebih jelas daripada reduce yang dipaksakan |
| Operasi melempar checked exception | Function/Predicate tidak boleh melempar checked exception |
| Perlu indeks atau elemen sebelumnya | Stream memproses elemen satu per satu tanpa tahu tetangganya |
| Sedang men-debug | Breakpoint dan stack trace di loop lebih mudah diikuti |
| Hot path yang sangat sensitif performa | Stream punya overhead kecil; ukur dulu sebelum memilih |
Checked exception adalah alasan yang paling sering ditemui. Kode berikut tidak dikompilasi:
java
// Potongan dari CheckedLambda.java — fetchRate(...) throws IOException
List<String> rates = List.of("USD", "EUR").stream()
.map(c -> fetchRate(c))
.toList();text
CheckedLambda.java:12: error: unreported exception IOException; must be caught or declared to be thrown
.map(c -> fetchRate(c))
^
1 errorMenambal dengan try/catch di dalam lambda lalu mengembalikan null hanya memindahkan masalah. Loop biasa membiarkan exception merambat dengan jujur:
java
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class CheckedLoop {
static String fetchRate(String currency) throws IOException {
if (currency.equals("XXX")) throw new IOException("kurs tidak tersedia: " + currency);
return currency + "=OK";
}
static List<String> fetchAll(List<String> currencies) throws IOException {
List<String> rates = new ArrayList<>();
for (String currency : currencies) {
rates.add(fetchRate(currency)); // exception merambat apa adanya
}
return rates;
}
public static void main(String[] args) {
try {
System.out.println(fetchAll(List.of("USD", "EUR")));
System.out.println(fetchAll(List.of("USD", "XXX", "EUR")));
} catch (IOException e) {
System.out.println("Gagal: " + e.getMessage());
}
}
}Output:
text
[USD=OK, EUR=OK]
Gagal: kurs tidak tersedia: XXXUntuk debugging stream, peek boleh dipakai sementara untuk melihat elemen yang lewat — sekaligus membuktikan bahwa stream bersifat lazy dan memproses elemen satu per satu melewati seluruh pipeline:
java
import java.util.List;
public class PeekDebug {
public static void main(String[] args) {
List<Long> totals = List.of(120_000L, 750_000L, 520_000L, 90_000L);
List<Long> big = totals.stream()
.peek(t -> System.out.println("masuk : " + t))
.filter(t -> t >= 500_000)
.peek(t -> System.out.println("lolos : " + t))
.map(t -> t * 90 / 100)
.toList();
System.out.println(big);
}
}Output:
text
masuk : 120000
masuk : 750000
lolos : 750000
masuk : 520000
lolos : 520000
masuk : 90000
[675000, 468000]Hapus peek setelah selesai; jangan menaruh logika bisnis di sana.
Parallel stream: kapan berbahaya
parallelStream() (atau .parallel()) membagi data ke beberapa thread dari common ForkJoinPool yang dipakai bersama seluruh aplikasi. Kedengarannya gratis, tetapi:
- Urutan efek samping hilang.
forEachparalel mencetak dalam urutan acak;forEachOrdereddantoList()tetap mempertahankan urutan sumber. - State bersama rusak seperti contoh
ArrayListdi atas. - I/O yang memblokir (panggil API, query database) di dalam parallel stream menahan thread di pool bersama dan bisa memperlambat bagian lain aplikasi.
- Data kecil biasanya justru lebih lambat karena biaya membagi dan menggabungkan pekerjaan.
java
import java.util.List;
public class ParallelOrder {
public static void main(String[] args) {
List<String> ids = List.of("O-1", "O-2", "O-3", "O-4", "O-5", "O-6");
ids.parallelStream().forEach(id -> System.out.print(id + " "));
System.out.println("← forEach");
ids.parallelStream().forEachOrdered(id -> System.out.print(id + " "));
System.out.println("← forEachOrdered");
System.out.println(ids.parallelStream().map(String::toLowerCase).toList() + " ← toList");
}
}Salah satu output (baris pertama berubah setiap kali dijalankan):
text
O-6 O-5 O-3 O-4 O-2 O-1 ← forEach
O-1 O-2 O-3 O-4 O-5 O-6 ← forEachOrdered
[o-1, o-2, o-3, o-4, o-5, o-6] ← toListDi aplikasi web, server sudah melayani banyak request secara paralel, sehingga parallel stream jarang dibutuhkan. Pakai hanya untuk komputasi CPU-bound pada data besar di memori dengan fungsi murni — dan setelah diukur.
Keterbacaan di atas segalanya
Beberapa pedoman praktis agar stream tetap mudah dibaca:
- Satu operasi per baris, dengan nama variabel lambda yang bermakna (
order, bukanobila pipeline panjang). - Lambda lebih dari 2–3 baris → ekstrak ke method bernama, lalu pakai method reference.
- Pipeline lebih dari ±6 langkah atau collector bersarang tiga tingkat → pecah menjadi beberapa variabel perantara bernama.
- Kalau versi loop lebih pendek dan lebih jelas, pilih loop. Gaya fungsional adalah alat, bukan tujuan.
Contoh di dunia backend
Service checkout menghitung penawaran harga dengan fungsi murni (mudah dites tanpa database), memakai stream untuk penjumlahan sederhana, dan memakai loop untuk validasi stok yang harus berhenti di masalah pertama.
java
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class CheckoutService {
record CartLine(String sku, int qty, long unitPrice) {}
record Quote(long subtotal, long discount, long total) {}
// Fungsi murni: input → output, tanpa membaca/mengubah state lain
static long subtotal(List<CartLine> lines) {
return lines.stream()
.mapToLong(line -> line.unitPrice() * line.qty())
.sum();
}
static long discount(long subtotal, String memberTier) {
return switch (memberTier) {
case "GOLD" -> subtotal * 10 / 100;
case "SILVER" -> subtotal * 5 / 100;
default -> 0;
};
}
static Quote quote(List<CartLine> lines, String memberTier) {
long subtotal = subtotal(lines);
long discount = discount(subtotal, memberTier);
return new Quote(subtotal, discount, subtotal - discount);
}
// Validasi dengan berhenti di masalah pertama: loop biasa lebih jelas
static Optional<String> firstStockProblem(List<CartLine> lines, Map<String, Integer> stock) {
for (CartLine line : lines) {
Integer available = stock.get(line.sku());
if (available == null) {
return Optional.of("SKU tidak dikenal: " + line.sku());
}
if (available < line.qty()) {
return Optional.of("Stok " + line.sku() + " kurang: minta " + line.qty() + ", ada " + available);
}
}
return Optional.empty(); // tidak ada masalah
}
public static void main(String[] args) {
List<CartLine> cart = List.of(
new CartLine("KOPI-250", 2, 85_000),
new CartLine("GULA-500", 3, 30_000));
Map<String, Integer> stock = Map.of("KOPI-250", 10, "GULA-500", 2);
System.out.println(quote(cart, "GOLD"));
System.out.println(quote(cart, "REGULAR"));
System.out.println(firstStockProblem(cart, stock));
System.out.println(firstStockProblem(List.of(cart.get(0)), stock));
}
}Output:
text
Quote[subtotal=260000, discount=26000, total=234000]
Quote[subtotal=260000, discount=0, total=260000]
Optional[Stok GULA-500 kurang: minta 3, ada 2]
Optional.emptyquote bisa dites dengan satu baris assertion untuk setiap tier tanpa menyiapkan database atau mock. Bagian yang benar-benar berefek samping — membaca stok terbaru, menyimpan pesanan — tinggal di lapisan lain yang memanggil fungsi-fungsi murni ini.
Pertanyaan interview
1. Apa itu fungsi murni dan kenapa berguna di backend?
Fungsi yang hasilnya hanya bergantung pada argumen dan tidak mengubah state di luar dirinya. Ia mudah dites (cukup input dan expected output), aman dipanggil berulang atau dari banyak thread, dan mudah dipahami karena tidak ada ketergantungan tersembunyi. Logika bisnis seperti perhitungan harga, diskon, dan pajak idealnya murni.
2. Kenapa lambda di stream sebaiknya tidak punya side effect?
Stream bebas mengatur kapan dan berapa kali lambda dipanggil: operasi bisa dilewati (misalnya map sebelum count()), dan pada parallel stream lambda berjalan di banyak thread tanpa urutan pasti. Side effect seperti menulis ke ArrayList bersama menghasilkan data hilang atau exception. Hasil sebaiknya dikeluarkan lewat operasi terminal.
3. Apakah parallelStream() selalu lebih cepat?
Tidak. Ia menambah biaya membagi dan menggabungkan pekerjaan, memakai common ForkJoinPool bersama seluruh aplikasi, dan berbahaya untuk I/O yang memblokir atau state bersama. Ia hanya layak untuk komputasi CPU-bound pada data besar dengan fungsi murni, dan keputusannya harus didasari pengukuran.
Jebakan umum
- Menaruh logika penting di lambda yang bisa dilewati —
mapsebelumcount()bisa tidak dipanggil sama sekali. forEach(list::add)untuk mengumpulkan hasil. PakaitoList()ataucollect; aman juga saat paralel.- Record "immutable" berisi
Listyang bisa diubah. Salin denganList.copyOfdi compact constructor. - Menelan checked exception di lambda lalu mengembalikan
null. Kegagalan jadi tak terlihat; pakai loop. - Menambahkan
.parallel()tanpa mengukur, apalagi untuk panggilan API/database di dalam handler HTTP. - Pipeline 10 langkah dengan lambda panjang. Ekstrak method, pecah ke variabel perantara, atau kembali ke loop.
Ringkasan
- Fungsi murni hanya bergantung pada argumen dan tanpa side effect — mudah dites dan aman dipakai ulang.
- Data immutable (
record+List.copyOf) mencegah perubahan tak sengaja; efek samping dikumpulkan di tepi aplikasi. - Lambda di stream harus murni; hasil keluar lewat operasi terminal, bukan lewat variabel luar.
- Refactor loop → stream dilakukan per langkah (
if→filter, nilai →map, penampung → terminal) dan dibuktikan dengan test. - Loop biasa lebih tepat untuk logika bercabang/berhenti di tengah, checked exception, dan saat debugging.
parallelStream()hanya untuk komputasi CPU-bound data besar tanpa state bersama, setelah diukur.