Ruby is my favorite programming language. Now, I’ll readily admit it’s not the right choice for many projects, but its syntax is so great. I feel like I can go from thought to code faster in Ruby than any other programming language (including Weave!)
But there’s one thing that Ruby (and many other languages!) could improve - error handling.
Ruby, like many other languages, relies primarily on Exception handling as its error-handling approach of choice.
Exceptions are.. well, they’re fine. By now, most everyone knows how to use them, so if you add Exceptions to your language, everyone pretty much knows how to write their error handlers immediately. Some languages of course, forego Exceptions - and error handling constructs - entirely. (C and Go being popular examples.) In such languages, functions must return results and/or an error on their single output channel - the returned data itself.
But what if I told you there are more than two ways to handle errors.
LISP still doing it best
LISP is one of the oldest programming languages in existence, first released in 1954. And it’s also ridiculously flexible. Via macros, programmers can extend the syntax of the language, allowing them to freely experiment with different ways to express their programs’ behavior. Small wonder then, that the best method of handling errors comes to us via LISP as well.
Condition/Restart.
And since Ruby is an Acceptable LISP I thought maybe I’d try my hand at recreating LISP’s superior error handling in Ruby itself!
Restarting instead of Throwing Up
Imagine you’ve been assigned to staple some pages together. You get through three sets - ka-chunk - ka-chunk - ka-chunk - and then - ka-click - the stapler runs out of staples. Quel horreur! Now, you could choose to give up, tear up all your work thus far and lie on the floor kicking your legs in the air, refusing to work until someone else comes over, sets everything back up for you - including a fresh load of staples - …or you could go ask somebody where the staples are so you can finish your task.
Condition/Restart is exactly this second approach - when something goes wrong, get help from someone who knows what to do.
Exception handling is like throwing a tantrum. At the first sign of trouble, the work stops. The program throws up, drops any partially completed work on the floor and immediately aborts, unwinding the stack until a handler catches the exception and does… well, usually something like this:
# File Not Found has a technically accurate, but ugly class name in Ruby. Alias it:
NoSuchFile = Errno::ENOENT
def setup
config = load_config("./config.toml")
do_stuff(config)
rescue NoSuchFile => e
logger.error("Well hells bells, somethin' durn went and blew up on me. #{e}")
# now what?
# re-raise? return nil? return a default? Depends. (as usual) But often - a log line before a crashout or work-stop is the
# de facto pattern for exception handling.
end
Conversely, Condition/Restart registers “Restart” strategies. When an error (a Condition) occurs, the inner scope (where the error occured) walks the stack looking for a matching Restart handler. (This does not unwind the stack! The inner scope is still there, just looking for help!) When a matching restart is found, the inner scope calls this handler and asks for help. The Restart, being in the outer scope where it has more context about the work-in-progress - sends instructions back to the inner scope so it can continue its work.
Once you see how effective this pattern is at making software more reliable, you’ll wonder why we ever adopted anything else - oh and
it’s easy too. In practice, it’s no harder to write that the try/catch block you already know and.. uh, tolerate!
Missing Config
Let’s take a look at an example.
Let’s say that our app has a configuration file. It’s written in TOML (because why would you ever use anything else?) and that we expect it to always contain certain, mandatory, settings. Finally, let’s say that if the file doesn’t exist, we can respond in only one of two ways:
- create a new config file with default values set
- abort the program.
During application launch, we want to use the first case - we assume it’s a fresh install or we’ve been reset, etc.
If the file is missing during a later check though - abort. Something strange has occurred and we don’t want to make things worse.
Here’s our basic load_config function, sans any error-handling:
def load_config(cfg_file)
File.open(cfg_file) do |f|
return TOML.parse(f)
end
end
Simple enough - but of course, this will still currently raise and crash if cfg_file is missing.
Here’s our default config creator. The contents don’t really matter, just know that the method exists and just dumps some TOML data into the named file.
def create_default_config(cfg_file)
File.open(cfg_file, 'w') do |f|
f.write(TOML.dumps({ foo: 12, bar: 22, baz: ['qux'] })
end
end
Cool.
Now, note how the load_config method doesn’t have any context to decide if it should create a default or abort if cfg_file is missing?
If we want to handle a File Not Found error inside the load_cfg method, we either guess what to do or just let the exception bubble up.
Frequently, we end up addressing this sort of decision by adding boolean “flag” arguments:
def load_config(cfg_file, create_if_missing)
if !File.exist?
if create_if_missing
create_default_config(cfg_file)
else
raise :file_not_found
end
else
File.open(cfg_file) do |f|
return TOML.parse(f)
end
end
end
…which leads to code sprinkled with:
load_config(cfg_file, true)
...
load_config(cfg_file, false)
Without reading the code of the load_config method - what does that flag do? …Who knows! All we can see from the outside is true
or false!
We can, of course, clean this up a bit with, say, named-param syntax, …or by injecting an error handler, …or by creating two methods, …or by passing a :symbol flag instead of a boolean flag - all to get around the lack of context available to the code.
Help Signals
…Or we could apply Condition/Restart.
The inner code doesn’t need to change much - instead of taking in a flag parameter, we signal an error Condition and then receive a Restart. A Restart contains the instructions to the erroring code on how to proceed.
Note: I don’t like the term Restart here I feel that it implies the erroring method is going to abort, pop off the stack and then
begin again with new inputs. But that’s not the case! Typically, the code just… resumes. That’s why in Weave, I used resume instead
of restart as the magic word.
In my Ruby implementation, signal is a method that accepts a Condition as input and returns the Restart:
def load_config(cfg_file)
if !File.exist?
# FileNotFoundCond is just a PORO - we don't need anything special:
case signal(FileNotFoundCond::new(cfg_file))
when FileNotFoundCond::USE_DEFAULT
create_default_file(cfg_file)
when FileNotFoundCond::ABORT
signal Abort::new("Could not find #{cfg_file} and I don't know what to do!")
end
end
File.open(cfg_file) { |f| return f.read }
end
Check out that FileNotFoundCond class - it’s just a basic, teensy class to let us pass some info around and namespace the
restart strategies:
class FileNotFoundCond
attr_reader :path
USE_DEFAULT = :use_default
ABORT = :abort
def initialize(path)
@path = path
end
end
And Abort is likewise - but let’s ignore it for right now:
class Abort
attr_reader :msg
def initialize(msg)
@msg = msg
end
end
When we call signal - the Condition system looks for a Restart to handle it. It uses a stack, so that we’re able to resolve handlers from
the inside -> out. i.e. the closest handler for a Condition wins.
So… how do we register those handlers? Let’s go look at our two outer scopes now, where we’ll declare our handlers:
First, the Startup:
def startup
cfg_file = "./config.toml"
# Note the order is opposite of try/catch - we declare our error handler blocks, then we have the code to be handled.
# Structurally akin to catch { exception_handlers } try { code }!
# Partially structured like this due to the constraints of working within Ruby syntax
# ...and partially because this helps differentiate it visually from try/catch syntax.
restart_with(-> { |cond|
# this handler Lambda returns either a Restart value or nil, if it doesn't match the Condition
case cond
when FileNotFoundCond
FileNotFoundCond::USE_DEFAULT
end
}) do
load_config(cfg_file)
end
end
Second, when we’re checking our config at runtime:
def get_config_value(key)
cfg_file = "./config.toml"
restart_with( -> { |cond|
case cond
when FileNotfoundCond
FileNotFoundCond::ABORT # okay, this time, we're not messing around - Halt and Catch Fire
end
}) do
load_config(cfg_file)[key] # We could also set up Conditions to signal KeyMissing, etc.
end
end
First off, read restart_with like a catch or rescue handler that precedes the ’try’ block in question instead of following it.
The bigger difference is under the hood - as we’re handling the Condition, signal is scanning the the stack of restart handlers to see if anyone handles our particular condition - but again, we don’t unwind the call stack. Instead, once we find a valid handler for the given key
(that’s the lambda we’re handing to restart_with) we pass the result back as the return of signal - sending the strategy right back
to the error location, where we can actually do something about it.
Abort
Here, Abort is just a deliberately unhandled Condition. signal never finds a handler for it, reaches the end of the stack and aborts the
program. In my Ruby implementation, I actually raise an exception for this (ssshhh) so I can take advantage of the built-in stacktrace
support. We even get Ruby-standard error formatting, pointing to the error location
# Just trust me, con_stack.rb:48 is where the AbortCond was signaled in my local implementation.
continue_rb/lib/con_stack.rb:48:in `handle': Could not find config.toml! And I don't know what to do! (ConStack::NoHandler)
In Weave, of course, Conditions have a stack trace built in by the interpreter.
What Makes This Better?
In Exception-handler languages, neither the caller nor the callee ever has all the information needed to handle an error. The callee knows what it is trying to do - and the state built up on the call stack. The caller knows the context of the task in flight. An exception drops the call stack state and the caller almost never gets a chance to correct or retry issues.
Condition/Restart encourages error handling at the appropriate layer - that is, at the layer where the context is available to actually decide what to do about a given error.
Condition handling is fast - often faster than exceptions! (Python’s a special case here because its exceptions are designed for performance)) Exceptions are slow (typically) because they have to build a lot of state-of-the-world before they start flowing back up the stack. The current call stack, for one, plus all the other metadata that goes into those balls of data. A Condition, OTOH, can be a simple linear-time lookup for a handler, plus a function call. It’s no more overhead than a typical event handler.
Finally - we can extend this pattern to simplify our code by passing in Procs for our Restart strategies. I’ve kept things simple here by passing up simple structs as my Condition objects and returning symbols as the Restarts. But we can just as easily send back a Proc to an inner function!
Now we can implement our load_cfg Restart to expect a Proc - like this:
def load_cfg(cfg_file)
# The Restart is a callable Proc passed down from the outer context! All we have to do is execute it.
signal(FileNotFoundCond::new(cfg_file)).call() unless File.exist? cfg_file
File.open(cfg_file){ |f| f.read() }
end
def setup
cfg_file = "./config.toml"
restart_with( -> { |cond|
case cond
when FileNotFoundCond
# Send back a Proc that initializes the default config file contents
-> { |cfg_file| create_default_file(cfg_file) }
end
}) do
load_cfg(cfg_file)
end
end
def get_config_value(key)
cfg_file = "./config.toml"
restart_with( -> { |cond|
case cond
when FileNotFoundCond
# send back a Proc that aborts - by signaling with the Abort condition!
-> { |cfg_file| signal Abort("Could not find config file: #{cfg_file}") }
end
}) do
load_cfg(cfg_file)
end
end
Now our config reader is fully decoupled from its error handling! Any future cases that come up - maybe creating an empty file, for instance, can be handled at the scope which has the context to make the proper decision - without changing any other code. The scope just sends a Restart with the new Proc.
What’s the Catch? Why not use this Everywhere?
Well… you may have noticed that this doesn’t map to Ruby with the cleanest syntax. My implementation has restart_with take a Proc
and yielding to an implicit Block:
def restart_with(handler)
ConStack.push(handler)
yield
ensure
ConStack.pop
end
restart_with( -> { |cond|
# handler lambda, with the error handlers enumerated
case cond
when FileNotFoundCond
-> { |f| create_default_file(f) }
when PermissionDeniedCond
-> { |f| Abort::new("Access dened to #{f}") }
end
}) do # This could also be }){... but that way madness lies.
# code under test, wrapped in a do block, right after just }) ... it looks clunky!
load_cfg(cfg_file)
end
end
In Weave, by comparison, the equivalent syntax reads a little cleaner:
fn setup {
cfg_file = "./config.toml"
handle {
file_not_found: ^(src, cond) { c.resume(:use_default) }
permission_denied: ^(src, cond) { c.abort() }
} for {
# `handle` {} `for` {} keywords make the blocks responsibilities a littler clearer.
# and I like the read of `handle { :errors } for { :code }`
read(cfg_file, :toml)
}
}
In Weave, the multiple Conditions become a list of Conditions and their Restart handlers. In Ruby - well, at least in my implementation -
the list of Condition/Restarts is addressed in the handlers’ case/when blocks - but there’s nothing enforcing that. You could as
easily write a bunch of nested if/else statements or whatever else you want. The language doesn’t have syntax support for Conditions,
so I ended up making something that looks sorta right and works, but the syntax of function(lambda{}) do code end is busy and
doesn’t parse easily.
The other issue is frankly the ecosystem.
Everything in the Ruby ecosystem uses Exceptions. Even if you adopt Condition/Restart code for all your future Ruby projects - any Gem you use is going to be raising Exceptions, not signaling Conditions. Best case, you end up with wrappers everywhere in your code, trying to catch and convert every Exception caught into a Condition.
Conversely, if you’re vending a Gem - can you imagine the hubris of saying “I don’t use Exceptions - you need to install ConditionsRB to handle my errors”? Good way to end up with zero users!
Wrap up
Still - this was a fun thing to build for Ruby. Implementing Condition/Restart at all with a reasonable syntax without needing macros or touching the AST, was a fun challenge.
As ever, Ruby impresses me with its sheer flexibility and expressiveness!