Существуют различные способы обработки ошибок в языках программирования:


  • стандартные для многих языков исключения (Java, Scala и прочий JVM, python и многие другие)
  • коды статуса или флаги (Go, bash)
  • различные алгебраические структуры данных, значениями которых могут быть как успешные результаты так и описания ошибок (Scala, haskell и другие функциональные языки)

Исключения используются очень широко, с другой стороны о них часто говорят, что они медленные. Но и противники функционального подхода часто апеллируют к производительности.


Последнее время я работаю со Scala, где в равной мере я могу использовать как исключения так и различные типы данных для обработки ошибок, поэтому интересно какой из подходов будет удобнее и быстрее.


Сразу отбросим использование кодов и флагов, так как этот подход не принят в JVM языках и по моему мнению слишком подвержен ошибкам (прошу прощения за каламбур). Поэтому будем сравнивать исключения и разные виды АТД. Кроме того АТД можно рассматривать как использование кодов ошибок в функциональном стиле.


UPDATE: к сравнению добавлены исключения без стек-трейсов


Конкурсанты


Немного подробнее об алгебраических типах данных

Для тех, кто не слишком знаком с АТД (ADT) — алгебраический тип состоит из нескольких возможных значений, каждое из которых может быть составным значением (структурой, записью).


Примером может служить тип Option[T] = Some(value: T) | None, который используется вместо null-ов: значением данного типа может быть либо Some(t) если значение есть, либо None если его нет.


Другим примером может быть Try[T] = Success(value: T) | Failure(exception: Throwable), который описывает результат вычисления, которое могло завершиться успешно либо с ошибкой.


Итак наши конкурсанты:


  • Старые добрые исключения
  • Исключения без стек-трейса, так как именно заполнение стек-трейса очень медленная операция
  • Try[T] = Success(value: T) | Failure(exception: Throwable) — те же исключения, но в функциональной обертке
  • Either[String, T] = Left(error: String) | Right(value: T) — тип, содержащий либо результат либо описание ошибки
  • ValidatedNec[String, T] = Valid(value: T) | Invalid(errors: List[String]) — тип из библиотеки Cats, который в случае ошибки может содержать несколько сообщений о разных ошибках (там используется не совсем List, но это не важно)

NOTE по-сути сравниваются исключения со стек-трейсом, без и АТД, но выбрано несколько типов, так как в Scala нет единого подхода и интересно сравнить несколько.


Кроме исключений тут используются строки для описания ошибок, но с тем же успехом в реальной ситуации использовались бы различные классы (Either[Failure, T]).


Проблема


Для тестирование обработки ошибок возьмем проблему парсинга и валидации данных:


case class Person(name: String, age: Int, isMale: Boolean)

type Result[T] = Either[String, T]

trait PersonParser {
  def parse(data: Map[String, String]): Result[Person]
}

т.е. имея сырые данные Map[String, String] нужно получить Person или ошибку если данные не валидны.


Throw


Решение в лоб с использованием исключений (тут и далее буду приводить только функцию person, с полным кодом ознакомится можно на github):
ThrowParser.scala


  def person(data: Map[String, String]): Person = {
    val name = string(data.getOrElse("name", null))
    val age = integer(data.getOrElse("age", null))
    val isMale = boolean(data.getOrElse("isMale", null))
    require(name.nonEmpty, "name should not be empty")
    require(age > 0, "age should be positive")
    Person(name, age, isMale)
  }

тут string, integer и boolean валидируют наличие и формат простых типов и производят преобразование.
В целом довольно просто и понятно.


ThrowNST (No Stack Trace)


Код такой же, как и в предыдущем случае, но используются исключения без стек-трейса где можно: ThrowNSTParser.scala


Try


Решение перехватывает исключения раньше и позволяет комбинировать результаты через for (не путать с циклами в других языках):
TryParser.scala


  def person(data: Map[String, String]): Try[Person] = for {
    name    <- required(data.get("name"))
    age     <- required(data.get("age")) flatMap integer
    isMale  <- required(data.get("isMale")) flatMap boolean
    _       <- require(name.nonEmpty, "name should not be empty")
    _       <- require(age > 0, "age should be positive")
  } yield Person(name, age, isMale)

