Pattern Matching untuk switch

Mendekonstruksi record dan memaksa switch ekshaustif dengan pattern matching Java 21.

Daftar isi

Switch pada Java 21 menerima pola di dalam labelnya, bukan lagi hanya konstanta, sehingga pemeriksaan tipe, dekonstruksi record, dan penyaringan nilai berjalan di satu tempat yang sama. Fitur ini distandardisasi lewat JEP 441, sedangkan dekonstruksinya memakai record pattern dari JEP 440. Pembahasannya berdiri di atas dua bekal yang sudah ada, yaitu variabel pola pada instanceof di modul OOP II dan hierarki tertutup pada topik Sealed Class dan Interface. Setelah selesai, rantai if yang berlapis menjadi switch yang terbaca dari atas ke bawah, dan kompiler ikut menjaga agar tidak ada jenis yang terlewat.

Pola tipe dan kasus null pada label switch#

Pola tipe pada label switch bekerja seperti variabel pola pada instanceof, yaitu label cocok bila objeknya bertipe tersebut, dan variabel pengikatnya langsung tersedia di tubuh kasusnya. Label-label diperiksa dari atas ke bawah, sehingga urutan penulisannya adalah bagian dari makna program. Satu tambahan yang belum pernah ada pada switch klasik adalah case null, yang menangkap pembanding bernilai null alih-alih melempar NullPointerException.

Program berikut memilah antrean notifikasi yang isinya campur, lengkap dengan satu elemen null.

java UjiPolaSwitch.java
public class UjiPolaSwitch {

    public static void main(String[] args) {
        Notifikasi[] antrean = {
            new Pengingat("Rapat triwulan", 3),
            new Sapaan("Selamat pagi"),
            null,
            new Pengingat("Bayar iuran", 0)
        };

        for (Notifikasi n : antrean) {
            System.out.println(ringkas(n));
        }
    }

    static String ringkas(Notifikasi n) {
        return switch (n) {
            case null -> "kosong, dilewati";
            case Sapaan s -> "sapaan: " + s.teks();
            case Pengingat(String judul, int sisa) when judul.isBlank() -> "pengingat tanpa judul";
            case Pengingat(String judul, int sisa) when sisa <= 0 ->
                "pengingat jatuh tempo hari ini: " + judul;
            case Pengingat(String judul, int sisa) ->
                "pengingat " + judul + " lagi " + sisa + " hari";
        };
    }
}

sealed interface Notifikasi permits Pengingat, Sapaan {
}

record Pengingat(String judul, int sisa) implements Notifikasi {
}

record Sapaan(String teks) implements Notifikasi {
}
text Keluaran
pengingat Rapat triwulan lagi 3 hari
sapaan: Selamat pagi
kosong, dilewati
pengingat jatuh tempo hari ini: Bayar iuran

Rantai labelnya terbaca seperti keputusan yang disusun berurutan. Nilai pertama jatuh ke label terakhir karena dua guard sebelumnya tidak terpenuhi, nilai kedua berhenti di label Sapaan, elemen null ditangkap case null, dan nilai keempat berhenti pada guard sisa <= 0. Tanpa case null, switch seperti ini melempar NullPointerException saat elemen null masuk, sehingga penanganan nilainya menjadi keputusan yang tertulis, bukan kecelakaan.

Mendekonstruksi record dengan record pattern#

Record pattern membuka isi record langsung pada labelnya. Bentuk Pengingat(String judul, int sisa) cocok hanya bila objeknya bertipe Pengingat, lalu komponen pertama terikat ke judul dan komponen kedua ke sisa tanpa satu pun pemanggilan aksesor yang ditulis manual. Polanya bekerja secara rekursif, sehingga komponen yang juga berupa record dapat dibuka lagi di dalam pola yang membungkusnya.

Program berikut menghitung panjang garis dengan membuka dua Titik yang bersarang di dalam Garis.

java UjiPolaBersarang.java
public class UjiPolaBersarang {

    public static void main(String[] args) {
        Object bentuk1 = new Garis(new Titik(0, 0), new Titik(3, 4));
        Object bentuk2 = new Titik(2, 5);
        Object bentuk3 = "bukan bentuk";

        System.out.println(deskripsi(bentuk1));
        System.out.println(deskripsi(bentuk2));
        System.out.println(deskripsi(bentuk3));
    }

    static String deskripsi(Object obj) {
        return switch (obj) {
            case Garis(Titik(int x1, int y1), Titik(int x2, int y2)) -> {
                double dx = x2 - x1;
                double dy = y2 - y1;
                yield "garis dengan panjang " + Math.sqrt(dx * dx + dy * dy);
            }
            case Titik(int x, int y) -> "titik pada (" + x + ", " + y + ")";
            default -> "bentuk tidak dikenal";
        };
    }
}

record Titik(int x, int y) {
}

record Garis(Titik awal, Titik akhir) {
}
text Keluaran
garis dengan panjang 5.0
titik pada (2, 5)
bentuk tidak dikenal

Label pertama membuka dua tingkat sekaligus, yaitu Garis lalu dua Titik di dalamnya, sehingga x1, y1, x2, dan y2 langsung tersedia sebagai int. Tubuh kasusnya memakai blok dengan yield karena lebih dari satu pernyataan, sebuah bentuk switch expression yang sudah dibahas pada topik switch di modul Alur Kontrol. Kasus default pada contoh ini memang diperlukan, karena Object tidak disegel dan pemeriksaannya tidak pernah lengkap.

