20 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Проверки и контракты | require, check, error, assert и контракты компилятора для умного приведения |
|
Стандартная библиотека даёт четыре функции для проверки предпосылок. Выбор между ними определяется тем, кто виноват в нарушении.
| Функция | Бросает | Виноват | Что проверяет |
|---|---|---|---|
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 castassert x > 0 : "msg"assert(x > 0) { "msg" }Guava Preconditions.checkArgumentrequireв stdlibthrow 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внутри цикла по большой коллекции стоит дорого не из-за проверки, а из-за того, что при нарушении создаётся исключение со стектрейсом. Для валидации списков собирайте список ошибок, а не бросайте на первой.
Дополнительно: Система исключений Далее: Коллекции