26

Not too long ago I started using Scala instead of Java. Part of the "conversion" process between the languages for me was learning to use Eithers instead of (checked) Exceptions. I've been coding this way for a while, but recently I started wondering if that's really a better way to go.

One major advantage Either has over Exception is better performance; an Exception needs to build a stack-trace and is being thrown. As far as I understand, though, throwing the Exception isn't the demanding part, but building the stack-trace is.

But then, one can always construct/inherit Exceptions with scala.util.control.NoStackTrace, and even more so, I see plenty of cases where the left side of an Either is in fact an Exception (forgoing the performance boost).

One other advantage Either has is compiler-safety; the Scala compiler won't complain about non-handled Exceptions (unlike the Java's compiler). But if I'm not mistaken, this decision is reasoned with the same reasoning that is being discussed in this topic, so...

In terms of syntax, I feel like Exception-style is way clearer. Examine the following code blocks (both achieving the same functionality):

Either style:

def compute(): Either[String, Int] = {

    val aEither: Either[String, String] = if (someCondition) Right("good") else Left("bad")

    val bEithers: Iterable[Either[String, Int]] = someSeq.map {
        item => if (someCondition(item)) Right(item.toInt) else Left("bad")
    }

    for {
        a <- aEither.right
        bs <- reduce(bEithers).right
        ignore <- validate(bs).right
    } yield compute(a, bs)
}

def reduce[A,B](eithers: Iterable[Either[A,B]]): Either[A, Iterable[B]] = ??? // utility code

def validate(bs: Iterable[Int]): Either[String, Unit] = if (bs.sum > 22) Left("bad") else Right()

def compute(a: String, bs: Iterable[Int]): Int = ???

Exception style:

@throws(classOf[ComputationException])
def compute(): Int = {

    val a = if (someCondition) "good" else throw new ComputationException("bad")
    val bs = someSeq.map {
        item => if (someCondition(item)) item.toInt else throw new ComputationException("bad")
    }

    if (bs.sum > 22) throw new ComputationException("bad")

    compute(a, bs)
}

def compute(a: String, bs: Iterable[Int]): Int = ???

The latter looks a lot cleaner to me, and the code handling the failure (either pattern-matching on Either or try-catch) is pretty clear in both cases.

So my question is - why use Either over (checked) Exception?

Update

After reading the answers, I realized that I might have failed to present the core of my dilemma. My concern is not with the lack of the try-catch; one can either "catch" an Exception with Try, or use the catch to wrap the exception with Left.

My main problem with Either/Try comes when I write code that might fail at many points along the way; in these scenarios, when encountering a failure, I have to propagate that failure throughout my entire code, thus making the code way more cumbersome (as shown in the aforementioned examples).

There is actually another way of breaking the code without Exceptions by using return (which in fact is another "taboo" in Scala). The code would be still clearer than the Either approach, and while being a bit less clean than the Exception style, there would be no fear of non-caught Exceptions.

def compute(): Either[String, Int] = {

  val a = if (someCondition) "good" else return Left("bad")

  val bs: Iterable[Int] = someSeq.map {
    item => if (someCondition(item)) item.toInt else return Left("bad")
  }

  if (bs.sum > 22) return Left("bad")

  val c = computeC(bs).rightOrReturn(return _)

  Right(computeAll(a, bs, c))
}

def computeC(bs: Iterable[Int]): Either[String, Int] = ???

def computeAll(a: String, bs: Iterable[Int], c: Int): Int = ???

implicit class ConvertEither[L, R](either: Either[L, R]) {

  def rightOrReturn(f: (Left[L, R]) => R): R = either match {
    case Right(r) => r
    case Left(l) => f(Left(l))
  }
}

Basically the return Left replaces throw new Exception, and the implicit method on either, rightOrReturn, is a supplement for the automatic exception propagation up the stack.

Eyal Roth
  • 623

6 Answers6

17

If you only use an Either exactly like an imperative try-catch block, of course it's going to look like different syntax to do exactly the same thing. Eithers are a value. They don't have the same limitations. You can:

  • Stop your habit of keeping your try blocks small and contiguous. The "catch" part of Eithers doesn't need to be right next to the "try" part. It may even be shared in a common function. This makes it much easier to separate concerns properly.
  • Store Eithers in data structures. You can loop through them and consolidate error messages, find the first one that didn't fail, lazily evaluate them, or similar.
  • Extend and customize them. Don't like some boilerplate you're forced to write with Eithers? You can write helper functions to make them easier to work with for your particular use cases. Unlike try-catch, you are not permanently stuck with only the default interface the language designers came up with.

