Нейт Эбель, разработчик Android

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

Наша команда Android в Premise недавно имела такую ​​возможность, когда мы реорганизовали некоторые ранее написанные вспомогательные функции, чтобы использовать API контрактов Kotlin, которые были добавлены в выпуске Kotlin 1.3.

Проблема с нашими существующими функциями

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

val Any?.isNull: Boolean get() = this == null
val Any?.isNotNull: Boolean get() = !this.isNull
val Any?.isNullOrEmpty: Boolean get() =
    when (this) {
        null -> true
        is Collection<*> -> this.isEmpty()
        is CharSequence -> this.isEmpty()
        else -> false
    }
    
val Any?.isNotNullOrEmpty: Boolean get() = !this.isNullOrEmpty

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

Итак, давайте рассмотрим несколько примеров того, как эти помощники не помогают нам в той степени, в которой мы хотели бы.

Неудачный пример 1

Прежде всего, давайте посмотрим на использование нашего isNull расширения:

val Any?.isNull: Boolean get() = this == null
fun main() {
    val name: String? = null
    if(name.isNull) return // compiler error - name variable might be null
    println("name is ${name.length} characters long")
}

В этом фрагменте мы возвращаемся, если name имеет значение NULL, но после этой проверки компилятор по-прежнему не может гарантировать, что name не равно NULL. Никакой умный кастинг нам не поможет.

Из-за этого мы в конечном итоге пишем излишне подробный код вроде следующего:

val Any?.isNull: Boolean get() = this == null
fun main() {
    val name: String? = null
    if(name.isNull) return
    
    println("name is ${name?.length ?: 0} characters long")
}

Мы вынуждены использовать нулевые безопасные вызовы для name даже после использования нашего вспомогательного расширения.

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

Неудачный пример 2

Давайте посмотрим на другой пример того, как эти помощники оказались не такими умными, как нам хотелось бы.

Следующий фрагмент демонстрирует пример использования нашего свойства расширения isNullOrEmpty.

val Any?.isNullOrEmpty: Boolean get() =
    when (this) {
        null -> true
        is Collection<*> -> this.isEmpty()
        is CharSequence -> this.isEmpty()
        else -> false
    }
fun main() {
    val names: List<String>? = null
    if (names.isNullOrEmpty) return// compiler error - names variable might be null
    
    println("names is ${names.size} characters long")
}

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

Неудачный пример 3

Проблема усугубляется при реализации одного свойства или функции с точки зрения другого.

В этом фрагменте мы определяем isNotNullOrEmpty, отрицая значение, возвращаемое из isNullOrEmpty:

val Any?.isNotNullOrEmpty: Boolean get() = !this.isNullOrEmpty
val Any?.isNullOrEmpty: Boolean get() =
    when (this) {
        null -> true
        is Collection<*> -> this.isEmpty()
        is CharSequence -> this.isEmpty()
        else -> false
    }
fun main() {
    val names: List<String>? = null
    if (names.isNotNullOrEmpty) {
        // compiler error - names variable might be null
        println("names is ${names.size} characters long")   
    }
}

И снова, после использования нашего isNotNullOrEmpty вспомогательного расширения умное вещание не работает.

Во всех этих случаях наши помощники не могут использовать всю мощь компилятора Kotlin. Затем наши разработчики вынуждены выполнять дополнительные проверки на null - даже если мы уже использовали наши расширения.

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

Чем могут помочь API контрактов Kotlin?

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

На абстрактном уровне контракты позволяют функциям предоставлять такие описания, как

if this functions returns true, the input parameter is guaranteed to be not null

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

Контракты создаются путем объединения нескольких конкретных API, в том числе:

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

Если мы рассмотрим ContractBuilder интерфейс, общедоступные API укажут нам, какие виды контрактных эффектов мы можем определить.

  • CallsInPlace () - Указывает, что параметр функции лямбда вызывается на месте.
  • Returns () - Указывает, что функция либо успешно вернется, либо вернется с определенным значением
  • ReturnsNotNull () - Указывает, что функция будет нормально возвращать любое значение, отличное от NULL

В следующих разделах мы рассмотрим несколько примеров того, как можно использовать эти функции.

