Pull to refresh

Контракты в Kotlin или как заключить сделку с совестью

Level of difficultyEasy
Reading time7 min
Views6K

Всем привет, меня зовут Иван, я Android-разработчик и сегодня мы с вами поговорим об контрактах. Предвосхищая вопрос попробую сразу дать все ответы:

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

  1. Нормальной статьи на русском языке я не обнаружил;

  2. Я решил выбрать эту тему, так как мы сталкиваемся с контрактами в нашей повседневности, при этом не осознавая, как именно работает та или иная вещь;

  3. Это моя первая проба пера и мне хотелось начать с чего-нибудь достаточно легкого, но при этом интересного.

Вводная часть

Во-первых, разберемся с основными понятиями:

  1. Контракт – это договор с компилятором, который мы используем чтобы обозначить определенное поведение на участке кода, он состоит из эффектов;

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

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

fun main() {
    `Сесть на стул`()
    `Долго работать`()
    `Встать со стула`() // Ошибка, боль в спине
}

Мы можем ее легко решить, разделив процесс.

fun main() {
    `Сесть на стул`()
    Работать()
    `Встать со стула`()
    Прогуляться()
    `Сесть на стул`()
    Работать()
    `Встать со стула`() // Успех!!!
}

Также и с кодом, вызывая определенные методы мы оказываем эффект на всю логику в месте вызова.

Как же контракты реализованы в Kotlin?

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

Эффекты

Взглянем на древо эффектов и расскажем об каждом по-отдельности.

Effect – это отправная точка для всех эффектов, верхний уровень абстракции. Она оповещает что функция имеет какой-то эффект, не вдаваясь в особые детали.

SimpleEffect – также как и Effect оповещает что функция влияет на участок кода, но уточняет, что он возникает по факту вызова самой функции. В текущей реализации контрактов делится на два подкласса:

  1. Returns – возникает при обычном вызове и выполнении функции;

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

Что Returns, что ReturnsNotNull описывают ситуации, когда мы что-либо возвращаем. При этом стоит учесть, что речь не идет о том, что мы можем вернуть только эти значения.

// Компилятор не видит никаких проблем
// мы лишь описываем ситуацию, что может вернуться true
@OptIn(ExperimentalContracts::class)
fun isAlwaysTrue(): Boolean{
    contract { 
        returns(true)
    }
    
    return false
}

fun main() {
    if(isAlwaysTrue()) println("Всегда true")
}

ConditionalEffect – это эффект, который возникает при выполнении какого-то условия. Он используется в связке с Simple-эффектами и представляет собой ситуацию: если было возвращено X, то условие Y верно.

@OptIn(ExperimentalContracts::class)
fun isNotNullIfTrue(obj: Any?): Boolean {
    contract { 
        returns(true) implies(obj != null)
    }
    
    return obj != null
}

fun main() {
    val obj: Any? = Any()
    if(isNotNullIfTrue(obj)) {
        obj?.toString() // Компилятор подсвечивает, что ? не нужен
    }
}

CallsInPlace – данный эффект оказался для моего понимания наиболее сложным элементом из всей библиотеки, но наличие простого примера расставило все на свои места.

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

fun main() {
    val number: Int
    run { 
        number = 5
    }
    println(number)
}

Отлично, значит попробуем написать собственный метод инициализации.

// Представим, что нам нужно инициализировать только в определенном потоке
// за поток отвечает этот метод
fun methodForInitialisation(call: () -> Unit) {
    // Сложный код вызова в отдельном потоке
    // ...
    // Вызов нашей функции
    call()
}

fun main() {
    val number: Int
    methodForInitialisation {
        number = 5 // Здесь возникает ошибка
    }
    println(number) // Здесь возникает ошибка
}

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

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

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

// Представим, что нам нужно инициализировать только в определенном потоке
// за поток отвечает этот метод
@OptIn(ExperimentalContracts::class)
fun methodForInitialisation(call: () -> Unit) {
    contract { 
        callsInPlace(call, InvocationKind.EXACTLY_ONCE)
    }
    // Сложный код вызова в отдельном потоке
    // ...
    // Вызов нашей функции
    call()
}

fun main() {
    val number: Int
    methodForInitialisation {
        number = 5
    }
    println(number) // Теперь все пройдет успешно
}

CallsInPlace позволяет отслеживать не только единоразовый вызов но и остальные виды (они представлены в enum-классе InvocationKind).

Необычное поведение

Благодаря этому эффекту мы также можем обманывать компилятор, из-за чего получаются такие курьезы

// Представим, что нам нужно инициализировать только в определенном потоке
// за поток отвечает этот метод
@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
}

DSL язык

