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

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

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

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. Но если это поведение не выражено в типе его легко пропустить.

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