Over the past six weeks, the development team here at Viget has been working through Confident Ruby, by Avdi Grimm. Confident Ruby makes the case that writing code is like telling a story, and reading code littered with error handling, edge cases, and nil checks is like listening to a bad story. The book presents techniques and patterns to write more expressive code with less noise—to tell a better story. Here are some of our favorites.
Using Hash#fetch for Flexible Argument Confidence
Lawson: Using option hashes as method arguments is great; it reduces argument order dependencies and increases flexibility. But with extra flexibility comes extra danger if you’re not careful.
def do_a_thing(options)
@important_thing = options[:important_thing]
end
# Inevitably
do_a_thing(important_thin: ImportantThing.new)
Typo-ing a key in the hash provided to method has started causing errors everywhere @important_thing
is used, and it’s not immediately apparent where the bug truly lies. This is most definitely not confident code.
You can increase the confidence of the method like so: @important_thing = options[:important_thing] || raise WheresTheThingError
, but this solution falls on its face when you require the ability to use nil
or false
as values.
Avdi suggests the liberal use of Hash#fetch
to increase your code’s confidence without such value inflexibility.
def do_a_thing(options)
@important_thing = options.fetch(:important_thing)
end
Using #fetch
establishes an assertion that the desired key exists. If it doesn’t, it raises a KeyError
right at the source of the bug, making your bug scrub painless.
And there’s another benefit too; you can #fetch
to set default values, while maintaining the flexibility to explicity use false
or nil
.
def do_a_thing(options = {})
@important_thing = options.fetch(:important_thing) { ImportantThing.new }
end
Document Assumptions with Assertions
Chris: It’s happened to every developer: you need to load data from an external system, but the documentation is incomplete, unhelpful, or nonexistent. You have sample data, but does it cover all possible inputs? Can you trust it?
Avdi suggests, “document every assumption about the format of the incoming data with an assertion at the point where the assumed feature is first used.” I like those words. Let’s see how it works in practice.
Suppose we’re writing an app to load sales reports from an external service and save them in your database for later querying. (Perhaps this app will show fancy charts and pizza graphs for sales of sportsball jerseys.) You might start with this code:
def load_sales_reports(date)
reports = api.read_reports(date)
reports.each do |report|
create_sales_report(
report['sku'],
report['quantity'],
report['amount']
)
end
end
Simple, clean, readable. And full of assumptions. We assume:
-
#read_reports
always returns an array -
report
is a hash and contains values forsku
,quantity
, andamount
-
the values in
report
are valid inputs for our application
Let’s make some changes. (Assume we’ve determined that amount
is a decimal string in the data and we will store it as an integer in our app.)
def load_sales_reports(date)
reports = api.read_reports(date)
unless reports.is_a?(Array)
raise TypeError, "expected reports to be an array, got #{reports.class}"
end
reports.each do |report|
sku = report.fetch('sku')
quantity = report.fetch('quantity')
amount = report.fetch('amount')
cents = (Float(amount) * 100).to_i
create_sales_report(sku, quantity, cents)
end
end
Here’s how we’ve documented those assumptions
-
Raise an exception if
reports
is not an array -
Retrieve the values using
Hash#fetch
-
Convert
amount
to a float usingKernel#float
, which, unlikeString#to_f
, will raise an exception if the input is not a valid float
Benefits to this approach include:
- Initial development is easier because each point of failure is explicitly tested
- Data is validated before it enters our database and app, reducing unexpected bugs down the road
- No silent failures—if the data format ever changes in the future, we’ll know
High five.
Bonus Tip: Transactions are your friend
All this input validation is great, but if you aren’t careful a validation failure can easily put your database in an inconsistent state. The solution is easy: wrap that thing in a transaction.
SalesReport.transaction do
sales_importer.load_sales_reports(date)
end
Any exception will rollback the transaction, leaving your data as it was before the import. Sweet.
You shall not pass!
Ryan S.: I came across a section that talked about a really helpful pattern referred to as bouncer methods. These are methods that serve as a kind of post-processing state-check that either:
- Raise errors based on result state; or
- Allow the application to continue if the result state is acceptable
If you’re in a situation where you have a method that performs some kind of action regardless of input and then has to make sure that the resulting output is valid or else throw an error, look no further!
def do_a_thing_with(stuff)
check_for_valid_output do
process(stuff)
end
end
def check_for_valid_output
output = yield
raise CustomError, 'Bad output!' unless valid_output?(output)
output
end
def valid_output?(output)
# do your validations
end
Well-written methods usually have a narrative to them. Bouncer methods are a great way to signal that the result of some nested action is going to be checked before allowing the application to continue on. They can help you maintain a narrative without cluttering up your method with explicit validations and/or error handling.
BRE GTFO LOL
David: Avdi opens his chapter on “Handling Failure” with a topic near to my heart: the awfulness of the begin/rescue/end
(“BRE”) block. My favorite quote:
BRE is the pink lawn flamingo of Ruby code: it is a distraction and an eyesore wherever it turns up.
A top-level rescue is always preferable to a BRE block. If you think you need to rescue an exception from a specific part of your method using a BRE, consider instead creating a new method that uses a top-level rescue. Before:
def foo
do_work
begin
do_more_work
rescue
handle_error
end
end
After:
def foo
do_work
safe_do_more_work
end
def safe_do_more_work
do_more_work
rescue
handle_error
end
Much cleaner (terrible method names aside).
throw/catch for great justice
Ryan: Just the words “throw” and “catch” scare me. They remind me of dark, sad Java programming days. I’ve always been vaguely aware that Ruby, like Java, had throw
and catch
, but I’ve never used them. In Ruby land, our exception handling is raise
and rescue
. So what are throw
and catch
for?
Avdi shows an example in the book where a long-running task has the option to terminate early by raising a custom DoneException
. Exceptions used like this can be handy to break out of logical loops. But is the act of “finishing” really exceptional? Not really.
Exceptions should be reserved for truly unusual and unexpected events. The code’s author was only hijacking DoneException
because it could punch out of the current stack to finish executing.
Enter throw
/catch
. They give you the same power of execution flow without raising an exception:
def consume_work
#do things
throw :finished if exit_early?
end
#elsewhere
def loopy_work
while read_line
consume_work
end
catch :finished
#clean up
end
throw
/catch
allow the code to be intentional. The execution is clearer to the reader and doesn’t raise unnecessary exception-esque attention.
Conclusion
Confident Ruby is an excellent book and we've already applied many of its lessons in our projects. It's packed with useful techniques and patterns—most of them are practical and immediately applicable, and all of them will help you write code that tells a better story.
Check it out and let us know in the comments what your favorite parts were. If you want to join us for the next round of book club, why not apply to work with us?