При написании примеров мы использовали методы contracts, callsInPlace и returns. Что это такое, какие еще есть и как ими правильно пользоваться?

  • contract – высокоуровневый метод, который позволяет нам объявить сам контракт. Он используется при создании абсолютно всех контрактов и является отправной точкой для работы с API.

  • returns, returnsNotNull, callsInPlace – строительные блоки для создания эффектов типа Returns, ReturnsNotNull, CallsInPlace соответственно. Могут быть вызваны только при создании контракта, так как работают с контекстом ContractBuilder, который предоставляется только методом contract. Стоит отметить что returns может принимать три вида значения (true/false/null), благодаря которым компилятор может отследить какой эффект оказывается на участок кода при возвращении разных значений;

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

В качестве закрепления предлагаю рассмотреть набор стандартных примеров.

@OptIn(ExperimentalContracts::class)
fun objectNotNullIfTrue(obj: Any?): Boolean {
    contract { 
        returns(true) implies(obj != null) 
    }
    return obj != null
}

@OptIn(ExperimentalContracts::class)
fun require(obj: Any?) {
    contract { 
        returns() implies(obj != null)
    }
    
    if(obj == null) throw Exception("Object is null")
}

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

Попытка соорудить велосипед №1

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

abstract class Motor {
    abstract fun doNoise()
}

class ElectroMotor: Motor() {
    fun iAmElectro() = true
    override fun doNoise() = println("BipBip")
}

class DieselMotor: Motor() {
    fun iAmDiesel() = true
    override fun doNoise() = println("Wruuum")
}

abstract class Car(val mainMotor: Motor, val additionalMotor: Motor?)
class Electro(mainMotor: ElectroMotor) : Car(mainMotor, null)
class Hybrid(mainMotor: Motor, additionalMotor: Motor) : Car(mainMotor, additionalMotor)
class Diesel(mainMotor: DieselMotor) : Car(mainMotor, null)

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

fun main() {
    val car = Hybrid(mainMotor = ElectroMotor(), additionalMotor = DieselMotor())
    checkCar(car)
}

fun checkCar(car: Car) {
    when {
        car is Hybrid -> println("Doing with hybrid")
    }
}

Мы бы могли продолжить расписывать подобным образом каждую конфигурацию отдельно, но можно только представить, каким по итогу захламленным и неудобным станет наш метод

fun checkCar(car: Car) {
    when {
        // И это только начало...
        car is Hybrid && car.mainMotor is ElectroMotor && car.additionalMotor is DieselMotor -> {
            println("Doing with hybrid and main electro motor and diesel additional motor")
        }
    }
}

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

fun isHybridCarWithMainElectroAndDieselAdditional(car: Car): Boolean {
    return car is Hybrid && car.mainMotor is ElectroMotor && car.additionalMotor is DieselMotor
}

Отлично, так стало гораздо лучше и теперь мы можем легко проверить какая конфигурация перед нами и выполнить поставленную перед нами задачу

fun checkCar(car: Car) {
    when {
        isHybridCarWithMainElectroAndDieselAdditional(car) -> {
            println("Doing with hybrid and main electro motor and diesel additional motor")
            car.mainMotor.iAmElectro() // Ошибка!!!
        }
    }
}

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

@OptIn(ExperimentalContracts::class)
fun isHybridCarWithMainElectroAndDieselAdditional(car: Car): Boolean {
    contract {
        returns(true) implies(car is Hybrid)
        returns(true) implies(car.mainMotor is ElectroMotor) // Ошибка 'mainMotor' is not a value parameter
        returns(true) implies(car.additionalMotor is DieselMotor) // Ошибка 'additionalMotor' is not a value parameter
    }
    return car is Hybrid && car.mainMotor is ElectroMotor && car.additionalMotor is DieselMotor
}

К сожалению, в текущей реализации контрактов мы не можем проверять те параметры, которые зашиты в класс Car, как же тогда нам поступить? Давайте попробуем вынести моторы как отдельные параметры

@OptIn(ExperimentalContracts::class)
fun isHybridCarWithMainElectroAndDieselAdditional(car: Car, mainMotor: Motor, additionalMotor: Motor?): Boolean {
    contract {
        returns(true) implies(car is Hybrid)
        returns(true) implies(mainMotor is ElectroMotor)
        returns(true) implies(additionalMotor is DieselMotor)
    }
    return car is Hybrid && mainMotor is ElectroMotor && additionalMotor is DieselMotor
}

fun checkCar(car: Car) {
    when {
        isHybridCarWithMainElectroAndDieselAdditional(car, car.mainMotor, car.additionalMotor) -> {
            println("Doing with hybrid and main electro motor and diesel additional motor")
            car.mainMotor.iAmElectro() // Успех!!!
        }
    }
}

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

Итог

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

При подготовке к написанию статьи я изучал материалы по контрактам и натолкнулся на очень интересное видео от команды JetBrains, где описывались возможности, которые они собирались внедрить в этот API. Если кого-то, как и меня это заинтересовало, то прошу по ссылке

Также, если у вас остались вопросы или хотите просто покритиковать, то прошу в комментарии

Tags:
Hubs:
+7
Comments8

Articles