Существуют различные способы обработки ошибок в языках программирования:
- стандартные для многих языков исключения (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)
sshikov
06.12.2018 19:31>Scala, haskell и другие функциональные языки
Это вы про Java 8? :)atamur Автор
06.12.2018 19:46+1В Java к сожалению ADT почти не используются и даже стандартный Optional не рекомендуется использовать в интерфейсах и передавать между методами.
sshikov
06.12.2018 20:01Я не знаю, с чего вы такое взяли, скажу только, что возможностей например vavr.io (javaslang), или других библиотек, где реализованы Either или Try — их вполне достаточно, чтобы в Java 8 пользоваться ровно тем же вариантом, который тут предлагается для скалы.
Я это лично делал уже в трех проектах.atamur Автор
06.12.2018 20:19Я не говорю про библиотеки, с ними можно все, стандартная библиотека ни в Java 8 ни в Java 11 не поощряет такой подход
sshikov
06.12.2018 20:28> с ними можно все
Ну, не совсем. До Java 8 как раз кое-что было нельзя (практически совсем). Лямбды дали возможность сделать Try.of(()->«вычисления»), и вычисления эти стали внезапно ленивыми. Потому что это всего-лишь параметр для метода, и раньше он вычислялся до вызова — теперь тоже, но теперь это функция.
И это пожалуй то самое изменение, которое и делает все это в целом возможным и осмысленным. Во всяком случае до Java 8 я таких библиотек припомнить не могу (хотя Either были уже лет 10 назад, сразу с выходом Java 5).atamur Автор
06.12.2018 20:32Ну, теоретически такое было возможно и до 8 версии, просто выглядело более громоздко, но та же IDEA это скрывала
YuryB
06.12.2018 23:00что-то я не вижу тут нигде 3-ёх волшебных букв jmh — а это значит что результаты можно считать близкими к рандомным и непригодными для каких-либо выводов
aleksandy
07.12.2018 13:41final case class Failure[+T](exception: Throwable) extends Try[T] {...}
Т.е. при использовании Try исключение равно создаётся, соответственно, и стек-трейс будет заполнять. Нужен ещё один вариант: исключение без заполнения стек-трейса вместе с Try. :)
Portnov
Теоретически, все перечисленные «кандидаты» представляют собой одно и то же. Разница вариантов, не использующих исключения, представляет собой «постоянный множитель». Отличие вариантов, использующих исключения, в том, что в исключении заполняется стек вызовов, и на это тратится время. (но, с другой стороны, стеки полезны при поиске ошибок). Так что для полноты картины надо в соревнование добавить вариант с исключениями без стека (в java для этого надо унаследоваться от Exception и перекрыть fillInStackTrace(), а в Scala, насколько я помню, такой наследник есть в стандартной библиотеке).
sshikov
>Так что для полноты картины надо в соревнование добавить вариант с исключениями без стека
Включить можно, но для практики это скорее всего ничего не даст. Вы можете свое исключение таким создать, но попробуйте для чужих проделать эту операцию. То есть, если вы ловите условный IOException, то стек за вас уже заполнили, и какое-то время потрачено.
valery1707
Это даст реальное сравнение в данном контексте, а так тут сравнивается две группы вариантов: со эксепшенами (с получением стека) и без них. Вторые конечно стали победителями.
А если в первой группе не вычитывать стэк, то победитель станет уже и не так очевиден.
Если же рассматривать условный вариант с
IOException
, то тут тоже не будет профита относительно второго варианта, так как эксепшн у вас уже всё равно есть.sshikov
>эксепшн у вас уже всё равно есть.
Ну да. Собственно, я тут про то, что если вы где-то внутри ловите чужие исключения, то вы фактически всегда имеете стек. И как вы дальше не выкручивайтесь, все равно это уже будет медленно, ибо поздно пить боржоми.
А померять конечно стоит.
atamur Автор
Конечно качественно мы имеем две категории — исключения и АТД, но поскольку таких типов в Scala несколько интересно не только сравнить эти две категории, но и несколько разных подходов к обработке ошибок с помощью АТД.
Исключения без трейса это интересный кандидат, пожалуй добавлю его к сравнению.