Note for most use cases, a Try has a simpler interface, has most of the above benefits, and usually meets your needs just as well. An Option is even simpler if all you care about is success or failure and don't need any state information like error messages about the failure case.

In my experience, Eithers aren't really preferred over Trys unless the Left is something other than an Exception, perhaps a validation error message, and you want to aggregate the Left values. For example, you are processing some input and you want to collect all the error messages, not just error out on the first one. Those situations don't come up very often in practice, at least for me.

Look beyond the try-catch model and you'll find Eithers much more pleasant to work with.

Update: this question is still getting attention, and I hadn't seen the OP's update clarifying the syntax question, so I thought I'd add more.

As an example of breaking out of the exception mindset, this is how I would typically write your example code:

def compute(): Either[String, Int] = {
  Right(someSeq)
    .filterOrElse(someACondition,            "someACondition failed")
    .filterOrElse(_ forall someSeqCondition, "someSeqCondition failed")
    .map(_ map {_.toInt})
    .filterOrElse(_.sum <= 22, "Sum is greater than 22")
    .map(compute("good",_))
}

Here you can see that the semantics of filterOrElse perfectly capture what we are primarily doing in this function: checking conditions and associating error messages with the given failures. Since this is a very common use case, filterOrElse is in the standard library, but if it wasn't, you could create it.

This is the point where someone comes up with a counter-example that can't be solved precisely the same way as mine, but the point I'm trying to make is there is not just one way to use Eithers, unlike exceptions. There are ways to simplify the code for most any error-handling situation, if you explore all the functions available to use with Eithers and are open to experimentation.

Karl Bielefeldt
  • 148,830
9

The two are actually very different. Either's semantics are much more general than just to represent potentially failing computations.

Either allows you to return either one of two different types. That's it.

Now, one of those two types may or may not represent an error and the other may or may not represent success, but that is only one possible use case of many. Either is much, much more general than that. You could just as well have a method that reads input from the user who is allowed to either enter a name or a date return an Either[String, Date].

Or, think about lambda literals in C♯: they either evaluate to an Func or an Expression, i.e. they either evaluate to an anonymous function or they evaluate to an AST. In C♯, this happens "magically", but if you wanted to give a proper type to it, it would be Either<Func<T1, T2, …, R>, Expression<T1, T2, …, R>>. However, neither of the two options is an error, and neither of the two is either "normal" or "exceptional". They are just two different types that may be returned from the same function (or in this case, ascribed to the same literal). [Note: actually, Func has to be split into another two cases, because C♯ doesn't have a Unit type, and so something that doesn't return anything cannot be represented the same way as something that returns something, so the actual proper type would be more like Either<Either<Func<T1, T2, …, R>, Action<T1, T2, …>>, Expression<T1, T2, …, R>>.]

Now, Either can be used to represent a success type and a failure type. And if you agree on a consistent ordering of the two types, you can even bias Either to prefer one of the two types, thus making it possible to propagate failures through monadic chaining. But in that case, you are imparting a certain semantic onto Either that it does not necessarily possess on its own.

An Either that has one of its two types fixed to be an error type and is biased to one side to propagate errors, is usually called an Error monad, and would be a better choice to represent a potentially failing computation. In Scala, this kind of type is called Try.

It makes much more sense to compare the use of exceptions with Try than with Either.

So, this is reason #1 why Either might be used over checked exceptions: Either is much more general than exceptions. It can be used in cases where exceptions don't even apply.

However, you were most likely asking about the restricted case where you just use Either (or more likely Try) as a replacement for exceptions. In that case, there are still some advantages, or rather different approaches possible.

The main advantage is that you can defer handling the error. You just store the return value, without even looking at whether it is a success or an error. (Handle it later.) Or pass it somewhere else. (Let someone else worry about it.) Collect them in a list. (Handle all of them at once.) You can use monadic chaining to propagate them along a processing pipeline.

The semantics of exceptions are hardwired into the language. In Scala, exceptions unwind the stack, for example. If you let them bubble up to an exception handler, then the handler cannot get back down where it happened and fix it. OTOH, if you catch it lower in the stack before unwinding it and destroying the necessary context to fix it, then you might be in a component that is too low-level to make an informed decision about how to proceed.

This is different in Smalltalk, for example, where exceptions don't unwind the stack and are resumable, and you can catch the exception high up in the stack (possibly as high up as the debugger), fix up the error waaaaayyyyy down in the stack and resume the execution from there. Or CommonLisp conditions where raising, catching and handling are three different things instead of two (raising and catching-handling) as with exceptions.

Using an actual error return type instead of an exception allows you to do similar things, and more, without having to modify the language.

And then there's the obvious elephant in the room: exceptions are a side-effect. Side-effects are evil. Note: I'm not saying that this is necessarily true. But it is a viewpoint some people have, and in that case, the advantage of an error type over exceptions is obvious. So, if you want to do functional programming, you might use error types for the sole reason that they are the functional approach. (Note that Haskell has exceptions. Note also that nobody likes to use them.)

Jörg W Mittag
  • 104,619
2

The nice thing about exceptions is that the originating code has already classified the exceptional circumstance for you. The difference between an IllegalStateException and a BadParameterException is much easier to differentiate in stronger typed languages. If you try to parse "good" and "bad", you aren't taking advantage of the extremely powerful tool at your disposal, namely the Scala compiler. Your own extensions to Throwable can include as much information as you need, in addition to the textual message string.

This is the true utility of Try in the Scala environment, with Throwable acting as the wrapper of left items to make life easier.

BobDalgleish
  • 4,749
2

Exceptions are quite good due to their deep integration in the Java ecosystem, and they should be used whenever they will do the job. Here's my view on when that would be, compared to some of the alternatives.

The sweet spot for exceptions is single-threaded code where you don't want to handle the exception except at some top-level entry point such as an HTTP endpoint or a Main routine. In these circumstances, exceptions are very powerful for the developer:

  • They include a stack trace that shows where the original problem occurred and also the chain of events that led to it happening. This is a massive productivity boost for debugging, and debugging is where developers spend a lot of our time.
  • Exceptions require no additional syntax to propagate them through the call stack.

The biggest place Either is helpful is if you want to process the failure on purpose rather than letting it propagate. Be careful before assuming this is obviously what you want to do, because it's easy to accidentally remove some information from a stack trace that the developer would have liked to know. However, if you are sure you want to handle the condition and keep going, then Either can help you be sure that you are catching the failure of the thing you want. Exceptions are really bad for catching them halfway in the callstack, because you don't really know what, in the tree of code that you invoked, actually caused the problem that occurred.

Either is also helpful if you are using a threading framework that does not propagate exceptions across threads. This is more unusual than it may seem, however. I can't even name a specific example. It's a common expectation that if a framework can defer work to another thread, and the work throws an excetion, then the exception will be caught and sent back to the originating thread.

A massive downside of Either is that it can be hard to remember what the "left" and "right" options of it correspond to. Try fixes this problem by changing "left/right" to instead be "success/failure". Also, with Try, a failure is always indicate by an exception. With Either, the failure is allowed to be any type, with exceptions just being a common choice.

lexspoon
  • 131
0

Adding to the other answers, I'd like to respond to this snippet:

One major advantage Either has over Exception is better performance; an Exception needs to build a stack-trace and is being thrown. As far as I understand, though, throwing the Exception isn't the demanding part, but building the stack-trace is.

Cost of stack traces

The stack traces can be computed lazily, so that you don't pay their cost for exceptions that you catch and recover from. If you do end up logging your error, then they're a useful thing to have, so it's not like you're paying a price for nothing.

Funnily enough, you'll see Either/Result based systems (like Rust) where people are asking how they can get back the price and benefit of stacktraces, to answer: "Where did this Err come from, and how did it get here?". There's many Rust libraries that do just that.

The actual cost

... is the time taken to unwind the stack, and the increased executable size from the stack unwinding metadata.

The performance trade-off between exceptions and Either/Result/error-codes is actually non-obvious.

The Result-style error-handling pays a small-but-consistent price for every function call, because there needs to be a branch to check for success vs failure, and respond accordingly.

Exception-style error-handling pays a high price for every exception exception that's actually thrown, plus a small-but-consistent code size price for every place an exception can be thrown or caught.

Notably, exceptions have zero runtime cost when there is no actual exception thrown, which means that for frequently called but rarely-failing operations, exceptions are faster than results (which are stuck checking for an error every time, on the off chance there is one).

Khalil Estell details this really well in this CPPCon 2024 talk, C++ Exceptions for Smaller Firmware.

Alexander
  • 5,185
0

You use an exception if there is a “normal” path and an “exceptional” path. You use “either” if there are two normal paths.

Say you have an employee and ask for his name. It is probably highly unusual that they don’t have a name, so no name throws an Exception.

Now you ask for the employee’s spouse. It is absolutely normal that many employees don’t have spouses - too young to be married, bachelor, divorced, widowed etc. So since having no spouse is not exceptional I’d return an Either: Either a Person or nil.

Now different languages have different cultures so if given two reasonable choices (both exception and Either seem reasonable) you may use what is preferred by the culture of this language.

gnasher729
  • 49,096