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

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


Когда в язык Java были введены sealed классы и интерфейсы (термин “sealed” в различных источниках может переводиться как “запечатанные”, “закрытые” или “изолированные”, либо не переводиться вовсе — прим. пер.), это стало существенным шагом в сторону улучшения безопасности типов и предсказуемости кода. Эти возможности языка предоставляют мощный механизм для ограничения иерархий наследования и для гарантии того, что только явно определенные подклассы или имплементации могли расширять или имплементировать заданный класс или интерфейс. Sealed классы и интерфейсы помогают создать более устойчивую, легко поддерживаемую и понятную кодовую базу, ограничивая возможное множество типов, которые могут наследоваться от них.

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

1. Понимание sealed классов

Что такое sealed классы?

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

Как создать sealed класс

Чтобы создать sealed класс, мы используем ключевое слово sealed, за которым следует ключевое слово permits для задания списка разрешенных подклассов:

sealed class Shape permits Circle, Rectangle, Triangle {
    // ...
}

В этом примере только Circle, Rectangle, и Triangle могут расширять класс Shape.

Non-sealed и final классы

Чтобы прояснить ситуацию, упомянем два других типа классов, относящихся к наследованию:

  • Non-sealed классы: это тип класса, используемый по умолчанию, любой класс может расширять их.

  • Final классы: эти классы вообще нельзя расширять.

Зачем использовать sealed классы?

Sealed классы имеют несколько преимуществ:

  • Предсказуемость: когда мы точно знаем, какие классы могут расширять sealed класс, код становится проще понимать и поддерживать.

  • Безопасность типов: ограничения на возможные подтипы помогает предотвращать неожиданное поведение и ошибки.

  • Паттерны проектирования: sealed классы могут эффективно использоваться для реализации паттернов проектирования, таких как State (состояние) или Visitor (посетитель).

Пример: иерархия класса Shape 

Для иллюстрации сказанного давайте создадим простую иерархию, используя sealed классы:

sealed class Shape permits Circle, Rectangle, Triangle {
    abstract double area();
}
 
final class Circle extends Shape {
    // ...
}
 
final class Rectangle extends Shape {
    // ...
}
 
final class Triangle extends Shape {
    // ...
}

В этом примере класс Shape является sealed, и только Circle, Rectangle, и Triangle могут наследовать от него. Это гарантирует нам, что любая соответствующая форме предмета переменная shape, с которой мы можем столкнуться, будет принадлежать только к одному из трех типов.

2. Случаи использования sealed классов

Моделирование конечных состояний

Одно из наиболее распространенных применений для sealed классов — это представление конечного множества состояний. Например:  

  • Состояния UI: загрузка, ошибка, успех, бездействие.

  • Состояния сетевого запроса: ожидает, в процессе, успех, неудача.

  • Состояния игры: играет, на паузе, окончена.

Используя sealed класс, мы можем гарантировать, что все возможные состояния определены в явном виде и корректно обрабатываются, предотвращая ошибки в runtime:

sealed class UIState {
    data class Loading(val isLoading: Boolean) : UIState()
    data class Error(val message: String) : UIState()
    data class Success(val data: Any) : UIState()
    object Idle : UIState()
}
Версия для Java от команды Spring АйО
import lombok.Getter;
import lombok.RequiredArgsConstructor;

public sealed class UIState permits UIState.Loading, UIState.Error, UIState.Success, UIState.Idle {

    @Getter
    @RequiredArgsConstructor
    public static final class Loading extends UIState {
        private final boolean isLoading;
    }

    @Getter
    @RequiredArgsConstructor
    public static final class Error extends UIState {
        private final String message;
    }

    @Getter
    @RequiredArgsConstructor
    public static final class Success extends UIState {
        private final Object data;
    }

    public static final class Idle extends UIState {
        private Idle() {}

        public static final Idle INSTANCE = new Idle();
    }
}

Представление алгебраических типов данных (Algebraic Data Types - ADTs)

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

sealed class Maybe<T> {
    data class Just(val value: T) : Maybe<T>()
    object Nothing : Maybe<Nothing>()
}
Версия для Java от команды Spring АйО
public sealed class Maybe<T> permits Maybe.Just, Maybe.Nothing {

    @Getter
    @RequiredArgsConstructor
    public static final class Just<T> extends Maybe<T> {
        private final T value;
    }

    public static final class Nothing<T> extends Maybe<T> {
        private Nothing() {}

        private static final Nothing<?> INSTANCE = new Nothing();

        public static <T> Nothing<T> get() {
            return (Nothing<T>) Nothing.INSTANCE;
        }
    }
}

