How to handle errors on JVM faster
There are various ways to handle errors in programming languages:
- standard exceptions for many languages (Java, Scala and other JVM, python and many others)
- status codes or flags (Go, bash)
- various algebraic data structures, the values of which can be both successful results and error descriptions (Scala, haskell and other functional languages)
Exceptions are used very widely, on the other hand they are often said to be slow. But opponents of the functional approach often appeal to performance.
Recently, I have been working with Scala, where I can equally use both exceptions and various types of data for error handling, so I wonder which approach will be more convenient and faster.
Immediately discard the use of codes and flags, as this approach is not accepted in the JVM languages and in my opinion is too susceptible to errors (I apologize for the pun). Therefore, we will compare exceptions and different types of ATD. In addition, ADT can be considered as the use of error codes in a functional style.
UPDATE : added no-stack exceptions to the comparison
Contestants
Для тех, кто не слишком знаком с АТД (ADT) — алгебраический тип состоит из нескольких возможных значений, каждое из которых может быть составным значением (структурой, записью).
Примером может служить тип Option[T] = Some(value: T) | None
, который используется вместо null-ов: значением данного типа может быть либо Some(t)
если значение есть, либо None
если его нет.
Другим примером может быть Try[T] = Success(value: T) | Failure(exception: Throwable)
, который описывает результат вычисления, которое могло завершиться успешно либо с ошибкой.
Итак наши конкурсанты:
- Good old exceptions
- Exceptions without a stack trace, since filling a stack trace is a very slow operation.
Try[T] = Success(value: T) | Failure(exception: Throwable)
- the same exceptions, but in a functional wrapperEither[String, T] = Left(error: String) | Right(value: T)
- the type containing either the result or the error descriptionValidatedNec[String, T] = Valid(value: T) | Invalid(errors: List[String])
- type from the Cats library , which in the case of an error may contain several messages about different errors (it is not completely used thereList
, but it does not matter)
NOTE in essence, exceptions are compared with stack-less, without and ADT, but several types are chosen, since Scala does not have a unified approach and it is interesting to compare several.
In addition to exceptions, strings are used to describe errors, but with the same success, different classes would be used in a real situation ( Either[Failure, T]
).
Problem
For testing error handling, let's take the problem of parsing and validating data:
caseclassPerson(name: String, age: Int, isMale: Boolean)typeResult[T] = Either[String, T]
traitPersonParser{
defparse(data: Map[String, String]): Result[Person]
}
those. having raw data Map[String, String]
needs to be received Person
or an error if the data is not valid.
Throw
The solution to the forehead with the use of exceptions (hereinafter I will give only the function person
, you can get acquainted with the full code on github ):
ThrowParser.scala
defperson(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)
}
here string
, integer
and boolean
validates the presence and format of simple types and performs the conversion.
In general, quite simple and understandable.
ThrowNST (No Stack Trace)
The code is the same as in the previous case, but exceptions are used without a stack-trace where you can: ThrowNSTParser.scala
Try
The solution catches exceptions earlier and allows you to combine the results through for
(not to be confused with cycles in other languages):
TryParser.scala
defperson(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")
} yieldPerson(name, age, isMale)
a bit more unusual for a weak eye, but at the expense of using it for
as a whole is very similar to the version with exceptions, besides, validation of the presence of a field and parsing of the necessary type occur separately ( flatMap
here you can read as and then
)
Either
Here the type Either
is hidden behind the alias Result
since the error type is fixed:
EitherParser.scala
defperson(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")
} yieldPerson(name, age, isMale)
Since the standard Either
as well as Try
forms a monad in Scala, the code came out exactly the same, the difference here is that the error appears here as a string and exceptions are used minimally (only for error handling when parsing a number)
Validated
Here, the Cats library is used to get in case of an error not the first thing that happened, but as much as possible (for example, if several fields were not valid, the result will contain parsing errors for all these fields)
ValidatedParser.scala
defperson(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)
}
This code is less similar to the original version with exceptions, but checking for additional restrictions is not divorced from parsing fields, and we still get several errors instead of one, it's worth it!
Testing
For testing, a set of data was generated with a different percentage of errors and parsed in each of the methods.
Result on all percent of errors:
For more details on the low rate of errors (while this is different because it has been used used to lshaya sampling):
If any part of the errors is still an exception with the stack trace (in our case, the error of parsing the number will be an exception that we do not control), then of course the performance of the “fast” error handling methods will significantly deteriorate. It suffers especially Validated
since it collects all the errors and as a result receives a slow exception more than others:
findings
As the experiment of elimination with stack-traces has shown, it’s really very slow (there is a 100% error difference between Throw
and Either
more than 50 times!), And when there are almost no exceptions, the use of ADT has its price. However, using exceptions without stack traces is as fast (and with a low percentage of errors faster) as ADT, however, if such exceptions go beyond the same validation, it will not be easy to track their source.
So, if the probability of an exception is more than 1%, exceptions without stack-traces work the fastest, Validated
or the usual one is Either
almost as fast. With a large number of errors, it Either
may be slightly faster Validated
only due to the semantics of fail-fast.
The use of ADT for error handling gives another advantage over exceptions: the possibility of an error is sewn into the type itself and it is more difficult to miss it, as when used Option
instead of nulls.