Hacker News new | ask | show | jobs
by skybrian 16 days ago
The problem with Java's checked exceptions is that it has too many kinds of exceptions to choose from and they're overly specific. Compare with Go, which has a single error interface and had it from the beginning, so it's used everywhere. Returning a new kind of error is always a local change, unless it's a function that didn't previously report errors at all.

Type systems permit either standardization or fragmentation and that's an ecosystem issue. Another example is that a language without a strong consensus on which string type to use will result in a fragmented ecosystem when each library goes its own way.

1 comments

> too many kinds of exceptions to choose from

I don't understand, why would you need to pick a checked exception? It's the dual or mirror of feeling paralyzed over a return-type because there are "too many kinds of Object to choose from."

If you're writing a CrystalBall class with a gaze_deeply() method, you'll probably return your own VisionResult (extends Object) unless it throws your TooCloudedException (extends Exception).

When someone else writes a wrapper or higher-level layer that uses your code, then it'll be up to them to convert or wrap those results and exceptions into something suitable for their level of abstraction.

> Returning a new kind of error is always a local change

One of my axioms here is that return-values and checked-exceptions are two sides of the same architectural type-system coin. While I'm not familiar with Go, that sounds like something that would be a symptom of bad architecture if it occurred for return values.

In other words, suppose all Java methods always returned Object [0]. That would also ensure that a new return type is "always a local change" to the compiler, but I think most developers would be rightly horrified if they came across code that worked that way.

[0] Let's ignore Java primitives for now.

> you'll probably return your own VisionResult (extends Object) unless it throws your TooCloudedException (extends Exception).

> When someone else writes a wrapper or higher-level layer that uses your code, then it'll be up to them to convert or wrap those results and exceptions into something suitable for their level of abstraction.

Why though? What do you gain other than longer stacktraces with all those wrappers? People always trot out some theoretical notion that a caller is going to catch that framework's different exceptions and handle them differently, but have you ever seen calling code that actually did that?

> In other words, suppose all Java methods always returned Object [0]. That would also ensure that a new return type is "always a local change" to the compiler, but I think most developers would be rightly horrified if they came across code that worked that way.

There are many different kinds of values. There really aren't that many different kinds of error - there's "transient error that you might want to retry", "programmer called the API wrong", and that's about it, most other cases (like bad user input) probably shouldn't be exceptions.

> Why though? What do you gain other than longer stacktraces with all those wrappers? People always trot out some theoretical notion that a caller is going to catch that framework's different exceptions and handle them differently, but have you ever seen calling code that actually did that?

You've never seen a try that has more than 1 catch block for different exception types?

> There are many different kinds of values. There really aren't that many different kinds of error - there's "transient error that you might want to retry", "programmer called the API wrong", and that's about it, most other cases (like bad user input) probably shouldn't be exceptions.

Do you think bad user input should be a result type? Because exceptions are essentially the same thing.

You've hit on a couple of problems with exceptions in Java though. The first is I think the default for checked exceptions should be no stack trace. As the designer of the method you've left it to the caller to decide to handle it or not, if they choose to turn it into an unchecked exception then I believe that is where the stack trace should start from. Assuming there's enough context in the checked exception the designer of the method gave you everything you needed to handle it so why do we need to capture that part of the stack trace? If it ends up getting logged the source of the issue was where it was changed to an unchecked exception.

The other issue comes down to usability. Try catch blocks aren't expressions so if you want to default something in the case of a checked exception it's a lot of low information density lines. Converting to an unchecked exception is also more ceremony than it really needs to be, but there's not really a reason why it couldn't be made simpler with some syntax sugar.

> You've never seen a try that has more than 1 catch block for different exception types?

I've seen it for APIs that throw exceptions for bad input or whatever. But what I've never seen is more than one catch block for wrapper exceptions (except perhaps to unwrap the cause), where the calling code handles FrameworkNetworkError differently from FrameworkDatabaseError or what have you.

> Do you think bad user input should be a result type?

Yes

> Because exceptions are essentially the same thing.

Well, except for all the ways they're not that you mentioned in the following paragraphs.

> Why though?

I'm not sure if this means:

1. "Why bother throwing a new exception of a different class, and not bubble up the original as-is?"

2. "Why would you use the standard feature of all Java exceptions which allows you to chain them, and not just throw away the original exception after copying some of its message string?"

For #1, it should be obvious in almost any language, if not instinctive: The library for managing customer records should return `Customer` objects instead `some.database.FetchResult` ones, and likewise it should throw `CustomerAccessException` instead of a `some.database.DatabaseException`. It's a matter of abstraction and preventing weird coupling.

For #2, surely you've been debugging something before and cursed at how the log is missing crucial details that could have saved you hours of trying to reproduce the problem? By chaining exceptions, you get automatic access the inner exception type, message string, additional properties, and deeper stack trace.

* Discarding the 'cause' at this point is a waste, the work was already done, the memory already allocated, why not benefit from it?

* Sometimes it's not your bug, and having the inner exception makes it much easier to get the necessary cooperation of someone else and get it fixed faster.

* Since the declared type is Throwable, it's not creating a compile-time dependency between layers. You could do a softer runtime test on the cause's type, but usually that's a temporary workaround while you complain that someone else's code is hiding critical details.

