Исключения, проверяемые и нет
Если кратко, то исключения нужны для отделения положительного сценария (когда все идет как надо) от отрицательного (когда случается ошибка и положительный сценарий прерывается). Это полезно, поскольку очень часто информации для обработки ошибки в коде мало и требуется передать информацию о случившемся выше.
Например, есть функция по считыванию числа из файла (или не числа, не важно):
String readStoredData(String id) throws FileNotFoundException, IOException {
File file = new File(storage, id + ".dat");
try (BufferedReader in = new BufferedReader(new FileReader(file))) {
return in.readLine();
}
}
Как видно, тут нет кода, решающего что делать в случае ошибки. Да и не ясно что делать – завершить программу, вернуть "", null или еще что-то? Поэтому исключения объявлены в throws
и будут обработаны где-то на вызывающей стороне:
int initCounter(String name) throws IOException, NumberFormatException {
try {
return Integer.parseInt(readStoredData(name));
} catch (FileNotFoundException e) {
return 0;
}
}
Исключения в Java делятся на проверяемые (checked) и непроверяемые (unchecked). В данном случае IOException
проверяемое – вы обязаны объявить его в throws
и потом где-то обработать, компилятор это проверит. NumberFormatException
же непроверяемое – его обработка остается на совести программиста и компилятор вас контролировать не станет.
Есть еще третий тип исключений – фатальные ошибки (Error
), но их обычно нет смысла обрабатывать, поэтому вас они не должны заботить.
Задумка тут состоит в том, что проверяемых исключений нельзя избежать – как ни старайся, но файловая система может подвести и чтение файла закончится ошибкой.
С этим подходом есть несколько проблем:
функциональное программирование в лице функций высших порядков плохо совместимо с проверяемыми исключениями;
непроверяемые исключения обычно теряются и обрабатывать их забывают пока тесты (или того хуже – клиенты) не обнаружат ошибку.
Из-за первой проблемы проверяемые исключения медленно вытесняются из языка, оборачиваясь непровеяемыми. С другой стороны обостряется вторая проблема и исключения легко теряются.
А что там в Scala?
Как пример другого подхода возьмем Scala: язык поддерживает так же и исключения (правда все они непроверяемые), но рекомендует возвращать исключения в виде результата используя алгебраические типы данных.
Возьмем к примеру Try[T]
– это тип, который содержит либо значение, либо исключение. Перепишем наш код на Scala:
def readStoredData(id: String): Try[String] =
Try {
val file = new File(storage, s"$id.dat")
val source = Source.fromFile(file)
try source.getLines().next()
finally source.close()
}
def initCounter(name: String): Try[Int] = {
readStoredData(name)
.map(_.toInt)
.recover {
case _: FileNotFoundException => 0
}
}
Выглядит вполне похоже, разница в том, что тип результата функции readStoredData
уже не String
, а Try[String]
– работая с функцией вы не забудете о возможных исключениях. В этом смысле Try похож на проверяемые исключения в Java – компилятор напомнит вам об исключении, но без проблем с лямбдами.
С другой стороны недостатки тоже есть:
вы не знаете какие конкретно виды исключений там могут быть (тут можно использовать
Either[Error, T]
, но это тоже не очень удобно);в целом happy-path требует больше синтаксических ритуалов, чем исключения (
Try/get
илиfor/map/flatMap
);люди из Java мира часто по-ошибке просто игнорируют результат вызова метода, неявно игнорируя исключения (люди из Java мира потому что такое случается в императивном коде, функциональный таким обычно не грешит).
В целом такой подход хорошо расширяется на другие эффекты (в данном случае Try[String]
означает строку с эффектом – возможностью содержать ошибку вместо значения). Примерами могут быть Option[T]
– потенциальное отсутствие значения, Future[T]
– асинхронное вычисление значения и т.п.
Исключения и ошибки
Возвращаясь к исходной проблеме стоит заметить, что если исключения можно избежать – это стоит сделать. Собственно именно исходя из этой логики были введены проверяемые/непроверяемые типы исключений в Java, когда непроверяемые исключения говорят об ошибке в коде (а не например в файловой системе).
Поэтому в изначальной реализации функции у нас было два скрытых случая ошибки:
FileNotFoundException
если файла нет, что вероятно логическая ошибка или ожидаемое поведениеДругие
IOException
если файл прочитать не удалось – настоящие ошибки среды
При наличии нужного инструментария в языке первый случай можно вообще не выражать в виде исключения:
def readStoredData(id: String): Option[Try[String]] = {
val file = new File(storage, s"$id.dat")
if (file.exists()) Some(
Try {
val source = Source.fromFile(file)
try source.getLines().next()
finally source.close()
}
)
else None
}
Тип результата Option[Try[String]]
может выглядеть непривычно, но теперь он явно говорит, что результатом могут быть три отдельных случая:
None
– нет файлаSome(Success(string))
– собственно строка из файлаSome(Failure(exception))
– ошибка считывания файла, в случае если он существует
Теперь Try
содержит только настоящие ошибки среды. В Java в таких случаях часто используются специальные значения, например null. Но если это поведение не выражено в типе его легко пропустить.
Обилие типов создает больше визуального шума и часто требует более сложного кода при работе с несколькими эффектами одновременно. Но за это предоставляет самодокументируемый код и дает возможность компилятору найти многие ошибки.
sshikov
Я бы хотел отметить, что есть скажем vavr.io (и это только один пример), когда в Java применяется ровно такой же подход. Так что Scala в этом месте от Java почти не отличается.
atamur Автор
Конечно, я говорил про идеоматические подходы в каждом из языков.
nehaev
Если вы автор библиотеки, например, вы скорее всего не будете тащить внешнюю зависимость от vavr или чего-то подобного. Соответственно, весь ваш арсенал будет ограничен тем, что есть в стандартном наборе. Так что отличия есть, и они весьма существенны.
darkit
А почему бы и нет? Например в resilience4j оно используется и ничем не мешает.
nehaev
Очевидно же почему: чтобы не навязывать пользователю вашей библиотеки нерелевантных решений и чтобы избежать jar hell.
Вот сделали вы библиотеку для логгинга, которая зависит от project-reactor. Те, у кого в проекте нет реактивщины или, например Akka вместо project-reactor, — точно не оценят и будут искать другую библиотеку без этих нерелевантных зависимостей. Ваша библиотека не пройдет фильтр естественного отбора.
Понятно, что если у вас в описании к библиотеке написано, что она про FP и fault tolerance в Java, тащить vavr логично. Но я же говорю про библиотеки в общем случае.
darkit
Мне кажется что здесь и ошибка. Библиотеки не живут в вакууме, за редким исключением. И мы в данном случае говорим про обработку ошибок в ФП стиле, значит что моя библиотека будет отдавать ошибку в этой парадигме и делать свою реализацию для
Try|Either
вообще нет смысла.sshikov
Зависит от размеров библиотеки или фреймворка. Я обычно не стесняясь затаскиваю как зависимость что-то типа груви, если нужен скриптовый язык в фреймворке. А уж vavr это вообще мелочь, которая кажется с собой вообще ничего не тянет.
nehaev
Хм… а я вот как-то стал очень разборчивым после всех этих депендеси хеллов в спарко-хадупе и прочих замечательных фрейморках, которые не могут без того, чтобы скачать пару гигабайт из мейвен централа, а потом на старте навернуться с чем-нибудь типа NoSuchMethodError. Лучше я возьму десять утилитарных zero-dependency либ, с каждой из которых легко разобраться в отдельности, чем одного огромного монстра, с котором непонятно что делать.
sshikov
Не, я вас вполне понимаю (особенно в спарко-хадупе, где ты затянул свою версию httpcomponents, и но отвалилось хз в каком месте).
Я просто говорю немного о другом — в scala же Try это тоже просто класс. И он реализован на основе других средств языка (возможно — try/catch, я просто не вникал). Т.е. в этом смысле и то и другое реализованы не идентично, но по сути одинаково — как библиотека. То что одна входит в стандартный рантайм, а другая нет — это уже другая сторона медали. Как-то так.
nehaev
Ну да, про это как раз был мой первоначальный комментарий.
Try
просто класс, который все равно откуда придет, — это так только на прикладном уровне. На библиотечном уровне вы не будете подключать все подряд с целью минимизации зависимостей (и как следствие проблем у юзеров вашей библиотеки), поэтому там стандартная поставка имеет большое значение.