Обновление наших вспомогательных функций для использования контрактов Kotlin

Преимущество контрактных API-интерфейсов лучше всего видно на примерах, поэтому давайте рассмотрим несколько примеров того, как мы можем улучшить предыдущие.

Улучшенный пример 1

Сначала мы обновим наше isNull расширение.

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

В следующем фрагменте мы видим обновленный метод isNull().

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun Any?.isNull(): Boolean {
    contract {
        returns(false) implies (this@isNull != null)
    }
    return this == null
}
fun main() {
    val name: String? = null
    if (name.isNull()) return
    
    println("name is ${name.length} characters long")
}

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

Давайте разберемся, что здесь произошло.

Сначала мы вызываем функцию contract() в качестве первой строки в теле нашего метода.

В лямбде, переданной в contract(), мы указываем две вещи:

  • ограничение возвращаемого значения
  • и что подразумевает это возвращаемое значение

В этом примере, если isNull() возвращает false,, это означает, что объект-получатель не равен нулю.

При наличии этого контракта, если isNull() возвращает false, последующее использование получателя не потребует нулевых безопасных средств доступа.

Улучшенный пример 2

Давайте проведем рефакторинг еще одного из наших предыдущих примеров.

Начнем с преобразования нашего isNullOrEmpty свойства extension в метод расширения.

fun Any?.isNullOrEmpty(): Boolean {
    return when (this) {
        null -> true
        is Collection<*> -> this.isEmpty()
        is CharSequence -> this.isEmpty()
        else -> false
    }
}

Затем мы снова хотим определить контракт, который сообщает компилятору, когда делать вывод о том, что получено non-null.

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun Any?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }
    return when (this) {
        null -> true
        is Collection<*> -> this.isEmpty()
        is CharSequence -> this.isEmpty()
        else -> false
    }
}
fun main() {
    val names: List<String>? = null
    if (names.isNullOrEmpty()) return
    println("names is ${names.size} items long")
}

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

Этот пример на самом деле достаточно распространен, поэтому он является частью стандартной библиотеки Kotlin.

/**
 * Returns `true` if this nullable char sequence is either `null` or empty.
 *
 * @sample samples.text.Strings.stringIsNullOrEmpty
 */
@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }
    return this == null || this.length == 0
}

Для нас это означало, что одним помощником стало меньше, поскольку мы удалили нашу реализацию в пользу использования стандартной библиотеки.

Улучшенный пример 3

Давайте вернемся к нашему примеру реализации одного помощника с точки зрения другого.

Мы начали со следующего свойства расширения

val Any?.isNotNullOrEmpty: Boolean get() = !this.isNullOrEmpty

Сначала мы преобразуем это в функцию расширения, как и в других примерах.

fun CharSequence?.isNotNullOrEmpty(): Boolean {
    return !this.isNullOrEmpty()
}

Что произойдет, если мы воспользуемся этой обновленной функцией?

isNullOrEmpty() определяет контракт, так что, возможно, компилятор сможет вывести для нас подходящие смарткасты?

К сожалению нет.

fun main() {
    val names: List<String>? = null
    if (names.isNotNullOrEmpty()) {
        // compiler error - names variable might be null
        println("names is ${names.size} items long")
    }
}

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

Это означает, что нам снова нужно определить контракт для этого метода.

@OptIn(ExperimentalContracts::class)
fun Any?.isNotNullOrEmpty(): Boolean {
    contract {
        returns(true) implies (this@isNotNullOrEmpty != null)
    }
    return !this.isNullOrEmpty()
}

И как только мы это сделаем, компилятор будет рад преобразовать нашу names переменную, что снова избавит нас от неприятностей.

fun main() {
    val names: List<String>? = null
    if (names.isNotNullOrEmpty()) {
        println("names is ${names.size} items long")
    }
}

Примеры из стандартной библиотеки

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

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

requireNotNull ()

Обратите внимание на вызов requireNotNull() в следующем фрагменте.

fun main() { 
    val name: String? = null
    requireNotNull(name)
    
    println("name is ${name.length} characters long")
}

Функция requireNotNull() вызовет исключение, если переданная переменная name равна null. После возврата из функции мы можем ссылаться на name, как если бы это был тип non-null.

