В чем смысл "запечатанного интерфейса" в Java?

Когда недавно вышла Java 15, я читал о новых функциях и поддерживаемых ею API. Java 15 имеет "запечатанные классы" и "запечатанные интерфейсы" (JEP 360: запечатанные классы (предварительная версия)) в качестве предварительной версии.

Они предоставили классические примеры, такие как Форма -> Круг, Прямоугольник и т. Д.

Я понимаю запечатанные классы: приведенный пример оператора switch мне понятен. Но герметичные интерфейсы для меня загадка. Любой класс, реализующий интерфейс, вынужден предоставлять для них определения. Интерфейсы не ставят под угрозу целостность реализации, потому что интерфейс сам по себе не имеет состояния. Неважно, хотел ли я ограничить реализацию несколькими выбранными классами.

Не могли бы вы рассказать мне, как правильно использовать запечатанные интерфейсы в Java 15+?

7 ответов

Решение

Хотя интерфейсы сами по себе не имеют состояния, они имеют доступ к состоянию, например, через геттеры, и могут иметь код, который что-то делает с этим состоянием через default методы.

Следовательно, аргументация в поддержку sealed for классы также могут применяться к интерфейсам.

В основном, чтобы дать запечатанную иерархию, когда нет конкретного состояния, которое можно было бы разделить между разными членами. В этом основное различие между реализацией интерфейса и расширением класса - интерфейсы не имеют собственных полей или конструкторов.

Но в каком-то смысле это не важный вопрос. Настоящая проблема в том, зачем вам начинать с запечатанной иерархии. Как только это будет установлено, должно быть яснее, где подходят герметичные интерфейсы.

(заранее извиняюсь за надуманность примеров и многословие)

1. Использовать подклассы без "проектирования для подкласса".

Допустим, у вас есть такой класс, и он находится в библиотеке, которую вы уже опубликовали.

public final class Airport {
    private List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    public void bookPeople(String... names) {
        for (String name : names) {
            this.bookPerson(name);
        }
    }

    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}

Теперь вы хотите добавить новую версию в свою библиотеку, которая будет распечатывать имена людей, забронированных по мере их бронирования. Для этого есть несколько возможных путей.

Если вы проектировали с нуля, вы могли бы разумно заменить Airport класс с Airport интерфейс и дизайн PrintingAirport сочинять с BasicAirport вот так.

public interface Airport {
    void bookPerson(String name);

    void bookPeople(String... names);

    int peopleBooked();
}
public final class BasicAirport implements Airport {
    private final List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    @Override
    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    @Override
    public void bookPeople(String... names) {
        for (String name : names) {
            this.bookPerson(name);
        }
    }

    @Override
    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}
public final class PrintingAirport implements Airport {
    private final Airport delegateTo;

    public PrintingAirport(Airport delegateTo) {
        this.delegateTo = delegateTo;
    }

    @Override
    public void bookPerson(String name) {
        System.out.println(name);
        this.delegateTo.bookPerson(name);
    }

    @Override
    public void bookPeople(String... names) {
        for (String name : names) {
            System.out.println(name);
        }

        this.delegateTo.bookPeople(names);
    }

    @Override
    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}

Однако в нашей гипотезе это невозможно, потому что Airportкласс уже существует. Будут звонки new Airport() и методы, которые ожидают чего-то типа Airport в частности, это невозможно сохранить обратно совместимым способом, если мы не используем наследование.

Итак, чтобы сделать это до java 15, вы должны удалить final из вашего класса и напишите подкласс.

public class Airport {
    private List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    public void bookPeople(String... names) {
        for (String name : names) {
            this.bookPerson(name);
        }
    }

    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}
public final class PrintingAirport extends Airport {
    @Override
    public void bookPerson(String name) {
        System.out.println(name);
        super.bookPerson(name);
    }
}

