Spis treści

Java 15 - czym są sealed classes?

Java 15- ilustracja - klasy sealed

W wydaniu 15 Javy pojawiła się interesująca nowość - klasy sealed, czyli klasy zaplombowane czy może zamknięte. “Nareszcie!” - pomyślałam. To jeden z tych egzotycznych smaków, które od dawna posiadała Scala. Sealed classes są też w Ceylonie, Kotlinie, Haskellu. Do Javy trafią pewnie dopiero w kolejnej wersji LTS, czyli w siedemnastce planowanej na wrzesień tego roku. Zarówno w wydaniu 15. jak i 16. są w fazie preview.

Czym dokładnie są klasy sealed? Jaki problem rozwiązują? Jakie są potencjalne przypadki ich użycia? Na te pytania postaram się odpowiedzieć w tym wpisie.

Czym są klasy sealed?

To klasy, które nie są final i których lista klas pochodnych jest statycznie zadeklarowana w kodzie. Nazwy klas pochodnych są albo wymienione w deklaracji klasy tuż po deklaracjach extends / implements, albo klasy pochodne są zadeklarowane bezpośrednio w tej samej jednostce kompilacji.

Na etapie kompilacji hierarchia klas jest już ustalona (zaplombowana) i żadna inna klasa nie może dziedziczyć po żadnej z klas w hierarchii.

Klasy sealed są więc sposobem na ograniczenie dziedziczenia, a ściślej na ograniczenie liczby klas dziedziczących jedynie do tych, które dopuszcza programista - twórca.

Potrzeba Ograniczania

Dlaczego w ogóle ktoś chciałby ograniczać możliwość dziedziczenia klas? Czy nie jest to jawny gwałt na samej istocie paradygmatu programowania obiektowego?

Ano, chciałby. I to z kilku powodów:

  • Przede wszystkim dlatego, że wprost daje wyraz swojej intencji - projektuje hierarchię klas nie przeznaczoną do rozszerzania, lecz jedynie do używania.
  • Po drugie dlatego, że wymaga tego dziedzina “biznesowa”, którą chce zareprezentować w kodzie.
  • Po trzecie, chce pisać prosty kod, w którym dokładnie wie, jakich podtypów może się spodziewać pod referencją do typu bazowego i na tej podstawie chce podejmować decyzje

(Uważam zresztą, że należy ograniczać na maksa wszystko, co się da, i dopuszczać nie wiecej, niż trzeba. Wtedy po prostu jest bezpiecznie i zmniejsza się przestrzeń możliwości popełnienia błędów. Jestem pewna, że takie podejście ma nawet jakąś formalną nazwę - jak ją znajdę, to tu dopiszę.)

Jaki problem rozwiązują?

Programista może chcieć na przykład:

  • uniemożliwić dodawanie nowych klas dziedziczących po pewnej klasie abstrakcyjnej ze względu na specyficzny charakter interakcji między klasą-przodkiem i klasami-potomkami (tight coupling)
  • zamodelować dziedzinę, której kształt jest znany i nie będzie podlegał dalszym zmianom
  • stworzyć kod, w którym hierarchia klas to szczegół implementacyjny pewnego ogólniejszego rozwiązania, a abstrakcyjna klasa bazowa nie powinna być w ogóle widoczna
  • mieć taki enum, którego wartości nie są singletonami, ale które mają możliwość tworzenia instancji, są templejtami wartości (doskonale pasują rekordy)
  • i mieć możliwość dopasowania referencji-wzorca (patrz: pattern matching w instanceof) do listy znanych podtypów w sposób wyczerpujący

Jak wyglądają klasy sealed?

Oto prosty przykład klasy abstrakcyjnej Shape, która jest sealed i pozwala na dziedziczenie trzem innym klasom:

1
2
3
4
package com.example.geometry;

public abstract sealed class Shape
permits Circle, Rectangle, Square { ... }

Popatrzmy bliżej:

  • w deklaracji klas sealed pojawia się słówko sealed
  • po sekcji extends/implements jest słówko permits z listą dopuszczonych “bezpośrednich” typów pochodnych
  • każdy z dopuszczonych typów pochodnych bezpośrednio rozszerza typ sealed
  • i musi zadeklarować sposób rozszerzenia klasy sealed.

