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

Привет, Хабр!
Сегодня рассмотрим@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 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 {
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 {
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».
Habrahabr.ru прочитано 7932 раза
