1. Синтаксис sealed‑класів: як це виглядає
Давайте почнемо з класичної проблеми ООП у Java: відкрита ієрархія наслідування. У звичайній Java будь-хто може наслідувати ваш клас, якщо тільки він не оголошений як final. Це зручно, але іноді призводить до неочікуваностей — наприклад, ви не можете заздалегідь знати, які саме класи будуть нащадками вашого класу, а отже, не можете гарантувати, що опрацювали всі варіанти в switch або if-else.
У результаті, коли ви пишете обробку об’єктів за типом (наприклад, через pattern matching у switch), ви змушені додавати гілку default «про всяк випадок»: а раптом хтось десь створив новий підклас?
Sealed‑класи розв’язують цю проблему: вони дозволяють явно обмежити список класів‑нащадків. Це робить ієрархію закритою та контрольованою, а ваш код — більш передбачуваним і безпечним.
Основний синтаксис
Sealed‑класи з’явилися в Java 17. Вони оголошуються за допомогою модифікатора sealed, а список дозволених нащадків указується через ключове слово permits:
public sealed class Shape permits Circle, Rectangle, Square {
// Загальна поведінка для всіх фігур
}
Тут ми оголосили клас Shape, і тільки класи Circle, Rectangle і Square можуть бути його прямими нащадками. Ніхто інший не зможе розширити Shape — компілятор не дозволить.
Важливо: Усі класи, зазначені в permits, мають бути оголошені в тому ж файлі або бути видимими для компілятора (зазвичай у тому ж пакеті). До речі, якщо всі нащадки оголошені в одному файлі з sealed‑класом, permits можна взагалі опустити — компілятор сам усе зрозуміє.
Приклад:
// Усе в одному файлі — permits не обов'язковий
public sealed class Shape {
}
final class Circle extends Shape {}
final class Rectangle extends Shape {}
Вимоги до нащадків
Кожен із нащадків зобов’язаний явно визначити свій статус:
- бути final (забороняє подальше наслідування),
- або бути sealed (і далі обмежувати наслідування),
- або бути non-sealed (дозволяє наслідування, знімає обмеження).
Приклад:
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 — остаточний, далі наслідувати не можна.
- Rectangle — також sealed, дозволяє лише двом підкласам.
- Square — non-sealed, будь‑хто може розширити.
Мінімальний приклад
public sealed class Animal permits Dog, Cat {}
public final class Dog extends Animal {}
public final class Cat extends Animal {}
Спробуйте оголосити новий клас public class Wolf extends Animal {} — отримаєте помилку компіляції:
class Wolf is not allowed to extend sealed class Animal
2. Застосування sealed‑класів: де й навіщо використовувати
Pattern matching і switch
Sealed‑класи особливо добре поєднуються з pattern matching у switch. Якщо компілятор знає всі можливі підкласи, він може переконатися, що ви опрацювали кожен варіант, і навіть не вимагатиме гілки 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("Ура!");
switch (result) {
case Success s -> System.out.println("Успіх: " + s.data);
case Error e -> System.out.println("Помилка: " + e.message);
}
}
}
Компілятор знає, що інших варіантів у Result бути не може, і не вимагає гілки default. Якщо ви забудете опрацювати один із варіантів, компілятор одразу вам нагадає.
Зверніть увагу: починаючи з Java 21, якщо у switch не всі варіанти опрацьовані, ви отримаєте помилку компіляції. У більш ранніх версіях (17–20) може знадобитися гілка default, але IDE все одно попередить про неповне покриття.
Безпека та контроль
Sealed‑класи дозволяють розробнику повністю контролювати ієрархію. Це особливо важливо для доменних моделей, де набір варіантів має бути фіксованим (наприклад, стан замовлення: New, Paid, Cancelled).
Спрощення підтримки та розвитку коду
Коли ви знаєте всі варіанти нащадків, простіше додавати нові функції, підтримувати код і проводити рефакторинг. IDE також знатиме про всі варіанти й зможе допомагати з автодоповненням і аналізом.
3. Практичні приклади використання sealed‑класів
Приклад 1: Геометричні фігури
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; }
}
Тепер можна безпечно використовувати switch з pattern matching:
Shape shape = new Circle(5);
switch (shape) {
case Circle c -> System.out.println("Коло радіусом " + c.radius);
case Rectangle r -> System.out.println("Прямокутник " + r.width + "x" + r.height);
case Square s -> System.out.println("Квадрат зі стороною " + s.side);
}
Приклад 2: Фінансові транзакції
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;
}
}
Тепер в обробнику можна бути певним, що ви не забули жоден тип транзакції:
Transaction tx = new Transfer(100, "ACC123");
switch (tx) {
case Deposit d -> System.out.println("Депозит: " + d.amount);
case Withdraw w -> System.out.println("Зняття: " + w.amount);
case Transfer t -> System.out.println("Переказ: " + t.amount + " на " + t.toAccount);
}
Приклад 3: Обмежена ієрархія з 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 {}
// Тепер хто завгодно може наслідувати SmsNotification
public class ViberNotification extends SmsNotification {}
4. Особливості, обмеження та нюанси sealed‑класів
Вимоги до модифікаторів
- Sealed‑клас зобов’язаний явно вказати всіх нащадків через permits.
- Усі нащадки мають бути або final, або sealed, або non-sealed.
- Нащадки мають бути оголошені або в тому ж файлі, або бути видимими для компілятора.
Абстрактні sealed‑класи
Sealed‑клас може бути й абстрактним, і interface, і звичайним класом. Наприклад:
public sealed abstract class Expr permits Const, Add, Mul {}
Сумісність з іншими модифікаторами
- Не можна оголосити sealed‑клас як final або non-sealed.
- interface теж може бути sealed (і це дуже зручно!).
Використання з record‑класами
Record‑класи можуть бути нащадками sealed‑класу, якщо вони оголошені як final (за замовчуванням record завжди 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. Корисні нюанси
Sealed‑класи та pattern matching: як це пов’язано
Головна перевага sealed‑класів — вичерпний pattern matching. Звучить складно, але працює просто. Компілятор знає всі можливі варіанти, і ви можете сміливо писати switch без гілки default:
Expr expr = ...;
switch (expr) {
case Const c -> ...
case Add a -> ...
case Mul m -> ...
}
Якщо ви раптом не опрацюєте якийсь варіант, компілятор не дасть зібрати проєкт — це дуже зручно та безпечно.
Застосування в реальних завданнях
Де sealed‑класи справді корисні? Скрізь, де у вас є фіксований набір варіантів:
- Результат операції: Success, Error (як у прикладі вище).
- Стан замовлення: New, Paid, Cancelled.
- Абстрактні синтаксичні дерева (AST) для парсерів.
- Відповіді API: Ok, NotFound, Error.
- Події в системі: UserLoggedIn, UserLoggedOut, UserRegistered.
6. Типові помилки під час роботи із sealed‑класами
Помилка № 1: забули вказати всіх нащадків у permits.
Якщо ви не перелічили всіх потрібних нащадків через permits, компілятор одразу поскаржиться. Наприклад, якщо ви написали permits Circle, Rectangle, але забули Square, а такий клас є — отримаєте помилку.
Помилка № 2: нащадок не final, sealed або non-sealed.
Якщо нащадок не оголошений із потрібним модифікатором, компілятор видасть помилку: «Class must be either final, sealed or non-sealed».
Помилка № 3: нащадок оголошений в іншому файлі й не видимий компілятору.
Усі класи, перелічені в permits, мають бути доступні компілятору — або в тому ж файлі, або в тому ж пакеті.
Помилка № 4: не опрацювали всі варіанти у switch.
Якщо ви використовуєте sealed‑клас у switch із pattern matching і забули опрацювати якийсь варіант, компілятор не дасть зібрати проєкт. Це добре — ви не пропустите жодного випадку.
Помилка № 5: спроба наслідувати sealed‑клас не з permits.
Якщо ви спробуєте створити клас‑нащадок, не вказаний у permits, отримаєте помилку: «is not allowed to extend sealed class».
Помилка № 6: спроба використовувати sealed‑класи на старих версіях JDK.
Sealed‑класи з’явилися лише в Java 17. Якщо ви спробуєте використовувати їх у старішій версії, отримаєте помилку компіляції або взагалі не зможете зібрати проєкт.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