немного более непривычно для неокрепшего глаза, но за счет использования for в целом очень похоже на версию с исключениями, кроме того валидация наличия поля и парсинг нужного типа происходят отдельно (flatMap тут можно читать как and then)


Either


Тут тип Either спрятан за алиасом Result так как тип ошибки фиксирован:
EitherParser.scala


  def person(data: Map[String, String]): Result[Person] = for {
    name    <- required(data.get("name"))
    age     <- required(data.get("age")) flatMap integer
    isMale  <- required(data.get("isMale")) flatMap boolean
    _       <- require(name.nonEmpty, "name should not be empty")
    _       <- require(age > 0, "age should be positive")
  } yield Person(name, age, isMale)

Поскольку стандартный Either как и Try формирует монаду в Scala то код вышел абсолютно такой же, отличие тут в том, что в качестве ошибки тут фигурирует строка и исключения используются минимально (только для обработки ошибки при парсинге числа)


Validated


Тут используется библиотека Cats для того чтобы получить в случае ошибки не первую произошедшую, но как можно больше (например если несколько полей были не валидными, то результат будет содержать ошибки парсинга всех этих полей)
ValidatedParser.scala


  def person(data: Map[String, String]): Validated[Person] = {
    val name: Validated[String] =
      required(data.get("name"))
        .ensure(one("name should not be empty"))(_.nonEmpty)
    val age: Validated[Int] =
      required(data.get("age"))
        .andThen(integer)
        .ensure(one("age should be positive"))(_ > 0)
    val isMale: Validated[Boolean] =
      required(data.get("isMale"))
        .andThen(boolean)
    (name, age, isMale).mapN(Person)
  }

этот код уже менее похож на исходную версию с исключениями, но зато проверка дополнительных ограничений не оторвана от парсинга полей и мы все-таки получаем несколько ошибок вместо одной, это того стоит!


Тестирование


Для тестирование генерировался набор данных с различным процентом ошибок и парсился каждым из способов.


Результат на всех процентах ошибок:


Более подробно на низком проценте ошибок (время тут другое так как использовалась большая выборка):


Если же какая-то часть ошибок все-же является исключением со стек-трейсом (в нашем случае ошибка парсинга числа будет исключением, которое мы не контролируем) то конечно производительность "быстрых" способов обработки ошибок будет значительно ухудшаться. Особенно страдает Validated, так как он собирает все ошибки и в результате получает медленное исключение больше других:


Выводы


Как показал эксперимент исключения со стек-трейсами действительно очень медленные (на 100% ошибок разница между Throw и Either более чем в 50 раз!), а когда исключений практически нет использование АТД имеет свою цену. Однако использование исключений без стек-трейсов так же быстро (а при низком проценте ошибок быстрее) как и АДТ, однако если такие исключения выйдут за пределы той же валидации отследить их источник будет не легко.


Итого, если вероятность исключения более 1% то быстрее всего работают исключения без стек-трейсов, Validated или обычный Either почти так же быстры. При большом количестве ошибок Either может быть немного быстрее Validated только за счет семантики fail-fast.


Использование АТД для обработки ошибок дает еще одно преимущество перед исключениями: возможность ошибки зашита в сам тип и ее сложнее упустить, как и при использовании Option вместо null'ов.