Реализация паттернов проектирования 

Sealed классы могут использоваться для реализации паттернов проектирования, таких как “Состояние” (State), в которых состояние объекта может меняться с течением времени.  Различные состояния могут быть представлены в виде подклассов sealed класса.  

Расширение сопоставления паттернов 

Sealed классы идеально работают с сопоставлением паттернов, позволяя вам полностью проверить все возможные случаи:

fun processUIState(uiState: UIState) {
    when (uiState) {
        is UIState.Loading -> showLoadingIndicator()
        is UIState.Error -> showErrorMessage(uiState.message)
        is UIState.Success -> showData(uiState.data)
        is UIState.Idle -> doNothing()
    }
}
Версия для Java от команды Spring АйО
public void processUIState(UIState uiState) {
    switch (uiState) {
        case UIState.Loading loading -> showLoadingIndicator();
        case UIState.Error error -> showErrorMessage(error.getMessage());
        case UIState.Success success -> showData(success.getData());
        case UIState.Idle idle -> doNothing();
        default -> throw new IllegalStateException("Unexpected value: " + uiState);
    }
}

Ограничение наследования

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

3. Сравнение с традиционным наследованием

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

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

Ключевые различия:

  • Открытые / закрытые: традиционное наследование является незамкнутым, в то время как sealed классы закрыты и ограничивают количество потенциальных подклассов.

  • Предсказуемость: sealed классы предлагают нам больше предсказуемости в том, что касается иерархии классов и поведения. 

  • Безопасность типов: sealed классы могут повысить безопасность типов, ограничивая набор возможных типов во время компиляции. 

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

4. Преимущества и недостатки sealed классов 

Внизу представлена таблица, сравнивающая sealed классы и традиционное наследование, за которой следует объяснение преимуществ и недостатков sealed классов.

Показатель

Традиционное наследование

Sealed классы

Расширяемость

Незамкнутая

Закрытая (ограниченный набор подклассов)

Предсказуемость

Ниже 

Выше

Безопасность типов

Умеренная

Выше

Поддерживаемость

Умеренная

Выше

Сопоставление паттернов

Менее эффективное

Более эффективное

Обеспечение соблюдения спроектированной архитектуры

Ограниченное

Более сильное

Преимущества sealed классов

  • Повышенная предсказуемость кода: ограничивая набор возможных подклассов, sealed классы делают код более предсказуемым и упрощают его анализ. Разработчики могут с уверенностью предполагать, что инстансы sealed классов могут принадлежать только к специфическому набору типов. 

  • Улучшенная безопасность типов: sealed классы вносят свой вклад в повышение безопасности типов, предотвращая появление подклассов, которые могут нарушать инварианты класса. Это может помочь поймать потенциальные ошибки во время компиляции. 

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

  • Облегчают сопоставление паттернов: sealed классы идеально работают с сопоставлением паттернов, позволяя совершать полные проверки и писать более чистый код. 

  • Обеспечивают соблюдение решений по спроектированной архитектуре: sealed классы могут использоваться для обеспечения специфических паттернов проектирования и ограничений, предотвращая неправильное использование иерархии классов.

Недостатки sealed классов

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

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

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

Еще один недостаток по мнению сообщества

Редакция Spring АйО упоминает тот факт, что sealed классы провоцируют разработчиков на использование instanceof-проверок. Например, у некоторого класса X наследник может A или B. В случае необходимости расширения класса, происходит явное нарушение Open/Closed principle. Данный код является по своей сути бомбой замедленного действия, так как при расширении данный код: 

if (o instanceof A) {
// logic for A
} else {
// logic for B
}

перестает работать. Митигировать можно использованием конструкции switch-case, однако факт остается фактом.

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

Дополнение от сообщества

5. Best practices и на что обратить внимание

Sealed классы предлагают значительные преимущества, но их эффективное использование требует внимательного рассмотрения всех факторов. Далее приведено обобщение ключевых best practices и отдельных моментов, которые могут потребовать дополнительного рассмотрения:

Практика / повод к рассмотрению

Описание

Идентифицируйте подходящие случаи использования

Используйте sealed классы, когда у вас есть хорошо определенный конечный набор подклассов, и вы хотите гарантированно обеспечить строгую иерархию. 

Ищите баланс между гибкостью и контролем

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

Подумайте о влиянии на производительность

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

Используйте сопоставление шаблонов

Комбинируйте sealed классы с сопоставлением паттернов для полных проверок и улучшения читаемости. 

Придерживайтесь соглашений по именованию

