1. Обзор
В выпуске Java SE 15
представлены запечатанные классы ( JEP 360 ) в качестве функции предварительного просмотра.
Эта функция предназначена для включения более тонкого контроля наследования в Java. Запечатывание позволяет классам и интерфейсам определять свои разрешенные подтипы.
Другими словами, класс или интерфейс теперь могут определять, какие классы могут его реализовывать или расширять. Это полезная функция для моделирования предметной области и повышения безопасности библиотек.
2. Мотивация
Иерархия классов позволяет нам повторно использовать код посредством наследования. Однако иерархия классов может иметь и другие цели. Повторное использование кода — это здорово, но не всегда является нашей основной целью.
2.1. Возможности моделирования
Альтернативной целью иерархии классов может быть моделирование различных возможностей, существующих в предметной области.
В качестве примера представьте бизнес-домен, который работает только с легковыми и грузовыми автомобилями, а не с мотоциклами. При создании абстрактного класса Vehicle
в Java мы должны разрешить расширять его только классам Car
и Truck .
Таким образом, мы хотим гарантировать, что в нашем домене не будет злоупотреблений абстрактным классом Vehicle .
В этом примере нас больше интересует ясность кода, обрабатывающего известные подклассы, чем защита от всех неизвестных подклассов .
До версии 15 в Java предполагалось, что повторное использование кода всегда является целью. Каждый класс можно было расширить любым количеством подклассов.
2.2. Индивидуальный подход к пакету
В более ранних версиях Java предоставляла ограниченные возможности в области управления наследованием.
Конечный класс не может иметь подклассов. Частный пакетный класс может иметь подклассы только в одном пакете.
Используя закрытый пакетный подход, пользователи не могут получить доступ к абстрактному классу, не разрешив им также расширять его:
public class Vehicles {
abstract static class Vehicle {
private final String registrationNumber;
public Vehicle(String registrationNumber) {
this.registrationNumber = registrationNumber;
}
public String getRegistrationNumber() {
return registrationNumber;
}
}
public static final class Car extends Vehicle {
private final int numberOfSeats;
public Car(int numberOfSeats, String registrationNumber) {
super(registrationNumber);
this.numberOfSeats = numberOfSeats;
}
public int getNumberOfSeats() {
return numberOfSeats;
}
}
public static final class Truck extends Vehicle {
private final int loadCapacity;
public Truck(int loadCapacity, String registrationNumber) {
super(registrationNumber);
this.loadCapacity = loadCapacity;
}
public int getLoadCapacity() {
return loadCapacity;
}
}
}
2.3. Доступный суперкласс, не расширяемый
Суперкласс, разработанный с набором его подклассов, должен иметь возможность документировать его предполагаемое использование, а не ограничивать его подклассы. Кроме того, наличие ограниченных подклассов не должно ограничивать доступность его суперкласса.
Таким образом, основная мотивация запечатанных классов состоит в том, чтобы иметь возможность для суперкласса быть широко доступным, но не широко расширяемым.
3. Создание
Запечатанная функция вводит пару новых модификаторов и предложений в Java: запечатанный, не запечатанный
и разрешает
.
3.1. Герметичные интерфейсы
Чтобы запечатать
интерфейс, мы можем применить модификатор seal к его объявлению. Затем в предложении разрешения
указываются классы, которым разрешено реализовывать запечатанный интерфейс:
public sealed interface Service permits Car, Truck {
int getMaxServiceIntervalInMonths();
default int getMaxDistanceBetweenServicesInKilometers() {
return 100000;
}
}
3.2. Запечатанные классы
Как и в случае с интерфейсами, мы можем запечатывать классы, применяя тот же самый запечатанный
модификатор. Предложение разрешения
должно быть определено после любого предложения расширения
или реализации
:
public abstract sealed class Vehicle permits Car, Truck {
protected final String registrationNumber;
public Vehicle(String registrationNumber) {
this.registrationNumber = registrationNumber;
}
public String getRegistrationNumber() {
return registrationNumber;
}
}
Разрешенный подкласс должен определять модификатор. Его можно объявить окончательным
, чтобы предотвратить дальнейшие расширения:
public final class Truck extends Vehicle implements Service {
private final int loadCapacity;
public Truck(int loadCapacity, String registrationNumber) {
super(registrationNumber);
this.loadCapacity = loadCapacity;
}
public int getLoadCapacity() {
return loadCapacity;
}
@Override
public int getMaxServiceIntervalInMonths() {
return 18;
}
}
Разрешенный подкласс также может быть объявлен запечатанным
. Однако, если мы объявим его незапечатанным,
то он открыт для расширения:
public non-sealed class Car extends Vehicle implements Service {
private final int numberOfSeats;
public Car(int numberOfSeats, String registrationNumber) {
super(registrationNumber);
this.numberOfSeats = numberOfSeats;
}
public int getNumberOfSeats() {
return numberOfSeats;
}
@Override
public int getMaxServiceIntervalInMonths() {
return 12;
}
}
3.4. Ограничения
Запечатанный класс накладывает три важных ограничения на разрешенные подклассы:
- Все разрешенные подклассы должны принадлежать тому же модулю, что и запечатанный класс.
- Каждый разрешенный подкласс должен явно расширять запечатанный класс.
- Каждый разрешенный подкласс должен определять модификатор:
final
,sealed
илиnon-sealed.
4. Использование
4.1. Традиционный путь
Запечатывая класс, мы позволяем клиентскому коду ясно рассуждать обо всех разрешенных подклассах.
Традиционный способ рассуждать о подклассе — использовать набор операторов if-else
и проверок instanceof :
if (vehicle instanceof Car) {
return ((Car) vehicle).getNumberOfSeats();
} else if (vehicle instanceof Truck) {
return ((Truck) vehicle).getLoadCapacity();
} else {
throw new RuntimeException("Unknown instance of Vehicle");
}
4.2. Сопоставление с образцом
Применяя сопоставление с образцом , мы можем избежать дополнительного приведения класса, но нам по-прежнему нужен набор операторов i f-else
:
if (vehicle instanceof Car car) {
return car.getNumberOfSeats();
} else if (vehicle instanceof Truck truck) {
return truck.getLoadCapacity();
} else {
throw new RuntimeException("Unknown instance of Vehicle");
}
Использование i f-else
затрудняет для компилятора определение охвата всех разрешенных подклассов. По этой причине мы выбрасываем исключение RuntimeException
.
В будущих версиях Java клиентский код сможет использовать оператор switch
вместо i f-else
( JEP 375 ).
Используя шаблоны проверки типов , компилятор сможет проверить, охватывается ли каждый разрешенный подкласс. Таким образом, больше не будет необходимости в предложении/кейсе по умолчанию .
4. Совместимость
Давайте теперь посмотрим на совместимость запечатанных классов с другими функциями языка Java, такими как записи и API отражения.
4.1. Рекорды
Запечатанные классы очень хорошо работают с записями . Поскольку записи неявно являются окончательными, запечатанная иерархия еще более лаконична. Попробуем переписать наш пример класса с использованием записей:
public sealed interface Vehicle permits Car, Truck {
String getRegistrationNumber();
}
public record Car(int numberOfSeats, String registrationNumber) implements Vehicle {
@Override
public String getRegistrationNumber() {
return registrationNumber;
}
public int getNumberOfSeats() {
return numberOfSeats;
}
}
public record Truck(int loadCapacity, String registrationNumber) implements Vehicle {
@Override
public String getRegistrationNumber() {
return registrationNumber;
}
public int getLoadCapacity() {
return loadCapacity;
}
}
4.2. Отражение
Запечатанные классы также поддерживаются API отражения , где к java.lang.Class
добавлены два общедоступных метода : ``
- Метод
isSealed
возвращает значениеtrue
, если данный класс или интерфейс запечатаны. - Метод
AllowSubclasses
возвращает массив объектов, представляющих все разрешенные подклассы.
Мы можем использовать эти методы для создания утверждений, основанных на нашем примере:
Assertions.assertThat(truck.getClass().isSealed()).isEqualTo(false);
Assertions.assertThat(truck.getClass().getSuperclass().isSealed()).isEqualTo(true);
Assertions.assertThat(truck.getClass().getSuperclass().permittedSubclasses())
.contains(ClassDesc.of(truck.getClass().getCanonicalName()));
5. Вывод
В этой статье мы рассмотрели запечатанные классы и интерфейсы — предварительную функцию в Java SE 15. Мы рассмотрели создание и использование запечатанных классов и интерфейсов, а также их ограничения и совместимость с другими функциями языка.
В примерах мы рассмотрели создание запечатанного интерфейса и запечатанного класса, использование запечатанного класса (с сопоставлением с образцом и без него) и совместимость запечатанных классов с записями и API отражения.
Как всегда, полный исходный код доступен на GitHub .