С выходом Kotlin 1.5.0, классы значения (известные ранее как inline классы) наконец-таки стабильны и были освобождены от аннотации
@OptIn
. Было много нового в релизе, что также создало много путаницы, так как теперь нам доступны три очень похожих инструмента: псевдонимы типов, классы данных и классы значения. Так какой же нам использовать теперь? Можно ли выбросить сразу псевдонимы типов и data-классы и заменить их на value-классы?
Проблема
Классы в Kotlin решают две проблемы:
Они передают смысл через их название и облегчают понимание, что за объект передается.
Они принуждают к типобезопасности утверждая, что объект класса А не может быть передан функции, которая ожидает объект класса Б входным параметром. Это предотвращает серьезные ошибки еще во время компиляции.
Примитивные типы такие как Int
, Boolean
, Double
также принуждают к типобезопасности (нельзя передать Double
туда, где ожидается Boolean
), но они не передают смысл (ну кроме того, что это число).
Double
числом может быть практически что угодно: температура в градусах Цельсия, вес в килограммах или уровень яркости вашего экрана в процентах. Все, что понятно, -- это только то, что мы имеем дело с числом с плавающей запятой двойной точности (64 бит), но это не говорит нам о том, что это число собой представляет. По этой причине, семантическая типобезопасность нарушена:
Если у нас есть функция для установки уровня яркости нашего дисплея:
fun setDisplayBrightness(newDisplayBrightness: Double) { ... }
мы можем вызвать эту функцию с любым Double
значением и можем случайно передать число с совершенно другим смыслом:
val weight: Double = 85.4
setDisplayBrightness(weight) // 💥
Компилятор такое пропустит, но это программная ошибка, которая может и "уронить" программу или , что даже хуже, к неожиданному поведению.
Решение
Есть несколько подходов к решению двух вышеупомянутых проблем. Можно просто обернуть примитивный тип классом, но это влечёт много издержек. Итак, давайте посмотрим как мы можем победить эти проблемы с помощью:
класса данных;
псевдонимом типа;
и класса значения.
и исследуем какой из этих способов наиболее целесообразный.
Попытка №1: классы данных
Самым простым путем (присутствующим изначально в Kotlin) будет использование класса данных:
data class DisplayBrightness(val value: Double)
fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }
✅ Преимущества
DisplayBrightness
здесь - тип сам содержит Double
, но несовместим по присваиванию с Double
(например, setDisplayBrightness(DisplayBrightness(0.5))
будет работать, но setDisplayBrightness(0.5)
даст ошибку компиляции). Также это решение все еще позволяет сделать так: setDisplayBrightness(DisplayBrightness(person.weight))
. Очевидно, что решение -- так себе.
⛔️ Недостатки
Однако, есть один огромный минус: инстанциирование классов данных очень дорогое. Примитивные значения могут быть записаны в стек, который быстрее и эффективнее. Экземпляры классов данных записываются в кучу, что требует больше времени и памяти.
Вы спросите: на сколько больше времени? Давайте протестируем:
data class DisplayBrightnessDataClass(val value: Double)
@OptIn(ExperimentalTime::class)
fun main(){
val dataClassTime = measureTime {
repeat(1000000000) { DisplayBrightnessDataClass(0.5) }
}
println("Data classes took ${dataClassTime.toDouble(MILLISECONDS)} ms")
val primitiveTime = measureTime {
repeat(1000000000) { var brightness = 0.5 }
}
println("Primitive types took ${primitiveTime.toDouble(MILLISECONDS)} ms")
}
...дает вывод:
Data classes took 9.898582 ms
Primitive types took 2.812561 ms
И хотя этот удар по производительности и кажется незначительным для современных очень очень быстрых электронно-вычислительных машин, такие небольшие улучшения играют большую роль в приложениях, требовательных к производительности.
Попытка №2: Псевдонимы типов
Псевдоним типа дает второе имя для типа. Например:
typealias DisplayBrightness = Double
fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }
✅ Преимущества
Под капотом, Double
и DisplayBrightness
стали синонимами.
Теперь, когда компилятор видит DisplayBrightness
он просто заменяет это на Double
и двигается дальше. Соответственно, новый псевдоним DisplayBrightness
работает так же быстро, как и Double
- он использует те же оптимизации, что и Double
.
Если мы расширим приведенный ранее тест, мы увидим, что и для синонима и для примитивного типа тест займет примерно тоже самое время:
Data classes took 7.743406 ms
Primitive types took 2.77597 ms
Type aliases took 2.688276 ms
Так как DisplayBrightness
это синоним Double
- все операции, которые работают с Double
, также работают и с DisplayBrightness
:
val first: DisplayBrightness = 0.5
val second: DisplayBrightness = 0.1
val summedBrightness = first + second // 0.6
first.isNaN() // false
⛔️ Недостатки
Подвох этого в том, что DisplayBrightness
и Double
теперь совместимы по присваиванию, и значит компилятор радостно примет это:
typealias DisplayBrightness = Double
typealias Weight = Double
fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }
fun callingFunction() {
val weight: Weight = 85.4
setDisplayBrightness(weight)
}
Так решили ли мы проблему на самом деле? Что же, отчасти. Тогда как псевдонимы типов делают сигнатуры функций более выразительными и намного быстрее классов данных, тот факт, что DisplayBrightness
и Double
совместимы по присваиванию оставляет проблему типобезопасности нерешённой.
Попытка №3: Классы значения
На первый взгляд, классы значения очень похожи на классы данных. Их сигнатура выглядит в точности одинаково, только вместо data class
ключевое слово value class
:
@JvmInline
value class DisplayBrightness(val value: Double)
fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }
Также, вы можете заметить @JvmInline
аннотацию. KEEP о классах значения объясняет это, а также причину того, что классы значений могут иметь только 1 поле на данный момент.
Почему необходим @JvmInline
Вкратце, тогда как Kotlin/Native и Kotlin/JS бэкенды технически могут поддерживать классы значения с больше чем одним полем, Kotlin/JVM на данный момент - нет. Это из-за того, что JVM поддерживает только её встроенные примитивные типы. Однако, есть планы и проект Valhalla (смотри соответствующий JEP), который позволит пользовательские примитивные типы. Дело обстоит сейчас так, что команда Kotlin полагает, что проект Valhalla - лучшая стратегия компиляции для классов значения. Однако, проект Valhalla еще не стабилен, и им было нужно найти временную стратегию компиляции, на которую можно было бы положиться. Для того, чтобы сделать это явным, на данный момент @JvmInline
- вынужденная мера.
✅ Преимущества
За сценой, компилятор считает классы значения псевдонимом типа, но с одним большим отличием:
Классы значения несовместимы по присваиванию, и это значит, что следующий код не скомпилируется:
@JvmInline
value class DisplayBrightness(val value: Double)
fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }
fun callingFunction() {
val weight: Double = 85.4
setDisplayBrightness(weight) // 💥
}
Расширив приведенный выше тест, мы увидим, что классы значений имеют такую же высокую производительность, как и примитивные типы и, таким образом, как и псевдонимы типа:
Data classes took 7.268809 ms
Primitive types took 2.799518 ms
Type aliases took 2.627111 ms
Value classes took 2.883411 ms
Должен ли я всегда использовать классы значения?
Итак, кажется, что классы значений проставили все галочки, так? Они...
Делают объявление переменных и сигнатуры функций более выразительными, ✅
Сохраняют производительность примитивных типов, ✅
Несовместимы по присваиванию с их базовым типом, предотвращая пользователя от совершения глупых вещей, ✅
и поддерживают множество особенностей классов данных, таких как конструкторы,
init
, методы и даже дополнительные свойства (но только через геттеры). ✅
Единственное оставшееся применение для классов данных - это когда вам нужно обернуть несколько параметров. Классы значения ограничены одним параметром в их конструкторе на данный момент.
Аналогично, псевдонимы типа все еще имеют свои применения, которые не могут быть покрыты классами значения (или идут в разрез с их предназначением):
Сокращение длинных сигнатур обобщенных типов:
typealias Restaurant = Organization<(Currency, Coupon?) -> Sustenance
Параметры функций высшего порядка:
typealias ListReducer<T> = (List<T>, List<T>) -> List<T>
За исключением этих исключений, классы значений действительно являются лучшим решением в большинстве случаев. (По этой причине, мы сейчас переводим наши проекты на классы значения.)
Идём дальше
Есть два документа, которые действительно помогут нам понять как работают классы значения и какие инженерные идеи возникли в процессе дизайна:
Также в KEEP рассказано о возможных будущих разработках и идеях дизайна. Эта статья на typealias.com объясняет как псевдонимы типа работают и как они должны использоваться - рекомендуется к прочтению.
Если же вам интересна разработка языка Kotlin в целом, может быть вам понравится статья Kotlin’s Sealed Interfaces & The Hole in The Sealing. Спасибо за внимание!