Kotlin

Часто нам приходится представлять ограниченный набор возможностей: веб-запрос либо успешно выполняется, либо не выполняется, User может быть либо про-пользователем, либо обычным.

Чтобы смоделировать это, мы могли бы использовать enum, но это несет в себе ряд ограничений. Классы Enum допускают только один экземпляр каждого значения и не могут кодировать дополнительную информацию о каждом типе, например случай Error, имеющий соответствующее свойство Exception.

Вы можете использовать абстрактный класс и ряд расширений, но при этом теряется преимущество ограниченного набора типов, добавляемое перечислениями. Запечатанные классы берут лучшее из обоих миров: свободу представления абстрактных классов и ограниченный набор типов перечислений. Читайте дальше, чтобы узнать больше о запечатанных классах, или, если вы предпочитаете видео, посмотрите его здесь (англ):

Основы запечатанных классов

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

// Result.kt

sealed class Result<out T : Any> {
    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

Попытка расширить запечатанный класс за пределы файла, в котором он был определен, приводит к ошибке компиляции:

Cannot access ‘<init>’: it is private in Result

Забываешь про ветку?

Часто мы хотим обрабатывать все возможные типы:

when(result) {
    is Result.Success -> { }
    is Result.Error -> { }
}

Но что делать, если кто-то добавляет новый тип Result: InProgress:

sealed class Result<out T : Any> {
    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
    object InProgress : Result<Nothing>()
}

Вместо того, чтобы полагаться на память или поиск средствами IDE, для гарантии того, что все использования when обрабатывают новый класс, компилятор может выдать нам ошибку, если ветвь не покрыта. when, как и оператор if, требует от нас лишь охватить все варианты (т.е. быть исчерпывающими), создавая ошибку компилятора, когда он используется в качестве выражения:

val action = when(result) {
    is Result.Success -> { }
    is Result.Error -> { }
}

Выражение when должно быть исчерпывающим, поэтому добавьте необходимую ветвь “is InProgress” или какую-либо другую. Чтобы получить это замечательное преимущество, даже если мы используем when в качестве оператора, добавьте следующее свойство вспомогательного расширения:

val <T> T.exhaustive: T
    get() = this

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

when(result){
    is Result.Success -> { }
    is Result.Error -> { }
}.exhaustive

Автозаполнение IDE

Поскольку известны все подтипы запечатанного класса, IDE может заполнить все возможные ветви оператора when за нас:

Эта функция действительно очень полезна при работе с более сложными иерархиями запечатанных классов, поскольку IDE может распознавать все ветви:

sealed class Result<out T : Any> {
  data class Success<out T : Any>(val data: T) : Result<T>()
  sealed class Error(val exception: Exception) : 
Result<Nothing>() {
     class RecoverableError(exception: Exception) :
 Error(exception)
     class NonRecoverableError(exception: Exception) : 

 Error(exception)
  }
  object InProgress : Result<Nothing>()
}

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

Под капотом

Так что же заставляет запечатанные классы вести себя именно так? Давайте посмотрим, что происходит в декомпилированном коде Java:

sealed class Result
data class Success(val data: Any) : Result()
data class Error(val exception: Exception) : Result()

@Metadata(
…
d2 = {“Lio/testapp/Result;”, “T”, “”, “()V”, “Error”, 
“Success”, “Lio/testapp/Result$Success;”, “Lio/testapp/Result$Error;” …}
)

public abstract class Result {
    private Result() {}
    // $FF: синтетический метод
    public Result(DefaultConstructorMarker 
$constructor_marker) {
        this();
    }
}

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

Result реализован в виде абстрактного класса с двумя конструкторами:

  • Приватный конструктор по умолчанию
  • Синтетический конструктор, который может использоваться только компилятором Kotlin

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

public final class Success extends Result {
    @NotNull
    private final Object data
    
    public Success(@NotNull Object data) {
      
       super((DefaultConstructorMarker)null);
       this.data = data;
    }

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

Читайте также:


Перевод статьи Florina Muntenescu: Sealed with a class

Предыдущая статьяКак пройти собеседование по проектированию систем?
Следующая статьяОбработка ошибок API в веб-приложении, используя Axios