Method pencarian adalah sumber NullPointerException yang paling sering menyengat: method cariSiswa(nama) menemukan siswa atau tidak menemukan apa-apa, dan versi lama Java hanya punya satu cara menyatakan “tidak ada”, yaitu mengembalikan null. Compiler tidak memaksa pemanggil memeriksa null, sehingga kelalaian baru terlihat saat program berjalan. Kelas java.util.Optional<T> sejak Java 8 menyediakan wadah berisi-nol-atau-satu-elemen yang mengangkat ketiadaan hasil menjadi bagian dari tipe method — dan stream sendiri memakainya di findFirst, max, dan average.

Masalah null dan Lahirnya Optional #

Optional<T> adalah objek pembungkus yang menyimpan paling banyak satu nilai. Tiga method statis membuatnya: Optional.of(nilai) untuk nilai yang diyakini tidak null (mengembalikan NullPointerException bila ternyata null), Optional.ofNullable(nilai) untuk nilai yang boleh null (hasilnya Optional.empty()), dan Optional.empty() untuk wadah kosong secara eksplisit.

Kekuatan sebenarnya bukan pada pembungkusnya, melainkan pada tanda tangan method. Ketika cariSiswa mendeklarasikan kembalian Optional<Siswa>, setiap pemanggil melihat di editor bahwa hasil mungkin kosong — kelalaian berubah dari bug runtime menjadi keputusan yang harus diambil saat menulis kode. Bandingkan kedua versi method berikut:

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

public class PencarianSiswa {

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

    // Versi null: ketiadaan hasil hanya diketahui lewat kebiasaan memeriksa
    static Siswa cariDenganNull(List<Siswa> daftar, String nama) {
        for (Siswa s : daftar) {
            if (s.nama().equals(nama)) {
                return s;
            }
        }
        return null;
    }

    // Versi Optional: ketiadaan hasil dinyatakan di tipe kembalian
    static Optional<Siswa> cariSiswa(List<Siswa> daftar, String nama) {
        return daftar.stream()
            .filter(s -> s.nama().equals(nama))
            .findFirst();
    }

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

        // Pola lama: pemanggil wajib ingat memeriksa null
        Siswa hasilNull = cariDenganNull(rombongan, "Ani");
        if (hasilNull != null) {
            System.out.println("versi null   = " + hasilNull.nama());
        } else {
            System.out.println("versi null   = tidak ditemukan");
        }

        // Versi Optional: map lalu orElse sebagai nilai cadangan
        String kelasBudi = cariSiswa(rombongan, "Budi")
            .map(Siswa::kelas)
            .orElse("tidak terdaftar");
        System.out.println("kelas Budi   = " + kelasBudi);

        String kelasAni = cariSiswa(rombongan, "Ani")
            .map(Siswa::kelas)
            .orElse("tidak terdaftar");
        System.out.println("kelas Ani    = " + kelasAni);

        // filter: pipeline hanya berlanjut bila syarat terpenuhi
        cariSiswa(rombongan, "Siti")
            .filter(s -> s.nilai() >= 90)
            .ifPresent(s -> System.out.println("nilai Siti   = " + s.nilai() + " (berprestasi)"));

        cariSiswa(rombongan, "Budi")
            .filter(s -> s.nilai() >= 90)
            .ifPresent(s -> System.out.println("nilai Budi   = " + s.nilai()));
    }
}

Output yang Dihasilkan:

text
versi null   = tidak ditemukan
kelas Budi   = X-2
kelas Ani    = tidak terdaftar
nilai Siti   = 92 (berprestasi)

Mari kita telaah baris-baris kode di atas:

  1. Baris 9-16 (cariDenganNull): versi lama mengembalikan null saat nama tidak ketemu. Tidak ada satu pun konstruksi bahasa yang mengingatkan pemanggil; disiplin memeriksa != null sepenuhnya menjadi tanggung jawab manusia.
  2. Baris 19-23 (cariSiswa versi Optional): pipeline filter(...).findFirst() secara natural menghasilkan Optional<Siswa> — findFirst memang mengembalikan wadah ini karena elemen pertama yang cocok mungkin tidak pernah ada.
  3. Baris 41-43 (map(Siswa::kelas).orElse(...)): bila wadah berisi siswa, map mengambil field kelas; bila kosong, map dilewati dan orElse menyuplai teks cadangan "tidak terdaftar". Rantai map-orElse menggantikan blok if-else manual secara utuh.
  4. Baris 52-58 (filter + ifPresent): filter pada Optional menjaga pipeline hanya bila isinya memenuhi predikat. Baris 56-58 membuktikan sebaliknya: nilai Budi 78 tidak memenuhi >= 90, sehingga ifPresent tidak mencetak apa pun — tanpa exception, tanpa null yang bocor.