Комментарии (17)


  1. Portnov
    06.12.2018 18:15
    +1

    Теоретически, все перечисленные «кандидаты» представляют собой одно и то же. Разница вариантов, не использующих исключения, представляет собой «постоянный множитель». Отличие вариантов, использующих исключения, в том, что в исключении заполняется стек вызовов, и на это тратится время. (но, с другой стороны, стеки полезны при поиске ошибок). Так что для полноты картины надо в соревнование добавить вариант с исключениями без стека (в java для этого надо унаследоваться от Exception и перекрыть fillInStackTrace(), а в Scala, насколько я помню, такой наследник есть в стандартной библиотеке).


    1. sshikov
      06.12.2018 19:41

      >Так что для полноты картины надо в соревнование добавить вариант с исключениями без стека

      Включить можно, но для практики это скорее всего ничего не даст. Вы можете свое исключение таким создать, но попробуйте для чужих проделать эту операцию. То есть, если вы ловите условный IOException, то стек за вас уже заполнили, и какое-то время потрачено.


      1. valery1707
        06.12.2018 19:52

        Это даст реальное сравнение в данном контексте, а так тут сравнивается две группы вариантов: со эксепшенами (с получением стека) и без них. Вторые конечно стали победителями.
        А если в первой группе не вычитывать стэк, то победитель станет уже и не так очевиден.


        Если же рассматривать условный вариант с IOException, то тут тоже не будет профита относительно второго варианта, так как эксепшн у вас уже всё равно есть.


        1. sshikov
          06.12.2018 19:59

          >эксепшн у вас уже всё равно есть.
          Ну да. Собственно, я тут про то, что если вы где-то внутри ловите чужие исключения, то вы фактически всегда имеете стек. И как вы дальше не выкручивайтесь, все равно это уже будет медленно, ибо поздно пить боржоми.

          А померять конечно стоит.


    1. atamur Автор
      06.12.2018 19:49
      +1

      Конечно качественно мы имеем две категории — исключения и АТД, но поскольку таких типов в Scala несколько интересно не только сравнить эти две категории, но и несколько разных подходов к обработке ошибок с помощью АТД.
      Исключения без трейса это интересный кандидат, пожалуй добавлю его к сравнению.


  1. sshikov
    06.12.2018 19:31

    >Scala, haskell и другие функциональные языки

    Это вы про Java 8? :)


    1. atamur Автор
      06.12.2018 19:46
      +1

      В Java к сожалению ADT почти не используются и даже стандартный Optional не рекомендуется использовать в интерфейсах и передавать между методами.


      1. sshikov
        06.12.2018 20:01

        Я не знаю, с чего вы такое взяли, скажу только, что возможностей например vavr.io (javaslang), или других библиотек, где реализованы Either или Try — их вполне достаточно, чтобы в Java 8 пользоваться ровно тем же вариантом, который тут предлагается для скалы.

        Я это лично делал уже в трех проектах.


        1. atamur Автор
          06.12.2018 20:19

          Я не говорю про библиотеки, с ними можно все, стандартная библиотека ни в Java 8 ни в Java 11 не поощряет такой подход


          1. sshikov
            06.12.2018 20:28

            > с ними можно все
            Ну, не совсем. До Java 8 как раз кое-что было нельзя (практически совсем). Лямбды дали возможность сделать Try.of(()->«вычисления»), и вычисления эти стали внезапно ленивыми. Потому что это всего-лишь параметр для метода, и раньше он вычислялся до вызова — теперь тоже, но теперь это функция.

            И это пожалуй то самое изменение, которое и делает все это в целом возможным и осмысленным. Во всяком случае до Java 8 я таких библиотек припомнить не могу (хотя Either были уже лет 10 назад, сразу с выходом Java 5).


            1. atamur Автор
              06.12.2018 20:32

              Ну, теоретически такое было возможно и до 8 версии, просто выглядело более громоздко, но та же IDEA это скрывала


  1. YuryB
    06.12.2018 23:00

    что-то я не вижу тут нигде 3-ёх волшебных букв jmh — а это значит что результаты можно считать близкими к рандомным и непригодными для каких-либо выводов



    1. atamur Автор
      07.12.2018 01:55

      Не jmh единым


  1. aleksandy
    07.12.2018 13:41

    final case class Failure[+T](exception: Throwable) extends Try[T] {...}


    Т.е. при использовании Try исключение равно создаётся, соответственно, и стек-трейс будет заполнять. Нужен ещё один вариант: исключение без заполнения стек-трейса вместе с Try. :)


  1. poxvuibr
    07.12.2018 15:35
    +2

    Подпишите, пожалуйста оси. Очень сложно понять что на графиках.


    1. atamur Автор
      07.12.2018 18:21

      Поправлю, по оси x доля ошибок в датасете, по оси у — время выполнения (в абстрактных попугаях, так как зависит от окружения)