Как это работает? Как вы уже догадались - контракты.

Ознакомьтесь с реализацией requireNotNull()

public inline fun <T : Any> requireNotNull(value: T?): T {
    contract {
        returns() implies (value != null)
    }
    return requireNotNull(value) { "Required value was null." }
}

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

isNullOrEmpty ()

Другой пример использования контрактов из стандартной библиотеки Kotlin - это функции расширения isNullOrEmpty() в приведенном ниже фрагменте.

/**
 * Returns `true` if this nullable char sequence is either `null` or empty.
 *
 * @sample samples.text.Strings.stringIsNullOrEmpty
 */
@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.length == 0
}
/**
 * Returns `true` if this nullable collection is either null or empty.
 * @sample samples.collections.Collections.Collections.collectionIsNullOrEmpty
 */
@SinceKotlin("1.3")
@kotlin.internal.InlineOnly
public inline fun <T> Collection<T>?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.isEmpty()
}

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

buildMap ()

Стандартная библиотека Kotlin также использует callsInPlace() функцию Contracts DSL, чтобы лучше понять, как будут использоваться функциональные параметры.

В следующем фрагменте показано одно такое использование из реализации buildMap()

@SinceKotlin("1.3")
@ExperimentalStdlibApi
@kotlin.internal.InlineOnly
public inline fun <K, V> buildMap(capacity: Int, @BuilderInference builderAction: MutableMap<K, V>.() -> Unit): Map<K, V> {
    contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) }
    return buildMapInternal(capacity, builderAction)
}

Используя здесь callsInPlace(), он указывает компилятору и разработчикам, что переданная функция builderAction будет вызываться ровно один раз.

Возможные проблемы с использованием API контрактов Kotlin

Включение функции контрактов

Для использования API-интерфейсов контракта требуется добавить возможность включения функции с использованием некоторой комбинации двух аннотаций:

1 - Вы можете добавить аннотацию @ExperimentalContracts к своим функциям, использующим контракт. Если эта аннотация используется, то все вызывающие аннотированные функции должны будут сами подписаться на функцию «Контракты».

@ExperimentalContracts
fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean { ... }

@ExperimentalContracts
fun main() {
    listOf("").isNotNullOrEmpty()
}

2 - Кроме того, вы можете использовать аннотацию @OptIn(ExperimentalContracts::class) для подтверждения. Если используется этот метод, то вызывающие аннотированные функции не нуждаются в аннотации. Однако использование аннотации @OptIn также требует использования следующего аргумента компилятора -Xopt-in=kotlin.RequiresOptIn.

@OptIn(ExperimentalContracts::class)
fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean { ... }

fun main() {
    listOf("").isNotNullOrEmpty()
}

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

Это объявление является экспериментальным, и его использование должно быть помечено как @kotlin .contracts.ExperimentalContracts »или @OptIn (kotlin.contracts.ExperimentalContracts :: class)»

Разные примечания

  • IntelliJ и Android Studio не всегда сразу принимают изменения в контракт. Иногда вам нужно удалить / добавить строку кода, чтобы она правильно сделала вывод о допустимости значения NULL.
  • Контракты могут быть определены только для функций. Их использование для свойств в настоящее время не поддерживается.
  • Контракты пишутся разработчиками, и разработчик должен убедиться, что контракты написаны правильно, чтобы обеспечить желаемое поведение компилятора.

Где узнать больше?

Существует ограниченная документация для построения ваших собственных контрактов. Следующие ресурсы содержат несколько примеров контрактов и выделяют новые функции, которые были добавлены в новых выпусках Kotlin.

Возможно, лучший ресурс для поиска примеров контрактов - это исходный код стандартной библиотеки Kotlin. Обзор реализаций таких методов, как isNullOrEmpty(), measureTime() или buildMap(), может продемонстрировать, как создавать различные типы контрактов, и помочь вам создать свои собственные.

Компания Premise постоянно ищет талантливых инженеров, которые помогут нам улучшить интерфейс, мобильные предложения и облачную инфраструктуру. Узнайте, почему нас постоянно называют одними из лучших мест для работы в Кремниевой долине, посетив нашу страницу Карьера.