Абстракции с нулевой стоимостью* в Kotlin

*Могут применяться особые условия

Внимание: этот пост в блоге охватывает экспериментальную функцию Kotlin, которая может быть изменена. Эта статья была написана с использованием Kotlin 1.3.50.

Безопасность типов не позволяет нам делать ошибки или отлаживать их позже. Для типов ресурсов Android, таких как String, Font или ресурсов Animation, мы можем использовать androidx.annotations, такой как @StringRes или @FontRes, и Lint заставляет нас передавать параметр правильного типа:

fun myStringResUsage(@StringRes string: Int){ }

// Ошибка: ожидаемый ресурс типа String
myStringResUsage(1)

Если наш идентификатор является не ресурсом Android, а идентификатором для доменного объекта, такого как Doggo или Cat, то дифференциация между этими двумя Int не может быть легко выполнена. Чтобы добиться безопасности типов, которая кодирует факт, что идентификатор собаки не совпадает с идентификатором кошки, вам придется завернуть свой идентификатор в класс. Недостатком этого является то, что вы платите производительностью, так как должен быть создан новый объект, когда на самом деле все, что вам нужно, — это примитив.

Встроенные классы Kotlin позволяют создавать эти типы оболочек без затрат производительности. Это экспериментальная функция, добавленная в Kotlin 1.3. Встроенные классы должны иметь ровно одно свойство. Во время компиляции экземпляры встроенных классов заменяются их базовым свойством (распаковываются) там, где это возможно, что снижает затраты на производительность обычного класса-оболочки. Это имеет еще большее значение в тех случаях, когда обернутый объект является примитивным типом, поскольку компилятор уже оптимизирует их. Таким образом, перенос примитивного типа во встроенный класс приводит, где это возможно, к значению, представленному как примитивное во время выполнения.

inline class DoggoId(val id: Long)
data class Doggo(val id: DoggoId, … )

// использование
val goodDoggo = Doggo(DoggoId(doggoId), …)
fun pet(id: DoggoId) { … }

Вставай в очередь

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

• Не более одного параметра (без ограничений по типу).

• Отсутствие резервных полей.

• Никаких блоков инициализаций.

• Отсутствие расширяющихся классов.

Но встроенные классы могут:

  • Наследовать от интерфейсов.
  • Иметь свойства и функции.
interface Id
inline class DoggoId(val id: Long) : Id {
    
    val stringId
    get() = id.toString()    
    fun isValid()= id > 0L
}

⚠️ Предупреждение: Typealias может показаться похожим на встроенные классы, но псевдонимы просто предоставляют альтернативное имя для существующих типов, в то время как встроенные классы создают новый тип.

Представление —обернутое или нет?

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

Параметр оказывается запакован при использовании в качестве другого типа.

Такой сценарий возможен, если вы используете его, когда ожидается тип Object или Any, например, в коллекциях, массивах или в качестве объектов, допускающих значение null. В зависимости от того, как вы проверяете два встроенных класса на структурное равенство, один или ни один из них не будет запакован:

val doggo1 = DoggoId(1L)

val doggo2 = DoggoId(2L)

doggo1 == doggo2 — ни doggo1, ни doggo2 не упакованы.

doggo1.equals(doggo2) — doggo1 использован как примитив, но doggo2 запакован.

Под капотом

Давайте возьмем простой встроенный класс:

interface Id
inline class DoggoId(val id: Long) : Id

Посмотрим, как выглядит декомпилированный код языка программирования Java шаг за шагом и какие последствия абстракции имеют для использования встроенных классов. Полный декомпилированный код вы можете найти здесь.

Под капотом — конструкторы

У DoggoId есть два конструктора:

  • Приватный синтетический конструктор DoggoId(long id).
  • Публичный constructor-impl.

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

val myDoggoId = DoggoId(1L)

// декомпилированный
static final long myDoggoId = DoggoId.constructor-impl(1L);

Если мы попытаемся создать идентификатор doggo на Java, то получим ошибку:

DoggoId u = new DoggoId(1L);
// Ошибка: DoggoId() у DoggoId не может быть применена к (long)

Нельзя создать экземпляр встроенного класса из Java.

Параметризованный конструктор является приватным, а второй конструктор содержит в имени — недопустимый символ в Java. Это означает, что встроенные классы не могут быть созданы из Java.

Под капотом — использование параметров

Идентификатор выставляется двумя способами:

• Как примитив, через getId.

• Как объект с помощью метода box_impl, который создает новый экземпляр DoggoId.

Когда встроенный класс используется там, где мог бы быть примитив, компилятор Kotlin будет знать об этом и будет непосредственно использовать его:

fun walkDog(doggoId: DoggoId) {}

// декомпилированный Java код
public final void walkDog_Mu_n4VY(long doggoId) { }

Когда ожидается объект, компилятор Kotlin будет использовать запакованную версию нашего примитива, что будет приводить каждый раз к созданию нового объекта.

Объекты, допускающие значение null

fun pet(doggoId: DoggoId?) {}

// декомпилированный Java код
public static final void pet_5ZN6hPs/* $FF was: pet-5ZN6hPs*/(@Nullable InlineDoggoId doggo) {}

Поскольку можно обнулить только объекты, то используется запакованная реализация.

Коллекции

val doggos = listOf(myDoggoId)

// декомпилированный Java код
doggos = CollectionsKt.listOf(DoggoId.box-impl(myDoggoId));

Подпись CollectionsKt.listOf:

fun <T> listOf(element: T): List<T>

Поскольку этот метод ожидает объект, то компилятор Kotlin запаковывает наш примитив, чтобы убедиться, что объект используется.

Базовые классы

fun handleId(id: Id) {}fun myInterfaceUsage() {
    handleId(myDoggoId)
}

