Files
obsidian/Kotlin/Исключения/Проверки и контракты.md
T

20 KiB
Raw Blame History

title, description, tags
title description tags
Проверки и контракты require, check, error, assert и контракты компилятора для умного приведения
kotlin
exceptions

Стандартная библиотека даёт четыре функции для проверки предпосылок. Выбор между ними определяется тем, кто виноват в нарушении.

Функция Бросает Виноват Что проверяет
require IllegalArgumentException вызывающий аргументы
check IllegalStateException вызывающий или сам объект состояние
error IllegalStateException - недостижимая ветка
assert AssertionError - инвариант при отладке

require - проверка аргументов

fun withdraw(balance: Long, amount: Long): Long {
    require(amount > 0) { "сумма должна быть положительной, получено $amount" }
    require(amount <= balance) { "недостаточно средств: $amount > $balance" }
    return balance - amount
}

fun main() {
    println(withdraw(100, 30))      // 70
    try {
        withdraw(100, -5)
    } catch (e: IllegalArgumentException) {
        println(e.message)           // сумма должна быть положительной, получено -5
    }
}

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

fun main() {
    val n = 5
    require(n > 0)                       // без сообщения: "Failed requirement."
    require(n > 0) { "дорогое сообщение: ${(1..1000).sum()}" }   // лямбда не вызовется
    println("проверки пройдены")
}

requireNotNull

Проверяет и возвращает non-null значение:

fun greet(name: String?): String {
    val safe = requireNotNull(name) { "имя обязательно" }
    return "Привет, ${safe.uppercase()}"      // safe имеет тип String
}

fun main() {
    println(greet("иван"))      // Привет, ИВАН
    try {
        greet(null)
    } catch (e: IllegalArgumentException) {
        println(e.message)       // имя обязательно
    }
}

Работает и как инструкция - после вызова переменная умно приводится:

fun process(input: String?): Int {
    requireNotNull(input)
    return input.length          // smart cast: String? -> String
}

fun main() { println(process("abc")) }    // 3

Это работает благодаря контракту, см. ниже.

check - проверка состояния

class Connection2 {
    private var open = false

    fun connect() { open = true }

    fun send(data: String): String {
        check(open) { "соединение не открыто" }
        return "отправлено: $data"
    }
}

fun main() {
    val c = Connection2()
    try {
        c.send("данные")
    } catch (e: IllegalStateException) {
        println(e.message)       // соединение не открыто
    }
    c.connect()
    println(c.send("данные"))    // отправлено: данные
}

Разница с require только в типе исключения, но она информативна: IllegalArgumentException говорит «ты передал не то», IllegalStateException - «ты вызвал не вовремя».

checkNotNull - аналог requireNotNull для состояния:

class Service18 {
    private var config: String? = null

    fun init2(value: String) { config = value }

    fun run2(): String {
        val cfg = checkNotNull(config) { "сначала вызовите init2()" }
        return "работаю с $cfg"
    }
}

fun main() {
    val s = Service18()
    try {
        s.run2()
    } catch (e: IllegalStateException) {
        println(e.message)       // сначала вызовите init2()
    }
    s.init2("настройки")
    println(s.run2())            // работаю с настройки
}

error - недостижимая ветка

error(msg) бросает IllegalStateException. Её тип - Nothing, поэтому она подходит в любое выражение:

fun describe(code: Int): String = when (code) {
    200 -> "ок"
    404 -> "не найдено"
    500 -> "ошибка сервера"
    else -> error("неизвестный код: $code")
}

fun main() {
    println(describe(200))      // ок
    try {
        describe(999)
    } catch (e: IllegalStateException) {
        println(e.message)       // неизвестный код: 999
    }
}

Благодаря типу Nothing компилятор понимает, что ветка не возвращает значение, и when остаётся выражением. Подробнее: Any, Unit, Nothing

TODO() - то же для незаписанного кода:

fun futureFeature(): String = TODO("реализовать после релиза")

fun main() {
    try {
        futureFeature()
    } catch (e: NotImplementedError) {
        println(e.message)       // An operation is not implemented: реализовать после релиза
    }
}

TODO() возвращает Nothing, поэтому подходит на место любого типа - код компилируется, пока функция не написана.

assert - только для отладки

fun average(values: List<Int>): Double {
    val result = values.sum().toDouble() / values.size
    assert(result >= values.min()) { "среднее меньше минимума — ошибка в вычислении" }
    return result
}

