Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial work on the error handling proposal #26

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open

Conversation

zah
Copy link
Contributor

@zah zah commented Apr 6, 2020

The commit also adds a facility for writing the generated macro
output to a file. It also introduces a new module named results
that should eventually replace all usages of import result.

stew/results.nim Outdated
@@ -0,0 +1,3 @@
import result
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's just do this in a commit to master and properly (ie rename the file)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and stick a deprecation warning in the old one - no use in waiting

##
## ```
## let x = chk foo():
## KeyError as err: defaultValue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this example has style issues that we probably want to forbid in our code: raising exceptions from different abstraction layers.

One of the issues we have is that the API's leak implementation details from different layers - this is a design flaw in these API because it means that error handling is no longer local, and you can no longer consider each layer separate and related only to the layer directly above and below it - it breaks the whole idea of abstraction and layering and turns the application into one giant module where all code depends on all code.

Copy link
Contributor Author

@zah zah Apr 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can easily remap exceptions. I was even considering short-cut syntax for this, but I haven't convinced myself it's appropriate:

let x = chk foo():
            OSError: defaultValue
            ValueError -> MyError(msg: "Some Message")

The existing alternative is just this:

let x = chk foo():
            OSError: defaultValue
            ValueError as e: raise (ref MyError)(msg: "Some Message", 
                                                 parent: e)

@arnetheduck
Copy link
Member

also, let's split out the save-macro-output in a separate PR - that seems standalone from this and fairly straightforward

incorrectChkSyntaxMsg =
"The `check` handlers block should consist of `ExceptionType: Value/Block` pairs"

template either*[E, R](x: Raising[E, R], otherwise: R): R =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is already called get for option and result..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get is not lazy though. Also, with either you can have a noReturn statement in the otherwise branch:

let x = either foo():
               raise (ref MyException)(msg: "...")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make it lazy? seems reasonable?

Copy link
Contributor Author

@zah zah Apr 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is that we can't do it for the Option type, so I decided to use a new name. either also reads for me better in English when you consider the semantics of the operation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

template valueOr*[T, E](self: Result[T, E], def: T): T =
- thought I had something like that..

Copy link
Member

@arnetheduck arnetheduck Apr 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which reminds me why I originally wrote or to take an untyped rhs:

template `or`*[T, E](self: Result[T, E], other: untyped): untyped = ...

res or raise XXX

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but that doesn't work.. hmm.. not sure why, gives expression expected found raise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the Nim parser. I wanted to use the ?? operator initially, but after realizing that the parser doesn't like return and raise used with infix operators, I've settled on either. valueOr is also OK as far as naming goes, but eitheris slightly more visually pleasing perhaps.

Copy link
Member