orElse yang Eager dan orElseGet yang Lazy #

Dua method ini tampak sama: keduanya menyuplai nilai cadangan untuk Optional kosong. Perbedaannya terletak pada kapan nilai cadangan dihitung. orElse(x) selalu mengevaluasi x lebih dulu — eager — karena x adalah argumen method biasa. orElseGet(() -> x) menyimpan Supplier dan hanya menjalankannya saat wadah benar-benar kosong — lazy. Program berikut membuktikannya dengan penghitung panggilan:

import java.util.Optional;

public class PerilakuOrElse {

    static int panggilan = 0;

    // Supplier yang meninggalkan jejak setiap kali dieksekusi
    static int nilaiPengganti() {
        panggilan++;
        System.out.println("  -> nilaiPengganti() dieksekusi, panggilan ke-" + panggilan);
        return 60;
    }

    public static void main(String[] args) {
        Optional<Integer> nilai = Optional.of(85);

        System.out.println("orElse dengan nilai tersedia:");
        int a = nilai.orElse(nilaiPengganti());
        System.out.println("  hasil = " + a + ", total panggilan = " + panggilan);

        System.out.println("orElseGet dengan nilai tersedia:");
        int b = nilai.orElseGet(() -> nilaiPengganti());
        System.out.println("  hasil = " + b + ", total panggilan = " + panggilan);
    }
}

Output yang Dihasilkan:

text
orElse dengan nilai tersedia:
  -> nilaiPengganti() dieksekusi, panggilan ke-1
  hasil = 85, total panggilan = 1
orElseGet dengan nilai tersedia:
  hasil = 85, total panggilan = 1

Mari kita telaah baris-baris kode di atas:

  1. Baris 8-12 (nilaiPengganti): setiap pemanggilan menaikkan penghitung panggilan dan mencetak jejak, sehingga eksekusinya terlihat di output berapa kali pun terjadi.
  2. Baris 18 (orElse(nilaiPengganti())): wadah berisi 85, hasil akhir memang 85, tetapi jejak panggilan ke-1 tetap tercetak — argumen orElse dievaluasi sebelum method dipanggil, lalu dibuang karena tidak dibutuhkan.
  3. Baris 22 (orElseGet(() -> nilaiPengganti())): tidak ada jejak baru; total panggilan tetap 1. Supplier tidak pernah dijalankan karena wadah berisi nilai.

ifPresentOrElse, orElseThrow, dan flatMap #

Wadah Optional juga menyediakan method untuk tiga sikap berbeda terhadap ketiadaan. ifPresentOrElse menyediakan dua cabang eksplisit (sejak Java 9), orElseThrow menyatakan bahwa ketiadaan berarti bug, dan flatMap menyambung langkah yang sama-sama menghasilkan Optional tanpa menghasilkan Optional<Optional<T>>. Konfigurasi toko berikut memakai ketiganya:

import java.util.Map;
import java.util.Optional;

public class KonfigurasiAman {

    static final Map<String, String> KONFIGURASI = Map.of(
        "nama-toko", "Toko Serba Ada",
        "mata-uang", "IDR",
        "port", "8080",
        "timeout-detik", "abc"
    );

    // ofNullable menampung hasil Map.get yang bisa menghasilkan null
    static Optional<String> ambil(String kunci) {
        return Optional.ofNullable(KONFIGURASI.get(kunci));
    }

