Record patterns (and beyond): exceptions
Tagir Valeev
amaembo at gmail.com
Thu Feb 17 08:46:18 UTC 2022
Hello!
I like the comparison with ExceptionInInitializerError and want to
draw more parallels to static initializers. People rarely expect an
exception popping when you do something harmless like reading a static
field (likely it's much less expected than exception from pattern), so
it's usually a bad idea to throw from static initializer. Yet, we
allow `throw` statements there. What we don't allow is unconditional
throwing (static initializer must be able to complete normally, not
abruptly). And yes, we do wrap the exception. It's especially
important, as a foreign class could throw a checked exception (if
compiled not from Java or using weird Java extensions like Lombok),
and seeing the checked exception popping out of nowhere is especially
bad (in particular, because you cannot catch it specifically in Java,
it will be a compilation error).
I think it's reasonable to implement a similar approach for
deconstructors: disallow always-abrupt deconstructor, and wrap the
exceptions.
With best regards,
Tagir Valeev
On Thu, Feb 17, 2022 at 1:35 AM Brian Goetz <brian.goetz at oracle.com> wrote:
>
> As we move towards the next deliverable -- record patterns -- we have two new questions regarding exceptions to answer.
>
> #### Questions
>
> 1. When a dtor throws an exception. (You might think we can kick this down the road, since records automatically acquire a synthetic dtor, and users can't write dtors yet, but since that synthetic dtor will invoke record component accessors, and users can override record component accessors and therefore they can throw, we actually need to deal with this now.)
>
> This has two sub-questions:
>
> 1a. Do we want to do any extra type checking on the bodies of dtors / record accessors to discourage explicitly throwing exceptions? Obviously we cannot prevent exceptions like NPEs arising out of dereference, but we could warn on an explicit throw statement in a record accessor / dtor declaration, to remind users that throwing from dtors is not the droid they are looking for.
>
> 1b. When the dtor for Foo in the switch statement below throws E:
>
> switch (x) {
> case Box(Foo(var a)): ...
> case Box(Bar(var b)): ...
> }
>
> what should happen? Candidates include:
>
> - allow the switch to complete abruptly by throwing E?
> - same, but wrap E in some sort of ExceptionInMatcherException?
> - ignore the exception and treat the match as having failed, and move on to the next case?
>
> 2. Exceptions for remainder. We've established that there is a difference between an _exhaustive_ set of patterns (one good enough to satisfy the compiler that the switch is complete enough) and a _total_ set of patterns (one that actually covers all input values.) The difference is called the _remainder_. For constructs that require totality, such as pattern switches and let/bind, we have invariants about what will have happened if the construct completes normally; for switches, this means exactly one of the cases was selected. The compiler must make up the difference by inserting a throwing catch-all, as we already do with expression switches over enums, and all switches over sealed types, that lack total/default clauses.
>
> So far, remainder is restricted to two kinds of values: null (about which switch already has a strong opinion) and "novel" enum constants / novel subtypes of sealed types. For the former, we throw NPE; for the latter, we throw ICCE.
>
> As we look ahead to record patterns, there is a new kind of remainder: the "spine" of nested record patterns. This includes things like Box(null), Box(novel), Box(Bag(null)), Box(Mapping(null, novel)), etc. It should be clear that there is no clean extrapolation from what we currently do, to what we should do in these cases. But that's OK; both of the existing remainder-rejection cases derive from "what does the context think" -- switch hates null (so, NPE), and enum switches are a thing (which makes ICCE on an enum switch reasonable.) But in the general case, we'll want some sort of MatchRemainderException.
>
> Note that throwing an exception from remainder is delayed until the last possible moment. We could have:
>
> case Box(Bag(var x)): ...
> case Box(var x) when x == null: ...
>
> and the reasonable treatment is to treat Box(Bag(var x)) as not matching Box(null), even though it is exhuastive on Box<Bag<?>>), and therefore fall into the second case on Box(null). Only when we reach the end of the switch, and we haven't matched any cases, do we throw MatchRemainderException.
>
> #### Discussion
>
> For (1a), my inclination is to do nothing for record accessors, but when we get to explicit dtors, warning on explicit throw is not a bad idea. Unlike ctors, where exceptions are part of the standard toolbox, deconstructors are handed an already-constructed object, and are supposed to be total. If you're inclined to write a partial dtor, you're probably doing it wrong. As it is a new construct, the additional error checking to guide people to its proper use is probably reasonable (and cheap.)
>
> For (1b), since an exception in a dtor is suppose to indicate an exceptional failure, I don't think swallowing it and trying to go on with the show is a good move. My preference would be to wrap the exception, as we do with ExceptionInInitializerError, to make it clear that an exception from a dtor is a truly unexpected thing, and clearly name-and-shame the offending dtor. So there's a bikeshed to paint for what we call this exception.
>
> For (2), trying to repurpose either NPE or ICCE here is a losing move. Better to invent an exception type that means "uncovered remainder" (which is more akin to an IAE than anything else; someone passed a bad value to an exhaustive-enough switch.) We would use the same exception in let/bind, so this shouldn't have "switch" in its name, but probably something more like MatchRemainderException.
>
>
More information about the amber-spec-experts
mailing list