Привет! Меня зовут Виталий. Я — Android‑разработчик в Альфа‑Банке. За время собеседований я заметил одну любопытную вещь: даже опытные котлиноводы частенько не в курсе такой мощной фичи, как Kotlin Contracts.

Когда на собесе спрашивают про Kotlin Contracts
Когда на собесе спрашивают про Kotlin Contracts

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

Какую проблему решают Kotlin Contracts?

Все мы любим Kotlin за умные проверки типов. Например, напишешь так: 

fun foo(x: Any) {
    if (x is List<*>) {
        x.size // Всё ок, компилятор молодец!
    }
}

И всё работает!

Но стоит вынести ту же проверку в отдельную функцию:

fun isList(x: Any): Boolean = x is List<*>

fun foo(x: Any) {
    if (isList(x)) {
        x.size // Ошибка: "Unresolved reference: size"
    }
}

В чём подвох?

Компилятор больше не верит, что после isList(x) переменная x — это List, ведь он не знает, что делает ваша функция.

Вот тут и начинается магия Kotlin Contracts!

С помощью специальной подсказки (контракта) можно объяснить компилятору, как именно устроена ваша логика проверки, и получить такой же «умный» smart‑cast, как и с оператором is, только уже с вашей собственной функцией.

Посмотрим на примере работы функции isList.

@OptIn(ExperimentalContracts::class)
fun isList(x: Any): Boolean {
    contract {
        returns(true) implies (x is List<*>)
    }

    return x is List<*>
}

fun foo(x: Any) {
    if (isList(x)) {
        x.size // Всё ок ??
    }
}

С помощью такого незамысловатого синтаксиса мы говорим Kotlin компилятору: «Если функция вернула true, то x гарантированно типа List<*>».

Давайте вместе залезем внутрь Kotlin Contracts DSL и разберём, какие ещё возможности открывает нам эта удивительная фича.

Contracts DSL

Все функции, связанные с Contracts DSL, располагаются всего в одном небольшом файле ContractBuilder.

Посмотрим, из чего состоит функция contract, с которой начинается взаимодействие c контрактами

public inline fun contract(builder: ContractBuilder.() -> Unit) { }

Здесь всё просто: inline — функция, которая в качестве параметра принимает лямбду с receiver'ом типа ContractBuilder. Данный трюк часто используется для описания своего DSL.

Примечание. Подробнее с возможностями Kotlin DSL вы можете ознакомиться в официальной Kotlin документации Type‑safe builders.

Теперь посмотрим, что из себя представляет ContractBuilder.

public interface ContractBuilder {

    public fun returns(): Returns

    public fun returns(value: Any?): Returns

    public fun returnsNotNull(): ReturnsNotNull

    public fun <R> callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN): CallsInPlace
}

Как видим, в контракте нам доступны 4 функции. Каждая из них возвращает объект‑эффект (Effect), о котором мы чуть позже поговорим отдельно.

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

Если вы уже знаете как читать Kotlin контракты — смело переходите к разделу Contracts API!

Функция returns()

returns() — описывает ситуацию, когда функция завершает свою работу без каких‑либо исключений.

Функция возвращает эффект Returns, который реализует интерфейс SimpleEffect. Внутри этого эффекта прописана функция implies, позволяющая прописывать условия, на которые Kotlin компилятор может гарантированно полагаться, если выполнится эффект SimpleEffect (в частности, если выполнится эффект Returns).

public infix fun implies(booleanExpression: Boolean): ConditionalEffect

returns() на практике:

public inline fun check(value: Boolean): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        throw IllegalStateException("Check failed.")
    }
}

fun foo(str: String?) {
    check(str != null)
    str.length // Тут компилятор уже знает, что "str != null"
}

Компилятор читает этот контракт следующим образом: «Если функция завершилась без исключений, то value истинно (value == true)».

После того как функция check(Boolean) успешно отработает (выполнится эффект Returns), Kotlin компилятор подкинет во Flow анализа данных информацию из параметра функции implies(str != null).

Функция returns(value: Any?)

returns(value: Any?) описывает ситуацию, когда функция завершает свою работу без каких‑либо исключений, и при этом функция возвращает значение указанное в параметре value.

