Наверное, не я один после прочтения документации о sealed классах подумал: «Ладно. Может быть это когда-нибудь пригодится». Позже, когда в работе я встретил пару задач, где удалось успешно применить этот инструмент, я подумал: «Недурно. Стоит чаще задумываться о применении». И, наконец, я наткнулся на описание класса задач в книге Effective Java (Joshua Bloch, 3rd) (да-да, в книге о Java).
Давайте рассмотрим один из вариантов применения и оценим его с точки зрения семантики и производительности.
Думаю, все, кто работал с UI, когда-то встречали реализации взаимодействия UI с сервисом через некие состояния, где одним из атрибутов был какой-то маркер типа. Механика обработки очередного состояния в таких реализациях, обычно, напрямую зависит от указанного маркера. Например такая реализация класса State:
Обработка нового состояния может выглядеть так:
Обратите внимание, для состояний типа ERROR и DATA компилятор не в состоянии определить безопасность использования атрибутов, поэтому пользователю приходятся писать избыточный код. Изменения в семантике можно будет выявить только во время исполнения.
Несложным рефакторингом, мы можем разбить наш State на группу классов:
На стороне пользователя, мы получим обработку состояний, где доступность атрибутов будет определяться на уровне языка, а неверное использование будет порождать ошибки на этапе компиляции:
Так как в экземплярах присутствуют только значимые атрибуты можно говорить об экономии памяти и, что немаловажно, об улучшении семантики. Пользователям sealed классов не нужно вручную реализовывать правила работы с атрибутами в зависимости от маркера типа, доступность атрибутов обеспечивается разделением на типы.
Java разработчики, кто пробовал Kotlin, наверняка заглядывали в декомпилированный код, чтобы посмотреть, на что похожи Kotlin выражения в терминах Java. Выражение с when будет выглядеть примерно так:
Ветвления с изобилием instanceof могут насторожить из-за стереотипов о «признаке плохого кода» и «влиянии на производительность», но нам ни к чему догадки. Нужно каким-то образом сравнить скорость выполнения, например, с помощью jmh.
На основе статьи «Измеряем скорость кода Java правильно» был подготовлен тест обработки четырёх состояний (LOADING, ERROR, EMPTY, DATA), вот его результаты:
Видно, что sealed реализация работает ≈25% медленнее (было предположение, что отставание не превысит 10-15%).
Если на четырёх типах мы имеем отставание на четверть, с увеличением типов (количество проверок instanceof) отставание должно только расти. Для проверки увеличим количество типов до 16 (предположим, что нас угораздило обзавестись настолько широкой иерархией):
Вместе со снижением производительности возросло отставание sealed реализации до ≈35% — чуда не произошло.
В этой статье мы не открыли Америку и sealed реализации в ветвлениях на instanceof действительно работают медленнее сравнения ссылок.
Тем не менее нужно озвучить пару мыслей:
Давайте рассмотрим один из вариантов применения и оценим его с точки зрения семантики и производительности.
Думаю, все, кто работал с 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» книги. Предлагаю ознакомиться и с ней.
- Расход памяти на атрибуты, которые инициализируются только для определённых типов. Фактор может быть значимым на больших объёмах. Ситуация усугубляется, если для заполнения атрибутов будут создаваться объекты по умолчанию.
- Излишняя семантическая нагрузка. Пользователю класса нужно следить за тем, для какого типа, какие атрибуты доступны.
- Усложнённая поддержка в классах с бизнес логикой. Предположим реализацию, где объект может осуществлять какие-то операции над своими данными. Такой класс будет выглядеть как комбайн, а добавление нового типа или операции может стать затруднительным.
Обработка нового состояния может выглядеть так:
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
Несложным рефакторингом, мы можем разбить наш 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 в пользу более низкоуровневых языков