В этот момент мы сталкиваемся с одной из самых основных проблем с наследованием - существует множество способов "нарушить инкапсуляцию". Поскольку bookPeople метод в Airport случается звонить this.bookPerson внутри наш PrintingAirport класс работает так, как задумано, потому что его новый bookPerson метод будет вызываться один раз для каждого человека.

Но если Airport класс были изменены на этот,

public class Airport {
    private List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    public void bookPeople(String... names) {
        for (String name : names) {
            this.peopleBooked.add(name);
        }
    }

    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}

затем PrintingAirport подкласс не будет вести себя правильно, если он также не переопределен bookPeople. Сделайте обратное изменение, и оно не будет вести себя правильно, если оно не отменяет bookPeople.

Это не конец света или что-то в этом роде, это просто то, что нужно рассмотреть и задокументировать - "как вы расширяете этот класс и что вам разрешено переопределять", но когда у вас есть открытый класс, открытый для любого расширения можно продлить.

Если вы пропустите документирование того, как создать подкласс, или недостаточно документируете, это легко может оказаться в ситуации, когда код, который вы не контролируете, который использует вашу библиотеку или модуль, может зависеть от небольшой детали суперкласса, с которым вы сейчас застряли.

Запечатанные классы позволяют сделать шаг вперед, открыв суперкласс до расширения только для тех классов, которые вам нужны.

public sealed class Airport permits PrintingAirport {
    // ...
}

И теперь вам не нужно ничего документировать внешним потребителям, только себя.

Так как же к этому подходят интерфейсы? Что ж, допустим, вы думали наперед и у вас есть система, в которой вы добавляете функции через композицию.

public interface Airport {
    // ...
}
public final class BasicAirport implements Airport {
   // ...
}
public final class PrintingAirport implements Airport {
    // ...
}

Возможно, вы не уверены, что не хотите использовать наследование позже, чтобы сохранить некоторое дублирование между классами, но поскольку ваш интерфейс аэропорта является общедоступным, вам нужно будет сделать некоторые промежуточные abstract class или что-то подобное.

Вы можете занять оборонительную позицию и сказать: "Знаете что, пока я не буду лучше представлять себе, где я хочу, чтобы этот API шел, я буду единственным, кто сможет реализовать интерфейс".

public sealed interface Airport permits BasicAirport, PrintingAirport {
    // ...
}
public final class BasicAirport implements Airport {
   // ...
}
public final class PrintingAirport implements Airport {
    // ...
}

2. Для представления "кейсов" данных, имеющих различную форму.

Допустим, вы отправили запрос веб-службе, и она вернет одно из двух значений в формате JSON.

{
    "color": "red",
    "scaryness": 10,
    "boldness": 5
}
{
    "color": "blue",
    "favorite_god": "Poseidon"
}

Конечно, несколько надумано, но вы можете легко представить себе поле "типа" или подобное, которое определяет, какие другие поля будут присутствовать.

Поскольку это Java, нам нужно отобразить необработанное нетипизированное представление JSON в классы. Давайте разыграем эту ситуацию.

Один из способов - создать один класс, содержащий все возможные поля, и только некоторые из них. null в зависимости.

public enum SillyColor {
    RED, BLUE
}
public final class SillyResponse {
    private final SillyColor color;
    private final Integer scaryness;
    private final Integer boldness;
    private final String favoriteGod;

    private SillyResponse(
        SillyColor color,
        Integer scaryness,
        Integer boldness,
        String favoriteGod
    ) {
        this.color = color;
        this.scaryness = scaryness;
        this.boldness = boldness;
        this.favoriteGod = favoriteGod;
    }

    public static SillyResponse red(int scaryness, int boldness) {
        return new SillyResponse(SillyColor.RED, scaryness, boldness, null);
    }

    public static SillyResponse blue(String favoriteGod) {
        return new SillyResponse(SillyColor.BLUE, null, null, favoriteGod);
    }

