Sytuacja kobiet w IT w 2024 roku
16.07.20218 min
Michał Karmelita
Asseco Poland S.A.

Michał KarmelitaJava DeveloperAsseco Poland S.A.

Pattern matching w instrukcjach switch w Javie

Dowiedz się, jak korzystać z nowych funkcjonalności instrukcji switch, z których będzie można korzystać w Javie 17.

Pattern matching w instrukcjach switch w Javie

Instrukcja wyboru (switch) jest elementem obecnym – w różnym kształcie – w większości języków programowania. Stanowi ona znakomitą alternatywę dla sekwencji kolejnych sprawdzeń if-else. Podnosi czytelność kodu, zaś dzięki temu, że jest jedną, integralną instrukcją (w przeciwieństwie do sekwencji if-elsów), umożliwia językowi programowania łączne sprawdzenie sensowności i prawidłowości zawartych w niej warunków już na etapie kompilacji.

Mimo tych zalet, możliwość wykorzystania switcha w Javie jest obecnie dość ograniczona, ponieważ jako wyrażenie, na podstawie którego następuje wybór, dopuszczalne są jedynie integralne typy proste (char, byte, int oraz long), ich typy opakowujące, enumy oraz Stringi. Nie jest to szczególnie rozbudowany katalog. Wszystko wskazuje jednak na to, że wraz z Javą 17 wprowadzone zostaną zmiany, które znacząco poszerzą spektrum zastosowań instrukcji wyboru w tym języku.

Zmiany, o których piszę mają obecnie status JDK Enhancement Proposal (JEP), czyli propozycji ulepszenia, wzbogacenia języka Java. Konkretnie, proponowane zmiany wyszczególnione zostały w ramach JEP 406: Pattern Matching for switch (Preview). Jak wspomniałem, zostaną one wprowadzone w Javie 17, czyli kolejnym planowanym wydaniu tego języka (choć już teraz możliwe jest pobranie early builda JDK 17).

Zmiany mają obecnie status preview. Oznacza to, że są już kompletną, w pełni wyspecyfikowaną i zaimplementowaną funkcjonalnością języka, jednak jeszcze nie oficjalną i domyślnie dostępną jego częścią. Funkcjonalności preview podlegają ocenie społeczności i w zależności od tej oceny w kolejnych wersjach mogą zostać już oficjalnie włączone do Javy, poprawione lub usunięte (w praktyce tej ostatniej opcji nie należy się spodziewać w opisywanym przypadku).

Korzystanie z nowych funkcjonalności instrukcji switch wymaga zainstalowania Javy 17 oraz dodatkowo jawnego ich włączenia (domyślnie funkcjonalności preview są wyłączone). W zależności od sposobu budowania i uruchamiana projektu, należy włączyć wsparcie takich funkcjonalności w IDE lub dodać flagę --enable-preview przy kompilacji i uruchamianiu programu z linii poleceń bądź dodać dodatkowe znaczniki z tą flagą w narzędziu automatyzującym budowę (np. w Mavenie).

Przechodząc do konkretów. Wraz z wprowadzeniem Javy 17 dozwolone staną się instrukcje wyboru wspierające pattern matching. Instrukcje te będą mogły opierać się na „tradycyjnym” switchu lub też na jego uproszczonej formie zapisu wprowadzonej w Javie 14. Przyjrzyjmy się obu wariantom. Wersja tradycyjna:

Object someObject = 2;
Object result = null;
switch (someObject) {
    case Integer i:
        result = i;
        break;
    case Long l:
        result = l;
        break;
    case Double d:
        result = d;
        break;
    default:
        result =  "something else";
}


Oraz wersja oparta na zapisie wprowadzonym w Javie 14:

Object someObject = 1;
String result = switch (someObject) {
    case Integer i -> String.format("int %d", i);
    case Long l -> String.format("long %d", l);
    case Double d -> String.format("double %f", d);
    default -> "something else";
};


W obu wypadkach do instrukcji przekazywane jest pewne wyrażenie, na podstawie którego następuje wybór (w naszym przypadku jest to obiekt o nazwie someObject, klasy Object). Następnie dokonywane jest dopasowanie do wzorca typu (czyli sprawdzenie, jakiego typu jest przekazany obiekt). W powyższych przykładach przekazane wyrażenie jest instancją Integera, a zatem spełniony zostaje warunek zawarty w pierwszym labelu case. W tym momencie tworzona jest zmienna typu Integer (oznaczona u nas symbolem i), a następnie do tej zmiennej przypisany zostaje obiekt, który przekazaliśmy do switcha. Innymi słowy: w obrębie switcha będziemy mogli korzystać z naszego obiektu someObject przypisanego do zmiennej Integer, mimo że nie dokonywaliśmy nigdzie jawnego rzutowania.

