There are various ways to handle errors in programming 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 different types of data for error handling, so I’m wondering which approach will be more convenient and faster.
Immediately discard the use of codes and flags, since this approach is not accepted in the JVM languages and in my opinion too prone 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
For those who are not too familiar with ADT ( ADT ) - the algebraic type consists of several possible values, each of which can be a composite value (structure, record).
An example is the type Option[T] = Some(value: T) | None
Option[T] = Some(value: T) | None
, which is used instead of nulls: the value of this type can be either Some(t)
if the value is, or None
if it is not.
Another example would be Try[T] = Success(value: T) | Failure(exception: Throwable)
Try[T] = Success(value: T) | Failure(exception: Throwable)
, which describes the result of a calculation that could have completed successfully or with an error.
So our contestants:
Try[T] = Success(value: T) | Failure(exception: Throwable)
Try[T] = Success(value: T) | Failure(exception: Throwable)
- the same exceptions, but in a functional wrapperEither[String, T] = Left(error: String) | Right(value: T)
Either[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])
ValidatedNec[String, T] = Valid(value: T) | Invalid(errors: List[String])
- a type from the Cats library , which in the case of an error may contain several messages about different errors (not quite a List
used there, but this is not important)NOTE in essence, exceptions are compared with stack-less, without and ADT, but several types are chosen, as there is no single approach in Scala and it is interesting to compare several.
In addition to exceptions, strings are used to describe errors, but with the same success in a real situation different classes would be used ( Either[Failure, T]
).
For testing error handling, let's take the problem of parsing and validating data:
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] }
those. having raw data Map[String, String]
you need to get a Person
or an error if the data is not valid.
The solution to the forehead with the use of exceptions (hereinafter I will only give the person
function, you can get acquainted with the full code on 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) }
here string
, integer
and boolean
validate the presence and format of simple types and perform the conversion.
In general, quite simple and understandable.
The code is the same as in the previous case, but exceptions are used without a stack-trace where you can: ThrowNSTParser.scala
The solution intercepts exceptions earlier and allows combining results using for
(not to be confused with cycles in other languages):
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)
a bit more unusual for a weak eye, but due to the use of for
in general, it is very similar to the version with exceptions, besides validation of the presence of the field and parsing of the desired type occur separately ( flatMap
can be read as and then
)
Here, the Either
type is hidden behind the Result
alias, since the error type is fixed:
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)
Since the standard Either
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 the exceptions are minimally used (only for error handling when parsing a number)
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, then the result will contain errors of parsing all these fields)
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) }
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 a few errors instead of one, it's worth it!
For testing, a data set was generated with a different percentage of errors and parsed in each of the methods.
Result on all percent of errors:
In more detail on a low percentage of errors (time is different here since most of the sample was used):
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. Validated
suffers especially since it collects all errors and as a result receives a slow exception more than others:
As the experiment of elimination with stack-traces has shown, it’s really very slow (100% error is the 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 regular Either
almost as fast. With a large number of errors, Either
can be slightly faster. Validated
only due to the fail-fast semantics.
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 harder to miss it, as when using Option
instead of nulls.
Source: https://habr.com/ru/post/431586/
All Articles