    // Parsing yang gagal menghasilkan Optional.empty, bukan exception
    static Optional<Integer> angka(String teks) {
        try {
            return Optional.of(Integer.parseInt(teks));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }

    public static void main(String[] args) {
        System.out.println("nama-toko     = " + ambil("nama-toko"));
        System.out.println("logo-toko     = " + ambil("logo-toko"));

        // ifPresentOrElse: dua cabang eksplisit, hadir atau kosong
        ambil("mata-uang").ifPresentOrElse(
            uang -> System.out.println("mata-uang     = " + uang),
            () -> System.out.println("mata-uang     = (tidak diatur)"));

        // orElseThrow: nilai wajib hadir, ketiadaan berarti galat
        String nama = ambil("nama-toko")
            .orElseThrow(() -> new IllegalStateException("nama-toko wajib diatur"));
        System.out.println("nama          = " + nama);

        // flatMap: menyambung dua langkah yang sama-sama Optional
        Optional<Integer> port = ambil("port").flatMap(KonfigurasiAman::angka);
        System.out.println("port          = " + port);

        Optional<Integer> timeout = ambil("timeout-detik").flatMap(KonfigurasiAman::angka);
        System.out.println("timeout-detik = " + timeout + " (gagal di-parse)");
    }
}

Output yang Dihasilkan:

text
nama-toko     = Optional[Toko Serba Ada]
logo-toko     = Optional.empty
mata-uang     = IDR
nama          = Toko Serba Ada
port          = Optional[8080]
timeout-detik = Optional.empty (gagal di-parse)

Mari kita telaah baris-baris kode di atas:

  1. Baris 14-16 (Optional.ofNullable(KONFIGURASI.get(kunci))): Map.get mengembalikan null untuk kunci yang tidak ada, dan ofNullable mengubah null menjadi Optional.empty() — pola standar membungkus API lawas yang berbasis null.
  2. Baris 19-25 (angka): parsing gagal ditangkap catch lalu diterjemahkan menjadi Optional.empty(). Teknik ini mengubah exception yang diharapkan menjadi aliran data normal.
  3. Baris 32-34 (ifPresentOrElse): lambda pertama berjalan bila nilai ada, Runnable kedua berjalan bila kosong. Kedua cabang ditulis berdampingan sehingga tidak ada jalur yang terlupakan.
  4. Baris 37-38 (orElseThrow): konfigurasi nama-toko diperlakukan wajib; ketiadaannya layak menghentikan program dengan IllegalStateException yang menjelaskan penyebabnya.
  5. Baris 42 dan 45 (flatMap): ambil("port") menghasilkan Optional<String>, angka menghasilkan Optional<Integer>. flatMap meratakan kedua lapisan menjadi satu Optional<Integer> — "8080" berhasil menjadi Optional[8080], sedangkan "abc" meluruh menjadi Optional.empty(). Bila memakai map, hasilnya akan bertipe Optional<Optional<Integer>> yang harus dibuka dua kali.

Anti-pattern yang Perlu Dihindari #

Optional memberi nilai penuh saat dipakai sebagai tipe kembalian method. Beberapa kebiasaan berikut justru memundurkan kualitas kode:

  • Optional sebagai field class: field Optional membuat serialisasi dan alokasi objek lebih berat; gunakan field biasa plus Optional.ofNullable(field) saat mengembalikannya.
  • Optional sebagai parameter method: pemanggil dipaksa membungkus nilai, dan tubuh method tetap harus menangani Optional.empty() — dua lapis pemeriksaan untuk satu keraguan.
  • isPresent() diikuti get(): pola ini hanyalah if (x != null) versi mahal. Ganti dengan map, filter, ifPresent, atau orElse.
  • Optional.get() tanpa pemeriksaan: melempar NoSuchElementException: No value present saat kosong; method ini bahkan ditandai @Deprecated sejak Java 10 — gunakan orElseThrow() bila nilai memang wajib ada.
  • Optional berisi koleksi: Optional<List<T>> berarti tiga keadaan (kosong, berisi daftar kosong, berisi daftar terisi). Kembalikan List<T> kosong saja.

Dengan Optional, findFirst, dan max, kita sudah sering melihat stream menunda keputusan sampai terminal operation berjalan. Chapter berikutnya membuktikan sifat lazy itu secara eksperimental: pipeline tanpa terminal operation tidak memproses satu elemen pun, dan findFirst menghentikan seluruh aliran begitu hasilnya ditemukan.

Referensi #

Berkas Sumber

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

KonfigurasiAman.java

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

public class KonfigurasiAman {

    static final Map<String, String> KONFIGURASI = Map.of(
        "nama-toko", "Toko Serba Ada",
        "mata-uang", "IDR",
        "port", "8080",
        "timeout-detik", "abc"
    );

