Исключения, проверяемые и нет
Если кратко, то исключения нужны для отделения положительного сценария (когда все идет как надо) от отрицательного (когда случается ошибка и положительный сценарий прерывается). Это полезно, поскольку очень часто информации для обработки ошибки в коде мало и требуется передать информацию о случившемся выше.
Например, есть функция по считыванию числа из файла (или не числа, не важно):
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. Но если это поведение не выражено в типе его легко
пропустить.
Обилие типов создает больше визуального шума и часто требует более сложного кода при работе с несколькими эффектами одновременно. Но за это предоставляет самодокументируемый код и дает возможность компилятору найти многие ошибки.