Warto zwrócić uwagę, że w przypadku nowszej formy zapisu (ze strzałką), nie jest wymagane wykorzystanie słówka break. Z zasady niedopuszczalne jest w tym przypadku „przejście” przez switcha, (tzw. fall-through), czyli zachowanie, któremu break ma za zadanie zapobiegać. Ponieważ dla każdego case’a zwracamy Stringa, możliwe jest także przypisane switcha bezpośrednio do zmiennej typu String.

Wprowadzenie pattern matchingu do Javy wymagało od twórców tej funkcjonalności zmierzenia się z czterema podstawowymi kwestiami:

  • poprawienie obsługi typów,
  • kompletność instrukcji switch,
  • zakres zmiennych tworzonych w tej instrukcji,
  • obsługa nulli.


Przyjrzyjmy się tym zagadnieniom po kolei.

Obecnie switch obsługuje bardzo niewiele typów. Nie miałoby większego sensu wprowadzanie dopasowania do wzorca, gdyby dopasowanie to mogło odbywać się tylko do kilku typów całkowitoliczbowych czy Stringa. Dlatego też zaproponowano, by od Javy 17 switch obsługiwał wszystkie dotychczasowe typy, a dodatkowo wszystkie typy referencyjne. Już niedługo będziemy mogli przekazać do instrukcji wyboru np. obiekt naszej własnej klasy, tablicę itp. Taki kod będzie całkowicie poprawny w Javie 17 (zakładam, że Month jest stworzonym przez programistę enumem, a MyClass – stworzoną przez niego klasą):

Object o = new int[]{1, 2, 3};
switch (o) {
    case String s -> System.out.println("String");
    case Month m -> System.out.println("Month: " + m.label);
    case MyClass mc -> System.out.println("My class");
    case int[] intArray -> System.out.println("Int array: " + intArray.length);
    default -> System.out.println("Something else");
}


Kolejnym zagadnieniem jest kompletność switcha. W tym przypadku przyjęto założenie, że jeżeli przekazujemy do instrukcji warunkowej pewne wyrażenie, na podstawie którego dokonywany będzie wybór, to musimy „pokryć” (jawnie wymienić w case’ach) wszystkie możliwości. W konsekwencji taki zapis nie będzie prawidłowy:

Object o = "Ala";
Integer result = switch (o){
    case String s -> s.length();
    case Integer i -> i;
}


Powyższy switch nie skompiluje się, a zamiast tego otrzymamy od kompilatora informację, iż the switch expression does not cover all possible input values. Stanie się tak dlatego, że do instrukcji wyboru przekazujemy Object, a następnie sprawdzamy, czy jest on Stringiem lub Integerem, podczas gdy może on być instancją także wielu innych klas.

Aby zapewnić kompletność switcha najprościej jest dodać do niego domyślne zachowanie, które wykonane zostanie dla wszystkich innych, „niepokrytych” przypadków (za pomocą słowa default):

Object o = "Ala";
Integer result = switch (o){
    case String s -> s.length();
    case Integer i -> i;
    default -> throw new RuntimeException("Invalid argument.");
}


Inną możliwością jest skorzystanie z tzw. interfejsów lub klas „zapieczętowanych” wprowadzonych w Javie 15. Jeśli spojrzymy na poniższy przykład:

sealed interface Aircraft permits Plane, Helicopter {}
final class Plane implements Aircraft{}
final class Helicopter implements Aircraft{}


Możemy go odczytać jako interfejs Aircraft, który jest zapieczętowany i może zostać zaimplementowany jedynie przez klasy Plane lub Helicopter. Próba implementacji przez inną klasę skończy się błędem kompilacji. Dlatego też, poniższy kod będzie już prawidłowy, mimo niezastosowania default:

Aircraft aircraft = new Plane();
String kindOfVehicle = switch (aircraft){
    case Plane p -> "Plane";
    case Helicopter h -> "Helicopter";
}


Oprócz zastosowania słówka default lub typów zapieczętowanych możliwe jest także użycie tzw. total patternu. Spójrzmy na poniższy przykład:

Aircraft aircraft = new Plane();
String kindOfVehicle = switch (aircraft){
    case Plane p -> "Plane";
    case Object o -> "Total pattern";
}


Total patternem 
jest tutaj klasa Object, ponieważ każdy Aircraft zawsze będzie również Objectem. Uogólniając: dla danego typu total patternem będzie jego typ nadrzędny. Jeżeli zatem do instrukcji switch przekażemy obiekt określonej klasy, zaś wewnątrz instrukcji umieścimy case z klasą nadrzędną, to kompilator również uzna, że zapewniliśmy pełne pokrycie, a zatem switch będzie prawidłowy (o ile nie jest obciążony innymi błędami).

