Files
obsidian/Kotlin/Синтаксис и типы/Базовые типы ООП/Value class.md
T

11 KiB

title, description, tags
title description tags
Value class Обёртка без аллокации, типобезопасность примитивов, mangling имён и границы инлайнинга
kotlin
oop
performance

value class - обёртка над одним значением, которая в байт-коде обычно исчезает: вместо объекта остаётся само значение.

@JvmInline
value class UserId(val value: Int)

fun load(id: UserId) = "пользователь ${id.value}"

fun main() {
    val id = UserId(42)
    println(load(id))        // пользователь 42
    println(id.value)        // 42
}

В скомпилированном коде load принимает int, а не объект. Аллокации нет.

Аннотация @JvmInline обязательна на JVM - без неё компилятор выдаст ошибку.

Зачем

Основная причина - типобезопасность без накладных расходов. Классическая проблема:

fun transfer(from: Int, to: Int, amount: Int) = "$from -> $to: $amount"

fun main() {
    println(transfer(100, 1, 2))     // перепутали порядок — компилятор молчит
}

Все параметры Int, поэтому их легко перепутать. С value class такая ошибка становится ошибкой компиляции:

@JvmInline value class AccountId(val value: Int)
@JvmInline value class Amount(val cents: Int)

fun transfer(from: AccountId, to: AccountId, amount: Amount) =
    "${from.value} -> ${to.value}: ${amount.cents}"

fun main() {
    println(transfer(AccountId(1), AccountId(2), Amount(100)))
    // println(transfer(Amount(100), AccountId(1), AccountId(2)))  // не скомпилируется
}

Цена - ноль: в байт-коде всё те же три int.

Что можно внутри

@JvmInline
value class Email(val value: String) {
    init {
        require(value.contains("@")) { "неверный email: $value" }
    }

    val domain: String get() = value.substringAfter("@")
    fun masked() = value.replaceRange(0, value.indexOf("@"), "***")

    companion object {
        fun parse(s: String): Email? = if (s.contains("@")) Email(s) else null
    }
}

fun main() {
    val e = Email("ivan@example.com")
    println(e.domain)             // example.com
    println(e.masked())           // ***@example.com
    println(Email.parse("нет"))   // null
}

Разрешены: init, свойства с геттером, функции, companion object, реализация интерфейсов.

Запрещены: второе свойство с полем, var-свойства с полем, наследование, блоки инициализации полей, lateinit.

// @JvmInline value class Bad(val a: Int, val b: Int)   // ошибка: только одно поле
// @JvmInline value class Bad2(var a: Int)              // ошибка: нужен val

Реализация интерфейса

interface Printable { fun print2(): String }

@JvmInline
value class Code(val value: Int) : Printable {
    override fun print2() = "код $value"
}

fun main() {
    println(Code(7).print2())       // код 7
    val p: Printable = Code(7)      // упаковка! см. ниже
    println(p.print2())             // код 7
}

Когда обёртка всё-таки создаётся

Инлайнинг не бесплатен в смысле предсказуемости: в некоторых позициях объект приходится создать.

Позиция Инлайнится
параметр и результат функции да
локальная переменная да
nullable-тип (UserId?) нет
приведение к супертипу/интерфейсу нет
элемент коллекции (List<UserId>) нет - дженерики стираются
поле дженерик-класса нет
@JvmInline value class UserId(val value: Int)

fun main() {
    val a: UserId = UserId(1)          // int, без объекта
    val b: UserId? = UserId(1)         // объект UserId
    val c: List<UserId> = listOf(UserId(1))   // объекты внутри списка
    println("${a.value} ${b?.value} ${c.size}")
}

То есть List<UserId> по памяти не лучше List<Integer>. Выгода - в типобезопасности, а не в аллокациях.

Mangling имён

Чтобы разные value class над одним типом не давали одинаковые JVM-сигнатуры, компилятор добавляет суффикс к имени метода:

@JvmInline value class AccountId(val value: Int)

fun process(id: AccountId) = id.value       // в байт-коде: process-<хеш>(I)I

fun main() { println(process(AccountId(1))) }   // 1

Из Java такую функцию вызвать практически нельзя - имя непредсказуемо. Решение - @JvmName:

