Как стать автором
Обновить
638.74
OTUS
Цифровые навыки от ведущих экспертов

Value-классы в Kotlin: коротко

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров561

Привет, Хабр!

Сегодня рассмотрим@JvmInline value class в Kotlin. Это не просто очередной синтаксический сахар, а инструмент, который реально влияет на производительность, API-дизайн, надёжность, сериализацию и даже структуру многомодульных систем.

Что такое @JvmInline value class и зачем он нужен

Классический кейс: у тебя есть userId: String, groupId: String, email: String, всё через typealias. А потом кто-то передал groupId туда, где ждали userId, и компилятор сказал: «всё ок». Runtime — нет. Ошибка уходит в прод, ловится через неделю.

Value-классы создают новый тип на уровне компиляции, но не создают новый объект в рантайме (если повезёт). Это zero-cost type-safe обёртка вокруг примитива или reference-типа.

@JvmInline
value class UserId(val raw: String)

Как это работает: boxing, байткод, оптимизация

На JVM value-классы — не полноценные объекты. Их можно представить как typedef, но с возможностью встраивания inline. Всё зависит от контекста.

fun acceptInline(x: UserId) {}       // без обёртки
fun <T> acceptGeneric(x: T) {}        // с обёрткой
fun acceptAny(x: Any) {}              // с обёрткой
fun acceptNullable(x: UserId?) {}     // с обёрткой

Проверим через javap:

// Kotlin
@JvmInline
value class InlineInt(val value: Int)

fun take(i: InlineInt) { println(i.value) }

// javap -c
public final void take(int); // то есть параметр — обычный int

А теперь nullable:

fun takeNullable(i: InlineInt?) { println(i?.value) }

// javap -c
public final void takeNullable(kotlin.jvm.internal.InlineClass); // обёртка

Т.е. InlineInt? превращается в объект, аналогично Integer вместо int.

Benchmark: стоит ли оно своих затрат?

Заменили String-based идентификаторы на value-классы. Ожидали ноль разницы. Получили интересное.

JMH:

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
open class ValueClassBenchmark {

    @JvmInline
    value class MyInlineInt(val value: Int)

    data class MyDataInt(val value: Int)

    private val inlineList = List(1000000) { MyInlineInt(it) }
    private val dataList = List(1000000) { MyDataInt(it) }

    @Benchmark
    fun sumInline(): Int = inlineList.sumOf { it.value }

    @Benchmark
    fun sumData(): Int = dataList.sumOf { it.value }
}

Результат:

sumInline()    ≈ 50-80% быстрее
sumData()      стабильно медленнее, из-за аллокаций

Если value-класс не упаковывается — выигрыша нет.

AuthService в микросервисной архитектуре

Микросервис auth-api предоставляет публичный POST /tokens и внутренний POST /validate. В старом коде:

@PostMapping("/validate")
fun validate(@RequestParam token: String) { ... }

Случайно туда кто-то передал session-id вместо access-token — и ушло в лог с 401.

Решение:

@JvmInline
value class AccessToken(val raw: String)

@PostMapping("/validate")
fun validate(@RequestParam token: AccessToken) { ... }

@Component
class AccessTokenConverter : Converter<String, AccessToken> {
    override fun convert(source: String): AccessToken = AccessToken(source)
}

Теперь в сигнатуре явно указан тип. Ошибка при использовании невалидного токена будет на этапе компиляции.

Value-классы и сериализация: проблемы и костыли

Jackson, Moshi, kotlinx.serialization — работают с value-классами не из коробки.

kotlinx.serialization

@JvmInline
@Serializable
value class Email(val raw: String)

Работает только с последними версиями плагина. Иначе получите IllegalArgumentException: Unsupported class kind.

Решение — использовать кастомный KSerializer.

object EmailSerializer : KSerializer<Email> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Email", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Email) = encoder.encodeString(value.raw)
    override fun deserialize(decoder: Decoder): Email = Email(decoder.decodeString())
}

