Наверное, не я один после прочтения документации о sealed классах подумал: «Ладно. Может быть это когда-нибудь пригодится». Позже, когда в работе я встретил пару задач, где удалось успешно применить этот инструмент, я подумал: «Недурно. Стоит чаще задумываться о применении». И, наконец, я наткнулся на описание класса задач в книге Effective Java (Joshua Bloch, 3rd) (да-да, в книге о Java).

Давайте рассмотрим один из вариантов применения и оценим его с точки зрения семантики и производительности.


Думаю, все, кто работал с UI, когда-то встречали реализации взаимодействия UI с сервисом через некие состояния, где одним из атрибутов был какой-то маркер типа. Механика обработки очередного состояния в таких реализациях, обычно, напрямую зависит от указанного маркера. Например такая реализация класса State:

class State(
        val type: Type,
        val data: String?,
        val error: Throwable?
) {
    enum class Type { LOADING, ERROR, EMPTY, DATA }
}

Перечислим недостатки такой реализации (попробуйте самостоятельно)
Замечания из главы 23 «Prefer class hierarchies to tagged classes» книги. Предлагаю ознакомиться и с ней.

  1. Расход памяти на атрибуты, которые инициализируются только для определённых типов. Фактор может быть значимым на больших объёмах. Ситуация усугубляется, если для заполнения атрибутов будут создаваться объекты по умолчанию.
  2. Излишняя семантическая нагрузка. Пользователю класса нужно следить за тем, для какого типа, какие атрибуты доступны.
  3. Усложнённая поддержка в классах с бизнес логикой. Предположим реализацию, где объект может осуществлять какие-то операции над своими данными. Такой класс будет выглядеть как комбайн, а добавление нового типа или операции может стать затруднительным.


Обработка нового состояния может выглядеть так:

fun handleState(state: State) {
    when(state.type) {
        State.Type.LOADING -> onLoading()
        State.Type.ERROR -> state.error?.run(::onError) 
                ?: throw AssertionError("Unexpected error state: $state")
        State.Type.EMPTY -> onEmpty()
        State.Type.DATA -> state.data?.run(::onData)
                ?: throw AssertionError("Unexpected data state: $state")
    }
}

fun onLoading() {}
fun onError(error: Throwable) {}
fun onEmpty() {}
fun onData(data: String) {}

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

Sealed class


image

Несложным рефакторингом, мы можем разбить наш State на группу классов:

sealed class State

// Состояние загрузки является stateless объектом - можно оформить в виде singleton
object Loading : State()

data class Error(val error: Throwable) : State()

// Отсутствие данных, равно как и состояние загрузки, является stateless объектом - тоже singleton
object Empty : State()

data class Data(val data: String) : State()

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

fun handleState(state: State) {
    when(state) {
        Loading -> onLoading()
        is Error -> onError(state.error)
        Empty -> onEmpty()
        is Data -> onData(state.data)
    }
}

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

Бесплатно ли всё это?


Спойлер
Нет, не бесплатно.

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

public static final void handleState(@NotNull State state) {
    Intrinsics.checkParameterIsNotNull(state, "state");
    if (Intrinsics.areEqual(state, Loading.INSTANCE)) {
        onLoading();
    } else if (state instanceof Error) {
        onError(((Error)state).getError());
    } else if (Intrinsics.areEqual(state, Empty.INSTANCE)) {
        onEmpty();
    } else if (state instanceof Data) {
        onData(((Data)state).getData());
    }
}

Ветвления с изобилием instanceof могут насторожить из-за стереотипов о «признаке плохого кода» и «влиянии на производительность», но нам ни к чему догадки. Нужно каким-то образом сравнить скорость выполнения, например, с помощью jmh.

На основе статьи «Измеряем скорость кода Java правильно» был подготовлен тест обработки четырёх состояний (LOADING, ERROR, EMPTY, DATA), вот его результаты:

Benchmark                        Mode     Cnt   Score         Error       Units
CompareSealedVsTagged.sealed    thrpt     500    940739,966 ±  5350,341   ops/s
CompareSealedVsTagged.tagged    thrpt     500   1281274,381 ± 10675,956   ops/s

Видно, что sealed реализация работает ≈25% медленнее (было предположение, что отставание не превысит 10-15%).

Если на четырёх типах мы имеем отставание на четверть, с увеличением типов (количество проверок instanceof) отставание должно только расти. Для проверки увеличим количество типов до 16 (предположим, что нас угораздило обзавестись настолько широкой иерархией):

Benchmark                        Mode     Cnt   Score        Error      Units
CompareSealedVsTagged.sealed    thrpt     500   149493,062 ±  622,313   ops/s
CompareSealedVsTagged.tagged    thrpt     500   235024,737 ± 3372,754   ops/s

Вместе со снижением производительности возросло отставание sealed реализации до ≈35% — чуда не произошло.

Заключение


В этой статье мы не открыли Америку и sealed реализации в ветвлениях на instanceof действительно работают медленнее сравнения ссылок.


Тем не менее нужно озвучить пару мыслей:

  • в большинстве случаев нам хочется работать с хорошей семантикой кода, ускорять разработку за счёт дополнительных подсказок от IDE и проверок компилятора — в таких случаях можно использовать sealed классы
  • если в задаче нельзя жертвовать производительностью, стоит пренебречь sealed реализацией и заменить её, например, на tagget реализацию. Возможно, стоит вовсе отказаться от kotlin в пользу более низкоуровневых языков

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


  1. Bolloky
    31.12.2019 10:56
    +1

    Спасибо, Сергей. Люблю короткие полезные статьи, которые покрывают ровно одну ситуацию.