Изолированные классы и интерфейсы

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

Примечание. Прямые наследники — это классы, которые непосредственно наследуют свой суперкласс.

Непрямые наследники — это классы, которые находятся ниже суперкласса более чем на один уровень наследования.

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

Изолированные классы лучше всего использовать в следующих случаях:

  • Нужно ограниченное наследование классов: у вас есть заранее определённый конечный набор наследников, расширяющих класс, и все они известны во время компиляции.
  • Требуется типобезопасный дизайн: в проекте важны безопасность и сопоставление с образцом, особенно при управлении состоянием или обработке сложной условной логики. Пример см. в разделе Использование изолированных классов с выражениями when.
  • Вы работаете с закрытыми API: вам нужны надёжные и поддерживаемые публичные API библиотек, которые гарантируют, что сторонние клиенты используют эти API по назначению.

Более подробные практические примеры см. в разделе Сценарии использования.

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

Объявление изолированного класса или интерфейса

Чтобы объявить изолированный класс или интерфейс, используйте модификатор sealed.

// Создание изолированного интерфейса
sealed interface Error

// Создание изолированного класса, реализующего изолированный интерфейс Error
sealed class IOError(): Error

// Определение наследников, расширяющих изолированный класс 'IOError'
class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

// Создание объекта-синглтона, реализующего изолированный интерфейс 'Error'
object RuntimeError : Error

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

Иерархия из примера выглядит так:

Иллюстрация иерархии изолированных классов и интерфейсов

Конструкторы

Сам изолированный класс всегда является абстрактным классом, поэтому его нельзя создать напрямую. Однако он может содержать или наследовать конструкторы. Эти конструкторы предназначены не для создания экземпляров самого изолированного класса, а для его наследников. Рассмотрим следующий пример с изолированным классом Error и несколькими его наследниками, экземпляры которых мы создаём:

sealed class Error(val message: String) {
    class NetworkError : Error("Network failure")
    class DatabaseError : Error("Database cannot be reached")
    class UnknownError : Error("An unknown error has occurred")
}

fun main() {
    val errors = listOf(Error.NetworkError(), Error.DatabaseError(), Error.UnknownError())
    errors.forEach { println(it.message) }
}
// Network failure
// Database cannot be reached
// An unknown error has occurred

{kotlin-runnable=“true” kotlin-min-compiler-version=“1.5”}

Внутри изолированных классов можно использовать классы enum, чтобы представлять состояния с помощью enum-констант и добавлять дополнительные сведения. Каждая enum-константа существует только как единственный экземпляр, тогда как у наследников изолированного класса может быть несколько экземпляров. В примере sealed class Error вместе со своими наследниками использует enum для обозначения серьёзности ошибки. Конструктор каждого наследника инициализирует severity и может менять своё состояние:

enum class ErrorSeverity { MINOR, MAJOR, CRITICAL }

sealed class Error(val severity: ErrorSeverity) {
    class FileReadError(val file: File): Error(ErrorSeverity.MAJOR)
    class DatabaseError(val source: DataSource): Error(ErrorSeverity.CRITICAL)
    object RuntimeError : Error(ErrorSeverity.CRITICAL)
    // Здесь можно добавить другие типы ошибок
}

Конструкторы изолированных классов могут иметь одну из двух видимостей: protected (по умолчанию) или private.

sealed class IOError {
    // Конструктор изолированного класса по умолчанию имеет видимость protected.
    // Он виден внутри этого класса и его наследников.
    constructor() { /*...*/ }

    // Приватный конструктор виден только внутри этого класса.
    // Приватный конструктор в изолированном классе позволяет ещё строже контролировать создание экземпляров
    // и выполнять специальные процедуры инициализации внутри класса.
    private constructor(description: String): this() { /*...*/ }

    // Это вызовет ошибку, потому что public- и internal-конструкторы в изолированных классах не допускаются.
    // public constructor(code: Int): this() {}
}

Наследование

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

Наследники изолированных классов должны иметь корректное квалифицированное имя. Они не могут быть локальными или анонимными объектами.

Примечание. Классы enum не могут расширять изолированный класс или любой другой класс. Однако они могут реализовывать изолированные интерфейсы:

> sealed interface Error
>
> // enum-класс расширяет изолированный интерфейс Error
> enum class ErrorType : Error {
>     FILE_ERROR, DATABASE_ERROR
> }
> ```

<!-- These restrictions don't apply to indirect subclasses. If a direct subclass of a sealed class is not marked as sealed,
it can be extended in any way that its modifiers allow: -->
Эти ограничения не применяются к непрямым наследникам. Если прямой наследник изолированного класса не помечен как
`sealed`, его можно расширять любым способом, который допускают его модификаторы.

```kotlin
// Изолированный интерфейс 'Error' имеет реализации только в том же пакете и модуле
sealed interface Error

// Изолированный класс 'IOError' расширяет 'Error', и его можно расширять только в том же пакете
sealed class IOError(): Error

// Открытый класс 'CustomError' расширяет 'Error', и его можно расширять везде, где он виден
open class CustomError(): Error

Наследование в мультиплатформенных проектах

В мультиплатформенных проектах действует ещё одно ограничение наследования: прямые наследники изолированных классов должны находиться в том же исходном наборе. Это применимо к изолированным классам без модификаторов expect и actual.

Если изолированный класс объявлен как expect в общем исходном наборе и имеет реализации actual в платформенных исходных наборах, и версия expect, и версии actual могут иметь наследников в своих исходных наборах. Более того, если вы используете иерархическую структуру, вы можете создавать наследников в любом исходном наборе между объявлениями expect и actual.

Подробнее об иерархической структуре мультиплатформенных проектов.

Использование изолированных классов с выражениями when

Ключевое преимущество изолированных классов проявляется при использовании их в выражении when. Выражение when, используемое с изолированным классом, позволяет компилятору Kotlin исчерпывающе проверить, что покрыты все возможные случаи. В таких случаях не нужно добавлять ветку else:

// Изолированный класс и его наследники
sealed class Error {
    class FileReadError(val file: String): Error()
    class DatabaseError(val source: String): Error()
    object RuntimeError : Error()
}

//sampleStart
// Функция для логирования ошибок
fun log(e: Error) = when(e) {
    is Error.FileReadError -> println("Error while reading file ${e.file}")
    is Error.DatabaseError -> println("Error while reading from database ${e.source}")
    Error.RuntimeError -> println("Runtime error")
    // Ветка `else` не нужна, потому что покрыты все случаи
}
//sampleEnd

// Список всех ошибок
fun main() {
    val errors = listOf(
        Error.FileReadError("example.txt"),
        Error.DatabaseError("usersDatabase"),
        Error.RuntimeError
    )

    errors.forEach { log(it) }
}

{kotlin-runnable=“true” kotlin-min-compiler-version=“1.5”}

Совет. Чтобы сократить повторения в выражениях when, попробуйте контекстно-зависимое разрешение (сейчас доступно в предварительном режиме). Эта возможность позволяет опускать имя типа при сопоставлении членов изолированного класса, если ожидаемый тип известен.

Подробнее см. в разделе Preview of context-sensitive resolution или в соответствующем KEEP-предложении.

При использовании изолированных классов с выражениями when также можно добавлять guard conditions, чтобы включать дополнительные проверки в одну ветку. Подробнее см. в разделе Guard conditions в выражениях when.

Примечание. В мультиплатформенных проектах, если в общем коде у вас есть выражение when для изолированного класса, объявленного как ожидаемое объявление, ветка else всё равно нужна. Причина в том, что наследники платформенных реализаций actual могут расширять изолированные классы, которые неизвестны в общем коде.

Сценарии использования

Рассмотрим несколько практических сценариев, в которых изолированные классы и интерфейсы особенно полезны.

Управление состоянием в UI-приложениях

Изолированные классы можно использовать для представления разных состояний пользовательского интерфейса в приложении. Такой подход позволяет структурированно и безопасно обрабатывать изменения UI. В этом примере показано, как управлять разными состояниями UI:

sealed class UIState {
    data object Loading : UIState()
    data class Success(val data: String) : UIState()
    data class Error(val exception: Exception) : UIState()
}

fun updateUI(state: UIState) {
    when (state) {
        is UIState.Loading -> showLoadingIndicator()
        is UIState.Success -> showData(state.data)
        is UIState.Error -> showError(state.exception)
    }
}

Обработка способов оплаты

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

sealed class Payment {
    data class CreditCard(val number: String, val expiryDate: String) : Payment()
    data class PayPal(val email: String) : Payment()
    data object Cash : Payment()
}

fun processPayment(payment: Payment) {
    when (payment) {
        is Payment.CreditCard -> processCreditCardPayment(payment.number, payment.expiryDate)
        is Payment.PayPal -> processPayPalPayment(payment.email)
        is Payment.Cash -> processCashPayment()
    }
}

Payment — это изолированный класс, который представляет разные способы оплаты в системе электронной коммерции: CreditCard, PayPal и Cash. У каждого наследника могут быть свои свойства, например number и expiryDate у CreditCard или email у PayPal.

Функция processPayment() показывает, как обрабатывать разные способы оплаты. Этот подход гарантирует, что учтены все возможные типы платежей, а система остаётся достаточно гибкой, чтобы в будущем добавлять новые способы оплаты.

Обработка запросов и ответов API

Изолированные классы и изолированные интерфейсы можно использовать для реализации системы аутентификации пользователей, которая обрабатывает API-запросы и ответы. В системе аутентификации пользователей есть функции входа и выхода. Изолированный интерфейс ApiRequest определяет конкретные типы запросов: LoginRequest для входа и LogoutRequest для операций выхода. Изолированный класс ApiResponse инкапсулирует разные сценарии ответа: UserSuccess с данными пользователя, UserNotFound для отсутствующих пользователей и Error для любых сбоев. Функция handleRequest типобезопасно обрабатывает эти запросы с помощью выражения when, а getUserById имитирует получение пользователя:

// Импорт необходимых модулей
import io.ktor.server.application.*
import io.ktor.server.resources.*

import kotlinx.serialization.*

// Определение изолированного интерфейса для API-запросов с использованием ресурсов Ktor
@Resource("api")
sealed interface ApiRequest

@Serializable
@Resource("login")
data class LoginRequest(val username: String, val password: String) : ApiRequest


@Serializable
@Resource("logout")
object LogoutRequest : ApiRequest

// Определение изолированного класса ApiResponse с подробными типами ответов
sealed class ApiResponse {
    data class UserSuccess(val user: UserData) : ApiResponse()
    data object UserNotFound : ApiResponse()
    data class Error(val message: String) : ApiResponse()
}

// Класс данных пользователя для успешного ответа
data class UserData(val userId: String, val name: String, val email: String)

// Функция для проверки учётных данных пользователя (для демонстрации)
fun isValidUser(username: String, password: String): Boolean {
    // Некоторая логика проверки (это только заглушка)
    return username == "validUser" && password == "validPass"
}

// Функция для обработки API-запросов с подробными ответами
fun handleRequest(request: ApiRequest): ApiResponse {
    return when (request) {
        is LoginRequest -> {
            if (isValidUser(request.username, request.password)) {
                ApiResponse.UserSuccess(UserData("userId", "userName", "userEmail"))
            } else {
                ApiResponse.Error("Invalid username or password")
            }
        }
        is LogoutRequest -> {
            // В этом примере предполагаем, что операция выхода всегда успешна
            ApiResponse.UserSuccess(UserData("userId", "userName", "userEmail")) // Для демонстрации
        }
    }
}

// Функция, имитирующая вызов getUserById
fun getUserById(userId: String): ApiResponse {
    return if (userId == "validUserId") {
        ApiResponse.UserSuccess(UserData("validUserId", "John Doe", "[email protected]"))
    } else {
        ApiResponse.UserNotFound
    }
    // Обработка ошибок также привела бы к ответу Error.
}

// Главная функция для демонстрации использования
fun main() {
    val loginResponse = handleRequest(LoginRequest("user", "pass"))
    println(loginResponse)

    val logoutResponse = handleRequest(LogoutRequest)
    println(logoutResponse)

    val userResponse = getUserById("validUserId")
    println(userResponse)

    val userNotFoundResponse = getUserById("invalidId")
    println(userNotFoundResponse)
}