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

404 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Проверки и контракты
description: require, check, error, assert и контракты компилятора для умного приведения
tags:
- kotlin
- exceptions
---
Стандартная библиотека даёт четыре функции для проверки предпосылок. Выбор между ними определяется тем, **кто виноват** в нарушении.
| Функция | Бросает | Виноват | Что проверяет |
|---|---|---|---|
| `require` | `IllegalArgumentException` | вызывающий | аргументы |
| `check` | `IllegalStateException` | вызывающий или сам объект | состояние |
| `error` | `IllegalStateException` | - | недостижимая ветка |
| `assert` | `AssertionError` | - | инвариант при отладке |
## require - проверка аргументов
```kotlin
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
}
}
```
Сообщение передаётся лямбдой, поэтому строка собирается только при нарушении - в успешном случае интерполяция не выполняется.
```kotlin
fun main() {
val n = 5
require(n > 0) // без сообщения: "Failed requirement."
require(n > 0) { "дорогое сообщение: ${(1..1000).sum()}" } // лямбда не вызовется
println("проверки пройдены")
}
```
### requireNotNull
Проверяет и **возвращает** non-null значение:
```kotlin
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) // имя обязательно
}
}
```
Работает и как инструкция - после вызова переменная умно приводится:
```kotlin
fun process(input: String?): Int {
requireNotNull(input)
return input.length // smart cast: String? -> String
}
fun main() { println(process("abc")) } // 3
```
Это работает благодаря контракту, см. ниже.
## check - проверка состояния
```kotlin
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` для состояния:
```kotlin
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`, поэтому она подходит в любое выражение:
```kotlin
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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md)
`TODO()` - то же для незаписанного кода:
```kotlin
fun futureFeature(): String = TODO("реализовать после релиза")
fun main() {
try {
futureFeature()
} catch (e: NotImplementedError) {
println(e.message) // An operation is not implemented: реализовать после релиза
}
}
```
`TODO()` возвращает `Nothing`, поэтому подходит на место любого типа - код компилируется, пока функция не написана.
## assert - только для отладки
```kotlin
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` даёт умное приведение.
```kotlin
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`:
```kotlin
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
}
```
### Виды контрактов
```kotlin
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` подходит для нарушений, которые означают баг. Для пользовательского ввода лучше вернуть ошибку как значение:
```kotlin
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](Result%20и%20runCatching.md)
## Инварианты в конструкторе
```kotlin
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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Value%20class.md), [Инициализация](../ООП/Инициализация.md)
> [!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` означает, что конкатенация происходит только при нарушении:
> ```kotlin
> 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](../../Программирование%20на%20Java/Исключения/Система%20исключений.md), [Инкапсуляция](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md)
> [!warning] Подводные камни
> **`assert` по умолчанию выключен.** Без `-ea` при запуске JVM проверка не выполняется совсем:
> ```kotlin
> fun main() {
> assert(1 == 2) { "это сообщение вы не увидите без -ea" }
> println("программа продолжила работу")
> }
> ```
> Код, который полагается на `assert` для защиты инвариантов в продакшене, не защищён ничем. Используйте `check`.
>
> **Путаница `require` и `check`.** Тип исключения - часть контракта: вызывающий может ловить `IllegalArgumentException`, чтобы отличить «я передал мусор» от «объект не готов». Перепутанные функции делают эту информацию ложной.
>
> **Сообщение без контекста бесполезно:**
> ```kotlin
> 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`:
> ```kotlin
> 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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md)
>
> **`require` внутри цикла по большой коллекции стоит дорого** не из-за проверки, а из-за того, что при нарушении создаётся исключение со стектрейсом. Для валидации списков собирайте список ошибок, а не бросайте на первой.
Дополнительно: [Система исключений](Система%20исключений.md)
Далее: [Коллекции](../Коллекции/Коллекции.md)