@arnetheduck arnetheduck Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it fixable in the language? ie for a future sugary syntax, it could be a nice-to-have (if we see that it's used a lot)

Copy link
Contributor Author

@zah zah Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. It's hard to tell whethere these grammar changes are possible before you try them out.

README.md Outdated
@@ -21,13 +21,14 @@ broken out into separate repositories.
Libraries are documented either in-module or on a separate README in their
respective folders

- `result` - friendly, exception-free value-or-error returns, similar to `Option[T]`, from [nim-result](https://github.com/arnetheduck/nim-result/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they're in alphabetic order

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, OK, will revert that

@arnetheduck
Copy link
Member

Compared to duffy's error handling proposal and the follow-up C++ proposal, there are several significant detractions here:

  • Try enables calling failing api without any indication of which sub-expressions may fail - this goes against annotating the call site of failing statements with raising which is the centrepiece of modern error handling - take for x in y: do_stuff(x) - the surprising thing that happens during an exception in do_stuff is that for some x's, the function was run and for the rest not - this is a common exception safety trap and not evident if you surround the whole thing in a block, which is the easiest thing to do and thus what programmers will do "by default" when writing the code - readers however will fail to see the hidden and early abortive control-flow and we're back to where we started. I can imagine that a middle road might be that for expressions, you maybe want to allow/bundle them in a single Try block, but once you move into control-flow or statement territory, pass-the-error-up must be explicit (try a + b + c is maybe ok, try: for ...: if ...: is not) - but at that point, perhaps you should be considering something else completely, like a baked-in NaN value for your type

  • the same mechanism is used for panics and errors - to panic I type raise ... and to raise an "ordinary" error I type raise ... - this makes the distinction between the two muddy - it is already arbitrary, subjective and contextual, and this adds fuel to the fire - it will not lead to Duffy's 90%ish of code does not have failure modes because there's not enough guidance in the proposal towards it

  • when errors are tracked, the expectation is that there will be more error handling code around - actually writing error-safe code often leads to there being more code around (explicit or implicit via defer/destructors) - C++ proposals acknowledge this in that they make EH statically typed and stack-based - the errors and failing stuff still is based on exceptions which are expensive, and remain expensive even with exceptions:goto because they still capture stack traces (which you mostly need of you're crashing, as during a Defect), do heap allocations (which you don't need for static error returns) and RTTI (which, if you're designing an API that doesn't leak internal implementation details, you don't need - it shouldn't be more expensive to design properly isolated API)

  • Try is really a pattern matcher in disguise that also defuses the Raising annotation - which is a bit sad because pattern matching in general is missing from Nim and having it only in situations where the artificial Raising workaround was introduced is.. weird - you'd want it with Option, Result, and variant objects of any kind, nested tuples etc etc

  • In general, a lot of "machinery" is needed in the language to make this fly - vs a much simpler world of Result for errors + Defect for abandonment that already works - this can be implemented in a page of code, largely solves the same problems but statically/opt-int RTTI+heap/etc (ie closer to the duffy/c++ proposals), and whose biggest downsides are really some small performance blemishes that are mostly solve:able ("move"). The amount of rewriting to existing code is the same: this proposal does not work until you rewrite all of the std lib to use errors and return Raising, if you want the full benefit of forcing the caller to deal with stuff.

over the status quo this proposal has a few benefits that I'd use already if I had them:

  • nodefect and noerrors are highly useful to create a "higher standard" of API - pragmatically makes sense that the "default" is noerrors which is the middle road between failing and nodefect and we can (probably?) get there by putting push noerrors on top of every "conformant" module - this also takes care of highlighting API that have not been refactored yet

  • failing, errors and raising annotations are viral and explicit - they require the caller to defuse them, which is excellent

    • however, because of exception polymorphism, when the exceptions change when using failing, it seems that Try will not "react" - it only sees CatchableError in the signature so the best it can detect is except CatchableError - this leads to lack of sympathy between the two construct causing Try to have complexity that in practise, as far as the offered type help is concerned, will not be used because the world remains binary: failing or not
    • doesn't work for existing api / std lib / etc, so we still need push noerrors everywhere we want to use this

In general, abandonment in duffy's model, has already been with programmers since times immemorial: assert in C, basically - exceptions make it a little fancier because you can in theory recover, but assert really ticks most of the abandonment boxes. what's really "different" in duffy's proposal and practically everywhere elsewhere these ideas are explored, and where (unchecked) exceptions fail remarkably, is that error must be part of the API/function signature and visible/enforced at the call site, preferably with static typing and stack allocation

all in all, a way forward from here that takes incremental steps would be the following:

  • introduce with the nodefects, noerrors, failing raises and errors pragmas from this proposal
  • gradually annotate our code with push noerrors - this remains necessary to capture legacy code, and makes it easy to surround less important code with try/except
  • use Result for the 90% case, or explicitly annotated errors annotations to "relax" noerrors
  • use nodefects to "tighten" API (hashing, a lot of cryptography etc fall in these categories, that are naturally "total" and shouldn't really fail) - really tight api can push nodefects
  • keep using Defect for abandonment - ideally, the compiler could expose the new panic mode to user code as well in the future
  • don't introduce Try - it needs more design thought
  • recognising that push noerrors is needed for pragmatic reasons, the Raising type trickery is less valuable - an even more pragmatic step forwards is to not introduce Raising, raising but leave those for v2.
  • iterate on Try and raising when the time is right (probably when arc/exceptions:goto/destructors/move's/etc are in place and closer to being usable in the compiler)

for v in mitems(values):
let
enumValue = parseEnum v
replacement = replacements[enumValue] # Error here
Copy link
Member

@arnetheduck arnetheduck Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the hallmark of exception-unsafe code, and a school-book example of a design issue in Try: mid-way through the iteration on values, an exception is raised and you're left with a partially mutated values input which the calling code cannot reason about and that is invisible when reading the code, unless you carefully analyze the control flow - there's no visual indication for the reader that this in particular is where there KeyError happens.

The most "natural" thing to do if the compiler is pestering me about KeyError here is to add a except KeyError below except ValueError which hides the actual exception safety issue with this code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notably, the raising annotation does not suffer from this issue, as I understand it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is just an illustrative example. After all, the code doesn't compile, so it being a "school-book example of a design issue" is reasonable. If you can offer a better example with exceptions that every Nim user will be familiar with, feel free to suggest it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need to put raising inside Try or not?

Copy link
Contributor Author

@zah zah Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have to put it for APIs that use the errors pragma.

The above example will fail to compile with an error indicating
that `replacements[enumValue]` may fail with an unhandled `KeyError`.

## The `either` expression
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a small, out-of-scope utility - I wouldn't add until there's significant evidence that it's generally useful in real-world code - using a default after getting an error is a bit weird: the operation failed to calculate a value so now I use a some other value? why didn't the calculation give me that replacement value directly if that makes sense for the type? clearly, this is used when taking "shortcuts" with the type, and seems less legitimate

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you censor a small utility like this? I find it useful, so let's see how much adoption it will get in practice. it's not just about providing a replacement value - it's also a control-flow construct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because it's meant to become a core concept in the language and imported in every module basically - you don't want cruft in there from the start - better start every feature at -100 points and work up a case for it, and in this case it's cheap and easy to do "manually"


## The `check` expression

The `check` macro provides a general mechanism for handling
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we don't call it match because... it's not general enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check is about unpacking the successful result while handling the errors. I think that's different enough from general pattern matching to deserve its own name.

```

When applied to a `Result` or an `Option`, `raising` will use the
`tryGet` API to attempt to obtain the computed value.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for unification with Result, this is practically the same as ? - should ? be smarter and convert back-and-forth between the error handling models? from a performance POV, that's a bit dumb (the visual cost is much smaller than the incurred penalty) but it's... pragmatic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this as well, but it may be indeed too magical. I guess we'll know soon with more practice.

@zah
Copy link
Contributor Author

zah commented Apr 16, 2020

Try enables calling failing api without any indication of which sub-expressions may fail

Try and failing are orthogonal features. If your API is using the errors pragma, you must still disarm it in Try blocks. Try is just about enforcing the exception-handling within a block, regadless of the APIs used there (errors or no errors).

not enough guidance in the proposal towards it

I plan to write some guidelines in the documentation. Hopefully, this weekend.

the errors and failing stuff still is based on exceptions which are expensive, and remain expensive even with exceptions:goto

I consider the delivery mechanism of exceptions a separate implementation detail in Nim which is subject to change. The proposal will be fully compatible with an improved version of Nim where the recoverable errors are much cheaper to raise and handle.

In general, a lot of "machinery" is needed in the language to make this fly - vs a much simpler world of Result

It's arguable whether Result works. Not being able to express var and lent return values and coming with significant caveats when it comes to performance leaves much to be desired.

Try defuses the Raising annotation

Not true, see above.

this proposal does not work until you rewrite all of the std lib to use errors and return Raising, if you want the full benefit of forcing the caller to deal with stuff.

This statement is largely untrue. Any interaction with the std lib will enforce the error handling at the boundary where errors is used (the enforcement will come from the raises list produced by the errors pragma). Much more code has to be rewritten if we outlaw exceptions as we already have a lot of libraries based on them (Chronos, LibP2P, Serialization, etc). This proposal offers an easier path forward.

it seems that Try will not "react" - it only sees CatchableError

Well, this is one of the key insights I wanted to cover in more depth in the guidelines. One of the design criteria when you make an API is whether you want introducing a new error type to be a breaking change for the API. You've argued sometimes that Result[T, cstring] is nice, because it creates a binary worldview where the function either succeeds or fails, but this is not always appropriate. Sometimes, the user must handle the different error types in a different way and that's where you must reach out for an enum error or the errors pragma both of which suggest that the error handling must be done with check construct.

A good example for this that will become classic I think is Chronos's CancelledError. It's an exception that must be handled differently at every call site, but it's hard to be expressed as Result[enum], because the OperationCancelled enum value will have to present in every single ResultEnum type used in your networking library. This is an example where exceptions or something like Zig's error sets are more expressive than enums.

recognising that push noerrors is needed for pragmatic reasons, the Raising type trickery is less valuable - an even more pragmatic step forwards is to not introduce Raising

The Raising type has two purposes:

  1. It is the work-horse that enforces the disarming mechanisms.
  2. It preserves the raising lists in async/await by storing them in the Future type.

Both of these are crucial and thus the Raising type is part of v1.

@arnetheduck
Copy link
Member

I plan to write some guidelines in the documentation

I mean code guidance, as in "what's the easiest thing to do" - nobody reads documentation unless they absolutely have to - reading a manual is a cost.

@arnetheduck
Copy link
Member

arnetheduck commented Apr 16, 2020

Any interaction with the std lib will enforce the error handling at the boundary where errors is used

where raises is used, so it's about the same - the benefit of errors however is that you technically don't need raises at the call site - for this to work, the std lib needs rewriting (because oh boy, once exceptions start being handled in there, we're looking at a whole different codebase - remember Duffy: 90%ish of all code ended up not raising anything...)

zah added 5 commits May 23, 2020 20:18
The commit also adds a facility for writing the generated macro
output to a file. It also introduces a new module named `results`
that should eventually replace all usages of `import result`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants