Перейти к основному содержимому

Запечатанные классы и интерфейсы в Java 15

· 6 мин. чтения

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. Ограничения

Запечатанный класс накладывает три важных ограничения на разрешенные подклассы:

  1. Все разрешенные подклассы должны принадлежать тому же модулю, что и запечатанный класс.
  2. Каждый разрешенный подкласс должен явно расширять запечатанный класс.
  3. Каждый разрешенный подкласс должен определять модификатор: 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 .