    // accessors, toString, equals, hashCode
}

Хотя технически это работает, так как содержит все данные, с точки зрения безопасности на уровне типов это не так много. Любой код, который получает SillyResponse нужно знать, чтобы проверить color перед доступом к другим свойствам объекта, и ему нужно знать, какие из них можно безопасно получить.

По крайней мере, мы можем сделать colorперечисление вместо строки, так что код не должен обрабатывать любые другие цвета, но это все еще далеко не идеально. Чем сложнее или многочисленнее становятся различные случаи, тем хуже становится.

В идеале мы хотим иметь какой-то общий супертип для всех кейсов, которые вы можете включить.

Поскольку его больше не нужно включать, color свойство не будет строго необходимым, но в зависимости от личного вкуса вы можете оставить его как нечто доступное в интерфейсе.

public interface SillyResponse {
    SillyColor color();
}

Теперь два подкласса будут иметь разные наборы методов, и код, который получает любой из них, может использовать instanceof чтобы выяснить, какие у них есть.

public final class Red implements SillyResponse {
    private final int scaryness;
    private final int boldness;

    @Override
    public SillyColor color() {
        return SillyColor.RED;
    }

    // constructor, accessors, toString, equals, hashCode
}
public final class Blue implements SillyResponse {
    private final String favoriteGod;

    @Override
    public SillyColor color() {
        return SillyColor.BLUE;
    }

    // constructor, accessors, toString, equals, hashCode
}

Проблема в том, что, потому что SillyResponse это общедоступный интерфейс, его может реализовать любой и Red и Blue не обязательно единственные подклассы, которые могут существовать.

if (resp instanceof Red) {
    // ... access things only on red ...
}
else if (resp instanceof Blue) {
    // ... access things only on blue ...
}
else {
    throw new RuntimeException("oh no");
}

Это означает, что такой случай "о нет" может произойти всегда.

Замечание: до java 15, чтобы исправить это, люди использовали шаблон "типизированный посетитель". Я рекомендую не изучать это для вашего здравомыслия, но если вам интересно, вы можете взглянуть на код, который генерирует ANTLR - все это большая иерархия структур данных различной "формы".

Запечатанные классы позволяют вам сказать: "Эй, это единственные важные случаи".

public sealed interface SillyResponse permits Red, Blue {
    SillyColor color();
}

И даже если у кейсов нет общих методов, интерфейс может работать так же хорошо, как "тип маркера", и по-прежнему давать вам тип, который нужно написать, когда вы ожидаете один из вариантов.

public sealed interface SillyResponse permits Red, Blue {
}

В этот момент вы можете начать видеть сходство с перечислениями.

public enum Color { Red, Blue }

enums говорят, что "эти два экземпляра - единственные две возможности". У них может быть несколько методов и полей.

public enum Color { 
    Red("red"), 
    Blue("blue");

    private final String name;

    private Color(String name) {
        this.name = name;
    }

    public String name() {
        return this.name;
    }
}

Но все экземпляры должны иметь одинаковые методы и одинаковые поля, и эти значения должны быть константами. В запечатанной иерархии вы получаете ту же гарантию "это только два случая", но разные случаи могут иметь непостоянные данные и разные данные друг от друга - если это имеет смысл.

Весь шаблон "запечатанный интерфейс + 2 или более классов записи" довольно близок к тому, что подразумевается конструкциями, такими как перечисления rust.

Это в равной мере применимо и к общим объектам, которые имеют разные "формы" поведения, но не имеют собственной точки маркера.

3. Чтобы заставить инвариант

Есть некоторые инварианты, такие как неизменяемость, которые невозможно гарантировать, если вы разрешаете подклассы.

// All apples should be immutable!
public interface Apple {
    String color();
}
public class GrannySmith implements Apple {
    public String color; // granny, no!

    public String color() {
        return this.color;
    }
}

