📜 ⬆️ ⬇️

How to handle errors on JVM faster

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


Contestants


A little more about algebraic data types.

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:



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] ).


Problem


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.


Throw


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.


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 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 )


Either


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)


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, 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!


Testing


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:


findings


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