1. Składnia klas sealed: jak to wygląda
Zacznijmy od klasycznego problemu OOP w Javie: otwarta hierarchia dziedziczenia. W zwykłej Javie każdy może dziedziczyć po twojej klasie, o ile nie jest ona zadeklarowana jako final. To wygodne, ale czasem prowadzi do niespodzianek — na przykład nie możesz z góry wiedzieć, które dokładnie klasy będą potomkami twojej klasy, a więc nie możesz zagwarantować, że obsłużyłeś wszystkie warianty w switch lub if-else.
W rezultacie, gdy piszesz obsługę obiektów według typu (na przykład przez pattern matching w switch), jesteś zmuszony dodać gałąź default „na wszelki wypadek”: a nuż ktoś gdzieś stworzy nową podklasę?
Klasy sealed rozwiązują ten problem: pozwalają jawnie ograniczyć listę klas‑potomków. Dzięki temu hierarchia jest zamknięta i kontrolowana, a twój kod — bardziej przewidywalny i bezpieczny.
Podstawowa składnia
Klasy sealed pojawiły się w Javie 17. Deklaruje się je za pomocą modyfikatora sealed, a listę dozwolonych potomków wskazuje słowo kluczowe permits:
public sealed class Shape permits Circle, Rectangle, Square {
// Wspólne zachowanie dla wszystkich figur
}
Tutaj zadeklarowaliśmy klasę Shape i tylko klasy Circle, Rectangle i Square mogą być jej bezpośrednimi potomkami. Nikt inny nie rozszerzy Shape — kompilator na to nie pozwoli.
Ważne: Wszystkie klasy wymienione w permits muszą być zadeklarowane w tym samym pliku lub być widoczne dla kompilatora (zwykle w tym samym pakiecie). Swoją drogą, jeśli wszyscy potomkowie są zadeklarowani w jednym pliku z klasą sealed, permits można w ogóle pominąć — kompilator sam wszystko rozpozna.
Przykład:
// Wszystko w jednym pliku — permits nie jest wymagane
public sealed class Shape {
}
final class Circle extends Shape {}
final class Rectangle extends Shape {}
Wymagania wobec potomków
Każdy z potomków musi jawnie określić swój status:
- być final (zabrania dalszego dziedziczenia),
- albo być sealed (i dalej ograniczać dziedziczenie),
- albo być non-sealed (pozwala na dziedziczenie, znosi ograniczenia).
Przykład:
public sealed class Shape permits Circle, Rectangle, Square {}
public final class Circle extends Shape {}
public sealed class Rectangle extends Shape permits FilledRectangle, EmptyRectangle {}
public non-sealed class Square extends Shape {}
- Circle — klasa finalna; nie można po niej dziedziczyć.
- Rectangle — sam jest sealed; dopuszcza tylko dwie podklasy.
- Square — non-sealed; każdy może ją rozszerzyć.
Minimalny przykład
public sealed class Animal permits Dog, Cat {}
public final class Dog extends Animal {}
public final class Cat extends Animal {}
Spróbuj zadeklarować nową klasę public class Wolf extends Animal {} — dostaniesz błąd kompilacji:
class Wolf is not allowed to extend sealed class Animal
2. Zastosowania klas sealed: gdzie i po co ich używać
Pattern matching i switch
Klasy sealed szczególnie dobrze łączą się z pattern matching w switch. Jeśli kompilator zna wszystkie możliwe podklasy, może upewnić się, że obsłużyłeś każdy wariant, i nawet nie wymaga gałęzi default.
public sealed interface Result permits Success, Error {}
public final class Success implements Result {
public final String data;
public Success(String data) { this.data = data; }
}
public final class Error implements Result {
public final String message;
public Error(String message) { this.message = message; }
}
public class Main {
public static void main(String[] args) {
Result result = new Success("Hurra!");
switch (result) {
case Success s -> System.out.println("Sukces: " + s.data);
case Error e -> System.out.println("Błąd: " + e.message);
}
}
}
Kompilator wie, że innych wariantów Result być nie może, i nie wymaga default. Jeśli zapomnisz obsłużyć któryś wariant, kompilator od razu ci o tym przypomni.
Zwróć uwagę: począwszy od Java 21, jeśli w switch nie wszystkie warianty są obsłużone, dostaniesz błąd kompilacji. W starszych wersjach (17–20) może być potrzebne default, ale IDE i tak ostrzeże o niepełnym pokryciu.
Bezpieczeństwo i kontrola
Klasy sealed pozwalają programiście w pełni kontrolować hierarchię. Jest to szczególnie ważne dla modeli domenowych, w których zestaw wariantów powinien być stały (na przykład stan zamówienia: New, Paid, Cancelled).
Ułatwienie utrzymania i rozwoju kodu
Gdy znasz wszystkie warianty potomków, łatwiej dodawać nowe funkcje, utrzymywać kod i przeprowadzać refaktoryzację. IDE też będzie „świadome” wszystkich wariantów i pomoże w autouzupełnianiu oraz analizie.
3. Praktyczne przykłady użycia klas sealed
Przykład 1: Figury geometryczne
public sealed interface Shape permits Circle, Rectangle, Square {}
public final class Circle implements Shape {
public final double radius;
public Circle(double radius) { this.radius = radius; }
}
public final class Rectangle implements Shape {
public final double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
}
public final class Square implements Shape {
public final double side;
public Square(double side) { this.side = side; }
}
Teraz można bezpiecznie używać switch z pattern matching:
Shape shape = new Circle(5);
switch (shape) {
case Circle c -> System.out.println("Okrąg o promieniu " + c.radius);
case Rectangle r -> System.out.println("Prostokąt " + r.width + "x" + r.height);
case Square s -> System.out.println("Kwadrat o boku " + s.side);
}
Przykład 2: Transakcje finansowe
public sealed interface Transaction permits Deposit, Withdraw, Transfer {}
public final class Deposit implements Transaction {
public final double amount;
public Deposit(double amount) { this.amount = amount; }
}
public final class Withdraw implements Transaction {
public final double amount;
public Withdraw(double amount) { this.amount = amount; }
}
public final class Transfer implements Transaction {
public final double amount;
public final String toAccount;
public Transfer(double amount, String toAccount) {
this.amount = amount;
this.toAccount = toAccount;
}
}
Teraz w obsłudze możesz mieć pewność, że nie pominąłeś żadnego typu transakcji:
Transaction tx = new Transfer(100, "ACC123");
switch (tx) {
case Deposit d -> System.out.println("Wpłata: " + d.amount);
case Withdraw w -> System.out.println("Wypłata: " + w.amount);
case Transfer t -> System.out.println("Przelew: " + t.amount + " na " + t.toAccount);
}
Przykład 3: Ograniczona hierarchia z non-sealed
public sealed class Notification permits EmailNotification, SmsNotification, PushNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
public final class PushNotification extends Notification {}
// Teraz każdy może dziedziczyć po SmsNotification
public class ViberNotification extends SmsNotification {}
4. Cechy, ograniczenia i niuanse klas sealed
Wymagania dotyczące modyfikatorów
- Klasa sealed musi jawnie wskazać wszystkich potomków przez permits.
- Wszyscy potomkowie muszą być albo final, albo sealed, albo non-sealed.
- Potomkowie muszą być zadeklarowani w tym samym pliku albo być widoczni dla kompilatora.
Abstrakcyjne klasy sealed
Klasa sealed może być zarówno abstrakcyjna, jak i interface, jak i zwykła klasa. Na przykład:
public sealed abstract class Expr permits Const, Add, Mul {}
Zgodność z innymi modyfikatorami
- Nie można zadeklarować klasy sealed jako final ani non-sealed.
- interface również może być sealed (i to bardzo wygodne!).
Użycie z klasami record
Klasy record mogą być potomkami klasy sealed, jeśli są zadeklarowane jako final (domyślnie record jest zawsze final):
public sealed interface Expr permits Const, Add, Mul {}
public record Const(int value) implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Mul(Expr left, Expr right) implements Expr {}
5. Przydatne niuanse
Klasy sealed a pattern matching: jak to się łączy
Główna zaleta klas sealed — wyczerpujący pattern matching. Brzmi groźnie, działa prosto. Kompilator zna wszystkie możliwe warianty i możesz śmiało pisać switch bez gałęzi default:
Expr expr = ...;
switch (expr) {
case Const c -> ...
case Add a -> ...
case Mul m -> ...
}
Jeśli przypadkiem nie obsłużysz któregoś wariantu, kompilator nie pozwoli zbudować projektu — to bardzo wygodne i bezpieczne.
Zastosowanie w realnych zadaniach
Gdzie klasy sealed są naprawdę przydatne? Wszędzie, gdzie masz stały zestaw wariantów:
- Wynik operacji: Success, Error (jak w przykładzie wyżej).
- Status zamówienia: New, Paid, Cancelled.
- Abstrakcyjne drzewa składniowe (AST) dla parserów.
- Odpowiedzi API: Ok, NotFound, Error.
- Zdarzenia w systemie: UserLoggedIn, UserLoggedOut, UserRegistered.
6. Typowe błędy przy pracy z klasami sealed
Błąd nr 1: zapomniano wskazać wszystkich potomków w permits.
Jeśli nie wymienisz wszystkich potrzebnych potomków przez permits, kompilator od razu to zgłosi. Na przykład jeśli napisałeś permits Circle, Rectangle, ale zapomniałeś o Square, a taka klasa istnieje — otrzymasz błąd.
Błąd nr 2: potomek nie jest final, sealed ani non-sealed.
Jeśli potomek nie jest zadeklarowany z wymaganym modyfikatorem, kompilator zgłosi błąd: „Class must be either final, sealed or non-sealed”.
Błąd nr 3: potomek jest zadeklarowany w innym pliku i niewidoczny dla kompilatora.
Wszystkie klasy wymienione w permits muszą być dostępne dla kompilatora — albo w tym samym pliku, albo w tym samym pakiecie.
Błąd nr 4: nie obsłużono wszystkich wariantów w switch.
Jeśli używasz klasy sealed w switch z pattern matching i zapomnisz obsłużyć jakiś wariant, kompilator nie pozwoli zbudować projektu. To dobrze — nie pominiesz żadnego przypadku.
Błąd nr 5: próba dziedziczenia po klasie sealed spoza permits.
Jeśli spróbujesz stworzyć klasę‑potomka, nie wskazaną w permits, otrzymasz błąd: „is not allowed to extend sealed class”.
Błąd nr 6: próba użycia klas sealed na starszych wersjach JDK.
Klasy sealed pojawiły się dopiero w Java 17. Jeśli spróbujesz użyć ich w starszej wersji, dostaniesz błąd kompilacji albo w ogóle nie zbudujesz projektu.
GO TO FULL VERSION