Jackson

Jackson видит value-класс как объект и не знает, что делать. Без адаптера будет:

{"raw":"a@b.com"}

А ожидалось просто "a@b.com".

Решение — @JsonValue + кастомный Module.

DI: Dagger, Koin, Hilt

Value-классы не работают как бины напрямую. Их нельзя внедрить через @Inject — особенно если они private. В Dagger/Guice приходится использовать обёртки или @Provides вручную:

@Module
class TokenModule {
    @Provides
    fun provideAccessToken(): AccessToken = AccessToken("debug-token")
}

Kotlin Multiplatform: подвохи

На JVM value class компилируется в инлайн-обёртку — это работает неплохо, предсказуемо и с минимальными накладными расходами. На Kotlin/Native — наоборот: это полноценный объект с аллокацией в памяти. Но больше всего вопросов вызывает Kotlin/JS.

В Kotlin/JS @JvmInline работает иначе: value-классы на уровне JavaScript — это просто обычные объекты с полем, то есть никакого инлайна там нет. Пример:

@JvmInline
value class Email(val raw: String)

fun main() {
    val e = Email("a@b.com")
    println(js("typeof e")) // object

Формально value-класс существует, но на JavaScript стороне он выглядит как обычный { raw: string }, что ломает ожидания, если ты рассчитываешь на zero-cost-абстракцию. Все фичи инлайна теряются.

Кроме того: сериализация через kotlinx.serialization на JS требует отдельного actual-адаптера; equals() и hashCode() работают неинтуитивно, так как на уровне JS нет строгости типов; DI и runtime-интеграции сложнее, потому что value class не имеет настоящего type identity.

Несмотря на формальную multiplatform-поддержку, value class пока не готов для shared-кода в expect/actual. Если ты строишь real-world MPP-проект, особенно с общей сериализацией и бизнес-логикой, — безопаснее пока использовать обычные inline-функции или обёртки data class.

Ограничения

Value-классы в Kotlin имеет ряд жёстких ограничений: допускается только один val-параметр в primary-конструкторе, запрещены var, lateinit, делегированные свойства (by). Нельзя наследоваться от классов (только интерфейсы), использовать sealed, abstract, а также рассчитывать на полноценную работу рефлексии — KClass и generic-анализ работают частично или вовсе ломаются. Кроме того, nullable и generic-контексты приводят к boxing — инлайн-преимущества теряются.

Интеграция со сторонними инструментами тоже не беспроблемна: DI-фреймворки (Koin, Hilt, Dagger) не умеют автоматически инжектить value-классы, а сериализация требует ручных адаптеров — будь то @JsonValue и Module в Jackson, или KSerializer в kotlinx.serialization. В мультиплатформенных проектах ситуация усложняется ещё сильнее: реализация value class отличается на JVM, Native и JS, поэтому поведение может быть непредсказуемым.


Итог

Value-классы — хороший способ навести порядок в типах без лишнего рантайм-мусора. Отлично заходят для ID, email, токенов — всё, что раньше было просто String, но по смыслу давно просилось стать отдельным типом.

Но использовать их вслепую — плохая идея. Nullable, generic, DI, сериализация — всё это ломает zero-cost концепцию. Нужно профилировать, тестить, адаптировать.

А вы как? Уже пробовали value-классы на проектах?

Подробнее про них можно прочесть здесь.


Продолжим разбираться с Kotlin на открытом уроке «Kotlin Multiplatform: Лайфхак для Java-разработчиков. Пишем ОДИН код для ВСЕХ проектов», который состоится 14 мая.

На занятии мы разберём, как Kotlin Multiplatform (KMP) позволяет использовать общий код в Java-проектах, интегрировать его с Android, iOS и backend-системами, избежать дублирования логики, а также настроить совместимость с существующим Java-стеком. Если интересно, записывайтесь бесплатно на странице курса Otus "Kotlin Backend Developer. Professional".

Теги:
Хабы:
+1
Комментарии4

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS