Pull to refresh

Классы значения в Kotlin: прощайте псевдонимы типов?

Level of difficultyMedium
Reading time6 min
Views6.8K
Original author: FREDERIK KAMMEL

С выходом Kotlin 1.5.0, классы значения (известные ранее как inline классы) наконец-таки стабильны и были освобождены от аннотации @OptIn. Было много нового в релизе, что также создало много путаницы, так как теперь нам доступны три очень похожих инструмента: псевдонимы типов, классы данных и классы значения. Так какой же нам использовать теперь? Можно ли выбросить сразу псевдонимы типов и data-классы и заменить их на value-классы?

Проблема

Классы в Kotlin решают две проблемы:

  1. Они передают смысл через их название и облегчают понимание, что за объект передается.

  2. Они принуждают к типобезопасности утверждая, что объект класса А не может быть передан функции, которая ожидает объект класса Б входным параметром. Это предотвращает серьезные ошибки еще во время компиляции.

Примитивные типы такие как 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, методы и даже дополнительные свойства (но только через геттеры). ✅

Единственное оставшееся применение для классов данных - это когда вам нужно обернуть несколько параметров. Классы значения ограничены одним параметром в их конструкторе на данный момент.

Аналогично, псевдонимы типа все еще имеют свои применения, которые не могут быть покрыты классами значения (или идут в разрез с их предназначением):

  1. Сокращение длинных сигнатур обобщенных типов:

typealias Restaurant = Organization<(Currency, Coupon?) -> Sustenance
  1. Параметры функций высшего порядка:

typealias ListReducer<T> = (List<T>, List<T>) -> List<T>

За исключением этих исключений, классы значений действительно являются лучшим решением в большинстве случаев. (По этой причине, мы сейчас переводим наши проекты на классы значения.)

Идём дальше

Есть два документа, которые действительно помогут нам понять как работают классы значения и какие инженерные идеи возникли в процессе дизайна:

Также в KEEP рассказано о возможных будущих разработках и идеях дизайна. Эта статья на typealias.com объясняет как псевдонимы типа работают и как они должны использоваться - рекомендуется к прочтению.

Если же вам интересна разработка языка Kotlin в целом, может быть вам понравится статья Kotlin’s Sealed Interfaces & The Hole in The Sealing. Спасибо за внимание!

Tags:
Hubs:
Total votes 10: ↑9 and ↓1+11
Comments10

Articles