Skip to content

Instantly share code, notes, and snippets.

@gokr
Created May 29, 2020 21:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gokr/42547120d4fe8dd52bfdb88c6648b0dd to your computer and use it in GitHub Desktop.
Save gokr/42547120d4fe8dd52bfdb88c6648b0dd to your computer and use it in GitHub Desktop.
Error handling ramblings for Spry

Error handling

  • Activation record handlers. Each record can have a handler. If not set it is equivalent of [throw :x] just passing it up to the next activation record. Top activation record catches all and is set to [echo (:x printString) exit 1]
  • Throwing does not unwind call stack, so a handler doing return will return back to the throw! Unless it calls activation unwind first, if so, the return will instead go to caller of the record where the handler is installed.
  • Errors as values...

Exceptions, errors etc are simply "not so often expected paths of execution". Clarity of code can often be enhanced if the "vanilla path" is clean of clutter for all these "less expected" paths.

In other words, we want most Spry code to be "uncluttered". We also don't want too many concepts since Spry is meant to be minimalistic in nature.

One mechanism has already been added, the catch-throw. The idea is to have it as a base for most of the rest of the call stack based mechanisms. For even more advanced stack manipulations the stack is gradually being reified, but that will be explored more when making the first Spry debugger.

There are some basic competing ideas in current languages:

  • Return errors just like values from the called function.
  • Throw errors and catch them at an appropriate level higher up the stack, thus avoiding boilerplate for just passing them along. Also avoids the issue with code expecting a proper result suddenly getting an error in their hands.
  • Multiple return so that a function can return both a value and a potential error, often exclusive.
  • Optionals, the idea of stuffing an error and a value into a "struct" so that we still return "just a single thing", but it can contain either a proper value, or an error.
  • defer and errdefer, the idea of installing a handler that can do cleanups, either always before returning or only before returning with an error etc.
  • Declarations showing the developer if a function can return errors, and potentially exactly what errors it can return.
  • Rebol treats errors as a specific kind of fundamental value type, but several languages treat errors as values (Go etc).
  • Rebol uses a "bomb" trick so that it can return either a proper value, or an error (that will blow up if evaluated).
  • Smalltalk and Dylan (and perhaps CLOS) has a conditional throw-catch system which means the stack is not unwound when the search for a handler is performed. This means resume after the throw is possible.
  • Smalltalk uses blocks pervasively in control structures and also in exceptional handling like for example with at:ifAbsent: etc.

In Spry we do have a basic catch-throw mechanism in place, and that is useful for several things, not just errors.

My feelings so far:

  • Go style multiple return and tons of "if err != nil {}" is NOT to my liking. I can understand the philosophy, but sorry, don't like it.
  • Only a try-catch conditional system like Smalltalk has is boring. It can also get fairly confusing to debug, for example try-catch with empty catch clauses that "swallows" errors in silence, nested calls get hairy. It can also blur the "vanilla case" quite a lot.
  • It would be fun with something simpler and different! Spry is an experiment, so let's try.
  • Spry shares the evaluation model of Rebol, so the "bomb" is tempting in its simplicity, especially when writing vanilla code to begin with!
  • Spry has tagging, we should be able to use it somehow. Funcs can be tagged.
  • I want to be able to write vanilla code as long as possible, then gradually hardening, for example as result of unit tests. But I don't want hardening to introduce clutter in the vanilla code! Thus, somehow, I would want to be able to add error handling as a "side track" and not inlined in the vanilla code.
  • Having a specific primitive value type for errors is tempting, since it enables specialized behavior in the VM for them. Alternative is to model them as tagged objects. Or both, a specialized Node that wraps an object that then can contain all the details.
  • The idea of being able to declare most handling of errors "on the side" and have these declarations map into the vanilla code, without actually being inlined in the vanilla code. Defer and errdefer are going in this direction, they are code blocks that are executed only if certain rules are met, but they are clearly separate.
  • We can either throw, or return. But we could also "signal". A signal is a neutral way of signalling a condition, it does not imply what to do next, we could hard exit the process :), or we could always log it and then throw, or we could always return it as a bomb etc.
  • Dylan has interesting features: https://opendylan.org/documentation/intro-dylan/conditions.html
  • https://opendylan.org/books/drm/Conditions_Background

The current proposal, so far:

  • We use a special node type for conditions, thus enabling special handling in the VM of conditions. It is however "like an object" so easy to attach extra information.
  • Conditions should be pre-created and either used "as is" or cloned-and-modified if we need to attach context to them.
  • We "signal" conditions. This creates a neutral abstraction. When you start coding you don't have to think about how conditions should be handled, you just sprinkle with "signal xxx" and postpone the issue.
  • The way we decide how signals behave is by declaring handlers.
    # In Spry you "signal a condition" when en error happens. In fact, you would
    # "signal a condition" whenever something happens that the code you are writing
    # does not really know (or should know) how to handle. The idea is centered around
    # the following steps:
    # 1. Ok, something happened! Signal!
    # 2. The first entity deciding how to proceed is the condition itself. A polymethod `signal:` is first called, which has a base implementation as fallback.
    # The different ways to proceed from the signal:
    #    - Print or log or perform other "side effect".
    #    - Do process exit.
    #    - Return something back to the signal call to proceed.
    #    - Throw the condition to be handled by installed catchers.
    #    - Return something back to the caller, for example the condition itself.
    #    - Manipulate call stack.
    # 3. The above means that Spry can use either try-catch style handling, or
    # "return errors as bombs" handling like Rebol.
    # A condition, if evaluated, 
    
    # You make a condition just like an object, but using the `condition` word.
    # The tags classify conditions. Querying all conditions by tags we can easily
    # list all defined Errors for example.
    # 
    # We DO NOT (unless we really need to) create them dynamically, better to clone
    # and adjust. Otherwise we lose this introspection property.
    errorDivisionByZero = condition [Math Error] {num = 99 msg = "You divided by zero"}
    # The most generic error.
    error = condition [Error] {num = 1 msg = ""}
    # An application level error
    errorBadBanana = condition [Error] {num = 1001 msg = "The banana was bad"}
    # A condition can also be something else, like a request to log etc.
    logInfo = condition [Logging] {msg = ""}
    
    # The reason conditions aren't just objects is because we can then have more
    # specific behavior of them in the VM, if it turns out we want that.

    # If no contextual information is needed you can signal one of the above
    # already existing conditions. Signalling an error, the simplest possible way:
    signal error
    
    # If one needs to add context information, a clone is needed:
    signal (clone errorBadBanana)::bananaId = 399

    # It is not advisable to create conditions "on demand" because then we
    # can not know which ones we have. Better to create them all at once starting up.
    # Convenience func to do the above, in this case 
    signalError = func [:x signal ((clone error)::msg = x)]
    
    # ...so we can write:
    signalError "Something happened"
    
    # Similarly for logging (which is not an error at all)
    logInfo = func [:x signal ((clone logInfo)::msg = x)]
    logInfo "We are good"
    
    # Signals are purposefully not implying what will happen. The actions
    # taken after the signal is made depends on installed handlers in the activations
    # of the call stack.
    
    # This catches all signals here and below
    listen: [echo :condition]

    # This catches all throws here and below
    catch: [echo :ball]
    
    # This is run before returning
    defer: []
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment