Привет, Хабр!
Сегодня рассмотрим@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".