> there really aren't that many different kinds of error

I feel that's naive, handling edge cases and errors becomes more important as any system gets larger. Some are emergent from the complexity, others always existed but we can't afford to ignore them anymore.

There are many errors because all errors are contextual! Bad-input in the HTML form-submit is not the same as bad-input in the SQL query. A failed invariant of a tree that somehow made a loop is not the same as a failed invariant of something reporting negative length. An SSH handshake error is not a TLS handshake error.

> It's a matter of abstraction and preventing weird coupling.

Does that actually work though? Who ever handles CustomerAccessException specifically, except by calling getCause() and looking at the underlying DatabaseException?

> There are many errors because all errors are contextual! Bad-input in the HTML form-submit is not the same as bad-input in the SQL query. A failed invariant of a tree that somehow made a loop is not the same as a failed invariant of something reporting negative length. An SSH handshake error is not a TLS handshake error.

But the way you handle them is the same. All you can ever really do is a) retry it or b) fail it and alert the developer. And the wrapper doesn't make either of those any easier.

Note: This subthread has become about exceptions in general, rather than checked-vs-unchecked. I don't mind, but I wanted to make it explicit.

> Who ever handles CustomerAccessException specifically, except by calling getCause() and looking at the underlying DatabaseException?

Ideally you call a method like `getDetailFoo()`, and using the inherited `getCause()` is for hacky workarounds, like when you need a change in behavior much sooner than Customer classes will be changed to give you the information you need in a proper way.

As I said before about abstraction and coupling, the programmer here shouldn't need to know that SomeDatabase v.1.2.3 is an implementation detail of Customer. Their code shouldn't need to have a direct dependency on some.database.DatabaseException to compile either. Sure, a Java programmer can use runtime reflection instead... but at that point they should definitely be having second-thoughts about whether they're on a path to the Dark Side.

Regardless of how you write the catch-logic, the chained cause remains important for logging and diagnosis.

> But the way you handle them is the same. All you can ever really do is a) retry it or b) fail it and alert the developer. And the wrapper doesn't make either of those any easier.

I'm going to assume this is equivalent to: "All reactions to errors usually fall into a few broad descriptive categories, and encapsulating the original exception doesn't help the handler make precise decisions."

Does that sound right? Because my initial readings went in much less charitable directions like "the person throwing the exception knows better than you do about how you should handle it later" or "all retries are the same basic logic."

> All you can ever really do is a) retry it or b) fail it and alert the developer. And the wrapper doesn't make either of those any easier.

The wrapper is quite important. Imagine these layers exist:

  1. 1-5 different HTTP libraries used by...
  2. 5 different API clients for 5 different fortune cookie service, used by...
  3. A library that promises a wide and resilient range of fortunes by putting many sources (today, 5) together and semi-randomly picking between them.
  4. The website you're making that shows fortunes when people log in.
Do you want layer #1 exceptions to travel all the way to #4 as-is, so that you get an HTTP 500 and you won't actually know which remote service is at fault? (Without grepping the stacktrace, which is *ick*.)

No, because either #2 or #3 should be wrapping the HTTP exception in a new one that can carry that extra "which one did I randomly pick" information in a defined way.

Do you want a #2 exception to travel to #4 without changing, so that you have to write catch-clauses (or reflection) for 5 or more exceptions from the 5 different services, code that will be wrong when a version update turns it into 7?

No, because #2's job is to abstract those N services away and throw its own much set of exceptions to cover common cases.

So after all that I realize I didn't specifically address retry-vs-fail choices, but I think this still sufficient to show that layering is necessary.

> Ideally you call a method like `getDetailFoo()`

I know that's the theory, but have you ever seen it work that way in practice? Because I haven't.

> Regardless of how you write the catch-logic, the chained cause remains important for logging and diagnosis.

Sure. But if it's not being caught and handled but only logged or investigated then you're not gaining anything from the wrapper.

> Do you want layer #1 exceptions to travel all the way to #4 as-is, so that you get an HTTP 500 and you won't actually know which remote service is at fault? (Without grepping the stacktrace, which is ick.)

Yes? If it's a fail-and-alert-the-developer case then I'm going to look at the stacktrace first anyway.

I might have retries at any layer, but I don't see any case where #4 uses information from a wrapper exception from #3 to make a decision. The only thing #4 is going to do with a failure from #3 is retry or fail, and it's not going to make a different decision based on which service it was. (Maybe you want metrics or circuit breakers on the individual fortune cookie services, but in practice what I've seen is that you'd always find a way to plumb them in at layer #1 or #2, by reflection if need be, rather than have them work of the exceptions coming out of #3. I know the theory you're talking about, I've just never seen it actually work that way)

This isn't like returning Object. It's more like returning a String. After using a language with a common String type, who wants to go back to writing code to convert between between different kinds of strings? Having to choose among different string implementations because there's no standard usually leads to boilerplate code doing conversions at the borders.

Usually you just want to propagate or log errors, so having a generic error interface is sufficient. It's true that in Java, you can wrap exceptions, but that's extra boilerplate.

(And yes, Go does notoriously have error propagation boilerplate that they should fix, but that isn't a type system problem.)