404 lines
20 KiB
Markdown
404 lines
20 KiB
Markdown
---
|
||
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)
|