// декомпилированный Java код
public static final void myInterfaceUsage() {
    handleId(DoggoId.box-impl(myDoggoId));
}

Поскольку здесь мы ожидаем супертип, то используется запакованная реализация.

Под капотом — проверка на равенство

Компилятор Kotlin пытается использовать распакованный параметр везде, где это возможно. Для этого встроенные классы имеют 3 различных реализации равенства: переопределение равенств и 2 сгенерированных метода:https://nuancesprog.ru/media/578f9a5fac50f406a05abfa969d6710a

doggo1.equals(doggo2)

Метод equals вызывает сгенерированный метод: equals_impl(long, Object). Поскольку equals ожидает объект, значение doggo2 будет запаковано, но doggo1 будет использоваться в качестве примитива:

DoggoId.equals-impl(doggo1, DoggoId.box-impl(doggo2))

doggo1 == doggo2

Использование== генерирует:

DoggoId.equals-impl0(doggo1, doggo2)

В случае с оператором == примитив будет использован для обоих doggo1 и doggo2.

doggo1 == 1L

Если Kotlin способен определить, что doggo1 на самом деле является типом long, то следует ожидать, что эта проверка равенства будет работать. Но поскольку мы используем встроенные классы для безопасности типов, то первое, что сделает компилятор — это проверит, является ли тип двух объектов, которые мы пытаемся сравнить, одинаковым. А поскольку это не так, мы получим ошибку компилятора: оператор == не может быть применен к long и DoggoId. В конце концов, для компилятора это так же, как если бы мы сказали cat1 == doggo1, что определенно не соответствует действительности.

doggo1.equals(1L)

Эта проверка равенства действительно компилируется, поскольку компилятор Kotlin использует реализацию equals, которая ожидает long и объект. Но поскольку первое, что делает этот метод, — это проверяет тип объекта, эта проверка равенства будет ложной, так как объект не является DoggoId.

Переопределение функций с примитивными и встроенными параметрами класса

Компилятор Kotlin позволяет определять функции как с примитивным, так и с необнуляемым встроенным классом в качестве параметров:

fun pet(doggoId: Long) {}
fun pet(doggoId: DoggoId) {}

// декомпилированный Java код
public static final void pet(long id) { }
public final void pet_Mu_n4VY(long doggoId) { }

В декомпилированном коде мы видим, что для обеих функций используется примитив.

Чтобы разрешить эту функциональность, компилятор Kotlin будет искажать имя функции, принимая встроенный класс в качестве параметра.

Использование встроенных классов в Java

Мы уже видели, что не можем создать экземпляр встроенного класса в Java. Как насчет того, чтобы использовать его?

✅Передача встроенных классов в функции Java

Мы можем передать их в качестве параметров, которые будут использоваться в качестве объектов, и получить свойство, которое они оборачивают.

void myJavaMethod(DoggoId doggoId){
    long id = doggoId.getId();
}

✅ Использование встроенных экземпляров классов в функциях Java

Если у нас есть встроенные экземпляры классов, определенные как объекты верхнего уровня, мы можем получить ссылку на них в Java как примитивы, то есть:

// Объявление Kotlin
val doggo1 = DoggoId(1L)

// использование Java
long myDoggoId = GoodDoggosKt.getU1();

✅ & ❌Вызов функций Kotlin, имеющих встроенные классы в качестве параметров

Если у нас есть функция Java, которая получает параметр встроенного класса, и мы хотим вызвать функцию Kotlin, которая принимает встроенный класс, то мы получим ошибку компиляции:

fun pet(doggoId: DoggoId) {}

// Java
void petInJava(doggoId: DoggoId){
    pet(doggoId) 
    // ошибка компиляции: pet(long) не может быть применена к pet(DoggoId)
}

Для Java DoggoId — это новый тип, но компилятор генерирует pet(long), а pet(DoggoId) не существует.

Но мы можем передать базовый тип:

fun pet(doggoId: DoggoId) {}

// Java
void petInJava(doggoId: DoggoId){
    pet(doggoId.getId)
}

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

fun pet(doggoId: Long) {}

fun pet(doggoId: DoggoId) {}

// Java
TestInlineKt.pet(1L);

Ошибка: Неявный вызов метода. Оба pet(long) и pet(long) совпадают

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

Безопасность типов помогает нам писать более надежный код, но это повлиять на производительность. Встроенные классы предлагают лучшее из обоих миров: безопасность типа без затрат, но должны ли вы всегда использовать их?

Встроенные классы содержат ряд ограничений, которые гарантируют, что создаваемый объект играет единственную роль: быть оболочкой. Это означает, что в будущем разработчики, незнакомые с кодом, не смогут ошибочно увеличивать сложность класса, добавляя в конструктор другие параметры, как это можно было бы сделать с классом данных.

С точки зрения производительности мы видели, что компилятор Kotlin старается изо всех сил использовать базовый тип, когда это возможно, но все равно во многих случаях создают новые объекты.

Использование Java имеет несколько предостережений, поэтому, если вы еще не полностью перешли на Kotlin, то не сможете спокойно использовать встроенные классы.

Наконец, это все еще экспериментальная функция, добавляющая неопределенность относительно того, останется ли ее реализация неизменной при переходе в стабильную версию и произойдет ли это вообще.

Таким образом, теперь, когда вы понимаете преимущества и ограничения встроенных классов, вы можете принять обоснованное решение о том, когда их использовать и использовать ли вообще.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Florina Muntenescu: Zero-cost* abstractions in Kotlin

Предыдущая статьяМы снова написали самый быстрый JS-фреймворк UI
Следующая статьяРазработчиком ПО может стать каждый - волшебных эликсиров не требуется