Используйте понятные и описательные имена для sealed классов и их разрешенных подклассов.

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

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

Оцените поддержку в рамках инструмента

Убедитесь, что ваша IDE и другие инструменты в должной мере поддерживают sealed классы.

Регулярно проверяйте код

Периодически перепроверяйте, как вы используете sealed классы, чтобы убедиться в том, что они все еще соответствуют вашим запланированным целям. 

Дополнительные поводы для рассмотрения

  • Избегайте чрезмерного использования подклассов: в то время как sealed классы позволяют использовать более одного подкласса, наличие чрезмерного их количества может привести к невозможности контролировать иерархию, как планировалось изначально.

  • Подумайте об альтернативах: в некоторых случаях перечисления (enums) или интерфейсы могут быть более подходящим выбором, чем sealed классы.

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

6. Выводы

Sealed классы в Java предлагают мощный механизм для контроля наследования и улучшения надежности кода. Ограничивая набор разрешенных подклассов, они улучшают предсказуемость кода, безопасность типов и поддерживаемость. В то время как они вводят дополнительное усложнение в код, из преимущества часто перевешивают недостатки. Тщательное рассмотрение случаев использования, потенциальные компромиссы и использование  best practices чрезвычайно важно для эффективного использования sealed классов. За счет понимания их сильных сторон и ограничений, разработчики могут принимать взвешенные решения по поводу того, где и как внедрять такие классы в их кодовую базу.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь

Комментарии (5)


  1. valery1707
    20.08.2024 20:22
    +5

    Да, Lombok это часто удобно, но реализовать UIState удобнее на рекордах:

    public sealed interface UIState permits UIState.Loading, UIState.Error, UIState.Idle, UIState.Success {
        record Loading(boolean isLoading) implements UIState {}
        record Error(String message) implements UIState {}
        record Success(Object data) implements UIState {}
        enum Idle implements UIState {INSTANCE}
    }
    

    и вот пример работы с ним:

    public void processUIState(UIState uiState) {
        switch (uiState) {
            case UIState.Loading loading -> System.out.println("loading: " + loading.isLoading());
            case UIState.Error error -> System.out.println("error: " + error.message());
            case UIState.Success success -> System.out.println("success: " + success.data());
            case UIState.Idle __ -> System.out.println("idle");
            default -> throw new IllegalStateException("Unexpected value: " + uiState);
        }
    }
    

    Даже с типизированным Maybe фактически нет проблем, причём за счёт использования enum создать ещё один инстанс Nothing даже через рефлексию будет проблематично.

    public sealed interface Maybe<T> permits Maybe.Just, Maybe.Nothing {
    
        @Nonnull
        @SuppressWarnings("unchecked")
        static <T> Maybe<T> maybe(@Nullable T value) {
            return value == null ? (Maybe<T>) Nothing.INSTANCE : new Just<>(value);
        }
    
        record Just<T>(T value) implements Maybe<T> {}
        enum Nothing implements Maybe<Object> {INSTANCE}
    
    }
    


    1. spring_aio Автор
      20.08.2024 20:22
      +1

      Отличное дополнение, спасибо!


  1. valery1707
    20.08.2024 20:22
    +2

    Интересно что структура sealed классов не обязана быть полностью закрытой так как один из разрешённых вариантов может иметь модификатор non-sealed и от него можно будет наследоваться.
    Может быть полезным для библиотек и прочих мест где нужно всё же дать возможность неконтролируемо расширять иерархию классов.

    public sealed interface Demo permits Demo.Sealed1, Demo.Sealed2, Demo.Open {
        // Эти классы входят в закрытую часть структуры
        final class Sealed1 implements Demo {}
        final class Sealed2 implements Demo {}
        non-sealed class Open implements Demo {}
    
        // Это пример структуры, которая уже не контролируется ограничениями по `sealed`
        class External extends Open {}
    }
    


  1. Klopper
    20.08.2024 20:22

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


    1. valery1707
      20.08.2024 20:22

      Я так понимаю что наличие информации о всех наследниках в родителе это не побочная проблема, а одна из целей функционала.
      А ещё такая зависимость есть в enum-ах, причём даже более жёсткая: родительский класс не только знает все его реализации, но они ещё и объявлены должны быть строго в пределах родительского класса.
      Тогда как наследники sealed-класса могут быть объявлены где угодно (но в пределах видимости родительского - так как в нём необходимо указать потомка). Ну и с учётом возможности дать возможность расширять иерархию классов через non-sealed потомка это вообще не выглядит не преодолимой проблемой.
      Другое дело что не все классы имеет смысл объявлять sealed.