Historia
Konstrukcja switch ewoluowała z instrukcji sterującej w stylu C do pełnego wyrażenia zdolnego do zwracania wartości w Java 14. W Java 17 wprowadzono klasy i interfejsy zamknięte, aby ograniczyć dziedziczenie, a dopasowywanie wzorców dla switch pojawiło się jako funkcja w wersji pokazowej, kończąc na standardyzacji w Java 21. Ta ewolucja przesunęła switch z prostego stołu skoków opartego na rozłącznych stałych do zaawansowanego mechanizmu dopasowywania wzorców, który musi zapewnić pełność, gdy jest używany jako wyrażenie.
Problem
Gdy switch działa jako wyrażenie (używając składni strzałkowej -> lub yield), musi produkować wartość dla każdego możliwego wejścia, aby spełnić statyczny system typów w Javie. W przeciwieństwie do tradycyjnych instrukcji switch, które mogą cicho pomijać nieobsługiwane przypadki lub przechodzić dalej, wyrażenie wymaga absolutnej pewności, że wszystkie ścieżki wykonania zwracają wartość. Hierarchie zamknięte wyraźnie enumerują wszystkie dozwolone podtypy, tworząc zamknięty wszechświat, co czyni całkowite pokrycie teoretycznie weryfikowalnym w czasie kompilacji. Kompilator musi pogodzić ten zamknięty świat z otwartymi wzorcami (takimi jak wzorce typów czy przypadki null), aby upewnić się, że nie wystąpi wyjątek MatchException w wyniku niepokrytych typów.
Rozwiązanie
Kompilator przeprowadza analizę dominacji i wyczerpującej obsługi podczas fazy atrybucji kompilacji. Traktuje klauzulę permits klasy zamkniętej jako skończony, zamknięty zbiór typów. Dla każdego wzorca w switch odejmuje pasujące typy od wszechświata dozwolonych typów. Jeśli po ostatnim wzorze jakikolwiek dozwolony podtyp pozostaje niepasujący i nie istnieje bezwarunkowy default lub całkowity wzorzec typów, kompilator odrzuca kod z błędem. Ta analiza respektuje zasady dominacji wzorców (gdzie konkretne wzorce muszą poprzedzać bardziej ogólne) i generuje syntetyczny mechanizm do obsługi wartości null osobno od wzorców typów.
sealed interface Payment permits Credit, Debit, Crypto {} record Credit() implements Payment {} record Debit() implements Payment {} record Crypto() implements Payment {} // Błąd kompilacji, jeśli brakuje przypadku Crypto double fee = switch (payment) { case Credit c -> 0.02; case Debit d -> 0.01; // Brak przypadku Crypto powoduje: "wyrażenie switch nie obejmuje wszystkich możliwych wartości" };
Opis problemu
W mikroserwisie przetwarzania płatności musieliśmy obliczyć opłaty w oparciu o typy instrumentów: Credit, Debit, BankTransfer i Crypto. Model domenowy używał zamkniętego interfejsu PaymentInstrument, który pozwalał dokładnie na te cztery implementacje. Młodszy programista zaimplementował kalkulator opłat korzystając z wyrażenia switch, ale nieumyślnie pominął przypadek Crypto, zakładając, że domyślnie zwróci zero. Gdy płatności kryptowalutowe zostały włączone w produkcji, ta nieścisłość spowodowała MatchException w czasie wykonywania, zawieszając proces transakcji i wymagając natychmiastowego wycofania.
Różne rozważane rozwiązania
Rozwiązanie A: Kryterium domyślne
Mogliśmy dodać klauzulę default -> 0.0, aby obsłużyć wszelkie niedopasowane instrumenty. To podejście oferuje natychmiastowe bezpieczeństwo, zapobiegając awarii. Jednakże zaciemnia intencje biznesowe, cicho pochłaniając nieobsługiwane typy. Jeśli później do zamkniętej hierarchii zostałby dodany nowy typ instrumentu, klauzula domyślna mogłaby to ukryć przed obliczeniami opłat, co mogłoby prowadzić do wycieku dochodów lub naruszeń przepisów.
Rozwiązanie B: Mapowanie typów oparte na enumach
Migracja do enumeracji InstrumentType pozwoliłaby na sprawdzenie wyczerpującej obsługi w czasie kompilacji poprzez enumerację stałych. Jednakże to tworzy równoległą taksonomię, wymagającą, aby każdy instrument płatniczy ujawniał zbędne metadane typu. Poświęca polimorficzną bogatość klas zamkniętych, gdzie każdy podtyp nosi unikalne pola danych, takie jak numery kart lub adresy blockchain, wymuszając sztuczną denormalizację danych.
Rozwiązanie C: Wzorce wyczerpujące egzekwowane przez kompilator Implementujemy wyrażenie switch z wyraźnymi przypadkami dla wszystkich czterech dozwolonych typów, korzystając z analizy hierarchii zamkniętej przez kompilator. To podejście traktuje brakujące przypadki jako błędy kompilacji, wymuszając aktualizacje bazy kodu za każdym razem, gdy zmieniają się zezwolenia. Eliminuję niespodzianki w czasie wykonywania poprzez przeniesienie weryfikacji do fazy budowy.
Wybrane rozwiązanie i rezultat
Wybraliśmy Rozwiązanie C i skonfigurowaliśmy pipeline budowania, aby traktować ostrzeżenia kompilatora dotyczące niepełnych wyrażeń switch jako błędy krytyczne. Gdy zespół produktowy później dodał BuyNowPayLater jako piąty dozwolony podtyp, pipeline CI/CD natychmiast zaznaczył siedemnaście miejsc, w których obliczenia opłat były niekompletne. To wymusiło skoordynowaną aktualizację w modułach podatkowych, zgodności i księgowości przed wdrożeniem, zapewniając, że nowy instrument otrzymał właściwą logikę finansową. Gwarancje w czasie kompilacji zapobiegły cichym domyślnym i utrzymały bezpieczeństwo typów w rozproszonych zespołach.
Jak obsługa null wpływa na sprawdzanie wyczerpującej obsługi w przełącznikach wzorców?
Wielu kandydatów myli się, zakładając, że pokrycie wszystkich podtypów klasy zamkniętej spełnia wymagania dotyczące wyczerpującej obsługi. Jednakże wyrażenia switch traktują null jako odrębne od wzorców typów; wymagana jest osobna klauzula case null lub całkowity wzorzec. Bez wyraźnej obsługi null kompilator generuje syntetyczną kontrolę null, która zgłasza NullPointerException, co oznacza, że wyrażenie jest technicznie wyczerpujące dla typów, ale nie dla samej wartości null.
Dlaczego dodanie klauzuli domyślnej do przełącznika nad zamkniętą hierarchią może potencjalnie naruszyć zasadę zamkniętych typów?
Kandydaci często dodają default jako nawyk defensywnego kodowania, nie dostrzegając, że podważa to zamknięte założenie klas zamkniętych. Klauzula domyślna pasuje do każdego typu, w tym tych dodanych do listy zezwoleń w przyszłych wydaniach, skutecznie zmieniając weryfikację wyczerpującej obsługi w czasie kompilacji w pułapkę na czasy wykonania. To ponownie wprowadza dokładną kruchość, którą klasy zamknięte miały na celu eliminację, pozwalając nieobsługiwanym nowym typom na cichą realizację niezamierzonej logiki.
Co się dzieje, gdy wyrażenie switch nad typem zamkniętym napotyka typ, który jest dozwolony, ale nie widoczny dla bieżącego modułu?
Scenariusz ten dotyczy granic widoczności, w których klasa zamknięta zezwala na podtyp o zakresie pakietu, który znajduje się w innym pakiecie lub module, który nie jest eksportowany do bieżącej jednostki kompilacji. Kompilator nie może zweryfikować wyczerpującej obsługi, ponieważ pełny zestaw dozwolonych typów jest nieznany w miejscu użycia, co skutkuje błędem kompilacji, pomimo że wszystkie lokalnie widoczne typy są obsługiwane. Rozwiązanie tego problemu wymaga albo dodania klauzuli domyślnej (co podważa wyczerpującą obsługę), albo dostosowania eksportów modułu JPMS, aby uczynić zezwolenia widocznymi, podkreślając interakcję między dostępnością modułów a dopasowywaniem wzorców.