И эти инварианты можно использовать позже в коде, например, при передаче объекта другому потоку или тому подобное. Запечатанная иерархия означает, что вы можете документировать и гарантировать более сильные инварианты, чем если бы вы разрешили произвольное создание подклассов.

Чтобы закрыть

Запечатанные интерфейсы более или менее служат той же цели, что и запечатанные классы, вы просто используете конкретное наследование только тогда, когда хотите поделиться реализацией между классами, которая выходит за рамки того, что может дать что-то вроде методов по умолчанию.

Предположим, вы пишете библиотеку аутентификации, содержащую интерфейс для кодирования пароля, т.е. char[] encryptPassword(char[] pw). В вашей библиотеке есть несколько реализаций, из которых пользователь может выбрать.

Вы не хотите, чтобы он мог передать свою собственную реализацию, которая может быть небезопасной.

Не могли бы вы рассказать мне, как правильно использовать запечатанные интерфейсы в Java 15+?

Я написал экспериментальный код и вспомогательный блог, чтобы проиллюстрировать, как можно использовать запечатанные интерфейсы для реализации ImmutableCollectionиерархия интерфейсов для Java, обеспечивающая договорную, структурную и проверяемую неизменность. Я думаю, это может быть практический пример использования закрытых интерфейсов.

Пример включает четыре sealed интерфейсы: ImmutableCollection, ImmutableSet, ImmutableList и ImmutableBag. ImmutableCollection расширяется ImmutableList/Set/Bag. Каждый из листовых интерфейсов permitsдве заключительные конкретные реализации. В этом блоге описывается цель дизайна, заключающаяся в ограничении интерфейсов, чтобы разработчики не могли реализовать "неизменяемые" интерфейсы и предоставлять реализации, которые являются изменяемыми.

Примечание: я являюсь участником коллекций Eclipse.

Поскольку Java представила записи в версии 14, одним из вариантов использования запечатанных интерфейсов, безусловно, будет создание запечатанных записей. Это невозможно с запечатанными классами, потому что записи не могут расширять класс (как и перечисления).

Интерфейсы не всегда полностью определяются только их API. Взять, например, ProtocolFamily. Этот интерфейс было бы легко реализовать, учитывая его методы, но результат не был бы полезен с точки зрения предполагаемой семантики, поскольку все методы, принимающие ProtocolFamilyпоскольку ввод просто бросит UnsupportedOperationException, в лучшем случае.

Это типичный пример интерфейса, который был бы запечатан, если бы эта функция существовала в более ранних версиях; интерфейс предназначен для абстрагирования реализаций, экспортируемых библиотекой, но не для реализации вне этой библиотеки.

Более новый тип ConstantDesc упоминает это намерение даже прямо:

Неплатформенные классы не должны реализовывать ConstantDesc прямо. Вместо этого они должны продлить DynamicConstantDesc

Примечание API:

В будущем, если это позволит язык Java, ConstantDesc может стать запечатанным интерфейсом, который запрещает создание подклассов, кроме явно разрешенных типов.

Что касается возможных вариантов использования, нет никакой разницы между запечатанным абстрактным классом и запечатанным интерфейсом, но запечатанный интерфейс по-прежнему позволяет разработчикам расширять различные классы (в пределах, установленных автором). Или реализуется enum типы.

Короче говоря, иногда интерфейсы используются для наименьшей связи между библиотекой и ее клиентами, без намерения иметь ее реализации на стороне клиента.

До Java 15 разработчики думали, что целью является повторное использование кода. Но это не всегда верно, в некоторых случаях нам нужна широкая доступность, но не расширяемость для повышения безопасности, а также управления кодовой базой.

Эта функция предназначена для включения более тонкого контроля наследования в Java. Запечатывание позволяет классам и интерфейсам определять свои разрешенные подтипы.

Запечатанный интерфейс позволяет нам четко понимать все классы, которые могут его реализовать.

Другие вопросы по тегам