@JvmInline value class AccountId(val value: Int)

@JvmName("processAccount")
fun process(id: AccountId) = id.value

fun main() { println(process(AccountId(1))) }   // 1

Подробнее: Kotlin из Java

Равенство

Сравнение идёт по содержимому, причём без упаковки:

@JvmInline value class UserId(val value: Int)

fun main() {
    println(UserId(1) == UserId(1))      // true
    println(UserId(1) == UserId(2))      // false
    println(setOf(UserId(1), UserId(1)).size)   // 1
}

equals/hashCode генерируются автоматически и переопределять их нельзя.

value class и data class

@JvmInline value class Meters(val value: Double)
data class Point(val x: Int, val y: Int)

value class - одно поле, без аллокации, нет copy. data class - любое число полей, всегда объект, есть copy и componentN.

Для одного поля data class тоже работает, но создаёт объект:

data class SlowId(val value: Int)              // объект на каждый вызов
@JvmInline value class FastId(val value: Int)  // int

fun main() {
    println(SlowId(1) == SlowId(1))    // true, но два объекта были созданы
    println(FastId(1) == FastId(1))    // true, объектов не было
}

typealias - не замена

typealias UserId2 = Int

fun load(id: UserId2) = id

fun main() {
    println(load(42))      // 42 — любое Int подходит, защиты нет
}

typealias - только псевдоним, он не создаёт новый тип и не защищает от путаницы. Подробнее: Typealias

[!info] Отличия от Java Прямого аналога нет. Java-разработчик выбирает между:

  • обычным классом-обёрткой - типобезопасно, но аллокация на каждый объект
  • примитивом с комментарием - быстро, но ошибки порядка аргументов не ловятся
// обёртка: безопасно, но объект
final class UserId {
    private final int value;
    UserId(int value) { this.value = value; }
    // equals, hashCode, геттер...
}

В Java это закрывает Project Valhalla (value class в JEP 401), но он ещё не вышел в релиз. Пока единственный способ получить безопасность без аллокации - value class Kotlin.

Java Kotlin
класс-обёртка + аллокация @JvmInline value class
int напрямую value class с тем же байт-кодом
Valhalla (в разработке) работает с Kotlin 1.5

Сравнить: Классы-обертки в Java

[!warning] Подводные камни @JvmInline обязательна. Без неё на JVM компилятор выдаст ошибку, а не предупреждение:

// value class Bad(val x: Int)    // error: value class without @JvmInline
@JvmInline value class Good(val x: Int)

fun main() { println(Good(1).x) }

Nullable-тип отменяет инлайнинг. UserId? - настоящий объект:

@JvmInline value class UserId(val value: Int)

fun find(id: UserId?): String = id?.value?.toString() ?: "нет"

fun main() { println(find(UserId(1))) }   // 1 — но обёртка была создана

В горячем коде вместо UserId? лучше использовать значение-маркер.

В коллекциях выгоды нет. List<UserId> хранит объекты из-за стирания типов.

init вызывается, но не защищает от Java. Java-код может создать экземпляр в обход проверки, потому что на уровне байт-кода это просто int:

@JvmInline
value class Age(val value: Int) {
    init { require(value >= 0) { "возраст отрицательный" } }
}

fun main() {
    println(Age(5).value)           // 5
    // Age(-1)                      // IllegalArgumentException из Kotlin
}

Mangling ломает вызов из Java и из фреймворков, работающих через рефлексию. Jackson, Spring, JPA требуют отдельной настройки для value class; иногда он просто не поддерживается.

Переопределить equals, hashCode нельзя - они генерируются. toString переопределить можно.

Два value class над одним типом взаимозаменяемы в байт-коде. Приведение через Any обойдёт проверку типов:

@JvmInline value class Meters(val v: Int)
@JvmInline value class Feet(val v: Int)

fun main() {
    val m = Meters(10)
    val any: Any = m
    println((any as Meters).v)       // 10
    // println((any as Feet).v)      // ClassCastException — на уровне Kotlin проверка есть
}

Нельзя наследоваться и нельзя быть open - иерархии из value class не построить.

Дополнительно: Typealias Далее: Object и companion object