Jak zostało wspomniane, w przypadku dopasowania do wzorca, tworzona jest zmienna określonego typu (tj. tego typu, do którego został dopasowany obiekt przekazany do switcha), zaś sam obiekt przypisywany jest do tej zmiennej. Zakres takiej zmiennej obejmuje:

  • wyrażenie zawarte w danym labelu case po prawej stronie dwukropka lub strzałki,
  • blok kodu po prawej stronie dwukropka lub strzałki,
  • klauzulę rzucenia wyjątku po prawej stronie dwukropka lub strzałki.


Dla zobrazowania; poniżej utworzona może zostać jedna z trzech zmiennych: slub i, zależnie od tego, czym będzie obiekt o. Zakresy tych zmiennych to kolejno: wyrażenie, blok kodu i wyjątek.

Object o = "Ala";
switch (o) {
    case String s -> System.out.println(s);
    case CharSequence c -> {
            if (c.length() > 0) {
                System.out.println(c);
            }
        }
    case Integer i -> throw new RuntimeException("Invalid argument: " + i.intValue());
    default -> {
            break;
        }
}


Mówiąc o zakresie zmiennych warto wspomnieć o tym, że niedopuszczalne jest:

  • pominięcie słówka break, jeśli korzystamy z pattern matchingu i z tradycyjnego zapisu switcha (z dwukropkiem),
  • umieszczenie w jednym case dwóch patternów, jeśli korzystamy z nowego zapisu (ze strzałką).


Taki przykładowy kod będzie zatem niedopuszczalny:

        Object o = "someObject";
        switch (o) {
            case String s:
                if (s.length() > 0) {
                    System.out.print("Not empty string " + s);
                }
            case Integer i:
                 System.out.println("Integer " + i);
            default:
                break;
        }


Dzieje się tak dlatego, że gdyby obiekt o okazał się Stringiem, to utworzona zostanie zmienna s, do której następnie obiekt ten zostanie przypisany. W przypadku braku słówka break dalsza logika będzie również wykonywana (nastąpi fall-through). Utworzona zostanie zatem kolejna zmienna (tym razem i), do której jednak nie można będzie przypisać żadnego obiektu (bo nie jest on instancją Integera). W konsekwencji już na etapie kompilacji Java 17 zapobiegać będzie takim błędom, wymuszając zastosowanie break’a.

Ostatnią dużą kwestią, z którą zmierzyć musieli się twórcy opisywanej funkcjonalności jest obsługa nulli. Tradycyjnie switch jest dość „bezbronny” wobec nich – w przypadku przekazania nulla do instrukcji wyboru rzucony zostanie Null Pointer Exception. Sprawdzenie czy wyrażenie, które chcemy przekazać do switcha nie jest nullem musi odbywać się zatem poza samą instrukcją. Rodzi to dwa problemy – po pierwsze: nadmiarowego kodu, po drugie: błędogenności. W Javie 17 możliwa będzie obsługa nulla bezpośrednio w ciele switcha. Będziemy mogli w tym celu stworzyć odrębny case lub też umieścić nulla w jednym case’ie wspólnie z innym warunkiem, np. w ten sposób:

Object o = null;
  switch(o) {
    case null, String s -> System.out.println("String or null");
    default -> {break;}
}


Ostatnią kwestią, o której warto wspomnieć jest wprowadzenie w Javie 17 tzw. guarded patterns. Dzięki nim w ramach jednego case będziemy mogli sprawdzić nie tylko, czy przekazany do instrukcji obiekt jest instancją konkretnego typu, ale także czy spełnia on również dodatkowe warunki. Spójrzmy na różnicę. Najpierw przykład, w którym odrębnie sprawdzamy dopasowanie do wzorca (czy vehicle jest instancją klasy Car), a dopiero dalej (w ifie), czy waży on więcej niż 3500 kg:

String kindOfVehicle = null;
 switch (vehicle) {
    case null:
        break;
    case Car c:
        if (c.getWeight() > 3500) {
            kindOfVehicle = "A big car";
            break;
        }
        kindOfVehicle = "A car";
        break;
    default:
        kindOfVehicle =  "Another vehicle, can't be a small car";
}


Stosując guarded pattern oba te sprawdzenia możemy wykonać łącznie:

String kindOfVehicle = switch (vehicle) {
    case null -> null;
    case Car c && (c.getWeight() > 3500) -> "A big car";
    case Car c -> "A car";
    default -> "Another vehicle";
};


Jak widać, kolejne wersje Javy przyniosą duże zmiany dla instrukcji switch, znacząco poszerzając możliwość jej wykorzystania. Pozostaje mi życzyć sobie i Wam, byśmy mogli jak najszybciej skorzystać z tych dobrodziejstw w produkcyjnych projektach :)

<p>Loading...</p>