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

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

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

Апдейт. А вот и вторая часть из серии. В ней я изучил то, о чём в документации не пишут: как парсится список эффектов, как работает новый Contracts API изнутри, и почему, чёрт возьми, на уровне компилятора можно использовать контракты не только на уровне функций.

Что не пишут в документации Kotlin Contracts: тёмные закоулки и пасхалки
Небольшой дисклеймер Я не писал компилятор Kotlin и, как и любой живой человек, мог что‑то упустить ...
habr.com

Какую проблему решают 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