В качестве аргумента в параметр value можно передать одно из трёх значений: true, false и null.

Посмотрим пример:

public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.length == 0
}

fun foo(str: String?) {
    if (!str.isNullOrEmpty()) {
        str.length // Тут компилятор уже знает, что "str != null"
    }
}

Компилятор читает этот контракт следующим образом: «Если функция завершилась без исключений и вернула „false“, то истинно выражение this@isNullOrEmpty!= null (str!= null)».

Функция returnsNotNull()

returnsNotNull() описывает ситуацию, когда функция завершает свою работу без каких‑либо исключений, и при этом функция возвращает из функции не null.

К сожалению, я не нашёл пример использования подобной функции в стандартной библиотеке Kotlin, но от этого returnsNotNull() не менее полезен. Посмотрим на такой пример:

@OptIn(ExperimentalContracts::class)
fun <T : Any> T?.forceCast(): T {
    contract { 
        returnsNotNull() implies (this@forceCast != null) 
    }
    if (this == null) {
        throw IllegalStateException("Object is null")
    } else {
        return this
    }
}

fun foo(str: String?) {
    val nonNullableStr = str.forceCast()
    nonNullableStr.length // Тут компилятор уже знает, что "nonNullableStr != null"
}

Здесь компилятор читает этот контракт следующим образом: «Если функция завершилась без исключений и вернула не null, то истинно выражение this@forceCast!= null (str!= null)».

Функция callsInPlace(Function, InvocationKind)

callsInPlace(Function, InvocationKind) передаёт компилятору информацию о том, что лямбда, переданная в функцию, не будет вызвана после завершения функции, а также опционально передает информацию о том, сколько раз может вызваться лямдба внутри функции. Это позволяет Kotlin компилятору снять механизм защиты на инициализацию val свойства внутри лямбды.

Посмотрим, как callsInPlace(Function, InvocationKind) работает на таком примере:

public inline fun <R> run(block: () -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

fun foo() {
    val x: Int
    run {
        x = 1 // Тут компилятор уже знает, лямбда вызовется только 1 раз, а значит не перезатрём значение `val`
    }
    println(x)
}

Компилятор читает этот контракт следующим образом: «Лямбда block будет вызвана только внутри текущей функции и будет вызвана гарантированно один раз».

InvocationKind определяет как много раз лябда переданная в функцию может быть вызвана внутри функции:

public enum class InvocationKind {
    AT_MOST_ONCE, // Будет вызвана 0 или 1 раз
    AT_LEAST_ONCE, // Будет вызвана [1..] раз
    EXACTLY_ONCE, // Будет вызвана ровно 1 раз
    UNKNOWN // Может быть вызвана любое кол-во раз
}

Ну что ж, с «детскими» вопросами разобрались: зачем нужны контракты, где они уже применяются и как выглядят в стандартных функциях.

Но на этом магия не заканчивается — давайте нырнём чуть глубже и посмотрим, как этот API устроен изнутри: какие там есть эффекты, как они сочетаются, и как на самом деле выглядит «контрактная кухня» Kotlin.

Contracts API

Общий вид контракта такой:

[
  effect_1
  effect_2
  ...
  effect_n
]
fun function() {
  ...
}

Можно читать это так: «вызов этой функции приводит к списку эффектов effect_1, effect_2,..., effect_n».

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

@OptIn(ExperimentalContracts::class)
fun <T : Any> requireNotNull(value: T?): T {
    contract {
        returnsNotNull() implies (value != null) // effect_1
    }
    ...
}

Наверняка, даже если вы уже встречали Kotlin‑контракты в стандартной библиотеке, то про такую штуку, как Effect (эффект), слышали редко. А между тем — именно эффекты лежат в самой основе всей магии Kotlin Contracts.

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

Effect'ы

Поведение функции в контракте описывается с помощью эффектов (Effect).

Эффект — понятие довольно широкое: по сути, это любое знание о состоянии программы, которое появляется после вызова функции. Например, если у функции f есть эффект e, это значит, что при вызове f возникает эффект e.
Компилятор отслеживает все возникшие эффекты и использует их, чтобы строить более умный анализ.

Все эффекты расположены в файле Effect.kt. Мы можем представить их в виде дерева.

Граф эффектов
Граф эффектов

Давайте рассмотрим каждый эффект по‑отдельности.

Effect

Effect — это обычный интерфейс‑маркер, от которого наследуются все эффекты.

public interface Effect

SimpleEffect

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

Импликация — это бинарная логическая связка, по своему применению приближенная к союзам «если…, то…». Посмотрим на общий пример импликации:

Effect -> Condition

Читать это выражение можно следующим образом: «Если функция излучила эффект Effect, то условие Condition гарантировано истинно».

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

В исходниках стандартной библиотеки Kotlin эффект SimpleEffect выглядит следующим образом:

public interface SimpleEffect : Effect {
  /**
    Specifies that this effect, when observed, guarantees [booleanExpression] to be true.
   
    Note: [booleanExpression] can accept only a subset of boolean expressions,
    where a function parameter or receiver (this) undergoes
    - true of false checks, in case if the parameter or receiver is Boolean;
    - null-checks (== null, != null);
    - instance-checks (is, !is);
    - a combination of the above with the help of logic operators (&&, ||, !).
   */
   public infix fun implies(booleanExpression: Boolean): ConditionalEffect
 }

Обратите внимание на комментарий! Тут подсветили 2 важных момента:

№1. В аргументы функции implies мы можем передавать только параметры функции, к которой относится контракт, и receiver (this).

№2. booleanExpression может принимать только подмножество таких операций, как:

  • проверки на true или false (например: ... implies (value == true) или ... implies value);

  • проверки на null (например: ... implies (value != null) или ... implies (value != null));

  • проверки на соответствие типу данных (например: ... implies (value is String) или ... implies (value !is String));

  • и комбинации выражений выше, используя логические операторы &&, ||, !

Кстати, обратите внимание, что при использовании this в контракте Extension функции необходимо использовать label, иначе Kotlin компилятор обратиться к ContractBuilder, который предоставляется функцией contract. Например, написать вот так не получится:

fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this != null) // Ошибка: Error in contract description: 'this' can only be a qualified reference to the extension receiver of contract owner..
    }

    return this == null || this.length == 0
}