fun main() {
    println(average(listOf(1, 2, 3)))      // 2.0
}

assert проверяется только при запуске JVM с флагом -ea (-enableassertions). Без него вызов ничего не делает, но лямбда-сообщение всё равно остаётся объектом в байт-коде.

Практический вывод: assert в Kotlin почти не используют. Для проверок, которые должны работать в продакшене, берут require и check.

Контракты

Контракт - способ сообщить компилятору то, что он не может вывести сам. Именно поэтому requireNotNull даёт умное приведение.

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract

@OptIn(ExperimentalContracts::class)
fun String?.isNotNullOrBlank(): Boolean {
    contract { returns(true) implies (this@isNotNullOrBlank != null) }
    return !this.isNullOrBlank()
}

fun main() {
    val input: String? = "текст"
    if (input.isNotNullOrBlank()) {
        println(input.length)      // smart cast: String? -> String
    }
}

Без контракта input.length потребовал бы !! или ?. - компилятор не знает, что true из функции означает non-null.

Стандартная библиотека использует контракты в isNullOrEmpty, isNullOrBlank, require, check, requireNotNull:

fun main() {
    val s: String? = "abc"
    if (!s.isNullOrEmpty()) {
        println(s.length)          // smart cast работает благодаря контракту
    }

    val n: Int? = 5
    check(n != null)
    println(n + 1)                 // 6, smart cast после check
}

Виды контрактов

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract

@OptIn(ExperimentalContracts::class)
fun requirePositive(value: Int?) {
    contract { returns() implies (value != null) }     // вернулась -> value не null
    require(value != null && value > 0) { "нужно положительное число" }
}

@OptIn(ExperimentalContracts::class)
inline fun <T> runOnce(block: () -> T): T {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    return block()
}

fun main() {
    val x: Int? = 5
    requirePositive(x)
    println(x + 1)                 // 6 — smart cast

    val value: String                // можно не инициализировать...
    runOnce { value = "присвоено" }  // ...потому что контракт гарантирует один вызов
    println(value)                   // присвоено
}

callsInPlace с EXACTLY_ONCE позволяет присваивать val внутри лямбды - компилятор знает, что присваивание произойдёт ровно один раз. На этом работают run, let, apply и with.

Контракт Что обещает
returns() implies (x) если функция вернулась нормально, условие x истинно
returns(true) implies (x) если вернула true, условие x истинно
returns(null) implies (x) если вернула null, условие x истинно
callsInPlace(f, AT_MOST_ONCE) лямбда вызовется не больше одного раза
callsInPlace(f, EXACTLY_ONCE) ровно один раз
callsInPlace(f, AT_LEAST_ONCE) хотя бы один раз

Требования к контракту:

  • только для функций верхнего уровня (не для методов класса)
  • блок contract - первая инструкция в теле
  • API экспериментальный: нужен @OptIn(ExperimentalContracts::class)
  • компилятор не проверяет, что контракт соответствует коду

Последний пункт - главный риск: контракт это обещание на доверии.

Валидация данных: исключение или значение

require подходит для нарушений, которые означают баг. Для пользовательского ввода лучше вернуть ошибку как значение:

data class Form4(val email: String, val age: Int)

fun validate(email: String, age: Int): List<String> = buildList {
    if ("@" !in email) add("email без @")
    if (age !in 0..150) add("недопустимый возраст: $age")
}

fun create(email: String, age: Int): Result<Form4> {
    val errors = validate(email, age)
    return if (errors.isEmpty()) Result.success(Form4(email, age))
    else Result.failure(IllegalArgumentException(errors.joinToString("; ")))
}

fun main() {
    println(create("a@b.ru", 20).getOrNull())                    // Form4(email=a@b.ru, age=20)
    println(create("нет", 200).exceptionOrNull()?.message)       // email без @; недопустимый возраст: 200
}

Разница: require падает на первой ошибке, а пользователю обычно нужен список всех. Подробнее: Result и runCatching

Инварианты в конструкторе

class Range4(val from: Int, val to: Int) {
    init {
        require(from <= to) { "неверный диапазон: $from..$to" }
    }

    val size get() = to - from + 1
}

@JvmInline
value class Email(val value: String) {
    init {
        require("@" in value) { "не похоже на email: $value" }
    }
}

fun main() {
    println(Range4(1, 5).size)      // 5
    println(Email("a@b.ru").value)  // a@b.ru
    try {
        Range4(5, 1)
    } catch (e: IllegalArgumentException) {
        println(e.message)           // неверный диапазон: 5..1
    }
}

