Хабрахабр

Sealed classes. Semantics vs performance

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

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

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

class State( val type: Type, val data: String?, val error: Throwable?
)
}

Перечислим недостатки такой реализации (попробуйте самостоятельно)

Предлагаю ознакомиться и с ней. Замечания из главы 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%).

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

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 в пользу более низкоуровневых языков
Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»