А так получится:

fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null) // Всё ок ??
    }

    return this == null || this.length == 0
}

Теперь давайте поговорим о наследниках SimpleEffect: Returns и ReturnsNotNull.

Returns

Returns — возникает при обычном вызове и выполнении функции. Является наследником SimpleEffect.

public interface Returns : SimpleEffect

ReturnsNotNull

ReturnsNotNull — данный эффект произойдет только если функция возвращает не нулевое значение. Является наследником SimpleEffect.

public interface ReturnsNotNull : SimpleEffect

ConditionalEffect

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

Пример использования ConditionalEffect:

fun CharSequence?.isNull(): Boolean {
    contract {
        returns(false) implies (this@isNull != null)
    }

    return this == null
}

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

[Returns(false) -> this@isNull != null] // effect_1
fun CharSequence?.isNull(): Boolean {
    return this == null
}

Читать этот контракт можно следующим образом: «Если функция вернёт false, то выражение 'this@isNullOrEmpty != null' гарантированно истинно».

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

[Returns(true) -> this@isNull == null] // effect_1
fun CharSequence?.isNull(): Boolean {
    return this == null
}

Но такой эффект не будет иметь никакой практической пользы. Давайте посмотрим на практике как будет работать функция с эффектом Returns(true).

[Returns(true) -> this@isNull == null] // effect_1
fun CharSequence?.isNull(): Boolean {
    return this == null
}

fun foo() {
  val myString: String? = ""
  if (!myString.isNull()) { // .isNull() вернет false
    myString.length // Ошибка: Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
  }
}

Почему произошла ошибка компиляции? Дело в том, что список эффектов функции выглядит так: [Returs(true) -> ...]. Функция isNull() в данном случае вернёт false. Компилятор попробует найти в списке эффектов функции эффект Returns(false), но такого эффекта там не окажется, значит компилятор не получится никакой дополнительной информации о функции, и в блоке if компилятор будет считать, что myString может быть null.