Guard when dan aturan dominasi#

Guard when menyempurnakan sebuah pola dengan syarat tambahan, dan labelnya hanya berlaku bila polanya cocok sekaligus syaratnya bernilai true. Bila syaratnya gagal, pencocokan berlanjut ke label berikutnya, sehingga beberapa label dengan pola sama dan guard berbeda dapat disusun seperti pada UjiPolaSwitch. Guard menjaga cabang tetap ringkas tanpa perlu if bersarang di dalam tubuh kasus.

Urutan penulisan label membawa konsekuensi yang dijaga kompiler. Label yang lebih umum, bila ditulis lebih dahulu, menutup label yang lebih spesifik di bawahnya, dan kondisi itu disebut dominasi. Potongan berikut ditolak kompiler dengan pesan yang diuji langsung.

java Contoh: label yang terdominasi ditolak
// tidak-dijalankan
return switch (n) {
    case Pengingat p -> "pengingat";
    case Pengingat(String judul, int sisa) -> "tidak tercapai";
};
// error: this case label is dominated by a preceding case label

Label pertama menerima setiap Pengingat, sehingga label kedua tidak pernah mungkin tercapai dan kompiler menolaknya sejak kompilasi. Aturan praktisnya sederhana, yaitu susun label dari yang paling spesifik ke yang paling umum, dan taruh default di ujung apabila memang dibutuhkan. Jaminan ini tidak dimiliki oleh rantai if sekalipun, karena if yang saling menutup tidak pernah menimbulkan error.

Kelengkapan switch pada hierarki tertutup#

Switch expression wajib ekshaustif, artinya setiap kemungkinan nilai harus punya labelnya, dan di sinilah hierarki tertutup bekerja penuh. Pada UjiPolaSwitch, Notifikasi disegel dengan dua penerap, dan switch-nya tidak membawa default sama sekali karena kompiler dapat membuktikan kelengkapannya dari permits. Label bertipe Pengingat yang ditulis tiga kali dengan guard berbeda tetap dihitung satu cakupan jenis, karena label terakhir tanpa guard menjamin seluruh Pengingat tertangani.

Diagram berikut merangkum urutan pencocokan dari atas ke bawah beserta nilai yang berhenti di tiap label.

Pencocokan berjalan dari atas ke bawahberhenti di sini:case nullnilai nulltidak cocokcase Sapaan sobjek Sapaantidak cocokcase Pengingat(...) when judul.isBlank()judul kosong atau spasiguard gagalcase Pengingat(...) when sisa <= 0sisa 0 atau negatifguard gagalcase Pengingat(...) tanpa whenPengingat lainnyaSetiap nilai berhenti pada label pertama yang cocok, dan guard gagal melanjutkan pencocokan.
Urutan pencocokan label switch dari atas ke bawah sampai satu label cocok

default tetap memiliki tempat yang sah pada dua situasi. Yang pertama adalah pembanding bertipe tidak disegel seperti Object pada UjiPolaBersarang, karena himpunannya tidak pernah diketahui lengkap. Yang kedua adalah hierarki yang memakai non-sealed, sebab cabang yang dibuka menghapus bukti kelengkapan yang dipegang kompiler. Selain dua situasi itu, default justru menutup pintu pengaman, karena jenis baru yang sah masuk permits akan ditandai kompiler pada setiap switch yang belum menanganinya.

Kesalahan umum#

  • Menulis label umum di atas label spesifik, misalnya case Pengingat p sebelum label bertipe Pengingat dengan guard. Kompilator menolaknya dengan pesan dominasi. Susun label dari yang paling spesifik ke yang paling umum.
  • Mengabaikan elemen null karena switch klasik tidak pernah menanganinya. Pada pola switch, null yang tidak punya case null melempar NullPointerException. Tangkap nilainya secara eksplisit bila memang mungkin masuk.
  • Mengira guard berkontribusi pada kelengkapan. Label dengan when bisa gagal untuk nilai yang cocok, sehingga kelengkapan tetap ditanggung label tanpa guard atau oleh default.
  • Memakai record pattern pada kelas biasa. Pola itu hanya sah untuk record, karena dekonstruksi mengandalkan komponen yang dijanjikan deklarasinya. Kelas biasa tetap memakai pola tipe biasa.
  • Mengikat nama variabel yang sama dua kali dalam satu pola, misalnya dua komponen bernama judul. Setiap pengikatan butuh nama unik, dan nilai yang tak dipakai cukup diabaikan tanpa diikat.

Ringkasan#

  • Label switch menerima pola tipe dan case null, diperiksa dari atas ke bawah, sehingga urutan label menjadi bagian dari perilaku program.
  • Record pattern membuka komponen record pada labelnya, termasuk bertingkat, sehingga aksesor dan cast tidak perlu ditulis manual.
  • Guard when menyempurnakan pola dengan syarat tambahan, dan kompiler menolak label yang terdominasi oleh label di atasnya.
  • Hierarki tertutup memberi kompiler bukti kelengkapan, sehingga switch tanpa default diterima, sedangkan tipe terbuka dan cabang non-sealed tetap membutuhkan default.

Referensi#