С выходом 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. Спасибо за внимание!

Комментарии (8)


  1. Cryvage
    03.10.2022 09:54
    +1

    А аналога шарповых структур в Kotlin нет? Раньше думал что это, как раз, data классы, а они, оказывается, в куче лежат.


    1. iShrimp
      03.10.2022 19:12

      Похоже, Kotlin так и не умеет оптимизировать массивы дата-классов из-за ограничений самой JVM. Буду рад, если профессионалы объяснят более подробно.

      Например, если мне нужен массив из 10000 элементов типа (Int, Int, Int), то надо создавать массив из 30000 элементов примитивного типа и индексировать каждое поле вручную. Это неструктурно, непонятно для компилятора, трудно векторизуемо, но всё равно работает быстрее, чем массив из 10000 экземпляров класса.


      1. Vaulter Автор
        03.10.2022 21:18

        есть вариант с

        class TupleSystem {
          val fieldA = IntArray()
          val fieldB = IntArray()
          val fieldC = IntArray()
        
          operator fun get(i: Int) = Tuple(fieldA[i], fieldB[i], fieldC[i])
          fun someBunchAction() = (0 until fieldA.size).forEachIndexed { 
            i, it -> fieldC[i] = fieldA[i] + fieldB[i]
          }
        }


        1. vanxant
          04.10.2022 02:44
          +1

          Не векторизуется и не кэш-френдли. Тормозить, скорее всего, будет даже больше, чем классы в куче.


  1. Vladekk
    03.10.2022 11:15
    +1

    Опасный путь - добавлять фичи постфактум. Язык разбухает и теряет простоту и элегантность.


    1. Vaulter Автор
      03.10.2022 15:56
      +2

      если не добавлять фичи постфактум - приложение умирает.


  1. rjhdby
    03.10.2022 14:14

    А Jackson умеет десериализацию в value class?


    1. Vaulter Автор
      03.10.2022 15:55

      да