Лайфхак: когда пишете контракт для функции — обращайте внимание на контекст её применения. Эффекты, которые вы опишите в контрактах должны быть актуальны для контекста использования функции

CallsInPlace

CallsInPlace — данный эффект помогает подсказать компилятору, что lambda, которая будет передаваться в функцию f будет вызвана в рамках функции f и не будет вызываться после завершения работы функции f. Дополнительно мы можем передать информацию компилятору о том, сколько раз будет вызвана lambda в рамках функции f.

В исходниках Kotlin библиотеки CallsInPlace эффект выглядит так:

public interface CallsInPlace : Effect

А когда эффект нам может пригодиться? Смотрим на пример ниже.

fun methodForInitialisation(call: (Int) -> Unit) {
    call(15)
}

fun main() {
    val number: Int
    methodForInitialisation { value ->
        number = value // Ошибка: Captured values cannot be initialized because of possible reassignments.
    }
    println(number)
}

В данном примере мы хотим при вызове лямбды call инициализировать val number свойство. Однако компилятор не даст нам это сделать, потому что не знает, а точно ли лямбда вызывается 1 раз? Если лямбда будет вызвана несколько раз, то значение в val number перезапишется, однако val переменная не должна перезаписываться.

С помощью контракта с эффектом CallsInPlace мы можем решить эту проблему. В рамках данного эффекта мы говорим компилятору, что лямбда call будет вызвана в рамках methodForInitialisation ровно 1 раз.

@OptIn(ExperimentalContracts::class)
fun methodForInitialisation(call: (Int) -> Unit) {
    contract {
        callsInPlace(call, InvocationKind.EXACTLY_ONCE)
    }
    call(15)
}

fun main() {
    val number: Int
    methodForInitialisation { value ->
        number = value // Всё ок ??
    }
    println(number) // Здесь будет 15
}

Вуаля, и ошибки больше нет! Теперь компилятор знает, что val будет инициализировано ровной 1 раз.

Доверие компилятора

А что будет. если мы скажем компилятору, что лямбда вызовем 1 раз, но по факту будем вызывать дважды? Другими словами, что будет, если "соврём" компилятору? ?

@OptIn(ExperimentalContracts::class)
fun methodForInitialisation(call: (Int) -> Unit) {
    contract {
        callsInPlace(call, InvocationKind.EXACTLY_ONCE) // Компилятор ругается, но даст выполнить
    }
    call(30)
    call(15)
}

fun main() {
    val number: Int
    methodForInitialisation { value ->
        number = value
    }
    println(number) // Здесь будет 15
}

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

Помните: контракты — это договорённость с компилятором. Нарушил договор — баги сам расхлёбывай!

Итоги

Contracts в Kotlin — недооценённая, но мощная фича ?. Она позволяет договориться с компилятором о поведении функций и тем самым избавиться от лишних !!, кастов и паранойи по поводу null. Особенно полезна с валидаторами и DSL, где важно точно знать, что происходит. Главное — помнить: контракт нужно выполнять. Это не подсказка, а обещание. Нарушишь — словишь багов. Используешь с умом — получаешь читаемый, надёжный и уверенный в себе код.

Это была первая часть. Во второй части мы разберём новые фичи контрактов, которые вот-вот появятся на свет. Мы заглянем в их начинку, покопавшись в исходниках компилятора Kotlin — и даже опробуем одну из них в деле!

Дополнительные материалы


Подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.

Может быть интересно:

Корутины с точки зрения компилятора
Привет! Меня зовут Абакар, я работаю главным техническим лидером разработки в Альфа-Банке. Сегодня м...
habr.com
Мечтают ли андроиды о Robolectric? Разбираем фреймворк по косточкам
Иногда наступают моменты в карьере, когда ты хочешь сделать следующий шаг в своём развитии, но можеш...
habr.com
Хочешь стать техлидом? Возможно, что не стоит
Привет! Меня зовут Абакар, я работаю главным техническим лидером разработки в Альфа-Банке. В этой ст...
habr.com
Что происходит с вашим JavaScript-кодом внутри V8. Часть 1
В этой серии статей мы пройдемся по каждому этапу работы V8: лексическому и синтаксическому анализу,...
habr.com

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