    // ofNullable menampung hasil Map.get yang bisa menghasilkan null
    static Optional<String> ambil(String kunci) {
        return Optional.ofNullable(KONFIGURASI.get(kunci));
    }

    // Parsing yang gagal menghasilkan Optional.empty, bukan exception
    static Optional<Integer> angka(String teks) {
        try {
            return Optional.of(Integer.parseInt(teks));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }

    public static void main(String[] args) {
        System.out.println("nama-toko     = " + ambil("nama-toko"));
        System.out.println("logo-toko     = " + ambil("logo-toko"));

        // ifPresentOrElse: dua cabang eksplisit, hadir atau kosong
        ambil("mata-uang").ifPresentOrElse(
            uang -> System.out.println("mata-uang     = " + uang),
            () -> System.out.println("mata-uang     = (tidak diatur)"));

        // orElseThrow: nilai wajib hadir, ketiadaan berarti galat
        String nama = ambil("nama-toko")
            .orElseThrow(() -> new IllegalStateException("nama-toko wajib diatur"));
        System.out.println("nama          = " + nama);

        // flatMap: menyambung dua langkah yang sama-sama Optional
        Optional<Integer> port = ambil("port").flatMap(KonfigurasiAman::angka);
        System.out.println("port          = " + port);

        Optional<Integer> timeout = ambil("timeout-detik").flatMap(KonfigurasiAman::angka);
        System.out.println("timeout-detik = " + timeout + " (gagal di-parse)");
    }
}

PencarianSiswa.java

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

public class PencarianSiswa {

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

    // Versi null: ketiadaan hasil hanya diketahui lewat kebiasaan memeriksa
    static Siswa cariDenganNull(List<Siswa> daftar, String nama) {
        for (Siswa s : daftar) {
            if (s.nama().equals(nama)) {
                return s;
            }
        }
        return null;
    }

    // Versi Optional: ketiadaan hasil dinyatakan di tipe kembalian
    static Optional<Siswa> cariSiswa(List<Siswa> daftar, String nama) {
        return daftar.stream()
            .filter(s -> s.nama().equals(nama))
            .findFirst();
    }

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

        // Pola lama: pemanggil wajib ingat memeriksa null
        Siswa hasilNull = cariDenganNull(rombongan, "Ani");
        if (hasilNull != null) {
            System.out.println("versi null   = " + hasilNull.nama());
        } else {
            System.out.println("versi null   = tidak ditemukan");
        }

        // Versi Optional: map lalu orElse sebagai nilai cadangan
        String kelasBudi = cariSiswa(rombongan, "Budi")
            .map(Siswa::kelas)
            .orElse("tidak terdaftar");
        System.out.println("kelas Budi   = " + kelasBudi);

        String kelasAni = cariSiswa(rombongan, "Ani")
            .map(Siswa::kelas)
            .orElse("tidak terdaftar");
        System.out.println("kelas Ani    = " + kelasAni);

        // filter: pipeline hanya berlanjut bila syarat terpenuhi
        cariSiswa(rombongan, "Siti")
            .filter(s -> s.nilai() >= 90)
            .ifPresent(s -> System.out.println("nilai Siti   = " + s.nilai() + " (berprestasi)"));

        cariSiswa(rombongan, "Budi")
            .filter(s -> s.nilai() >= 90)
            .ifPresent(s -> System.out.println("nilai Budi   = " + s.nilai()));
    }
}

PerilakuOrElse.java

Unduh berkas
java
import java.util.Optional;

public class PerilakuOrElse {

    static int panggilan = 0;

    // Supplier yang meninggalkan jejak setiap kali dieksekusi
    static int nilaiPengganti() {
        panggilan++;
        System.out.println("  -> nilaiPengganti() dieksekusi, panggilan ke-" + panggilan);
        return 60;
    }

    public static void main(String[] args) {
        Optional<Integer> nilai = Optional.of(85);

        System.out.println("orElse dengan nilai tersedia:");
        int a = nilai.orElse(nilaiPengganti());
        System.out.println("  hasil = " + a + ", total panggilan = " + panggilan);

        System.out.println("orElseGet dengan nilai tersedia:");
        int b = nilai.orElseGet(() -> nilaiPengganti());
        System.out.println("  hasil = " + b + ", total panggilan = " + panggilan);
    }
}