Обработка ошибок в Kotlin/Java: как правильно это делать?

Источник
Обработка ошибок в любой разработке играет важнейшую роль. В программе может пойти не так практически всё: пользователь введёт некорректные данные, или они могут прийти такими по http, или мы ошиблись при написании сериализации/десериализации и в процессе обработки программа падает с ошибкой. Да может банально закончится место на диске.
¯_(ツ)_/¯, нет единого способа, и в каждой конкретной ситуации придётся подбирать наиболее подходящий вариант, но есть рекомендации, как это делать лучше.
Предисловие
К сожалению (или просто такая жизнь?), этот список можно продолжать бесконечно. Разработчику постоянно нужно думать о том, что где-то может возникнуть ошибка, и тут есть 2 ситуации:
- когда происходит ожидаемая ошибка в вызове функции, которую мы предусмотрели и можем попробовать обработать;
- когда в процессе работы происходит неожиданная ошибка, которую мы не предусмотрели.
И если ожидаемые ошибки хотя бы локализованы, то остальные могут произойти практически везде. В случае, если мы не обрабатываем ничего важного, то можно просто упасть с ошибкой (хотя и такое поведение недостаточно и требуется как минимум добавить сообщение в лог об ошибке). Но если именно сейчас происходит обработка платежа и нельзя просто упасть, а нужно хотя бы вернуть ответ о неуспешной операции?
Перед тем как рассмотрим способы обработки ошибок, несколько слов об Exception (исключениях):
Exception

Источник
Иерархия исключений хорошо описана и о ней можно найти много информации, поэтому нет смысла тут её расписывать. Что до сих пор иногда вызывает жаркое обсуждение, так это checked и unchecked ошибки. И хоть unchecked исключения большинство приняло предпочтительными (в Kotlin вообще нет checked исключений), с этим не все ещё согласны.
За checked исключениями действительно стояло благое намерение сделать их удобным механизмом обработки ошибок, но реальность внесла свои корректировки, хоть и сама идея внесения в сигнатуру всех исключений, которые могут быть брошены из этой функции, понятна и логична.
Давайте рассмотрим это на примере. Предположим, у нас есть функция method, которая может бросить проверяемое исключение PanicException. Такая функция будет выглядеть следующим образом:
public void method() throws PanicException { }
Из её описания видно, что она может бросить исключение и что исключение может быть только одно. Вроде выглядит вполне удобным? И пока у нас маленькая программа, всё так и есть. Но если программа чуть больше и таких функций становится больше, то появляются некоторые проблемы.
Проверяемые исключения требуют по спецификации, чтобы в сигнатуре функции перечислялись все возможные проверяемые исключения (либо общий предок для них). Поэтому, если у нас есть цепочка вызовов a → b → c и самая вложенная функция кидает какое-либо исключение, то оно должно по цепочке быть проставлено у всех. А если этих исключений несколько, то и у самой верхней функции в сигнатуре должно быть описание их всех.
Так, по мере усложнения программы, этот подход приводит к тому, что у верхней функции исключения постепенно схлопываются к общим предкам и сводятся в конечном счёте к Exception. Что в таком виде становится похожим на unchecked исключение и сводит на нет все преимущества проверяемых исключений.
А если учесть, что программа, как живой организм, постоянно изменяется и эволюционирует, то практически невозможно заранее предусмотреть, какие исключения могут в ней возникать. И в результате получается ситуация, что когда мы добавляем новую функцию с новым исключением, приходится пройтись по всей цепочке её использования и менять сигнатуры у всех функций. Согласитесь, это не самое приятное занятие (даже учитывая, что современные IDE это делают за нас).
Но последний, и, наверное, самых большой гвоздь в проверяемые исключения «вогнали» лямбды из Java 8. В их сигнатуре нет никаких проверяемых исключений ¯_(ツ)_/¯ (т.к. в лямбде можно вызывать любую функцию, с любой сигнатурой), поэтому любой вызов функции с проверяемым исключением из лямбды заставляет оборачивать её в проброс исключения как непроверяемое:
Stream.of(1,2,3).forEach(item -> {
try {
functionWithCheckedException();
} catch (Exception e) {
throw new RuntimeException("rethrow", e);
}
});
К счастью, в спецификации JVM вообще нет проверяемых исключений, поэтому в Kotlin можно в такой же лямбде ничего не оборачивать, а просто вызывать нужную функцию.
Хоть это и приводит иногда к неожиданным последствиям, как, например, к неверной работе @Transactional в Spring Framework, который «ожидает» только unckecked исключения. Но это больше особенность фреймворка, и, возможно, такое поведение в Spring изменится в ближайшее время github issue.
Исключения сами по себе являются особыми объектами. Помимо того, что их можно «пробрасывать» через методы, они ещё и собирают stacktrace при создании. Эта особенность потом помогает с анализом проблем и поиском ошибок, но может и привести к некоторым проблемам с производительностью, если логика работы приложения становится сильно завязанной на бросаемые исключения. Как показано в статье, отключение сборки stacktrace позволяет в этом случае значительно увеличить их производительность, но к нему стоит прибегать только в исключительных случаях, когда это действительно требуется!
Обработка ошибок
Основное, что нужно сделать с «неожиданными» ошибками, — найти место, где можно их перехватить. В JVM-языках это может быть либо точка создания потока, либо фильтр/точка входа в http-метод, где можно поставить try-catch с обработкой unchecked ошибок. Если вы используете какой-либо фреймворк, то, скорее всего, в нём уже есть возможность создавать общие обработчики ошибок, как, например, в Spring Framework можно использовать методы с аннотацией @ExceptionHandler.
До этих же центральных точек обработки можно «поднимать» исключения, которые мы не хотим обрабатывать в конкретных местах, прокидывая те же unckecked исключения (когда, например, не знаем, что делать именно в конкретном месте и как обрабатывать ошибку). Но этот способ не всегда подходит, потому что иногда может потребовать обработать ошибку на месте, и нужно проверять, что все места вызовов функций правильно обрабатываются. Рассмотрим способы сделать это.
Всё же использовать исключения и тот же try-catch:
int a = 10; int b = 20; int sum; try { sum = calculateSum(a,b); } catch (Exception e) { sum = -1; }Основной недостаток в том, что мы можем «забыть» обернуть его в try-catch в месте вызова и пропустить попытку обработки на месте, из-за чего исключение пробросится наверх до общей точки обработки ошибки. Тут можно перейти к
checkedисключениям (для Java), но тогда мы получим все те недостатки, о которых упоминалось выше. Этот подход удобно использовать, если обработка ошибки на месте не всегда требуется, но в редком случае она нужна.Использовать sealed class как результат вызова (Kotlin).
В Kotlin можно ограничить количество наследников у класса, сделать их вычисляемыми на этапе компиляции — это позволяет компилятору проверять, что все возможные варианты будут разобраны в коде. В Java можно сделать общий интерфейс и несколько наследников, правда, теряя проверки на уровне компиляции.sealed class Result data class SuccessResult(val value: Int): Result() data class ExceptionResult(val exception: Exception): Result() val a = 10 val b = 20 val sum = when (val result = calculateSum(a,b)) { is SuccessResult -> result.value is ExceptionResult -> { result.exception.printStackTrace() -1 } }Тут мы получаем что-то вроде
golang-подхода к ошибкам, когда нужно в явном виде проверять результирующие значения (или явно игнорировать). Подход достаточно практичный и особенно удобный, когда требуется в каждой из ситуаций прокидывать много параметров. КлассResultможно расширить различными методами, которые упрощают получение результата с пробросом исключения выше, если таковое есть (т.е. нам не нужно в месте вызова обрабатывать ошибку). Основным недостатком будет только создание промежуточных лишних объектов (и чуть более многословная запись), но и его можно убрать, используяinlineклассы (если нам достаточно одного аргумента). и, как частный пример, есть классResultиз Kotlin. Правда, он пока только для внутреннего использования, т.к. в будущем его реализация может немного измениться, но если хочется им воспользоваться, то можно добавить флаг компиляции-Xallow-result-return-type.Как один из возможных видов п.2, использование типа из функционального программирования
Either, который может быть либо результатом, либо ошибкой. Сам тип может быть какsealedклассом, так иinlineклассом. Ниже пример использования реализации из библиотекиarrow:val a = 10 val b = 20 val value = when(val result = calculateSum(a,b)) { is Either.Left -> { result.a.printStackTrace() -1 } is Either.Right -> result.b }Больше всего
Eitherподойдёт тем, кто любит функциональный подход и кому по душе строить цепочки вызовов.Использовать
Optionилиnullableтип из Kotlin:fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) ?: throw RuntimeException("some exception") } fun calculateSum(a: Int, b: Int): Int?Такой подход подойдёт, если не очень важна причина ошибки и когда она только одна. Пустой ответ считается ошибкой и пробрасывается выше. Самая короткая запись, без создания дополнительных объектов, но такой подход не всегда можно применить.
Аналогичен п.4, только использует хардкодное значение как маркер ошибки:
fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) if (sum == -1) { throw RuntimeException("error”) } } fun calculateSum(a: Int, b: Int): IntНаверное, это самый старый подход к обработке ошибок, пришедший ещё из
C(или даже с Algol). Никаких накладных расходов, только не совсем понятный код (вместе с ограничениями на выбор результата), но, в отличие от п.4, появляется возможность делать различные коды ошибок, если требуется больше одного возможного исключения.
Выводы
Все подходы можно комбинировать в зависимости от ситуации, и нет среди них того, который подойдёт во всех случаях.
Так, например, можно добиться подхода golang к ошибкам, используя sealed классы, а там, где это не очень удобно, переходить к unchecked ошибкам.
Или использовать в большей части мест nullable-тип как маркер того, что не удалось подсчитать значение или достать его откуда-либо (например, как индикатор, что значение не нашлось в базе).
А если же у вас полностью функциональный код вместе с arrow или ещё какой-либо аналогичной библиотекой, то тогда, скорее всего, лучше использовать Either.
Что же до http-серверов, то в них проще всего поднимать все ошибки до центральных точек и только в некоторых местах комбинировать nullable подход с sealed классами.
Буду рад увидеть в комментариях, что из этого используете вы, а может, есть ещё другие удобные методы обработки ошибок?
И спасибо всем, кто дочитал до конца!