Проверка в конструкторе гарантирует, что невалидного объекта не существует, - дальше по коду проверять не нужно. Подробнее: Value class, Инициализация

[!info] Отличия от Java

Java Kotlin
if (x < 0) throw new IllegalArgumentException(...) require(x >= 0) { ... }
Objects.requireNonNull(x) requireNotNull(x) + smart cast
assert x > 0 : "msg" assert(x > 0) { "msg" }
Guava Preconditions.checkArgument require в stdlib
throw new UnsupportedOperationException() TODO()
нет аналога контракты для smart cast

require и check - это то, за чем в Java идут в Guava. Preconditions.checkArgument и checkState из Guava выполняют ту же роль, но требуют внешней зависимости. В Kotlin они в stdlib и вдобавок inline, то есть вызова функции в байт-коде не остаётся.

Сообщение в лямбде экономит работу. В Java checkArgument("плохое значение: " + value) собирает строку всегда, даже когда проверка пройдена. Передача лямбды плюс inline означает, что конкатенация происходит только при нарушении:

fun main() {
    val value = 5
    require(value > 0) { "дорогая строка: ${value.toString().repeat(1000).length}" }
    println("лямбда не вызывалась")
}

requireNotNull даёт smart cast, Objects.requireNonNull - нет. В Java результат нужно присвоить: this.name = Objects.requireNonNull(name). В Kotlin после проверки компилятор сам считает переменную non-null.

Контрактов в Java нет совсем. Ближайший аналог - аннотации @Nullable/@NotNull и внешние анализаторы, которые работают вне компилятора и не влияют на типизацию.

Сравнить: Система исключений в Java, Инкапсуляция

[!warning] Подводные камни assert по умолчанию выключен. Без -ea при запуске JVM проверка не выполняется совсем:

fun main() {
    assert(1 == 2) { "это сообщение вы не увидите без -ea" }
    println("программа продолжила работу")
}

Код, который полагается на assert для защиты инвариантов в продакшене, не защищён ничем. Используйте check.

Путаница require и check. Тип исключения - часть контракта: вызывающий может ловить IllegalArgumentException, чтобы отличить «я передал мусор» от «объект не готов». Перепутанные функции делают эту информацию ложной.

Сообщение без контекста бесполезно:

fun main() {
    val size = -1
    try {
        require(size >= 0) { "неверный размер" }          // какой именно?
    } catch (e: IllegalArgumentException) {
        println(e.message)
    }
    try {
        require(size >= 0) { "размер должен быть >= 0, получено $size" }
    } catch (e: IllegalArgumentException) {
        println(e.message)       // размер должен быть >= 0, получено -1
    }
}

require для пользовательского ввода - ошибка проектирования. Некорректный ввод - штатная ситуация, а не баг. Исключение в этом случае заставляет клиентский код оборачивать каждый вызов в try/catch и сообщает только о первой проблеме.

Контракт не проверяется компилятором. Неверный контракт ломает проверки типов - компилятор поверит обещанию и уберёт нужные проверки на null:

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract

@OptIn(ExperimentalContracts::class)
fun String?.lies(): Boolean {
    contract { returns(true) implies (this@lies != null) }
    return true                    // врёт: true даже для null
}

fun main() {
    val s: String? = null
    if (s.lies()) {
        try {
            println(s.length)      // компилятор уверен, что s != null
        } catch (e: NullPointerException) {
            println("NPE в non-null коде из-за неверного контракта")
        }
    }
}

Контракт нельзя объявить в методе класса - только в функции верхнего уровня. Это ограничение текущей реализации.

Контракты экспериментальны - @OptIn(ExperimentalContracts::class) обязателен, API может измениться.

TODO() компилируется и попадает в релиз. Он возвращает Nothing, поэтому ошибок сборки не вызывает - только падение в рантайме. Перед релизом стоит проверять: grep -r 'TODO(' src/.

Проверки в init не спасают data class от copy в версиях до Kotlin 2.1: copy() вызывает конструктор, то есть init выполнится, но саму валидацию обойти через copy нельзя - а вот приватный конструктор обойти можно. Подробнее: Data class

require внутри цикла по большой коллекции стоит дорого не из-за проверки, а из-за того, что при нарушении создаётся исключение со стектрейсом. Для валидации списков собирайте список ошибок, а не бросайте на первой.

Дополнительно: Система исключений Далее: Коллекции