Caller-supplied Fallback Strategy
Jul 03, 2016I’m reading the exceptional Avdi Grimm’s Exceptional Ruby book. (Thanks Cass!)
Like Avdi and Ruby, the book is also exceptional!
One section of the book is “Your Failure handling Strategy”. It has a handful of advices, one of which is this: Caller-supplied fallback strategy.
Last week at work, I stumbled upon a problem. Within minutes I had the solution too. I wrote test cases, wrote the solution code and pushed and closed the story when tests were green.
But it felt wrong to me. I wasn’t convinced of my solution. I knew there had to be a better way, but I couldn’t come up with an elegant replacement.
Until now, after reading this chapter in the book.
First, the problem, and the original solution I had conceived.
Problem statement
I want all the ineffective cats of a user. (Don’t ask me what I’m going to do with them).
User has many cats.
A Cat is effective when it has both teeth and claws (or nails?).
A Cat is ineffective when it has only one of these 2.
For the sake of the example, assume that humans are fair people and don’t consider a cat ineffective if it doesn’t have both.
It’s a fair comparison right? If you compare a weaponless cat with a weaponised cat, then you are a… catist! Don’t be a catist, be a fair human. OK?
So here’s the cat.rb
:
def effective?
if teeth && claws
return true
elsif teeth or claws
return false
else
fail "You Catist!"
end
end
My Design rationale: the cat.effective?
method can only return true or false. Anything else it is asked to do that it doesn’t know, it just gives up by raising an exception.
And at user.rb
, I collect all ineffective cats like so:
def ineffective_cats
self.cats.reject do |cat|
cat.effective? rescue true
end
end
That line with rescue true
is what made me uncomfortable. I had to add it because that’s the only way the cats with neither weapons will not be included in the ineffective_cats
list.
Enter Exceptional Ruby
One of the gems from the book is this:
Exceptions shouldn’t be expected. Use exceptions only for exceptional situations.It is hardly exceptional to fail to open a file.
When writing an application you expect invalid input from users. Since we expect invalid input we should NOT be handling it via exceptions because exceptions should only be used for un-expected situations.
Light-bulb moment! So a cat with no weapons is totally expected inside of the cat.effective?
method! It’s not an abnormality. It’s not an exception. It’s just a different part of the rule.
So, what can be returned in the place of the fail
line? The truth obviously. If you ask a weaponless cat if it is effective or not, it can’t say true or false. Technically it can only say ‘nothing’, aka nil
.
So, cat.rb
becomes:
def effective?
if teeth && claws
return true
elsif teeth or claws
return false
else
nil # NOTE
end
end
Now, another gem from the book:
In most cases, the caller should determine how to handle an error, not the callee.
So it’s now obvious that the caller has to decide what to do if one of the cats returned nil
when asked if effective.
Well, for your usecase, we can simply ignore the cat that returned nil
from the final list of ineffective cats. Like so:
# user.rb
def ineffective_cats
self.cats.reject do |cat|
result = cat.effective?
(result.nil? || result) ? true : false
end
end
This can be expressed more succintly as:
def ineffective_cats
self.cats.reject do |cat|
cat.effective? or true
end
end
This appears to be only slightly better than our initial version where we used recue true
. But we have deliberately used 2 of the approaches described earlier here from the book:
- For the case of a weaponless cat, instead of raising an exception, we are returning nil. A weaponless cat is not unexpected in that method, so no need for exceptions
- We have let the caller decide what to do with the
nil
return value. Our caller had various options: It could have raised hell, it could have logged a warning saying there’s a weaponless cat in the system, or it could have sneakily send it along with the weaponised cats. But our caller chose to simply ignore the weaponless cats, because it should!
This post still doesn’t use “caller supplied fallback strategy” in its proper form. I’ll do it here:
# cat.rb
def effective?(failure_policy=method(:raise))
if teeth && claws
return true
elsif teeth or claws
return false
else
fail "You Catist! This cat has no weapons, you can't consider it weaponsgrade!"
end
rescue => e
# we are 'punting' the decision to act on the exception up to the caller.
failure_policy.call(e.message)
end
# user.rb
def ineffective_cats
failure_policy = ->(exception) do # NOTICE: we don't do anything with the exception
warn "A weaponless cat found. Just thought you should know."
true
end
self.cats.reject do |cat|
cat.effective?(failure_policy)
end
end
This is “Caller supplied fallback strategy” in a nutshell.