Исключения в Kotlin и их особенности

Наша компания уже более двух лет использует Kotlin в продакшене. Лично я с этим языком столкнулся около года назад. Тут есть много тем для разговора, но сегодня поговорим об обработке ошибок, в том числе в функциональном стиле. Расскажу, как это можно делать в Kotlin.

(Фото с митапа по этой теме, проходившего в офисе одной из компаний Таганрога. Выступал Алексей Шафранов — лидер рабочей группы (Java) в «Максилект»)

Как можно в принципе обрабатывать ошибки?

Я нашел несколько путей:

  • можно использовать некое возвращаемое значение в качестве указателя на то, что есть ошибка;
  • можно с той же целью использовать параметр-индикатор,
  • ввести глобальную переменную,
  • обрабатывать исключения,
  • добавлять контракты (DbC).

Остановимся чуть подробнее на каждом из вариантов.

Возвращаемое значение

Некое “магическое” значение возвращается, если возникла ошибка. Если вы когда-либо использовали скриптовые языки, наверняка видели подобные конструкции.

Пример 1:

function sqrt(x) {
	if(x < 0)
	return -1;
	else
		return √x;
}

Пример 2:

function getUser(id) {
	result = db.getUserById(id)
	if (result)
		return result as User
	else
		return “Can’t find user ” + id
}

Параметр-индикатор

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

Пример:

function divide(x,y,out Success) {
	if (y == 0)
		Success = false
	else
		Success = true
		return x/y
}
divide(10, 11, Success)
id (!Success)	//handle error

Глобальная переменная

Примерно так же работает и глобальная переменная.

Пример:

global Success = true
function divide(x,y) {
	if (y == 0)
		Success = false
	else
		return x/y
}
divide(10, 11, Success)
id (!Success)	//handle error

Исключения

К исключениям мы все привыкли. Они используются практически везде.

Пример:

function divide(x,y) {
	if (y == 0)
		throw Exception()
	else
		return x/y
}
try{ divide(10, 0)}
catch (e) {//handle exception}

Контракты (DbC)

Откровенно говоря, вживую я этого подхода никогда не видел. Путем долгого гугления я нашел, что в Kotlin 1.3 есть библиотека, фактически позволяющая использовать contracts. Т.е. вы можете ставить condition на переменные, которые передаются в функцию, condition на возвращаемое значение, количество вызовов, то, откуда она вызывается и т.д. И если все условия выполняются, считается, что функция сработала правильно.

Пример:

function sqrt (x)
	pre-condition (x >= 0)
post-condition (return >= 0)
begin
	calculate sqrt from x
end

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

Исключения в Java

Перейдем к Java и к тому, как все это изначально работало.

При проектировании языка заложили два типа исключений:

  • checked – проверяемые;
  • unchecked – непроверяемые.

Для чего нужны checked исключения? Теоретически они нужны, чтобы люди обязательно проверяли ошибки. Т.е. если возможно определенное checked исключение, в дальнейшем оно обязательно должно быть проверено. Теоретически такой подход должен был привести к отсутствию необработанных ошибок и повышению качества кода. Но на практике это не так. Думаю, каждый хотя бы раз в жизни видел пустой блок catch.

Почему это может быть плохо?

Вот классический пример прямо из документации по Kotlin – интерфейс из JDK, реализованный в StringBuilder:

Appendable append(CharSequence csq) throws IOException;

try {
	log.append(message)
}
catch (IOException e) {
	//Must be safe
}

Уверен, вы встречали достаточно много кода, обернутого в try-catch, где catch – пустой блок, поскольку такой ситуации просто не должно было произойти, по мнению разработчика. Во многих случаях обработка checked исключений реализуется следующим способом: просто бросают RuntimeException и где-то выше его ловят (или не ловят…).

try {
	// do something
}
catch (IOException e) {
	throw new RuntimeException(e); // там где-нибудь поймаю...

Что можно в Kotlin

С точки зрения исключений компилятор Kotlin отличается тем, что:

1. Не различает checked и unchecked исключения. Все исключения – только unchecked, и вы самостоятельно принимаете решение, стоит ли их отлавливать и обрабатывать.

2. Try можно использовать как выражение – можно запустить блок try и либо вернуть из него последнюю строчку, либо вернуть последнюю строчку из блока catch.

val value = try {Integer.parseInt(“lol”)}
	catch(e: NumberFormanException) { 4 } //Рандомное число

3. А также можно использовать подобную конструкцию при обращении к какому-либо объекту, который может быть nullable:

val s = obj.money
	?: throw IllegalArgumentException(“Где деньги, Лебовски”)

Совместимость с Java

Kotlin-код можно использовать в Java и наоборот. Как при этом обращаться с исключениями?

  • Проверяемые исключения из Java в Kotlin можно не проверять и не объявлять (поскольку в Kotlin нет проверяемых исключений).
  • Возможные проверяемые исключения из Kotlin (например, появившиеся изначально из Java) в Java проверять необязательно.
  • Если проверить необходимо, исключение можно сделать проверяемым, используя в методе аннотацию @Throws (необходимо указать, какие исключения этот метод может выбрасывать). Упомянутая аннотация нужна только для совместимости с Java. Но на практике у нас ее многие используют, чтобы декларировать, что подобный метод в принципе может передавать какие-то исключения.

Альтернатива блоку try-catch

У блока try-catch есть существенный недостаток. При его появлении часть бизнес-логики переносится внутрь catch, причем это может происходить в одном из множества методов выше. Когда бизнес-логика размазана по блокам или всей цепочке вызова, понимать, как работает приложение, сложнее. Да и сами блоки читаемости коду не добавляют.

try {
	HttpService.SendNotification(endpointUrl);
	MarkNotificationAsSent();
} catch (e: UnableToConnectToServerException) {
	MarkNotificationAsNotSent();
}

Какие есть альтернативы?

Один из вариантов нам предлагает функциональный подход к обработке исключений. Выглядит подобная реализация следующим образом:

val result: Try<Result> =
Try{HttpService.SendNotification(endpointUrl)}

when(result) {
	is Success -> MarkNotificationAsSent()
	is Failure    -> MarkNotificationAsNotSent()
}

У нас есть возможность использовать монаду Try. По сути это контейнер, который хранит некоторое значение. flatMap – метод работы с этим контейнером, который вместе с текущим значением может принимать функцию и возвращать опять же монаду.

В данном случае вызов обернут в монаду Try (мы возвращаем Try). Обработать это можно в единственном месте – там, где нам нужно. Если на выходе есть значение, мы совершаем с ним последующие действия, если же у нас выброшено исключение, мы его обрабатываем в самом конце цепочки.

Функциональная обработка исключений

Откуда можно взять Try?

Во-первых, существует достаточно много реализаций классов Try и Either от сообщества. Можно взять их или даже написать реализацию самостоятельно. В одном из “боевых” проектов мы использовали самописную реализацию Try – обошлись одним классом и прекрасно справлялись.

Во-вторых, есть библиотека Arrow, которая в принципе добавляет много функциональщины в Kotlin. Естественно, там есть Try и Either.

Ну и кроме того, в Kotlin 1.3 появился класс Result, подробнее о котором я расскажу немного позже.

Try на примере библиотеки Arrow

Библиотека Arrow дает нам класс Try. Фактически он может быть в двух состояниях: Success или Failure:

  • Success при успешном выводе сохранит наше значение,
  • Failure хранит исключение, которое возникло в процессе выполнения блока кода.

Вызов выглядит следующим образом. Естественно, он обернут в обычный try – catch, но это будет происходить где-то внутри нашего кода.

sealed class Try<out A> {
	data class Success<out A>(val value: A) : Try<A>()
	data class Failure(val e: Throwable) : Try<Nothing>()

	companion object {
		operator fun <A> invoke(body: () -> A): Try<A> {
		return try {
			Success(body())
		} catch (e: Exception) {
			Failure(e)
		}
	}
}

Этот же класс должен реализовать метод flatMap, который позволяет передать функцию и вернуть нашу монаду try:

inline fun <B> map(f: (A) -> B): Try<B> =
	flatMap { Success(f(it)) }

inline fun <B> flatMap(f: (A) -> TryOf<B>): Try<B> =
	when (this) {
		is Failure -> this
		is Success -> f(value)
	}

Для чего это нужно? Чтобы не обрабатывать ошибки на каждый из результатов, когда у нас их несколько. К примеру, мы получили несколько значений с разных сервисов и хотим их объединить. Фактически у нас может быть две ситуации: либо мы успешно их получили и объединили, либо что-то упало. Поэтому мы можем поступить следующим образом:

val result1: Try<Int> = Try { 11 }
val result2: Try<Int> = Try { 4 }

val sum = result1.flatMap { one ->
	result2.map { two -> one + two }
}
println(sum) //Success(value=15)

Если оба вызова прошли успешно и мы получили значения, мы выполняем функцию. Если же они не успешны, то вернется Failure с исключением.

Вот как это выглядит, если что-то упало:

val result1: Try<Int> = Try { 11 }
val result2: Try<Int> = Try { throw RuntimeException(“Oh no!”) }

val sum = result1.flatMap { one ->
	result2.map { two -> one + two }
}
println(sum) //Failure(exception=java.lang.RuntimeException: Oh no!

Мы использовали ту же функцию, но на выходе получается Failure от RuntimeException.

Также библиотека Arrow позволяет использовать конструкции, которые по факту являются синтаксическим сахаром, в частности binding. Все то же самое можно переписать через последовательный flatMap, но binding позволяет сделать это читабельным.

val result1: Try<Int> = Try { 11 }
val result2: Try<Int> = Try { 4 }
val result3: Try<Int> = Try { throw RuntimeException(“Oh no, again!”) }

val sum = binding {
	val (one)   = result1
	val (two)   = result2
	val (three) = result3
	one + two + three
}
println(sum) //Failure(exception=java.lang.RuntimeException: Oh no, again!

Учитывая, что у нас один из результатов упал, мы получаем на выходе ошибку.

Подобную монаду можно использовать для асинхронных вызовов. Вот, например, две функции, которые запускаются асинхронно. Мы точно так же объединяем их результаты, не проверяя отдельно их состояния:

fun funA(): Try<Int> {
	return Try { 1 }
}
fun funB(): Try<Int> {
	Thread.sleep(3000L)
return Try { 2 }
}

val a = GlobalScope.async { funA() }
val b = GlobalScope.async { funB() }
val sum = runBlocking {
	a.await().flatMap { one ->
		b.await().map {two -> one + two }
	}
}

А вот более “боевой” пример. У нас есть запрос к серверу, мы его обрабатываем, получаем из него тело и пытаемся намапить его на наш класс, из которого уже возвращаем данные.

fun makeRequest(request: Request): Try<List<ResponseData>> =
	Try { httpClient.newCall(request).execute() }
		.map { it.body() }
		.flatMap { Try { ObjectMapper().readValue(it, ParsedResponse::class.java) } }
		.map { it.data }

fun main(args : Array<String>) {
	val response = makeRequest(RequestBody(args))
	when(response) {
		is Try.Success    -> response.data.toString()
		is Try.Failure       -> response.exception.message
	}
}

Try-catch сделал бы этот блок гораздо менее читабельным. А в данном случае мы на выходе получаем response.data, который можем обработать в зависимости от результата.

Result из Kotlin 1.3

В Kotlin 1.3 ввели класс Result. По факту он представляет собой нечто похожее на Try, но с рядом ограничений. Его изначально предполагается использовать для различных асинхронных операций.

val result: Result<VeryImportantData> = Result.runCatching { makeRequest() }
	.mapCatching { parseResponse(it) }
	.mapCatching { prepareData(it) }
result.fold{
	{ data -> println(“We have $data”) },
	exception -> println(“There is no any data, but it’s your exception $exception”) }
)

Если не ошибаюсь, этот класс на данный момент экспериментальный. Разработчики языка могут поменять его сигнатуру, поведение или вообще убрать, поэтому на данный момент его запрещено использовать в качестве возвращаемого значения из методов или переменной. Однако его можно использовать как локальную (приватную) переменную. Т.е. по факту его можно применять как try из примера.

Выводы

Выводы, которые я сделал лично для себя:

  • функциональная обработка ошибок в Kotlin – это просто и удобно;
  • никто не мешает обрабатывать их через try-catch в классическом стиле (и то, и то имеет право на жизнь; и то, и то удобно);
  • отсутствие проверяемых исключений не означает, что можно не обрабатывать ошибки;
  • непойманные исключения на продакшене приводят к печальным последствиям.

Автор статьи: Алексей Шафранов, лидер рабочей группы (Java), компания Maxilect

Все статьи

Связаться с нами

Мы свяжемся с вами в течение 24 часов.