
С выходом 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. Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/post/691152/
Добавить комментарий