Jak można zadeklarować sposób rozrzerzania?

  • klasa pochodna może zabronić roszerzania jej przez zadeklarowanie siebe jako final
  • klasa pochodna może być sealed
  • klasa pochodna może być non-sealed, dając możliwość dziedziczenia (a tym samym otwierając hierarchię i niszcząc korzyści wynikające z jej zamknięcia)

Na przykład:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.example.geometry;

public abstract sealed class Shape
  permits Circle, Rectangle, Square { ... }

public final class Circle extends Shape { ... }

public sealed class Rectangle extends Shape
  permits TransparentRectangle, FilledRectangle { ... }
public final class TransparentRectangle extends Rectangle { ... }
public final class FilledRectangle extends Rectangle { ... }

public non-sealed class Square extends Shape { ... }

Typy pochodne muszą być zadeklarowane w tym samym module bądź w tym samym pakiecie (jeśli klasa nadrzędna jest w module nienazwanym)

Dopasowywanie wzorca

Jeśli hierarchia klas jest zamknięta, można bezpiecznie przeiterować po liście znanych typów:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Shape rotate(Shape shape, double angle) {
  if (shape instanceof Circle) {
    return shape;
  } else if (shape instanceof Rectangle) {
    return shape.rotate(angle);
  } else if (shape instanceof Square) {
    return shape.rotate(angle);
  }
// else nie jest potrzebny
}

a w niedalekiej (oby!) przyszłości będzie możliwy pełen pattern matching i dopasowanie do klasy:

1
2
3
4
5
6
7
8
Shape rotate(Shape shape, double angle) {
return switch (shape) {   // pattern matching switch
  case Circle c    -> c;
    case Rectangle r -> r.rotate(angle);
    case Square s    -> s.rotate(angle);
    // no default needed!
  }
}

Przypadki użycia

Jednym z przypadków użycia jest modelowanie hierarchii bytów, dla których Enum jest niewystarczający: nie mamy ustalonej z góry listy pojedynczych bytów, lecz listę rodzajów bytów. Każdy z bytów może nieść ze sobą inne dane. I tutaj doskonale sprawdzą się rekordy, które - jako że są final z definicji - utworzyć mogą spójny front “liści” w hierarchii klas sealed.

Planety

Zamiast

1
2
3
4
5
6
7
8
enum Planet { MERCURY, VENUS, EARTH }

Planet p = ...
switch (p) {
case MERCURY: ...
case VENUS: ...
case EARTH: ...
}

chcemy mieć coś więcej: możliwość wyrażenia różnych instancji typów Planet, Star czy Comet:

1
2
3
4
5
6
sealed interface Celestial
  permits Planet, Star, Comet { ... }

final class Planet implements Celestial { ... }
final class Star   implements Celestial { ... }
final class Comet  implements Celestial { ... }

Wyrażenia

Jeśli użyjemy rekordów, uzyskamy charakterystyczny kod reprezentujący typy wyrażenia:

1
2
3
4
5
6
7
8
9
package com.example.expression;

public sealed interface Expr
  permits ConstantExpr, PlusExpr, TimesExpr, NegExpr { ... }

public record ConstantExpr(int i)       implements Expr { ... }
public record PlusExpr(Expr a, Expr b)  implements Expr { ... }
public record TimesExpr(Expr a, Expr b) implements Expr { ... }
public record NegExpr(Expr e)           implements Expr { ... }

Algebraiczne typy danych

Kombinacja rekordów oraz klas sealed pozwala stwierdzić, że w Javie występują algebraiczne typy danych:

Rekordy - to typy produktowe (od “product” czyli iloczyn wektorowy; każda instancja rekordu reprezentuje krotkę w iloczynie kartezjańskim typów wchodzących w skład rekodrdu) Klasy sealed - to typu sumowe (pewien byt, np. Caelestial, może być Planetą lub Cometą lub Starem)

Zakończenie

Przyjemności są programistom Javy dozowane bardzo skromnie: najpierw pattern matching w instanceof, później rekordy, później sealed classes. Ciekawe, kiedy będzie możliwy pełem pattern matching po liście typów. W wydaniu 17? Kto wie. Warto trzymać rękę na klawiaturze.

Źródła