Еще раз про try и Try

от автора

Исключения, проверяемые и нет

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

Например, есть функция по считыванию числа из файла (или не числа, не важно):

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, когда непроверяемые исключения говорят об ошибке в коде (а не например в файловой системе).

Поэтому в изначальной реализации функции у нас было два скрытых случая ошибки:

  1. FileNotFoundException если файла нет, что вероятно логическая ошибка или ожидаемое поведение

  2. Другие 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]] может выглядеть непривычно, но теперь он явно говорит, что результатом могут быть три отдельных случая:

  1. None – нет файла

  2. Some(Success(string)) – собственно строка из файла

  3. Some(Failure(exception)) – ошибка считывания файла, в случае если он существует

Теперь Try содержит только настоящие ошибки среды. В Java в таких случаях часто используются специальные значения, например null. Но если это поведение не выражено в типе его легко пропустить.

Обилие типов создает больше визуального шума и часто требует более сложного кода при работе с несколькими эффектами одновременно. Но за это предоставляет самодокументируемый код и дает возможность компилятору найти многие ошибки.

ссылка на оригинал статьи https://habr.com/ru/post/540172/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *