Hacker News new | ask | show | jobs
by adimitrov 2020 days ago
I really, really dislike exceptions.

Unchecked exceptions don't tell the caller something might go wrong. Fine for Python, where strong guarantees aren't a thing anyway, but any statically typed language cannot be content with essentially adding bottom to every single type.

Checked exceptions have failed, or at least I haven't seen anybody fix their issues. They proliferate spurious exception types in interfaces. They are inflexible, as they usually can't be generic. They suck at typing error cases for higher order functions. They're big heavy and expensive, so can't be used for hot code paths. They're exceptions but more often than not you want to signal expected failure...

The list goes on...

6 comments

Spurious exception specifications are the flip side of avoiding not telling the caller something might go wrong. It's a fundamental tension and is unavoidable.

Failure modes are an abstraction violation; they're a function of implementation. That's what makes checked exceptions not work, at the end of the day. Information-carrying exceptions reveal implementation details. So a module author must decide between hiding details and wrapping everything up in module-specific exceptions that user code can't actually use to make decisions most of the time, or expose implementation details that turn into a versioning problem over time.

There's roughly two situations for error handling: near the leaf of the call tree, where you have enough context to deal with an error, and need to switch on error type and take compensating action; or near the root of the call tree, in the main loop, where you log errors and terminate requests etc. in a generic way (e.g. 500 response).

Exceptions aren't ideal for the first situation but are great for the second. Error codes are adequate for the first situation - monadic error types (Result<T>, Either) are a bit better - but suck horribly for the second, because you need to manually unwind, writing boilerplate that should be automatic.

And at the limit, error types are isomorphic to checked exceptions, with the same problems, and more - error types introduce an aggregation problem, where multiple errors need to be joined together. You can still get that with exceptions too but it usually requires parallelism.

> There's roughly two situations for error handling: near the leaf of the call tree, where you have enough context to deal with an error, and need to switch on error type and take compensating action; or near the root of the call tree, in the main loop, where you log errors and terminate requests etc. in a generic way (e.g. 500 response).

There is actually a third case: in library code which calls other code which may fail. Take java.io.BufferedReader - to be usable, it has to be at a level of abstraction where it cannot deal with any errors the underlying Reader may throw; but the code using BufferedReader will have provided it with its underlying Reader, and will have a good idea of what errors are reasonable to expect from it.

The reason java's checked exceptions are so bad is that they cannot (or could not, before generics, and hence in most of the standard library do not) serve this use case, leading to checked exceptions that one really can't do anything with.

Sure, and there's also functional composition (functional code has the same problem - what does map(f) return if f throws?).

I think this is covered by the abstraction-breaking nature of failure modes, though. If your BufferedReader exposed the underlying Reader's failure modes, it's not just any BufferedReader any more, it's a BufferedReader<MySpecialReader>, and you don't get runtime polymorphism. You can write more generics to keep the polymorphism in static-land, but then you lose the ability to make choices based on error types.

The incompatibility is between errors and abstraction, not simply a single instance of composition.

> Exceptions aren't ideal for the first situation but are great for the second. Error codes are adequate for the first situation - monadic error types (Result<T>, Either) are a bit better - but suck horribly for the second, because you need to manually unwind, writing boilerplate that should be automatic.

At least in Rust, Result<t> can be unwrapped and bubbled up (in the error case) with a single `?`.

My hot take on this is that java checked exceptions are bad because their design predates java generics. Because of the lack of generics, authors of packages such as java.io had to create god-types of exceptions for their general interfaces to throw. A good example is

  public int java.io.Reader.read() throws IOException
As I see it, the purpose of checked exceptions was to allow declaring expected failure modes in the function signature, so that the programmer (and the compiler!) could check against them - but when my StringReader declares itself capable of throwing an SSLException (subclass of IOException), this benefit is lost. Instead, I must rely on other sources to determine which errors may actually occur, and which I can't do anything about - and the latter I must swallow or pollute all of my package's function signatures with. If the Reader interface had instead been generic

  java.io.Reader<T extends Throwable>
read() could be declared as

  public int read() throws T
This would rescue much, and is something that could be done in modern java; but by the time generics were introduced, all the core packages like java.io were written, and the patterns for how to deal with checked exceptions were set.
Yeah, I dislike also exceptions. I'm more fan of Rust error handling for example via `Result<T, E>`. I believe that make a distinction between recoverable and unrecoverable errors is key.
I see a lot of people espousing this viewpoint and I just don't get it. What's the big difference between the way Java does it and the way Rust does it?

In Java, unchecked exceptions are similar to Rust's panics. Sure your less inclined to catch panics than you might be to catching unchecked exceptions, but you can do it in either. Though perhaps Java's Error is closer to a rust panic.

Checked exceptions are just like the Result type IMO. The discoverability of the error surface is the same, you just don't get the nice pattern matching and sum types to handle/represent it.

What I'm getting at is that I'm a fan of the way Rust does error handling, but I don't get _why_ it feels different to the way Java does it. On the surface they're the same, but I loathe the Java approach. Maybe it's just whatever the opposite of rose-tinted glasses is.

As you say, the API feels similar. However, result types have many benefits over exceptions, with a few drawbacks.

With a result type, in most languages, you can't just ignore failure. You must handle it, if you want to access the value. This prevents the common Java bug where a checked exception is caught but not handled. In rust, you must handle the error, propagate the error, or panic and fail fatally.

Results don't abuse a runtime either. The exception process is slow - stack traces must be generated and the runtime itself needs to manually find the catch block responsible. Returning a result doesn't find this problem.

Checked exceptions are fine for exceptional cases. At risk of sounding facetious, that's why they're called exceptions.

But there's an important distinction that we need to make: logic failures are not exceptional.

If you expect an operation may produce an error, you should handle it or crash the program. You should do this without incurring a performance hit, and using reliable and transparent primitives. Result types achieve both of these criteria, checked exceptions neither.

The drawback to results is mainly the loss of data. Generating a stack trace is expensive, but often that's a worthwhile cost. Exceptions could be more versatile, in that any part of your code can throw any type of exception, but a result can only contain a specific type of error. That's not always a pro, though.

So in general, checked exceptions are pretty awful for expressing normal failure cases, such as not finding a record in a database. They aren't great for anticipated errors, such as not finding a file, because of the chance a programmer might allow this to silently fail. Runtime exceptions are highly valuable, however.

[1] - A decent SO post on the topic: https://stackoverflow.com/questions/613954/the-case-against-...

> This prevents the common Java bug where a checked exception is caught but not handled.

How does the Rust way avoid this? You can just as easily match on an error type with a catch-all case and ignore the error.

But you still acknowledge the presence of and explicitly supply a response to the failure case.
Is that not identical to an empty catch block? I fail to see how this is materially different
> What I'm getting at is that I'm a fan of the way Rust does error handling, but I don't get _why_ it feels different to the way Java does it.

It's because of the way the exception information gets annotated. In Rust, exception information is part of the return type, so you can handle potentially-exceptional functions using mechanisms that are generic over any return type. For example, Iterator::map lets you call every function that might fail, and gather the results as failures, even if you don't fail-fast. But in Java, the exception information is passed in a separate part of the type signature, so generic functions have to be written differently to also handle functions that might fail.

Try to stick a function that may fail into a higher order function. The best example would be .map on streams/collections.

You can only do it with unchecked exceptions in Java. In Rust, you can transparently do it with iterators and the result type.

So given xs: [A] and f: A -> Result<A, E>, it is trivial to typesafely get Result<[A], E> by xs.map(f) where map: forall T. [T] -> (T -> S) -> [S]. This is outright impossible with Java. You have to circumvent the type system, or emulate Rust's approach.

Maybe I'm missing something but why does this not work with Java?

Is it the fact you would put your xs.map call in a mapper or something?

Because I know for sure you can do it in Kotlin: https://github.com/michaelbull/kotlin-result

But I don't see reified generics as a requirement for what you describe, and that's the main Kotlin-only feature I see being used

The idiomatic error handling mechanism of Java is the checked exception. This mechanism does not work with higher order functions.

You can emulate Rust's approach with Java, by creating what is essentially a sum type like Result. You'd have to enforce that any access to its content must also handle the error case, and I don't really know how to do that generally. There are various hacks, like having a bespoke sum type for that particular operation that twrows a particular exception on access to its content. But that gets really old, really fast.

Kotlin doesn't have checked exceptions, so being able to do it in Kotin is irrelevant.
What?

a) I asked how is it not possible in Java. The Kotlin library is just an example of something I use, it's not relying anything impossible in Java as far as I can tell... like I said reified generics don't change what the calls would look like, just what the implementation looks like

b) What do checked exceptions have using Result? The whole point is you use Result monads in all your code instead of exceptions. When interfacing with legacy code you wrap any exceptions in a Result class as well.

With checked exceptions it's not clear how control can pass into the catch block (using Java terminology). The fact that a method can throw a particular exception is not visible at the call site so unless you examine the declaration or definition of every method call within the try block, you cannot be sure from where control can jump to the catch block.

And if there are multiple places within the try block where the caught checked exception can be thrown, then basically the catch block can do nothing sensible with respect to recovery or even guaranteeing anything about the current state of the world once you're in the catch block.

I've seen some horribly ugly patterns used in an attempt to deal with this - basically you need to explicitly record progress within the try block which can be tested in the catch block.

And whatever about Java - at least the design decision hasn't completely poisoned the subsequent development of the language. Whenever I use C++, it feels like a massive amount of the complexity, wizardry and arcane/complex patterns required to write "safe" C++ would not be necessary if they hadn't added exceptions to the language early on.

You might enjoy this 2005 blog post from the great Raymond Chen titled Cleaner, more elegant, and harder to recognize.

https://devblogs.microsoft.com/oldnewthing/20050114-00/?p=36...

I have to agree with the author. It is extraordinarily difficult to see the difference between bad exception-based code and not-bad exception-based code. In particular, the example of bad vs not-bad doesn't show substantive differences between the two versions.

    // bad
    NotifyIcon CreateNotifyIcon()
    {
      NotifyIcon icon = new NotifyIcon();
      icon.Text = "Blah blah blah";
      icon.Visible = true;
      icon.Icon = new Icon(GetType(), "cool.ico");
      return icon;
    }

    // not-bad
    NotifyIcon CreateNotifyIcon()
    {
      NotifyIcon icon = new NotifyIcon();
      icon.Text = "Blah blah blah";
      icon.Icon = new Icon(GetType(), "cool.ico");
      icon.Visible = true;
      return icon;
    }
What is the problem with the first version and how is the second version fixing it?
I agree the article might have been more explicit there.

If I'm understanding correctly, it's all about possibility of this statement throwing an exception, as for example the cool.ico file might not be found or might be corrupt:

   icon.Icon = new Icon(GetType(), "cool.ico");
I think the point is that the 'bad' version can make the icon visible in the UI and then throw an exception. The 'not bad' version has a more transactional flavour: if the aforementioned statement throws, then, because of the better ordering, the 'not bad' version doesn't make the unfinished icon object visible, it just bails out with the exception having made no change to the UI, and the unfinished NotifyIcon instance gets garbage-collected.

I presume that the real-world code included some extra machinery to hook it up to the existing UI objects, omitted for brevity in the example. It's a bit confusing as it looks rather like it's just building up and returning a NotifyIcon instance for the caller to make use of, but I think the icon.Visible = true; is meant to represent truly making the icon visible in the UI.

Thanks. Then the 'bad' code should look something like:

    // bad
    NotifyIcon CreateNotifyIcon()
    {
      NotifyIcon icon = new NotifyIcon();
      icon.Text = "Blah blah blah";
      system.Display(icon);
      icon.Icon = new Icon(GetType(), "cool.ico");
      return icon;
    }
Which would make noticing the coding error quite a bit easier, as we are passing an obviously incompletely initialized object to a third party. As presented, the code presumably is intended to be used like so:

    NotifyIcon icon = CreateNotifyIcon();
    system.Display(icon);
which works in both versions. If 'icon.Icon = new Icon(GetType(), "cool.ico");' throws, then the NotifyIcon is never displayed.

As presented, this is hardly an argument that explicit error passing code is better at detecting bad code than exception based code. His other article, https://devblogs.microsoft.com/oldnewthing/20040422-00/?p=39..., is making the point a bit more clearly.

The point itself is half-sound. While it is true that with exception based code, it is hard to distinguish which statements may fail, the real issue is about correctly releasing resources. Assume that every statement can fail, and use some form RAII to manage resource cleanup. The exact content of RAII is hard to glean in both explicit error code and exception based code, as it depends on how the specific third party API acquires/releases resources. Though APIs can be organized to essentially force the use of RAII, even if popular but ancient APIs like POSIX file system are not designed that way.

The real language design issue is that Java/C# mechanism for running cleanup code 'try {} finally {}' a. fails to pass through a handle to the resource that needs cleanup and b. is not scoping the lifetime of the resource that needs cleanup. Furthermore, the standard API makes no effort to provide RAII constructs for correctly managing resource lifetimes. Those language ecosystems actively steer people towards writing bad code. Exceptions may be fine, but the lack of language supported RAII is definitely poor language design. For better language design, exception based Python offers 'with' mechanism and error code based Golang offers 'defer'.

https://www.python.org/dev/peps/pep-0343

https://golang.org/ref/spec#Defer_statements

> Assume that every statement can fail, and use some form RAII to manage resource cleanup.

I think that's part of Chen's point: programmers just aren't good at doing this, they even get it wrong in published code samples. Exception-handling tends to become an afterthought, and even if you do pay attention to it, it's hard to get right.

Chen is hardly alone in his scepticism. Exceptions are forbidden in the Google C++ style guide. They're also forbidden in certain critical-systems subsets of languages. Ravenscar Ada forbids exceptions, [0] as does Spark Ada (though in that case it's for a slightly different reason: it's difficult to formally reason about exceptions). edit Apparently the JSF C++ standard forbids exceptions, but MISRA C++ permits them provided certain guidelines are followed.

I agree that RAII is very useful for robust exception-handling.

> APIs can be organized to essentially force the use of RAII, even if popular but ancient APIs like POSIX file system are not designed that way

Right, this is just the kind of thing C++ wrappers add (when wrapping C APIs).

> Java/C# mechanism for running cleanup code 'try {} finally {}' a. fails to pass through a handle to the resource that needs cleanup

Short of proper RAII (destructors), I'm not sure what that would look like.

I'm not sure what Chen makes of destructors. They're non-local flow-control, but he seems to like them.

Somewhat related: Zig's optional types, which essentially force the programmer to explicitly handle the case where the data doesn't exist. [1] Much more robust than the approach C takes, where the programmer is trusted to perform the check when necessary.

[0] p20 of PDF: https://www.sigada.org/ada_letters/jun2004/ravenscar_article...

[1] https://ziglang.org/documentation/master/#Optionals

RAII can be decoupled from ctor/dtor mechanism, see Python's 'with' statement. Javaish pseudocode, similar to a 'for' loop:

    // on exit the finally fn will be called with 'x' as an argument.
    with (Type x: create(args); finally (Type x) -> cleanup(x)) {
      // safely use x, throw at will.
    }
Re C++ and exceptions, there is an additional layer of wrinkles: throwing exceptions from ctors or, worse, dtors. Herb Sutter used to have a loooong list of what can go wrong in such situations back in the day. Explicit error codes happen to make it impossible to write ctors / dtors that can enter an error state by virtue of the fact that there is no way to return the error. My suspicion is that this is 50% of the reason of banning C++ exceptions in solid C++ style guides.
Walter Bright (D's author) said something like: who's going to check the error returned by the log functions?

I would add if you want to be correct then the addition signature should be (IntX, IntX) -> Either<IntX,Error>. Are you going to check also the return value of every arithmetic operation?

For me potentials errors are everywhere, so exception aren't bad, even if they make writing 'exception safe' code hard. That's why I was quite interested in the Vale language which claims to improve RAII: https://vale.dev/blog/next-steps-raii

What about this middle ground? These are my thoughts, very curious whether there are languages doing this & if so, what the real world problems are.

1. Statically typed.

2. Unchecked exceptions by default. IE, implicit bottom on every type.

3. Optional checked exceptions. Implementation could be `checked` keyword (e.g. `checked fun foo() throws XyzError {}`), or a generic type, or something else depending on the language. In order to mark something as checked, it must catch all exceptions from unchecked values it touches.

4. The compiler infers exceptions types when possible. So, if a regular unchecked function (`fun bar() {}`) only calls checked functions (foo from above), the compiler will carry the information about what can be thrown (XyzError) up the chain, even if there is no explicit signature. This way if another function (`checked fun baz() {}`) calls bar(), then it only has to catch or declare the XyzException, rather than all exceptions generically.

Finally, and to tie this all together: as a matter of style, reserve exceptions for actual unexpected errors. To use an example from downthread, a JSON parsing library should not be throwing parsing errors, it should be returning a Maybe<Parsed, Error>. Why? Because the library cannot determine whether the error is unexpected. If you're using it to parse user input, then yes, you're going to frequently encounter invalid data, and this should be part of your normal control flow, not something that can crash your application at runtime (you may want to log the event, maybe with a stack trace, but without interrupting normal operations). On the other hand, say you're parsing JSON from your database, (also, why are storing json in your db? But bear with me..). And invalid json means data corruption, which means your db is fubar. Then, it's fine to throw, because in that case you're operating on state that you assumed to be impossible, and your program behavior is now undefined.

The key point here is that exceptions are useful as a safety valve for early termination in the case of invalid assumptions. That's a subset of all error handling. If you try to force all error handling into the same paradigm and the same level of reliability, you're going to have awkward edge cases.

Reply to self: barrkel's comment upthread describes the premises I was working off of quite elegantly: https://news.ycombinator.com/item?id=25260631