Sealed Types vs Exceptions?
Holo The Sage Wolf
holo3146 at gmail.com
Wed Dec 6 08:22:56 UTC 2023
David,
The discussion about checked vs. unchecked exceptions is quite common. The
blog Brain linked is very vocal about their opinion, but it is far from the
only opinion.
I know people who prefer by a great margin the ease of use of unchecked
exception, and how you can handle them without the restrain of the language
type system (see below for more info). To be clear, I completely agree with
this view and I am a strong believer that checked exceptions are better,
and that their potential is not fully realized in Java, see
https://koka-lang.github.io/koka/doc/index.html for a true full
implementation of effect system (generalisation of checked exceptions).
> I find that most functions attempt to handle "the good values" of the ADT.
Just like how people like uniformity in the type system, people like
uniformity in their code.
We try to put all failures in few as possible boxes.
When using Java's exceptions it is "exception", "runtimeException" and
"error", all of which are under "throwable", in ADT it is usually
"optional" and "either" where either has generic parameters (which replace
the inheritance we use in exceptions), (in this case DivResult will be an
instance of either).
When you have uniformity, you can easily delay handling the failure case to
the appropriate time.
In addition, "living in the monad space" usually simplify logic, instead of
going back and force from values to monads:
Say I work on float type/double type, and I write a method that tries to
solve an equation:
Either<...> solve(String expr)
(The generic type of the result omitted on purpose)
The flow of this method is "validate input" -> "simplify" -> "validate
again" (maybe after simplifying there is division by 0, or
root-if-negativr) -> "try to solve with a strategy" -> "collect results of
strategies".
Now, failures of "strategy" need to be handled in "solve", but failures in
"validate" or "simplify" need to pass to the return value.
Now you can implement it as:
var validation = validate(expr)
if (validation is Either.right(_)) return validation
var simplifyExpr = simplify(expr)
if ...
Or:
return validate(expr)
.mapLeft(simplify)
.mapLeft(...)
Or
var validate = validate(expr)
var simplified = simplify(expr)
var ....
While in the last solution, all of the methods looks like:
switch(input)
Either.right(_) -> input
Either.left(var val) -> ...
I think that the second and third options are obviously much clearer
(especially if you consider "if" to be an anti pattern), the advantages of
the last option is that it solves the common problem with pipes like code,
of having dependency on older variables (as the absolute majority of
programming languages are not affine)
Now, if your language support monads, then just how you can lift a value
into Either, you could lift a function U->T and U->Either[T,_] into
Either[U,_]->Either[T,_], so when you have a list of methods it is a lot
more natural to just work with the same monad all the way (but of course,
if you know how to handle *all* the failure options gracefully, you should
lift it down before returning, and if the caller need, they will lift your
method).
> I am missing out on that chaining map-reduce style flow state that ADT's
get
If you take a look at Koka I linked above, you can see that it is actually
possible to have chaining with effect based system. Java just don't have
sum and difference types on generic level. Which is a big reason why some
people love unchecked exceptions.
If semantically you know about all of the exceptions, you can do stuff like:
Assume func is a method whose possible exceptions are unchecked exceptions,
A,B,C:
Stream.generate(...)
.handle(A.class, (A a) -> handleA(...))
.handle(B.class, (B b) -> handleB(...))
.forEach(func)
Then you know that the above chain type is: _->() throws A+B+C-A-B+rA+rB
(Where handleX is of type X->() throws rX)
Java's type system don't have this ability, so you either have all of your
lambdas throw Throwable, or use unchecked exceptions.
> comparing apples and oranges
These are 2 possible designs for languages/systems, so it is important to
compare them.
On Wed, 6 Dec 2023, 07:36 David Alayachew, <davidalayachew at gmail.com> wrote:
> Hello Brian,
>
> Thank you for your response!
>
> > Error handling is hard, no matter how you slice
> > it. (See
> > https://joeduffyblog.com/2016/02/07/the-error-model/
> > for a mature analysis of all the options.)
>
> That was a really nice read! And yes, it was definitely comprehensive, no
> question.
>
> I especially appreciated the focus on how Java does Checked Exceptions and
> Unchecked Exceptions, as well as the strengths and weaknesses of both. I
> had never really done much trying to make or catch Unchecked Exceptions,
> but seeing how they "poison the water" was insightful. It was also really
> interesting to see them talk about how they used assert. Makes me wonder
> what Java would look like if we had precondition/postcondition tools like
> that. Or if assert had meaning in the method signature.
>
> > The benefit of using algebraic data types
> > (e.g., the Either or Try monads) for error
> > handling is _uniformity_. Type systems such as
> > the simply typed lambda calculus (Peirce, Ch9)
> > say that if we have types T and U, then the
> > function type `T -> U` is also a type. Such
> > function types are well behaved, for example
> > they can be composed:
> > if f : T -> U and g : U -> V, then g.f : T -> V
> >
> > A Java method
> >
> > static int length(String s)
> >
> > can be viewed as a function String -> int. But
> > what about a Java method
> >
> > static int parseInt(String s) throws NumberFormatException
> >
> > ? This is a _partial function_; for some
> > inputs, parseInt() does not produce an output,
> > instead it produces an effect (throwing an
> > exception.) Type systems that describe partial
> > or effectful computations are significantly
> > more complicated and less well-behaved.
> >
> > Modeling a division result as in the following
> > Haskell data type:
> >
> > data DivResult = Success Double | Failure
> >
> > means that arithmetic operations are again total:
> >
> > DivResult divide(double a, double b)
> >
> > This has many benefits, in that it becomes
> > impossible to ignore a failure, and the
> > operation is a total function from double x
> > double to DivResult, rather than a partial
> > function from double x double to double. One
> > can represent the success-or-failure result
> > with a single, first-class value, which means I
> > can pass the result to other code and let it
> > distinguish between success and failure; I
> > don't have to deal with the failure as a
> > side-effect in the frame or stack extent in
> > which it was raised. The common complaints
> > about "lambdas don't work well with exceptions"
> > comes from the fact that lambdas want to be
> > functions, but unless their type (modeled in
> > Java with functional interfaces) accounts for
> > the possibility of the exception, we have no
> > way to tunnel the exception from the lambda
> > frame to the invoking frame.
>
> So, this was interesting to read. And they said something similar in the
> article that you linked.
>
> On the one hand, that uniformity allows us to cleanly and easily
> latch/compose things in a happy path sort of way.
>
> But maybe I am wrong here, but when we are talking about Sealed Types
> (Algebraic Data Types), I find that most functions attempt to handle "the
> good values" of the ADT. Obviously, there are things like Stream or
> Optional that actually do behave the way that this is described as, but how
> often are we writing something that rich and involved? Usually, we are
> doing something more akin to DivisionResult. And in those cases, do we
> really want to hand over DivisionResult as a parameter as opposed to
> Success? I guess I just don't see much value in passing the "uncertainty"
> over to the next level unless that uncertainty is the desirable part
> (again, Stream and Optional).
>
> My reasoning for this is from an article by Alexis King (Lexi Lambda) --
> "Parse, don't validate" (
> https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/). For
> me, ADT's are at their best when you can extract the good from the bad,
> then pass along the safer, more strongly typed good, and then handle the
> safer, more strongly typed bad as desired. The idea of continuing to pass
> along the "Unknown" type seems to me like passing along the uncertainty
> when I could have certainty - whether good or bad.
>
> > It is indeed true that exceptions carry more
> > information, and that information comes at a
> > cost -- both a runtime cost (exceptions are
> > expensive to create and have significant memory
> > cost) and a user-model cost (exceptions are
> > constraining to deal with, and often we just
> > throw up our hands, log them, and move on.)
> > On the other hand, algebraic data types have
> > their own costs -- wrapping result
> > success/failure in a monadic carrier intrudes
> > on API types and on code that consumes results.
>
> Ok, cool. So it's a trade off between performance and information.
>
> I guess that leads to the next question then -- when does it make sense to
> include that level of information? Knowing that this is the primary
> difference sends me down some interesting and probably incorrect trains of
> thought. If the difference is purely a contextual one, then unless I want
> context, I should default to an ADT. And it is only when I want context
> that I should opt into Exceptions. How do you feel about that?
>
> But that gets even more nuanced because then I am missing out on that
> chaining map-reduce style flow state that ADT's get. Exceptions don't have
> that. It really feels like I am comparing apples and oranges, and it's not
> really a matter of which is better, as opposed to if my context happens to
> want apples vs oranges at that particular moment.
>
> I can see use cases for both. For example, if I wanted to implement retry
> functionality, it becomes clear almost immediately that Exceptions are a
> much better vehicle for this then ADT's. Yes, you could easily retry if
> ServiceException or something. But we don't want to retry infinitely. Thus,
> you need to know which function and in which context we failed. And what if
> the second time we reattempt, we get further but get a ServiceException on
> the next statement? Start from 0 or continue on? What if the user wants the
> choice? All of this is way easier if you have a stack trace. Whereas for
> ADT's, the only (easy) way you could do that is if you pass in an instance
> of your ADT as a parameter of some sort (or a ScopedValue, now that that is
> an option available to us), and even then, you would end up recreating the
> concept of a stack trace. Exceptions give you a stack trace, they throw and
> rewrap with less ceremony, and all while uncluttering your happy path, even
> more than ADT's. ADT's make everything, success and failure, a first class
> citizen, which, like you said, forces everyone to hold the exceptional
> cases front and center, regardless of how important they are. If that hurts
> your API's ergonomics, well, you opted into that.
>
> > Error handling is hard, no matter how you slice
> > it.
>
> I see that much more now. Still, I am making excellent progress in
> mastering that, especially in these past few days.
>
> Thank you for your time and help!
> David Alayachew
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/amber-dev/attachments/20231206/3bf2906e/attachment-0001.htm>
More information about the amber-dev
mailing list