Typy zapieczętowane (Sealed classes)

Typy zapieczętowane (sealed classes) , to mechanizm wprowadzony w JDK15, który pozwala wprowadzić ograniczenia w hierarchii typów, bez całkowitego blokowania dziedziczenia.

Hierarchia typów w Javie

Java jest językiem programowania, w którym wszystko oparte jest o klasy, a klasy tworzą hierarchię dziedziczenia. Takie rozwiązanie pozwala rozwiązać wiele problemów, redukuje powtarzalność kodu, ale ma także pewne luki. Przed Javą 15 w danej klasie mieliśmy informację o tym po jakich innych klasach ona dziedziczy, lub jakie interfejsy implementuje, albo inaczej mówiąc jakie ma typy nadrzędne. Z kolei typ nadrzędny nie ma pojęcia o tym jakie klasy go rozszerzają (jakie ma typy pochodne).

W ramach projektu Amber rozwijane jest wiele usprawnień takich jak np. rekordy, czy pattern matching for instanceof i szczególnie w kontekście tego drugiego, czynione są starania ku temu, aby wykorzystać podobny mechanizm w instrukcji switch, np. do dekonstrukcji obiektów.

Kurs Java

Blokowanie dziedziczenia

W Javie 14 i wcześniejszych, jedynym sposobem na wprowadzenie ograniczeń w hierarchii dziedziczenia było wykorzystanie słowa final. Problem z takim podejściem jest taki, że dodając słowo final przy jakiejkolwiek klasie, całkowicie blokujemy możliwość dziedziczenia po niej. Czasami jest to przydatne, jak np. w przypadku klasy String, jednak czasami chcielibyśmy uzyskać nieco większą elastyczność, czyli mieć możliwość dziedziczenia, ale jednocześnie mieć kontrolę nad wszystkimi typami pochodnymi jakie powstaną.

import java.math.BigDecimal;

final class Employee {
    private String firstName;
    private String lastName;
    private BigDecimal salary;
    
    //...
}

W powyższym przykładzie nie będzie można utworzyć klasy wyspecjalizowanego pracownika, który rozszerzałby klasę bazową Employee. Z tego powodu nie będzie się dało zastosować np. polimorfizmu, co nieco kłóci się z zasadami programowania obiektowego.

Pattern matching for instanceof

Od Javy 14 możliwe jest wykorzystanie nowej wersji operatora instanceof, który pozwala skrócić zapis:

Animal animal = new Cat("Mruczek", "czarny");
if (animal instanceof Cat) {
    Cat c = (Cat)animal;
    System.out.println("Kot w kolorze: " + c.getColor());
}

do takiej postaci:

Animal animal = new Cat("Mruczek", "czarny");
if (animal instanceof Cat c) {
    System.out.println("Kot w kolorze: " + c.getColor());
}

Był to pierwszy krok ku temu, żeby do Javy wprowadzić dopasowanie wzorca. Twórcy podchodzą do tematu bardzo ostrożnie i w Javie 15, pattern matching for instanceof jest w fazie second preview, więc nadal może być zmieniony, lub całkowicie usunięty.

Pattern matching for switch

W dalszych etapach twórcy chcą wprowadzić dopasowanie wzorców także np. do instrukcji switch. W momencie pisania tego artykułu pattern matching for switch jest oznaczony jako draft, więc jest to tylko propozycja, ale miałoby to funkcjonować w taki sposób, że zamiast rozgałęzionej instrukcji if else:

if (animal instanceof Cat c) {
    System.out.println("Kot w kolorze: " + c.getColor());
} else if (animal instanceof Dog d) {
    System.out.println("Pies rasy " + d.getBreed());
} else {
    System.out.println("Nieznane zwierzę");
}

chcielibyśmy podobny efekt osiągnąć z użyciem instrukcji switch:

switch (animal) {
    case Dog d:
        System.out.println("Pies rasy " + d.getBreed());
        break;
    case Cat c:
        System.out.println("Kot w kolorze: " + c.getColor());
        break;
    default:
        System.out.println("Nieznane zwierzę");
}

Na ten moment drugi zapis nie jest poprawny.

Sealed classes / Typy zapieczętowane

W ten sposób dochodzimy do tego co mogą nam dać typy zapieczętowane. W instrukcji switch, np. z wykorzystaniem typów wyliczeniowych, kompilator, albo środowisko programistyczne jest w stanie zweryfikować, czy rozpatrzyliśmy wszystkie możliwe przypadki, które mogą się pojawić. Jeżeli chodzi o powyższą wersję instrukcji switch, to nie jest to możliwe, ponieważ jeżeli spojrzymy na klasę Animal, to nie ma ona żadnej informacji o tym, jakie są jej typy pochodne:

class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Co więcej nawet jeżeli taka informacja byłaby gdzieś zapisana, to nie ma żadnej gwarancji, że ktoś w pewnym momencie nie zdecyduje się rozszerzyć klasy Animal o dodatkową klasę. Problem ten rozwiązują typy zapieczętowane - sealed classes. Są to typy (klasy lub interfejsy), które poprzedzamy słowem kluczowym sealed . Dodatkowo musimy w nich określić jakie typy są ich typami pochodnymi po słowie permits.

Klasa Animal w nowej odsłonie będzie więc wyglądała tak:

sealed class Animal permits Cat, Dog {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    //...
}

(klasę Animal można dodatkowo oznaczyć jako abstrakcyjną, aby zablokować możliwość tworzenia obiektów na jej podstawie, jest to dla nas jedynie warstwa abstrakcji i punkt wyjścia do dziedziczenia).

Klasy pochodne powinny być oznaczone jako:

  • final - jeżeli chcemy zamknąć hierarchię dziedziczenia,
  • zapieczętowane - czyli oznaczone słowem sealed, dzięki czemu będziemy musieli wskazać dalsze typy pochodne,
  • niezapieczętowane - oznaczone jako non-sealed, dzięki czemu pozostawiamy drogę do dalszego dziedziczenia, bez ograniczeń.

Przykładowo:

non-sealed class Dog extends Animal {
    private String breed;

    public Dog(String name, String breed) {
        super(name);
        this.breed = breed;
    }
//...
}
final class Cat extends Animal {
    private String color;

    public Cat(String name, String color) {
        super(name);
        this.color = color;
    }

//...
}

Podobne ograniczenia można wprowadzić także do interfejsów oraz rekordów.

sealed_classes

Co dzięki temu zyskujemy? M.in. to, że jeżeli w Javie pojawi się dopasowanie wzorca w instrukcji switch (pattern matching), to kompilator lub środowisko programistyczne będzie w stanie zweryfikować, czy zdefiniowaliśmy bloki case dla każdego możliwego przypadku.

Mechanizm ten może być także wykorzystany jako dodatkowy mechanizm bezpieczeństwa. Twórcy JDK, czy różnych frameworków mają dzięki temu większą kontrolę nad tym, czy i w jaki sposób ktoś może rozszerzać wybrane typy, zabezpieczając się tym samym przed nieoczekiwanymi zachowaniami.

Więcej szczegółów możesz doczytać na stronie OpenJDK.

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.