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


    A little more about algebraic data types.

    Для тех, кто не слишком знаком с АТД (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 wrapper
    • Either[String, T] = Left(error: String) | Right(value: T) - the type containing either the result or the error description
    • ValidatedNec[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 there List, 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 Personor 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, integerand booleanvalidates 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 foras 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 ( flatMaphere you can read as and then)


    Either


    Here the type Eitheris hidden behind the alias Resultsince 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 Eitheras well as Tryforms 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 Validatedsince 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 Throwand Eithermore 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, Validatedor the usual one is Eitheralmost as fast. With a large number of errors, it Eithermay be slightly faster Validatedonly 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 Optioninstead of nulls.


    Also popular now: