This is motivated/inspired by Avdi Grimm’s post on Boolean Externalties
http://devblog.avdi.org/2014/09/17/boolean-externalities/
In his post he asks the question: if a predicate returns false, why does it do so? If you chain a lot of predicates, it’s hard to figure out why you get the answer you get.
Consider this example. It implements simple chained predicate logic to determine if the object is scary.
class SimpleBoo
def scary?
ghost? || zombie?
end
def ghost?
!alive? && regrets?
end
def zombie?
!alive? && hungry_for_brains?
end
def alive?
false
end
def regrets?
false
end
def hungry_for_brains?
false
end
end
Following the chain of logic, something is scary if it’s either a ghost or a zombie. They are both not alive, but a ghost has regrets and a zombie is hungry for brains. This is the code as I would probably write it for a production app. It’s simple and very easy to read.
The downside is that if you want to know why something is scary, you have to go and read the code. You can not ask the object why it arrived at its conclusion.
Why
The following is a logical next step in the evolution of the code: I have modified the code so it can explain why a predicate returns true or false, though there is a tremendous “cost” in length and legibility.
class WhyNotBoo
# The object is scary if there is a reason for it to be scary.
def scary?
why_scary.any?
end
# Why is this object scary?
def why_scary
reasons = []
# Early termination if this object is *not* scary.
return reasons unless ghost? || zombie?
# Recursively determine why this object is scary.
reasons.concat([:ghost => why_ghost]) if ghost?
reasons.concat([:zombie => why_zombie]) if zombie?
reasons
end
# For the "why not" question we re-implement the "why" logic in reverse.
def why_not_scary
reasons = []
return reasons if ghost? || zombie?
reasons.concat([:not_ghost => why_not_ghost]) unless ghost?
reasons.concat([:not_zombie => why_not_zombie]) unless zombie?
reasons
end
def ghost?
why_ghost.any?
end
def why_ghost
return [] unless !alive? && regrets?
[:not_alive, :regrets]
end
def why_not_ghost
reasons = []
return reasons if ghost?
reasons << :alive if alive?
reasons << :no_regrets unless regrets?
reasons
end
def zombie?
why_zombie.any?
end
def why_zombie
return [] unless !alive? && hungry_for_brains?
[:not_alive, :hungry_for_brains]
end
def why_not_zombie
reasons = []
return reasons if zombie?
reasons << :alive if alive?
reasons << :not_hungry_for_brains unless hungry_for_brains?
reasons
end
def alive?
true
end
def regrets?
false
end
def hungry_for_brains?
false
end
end
Yes, that’s a lot more code. All composite predicates have a “why_[predicate]” and a “why_not_[predicate]” version. Now you can ask if something is scary and why (or why not).
There are a few problems with this approach:
- The logic is not in
scary?
, where you would expect it. - The logic is duplicated between
why_scary
andwhy_not_scary
. Don’t Repeat Yourself, or you will get logic bugs. - There is a lot more code. A lot of boilerplate code, but also multiple concerns in the same method: bookkeeping and actual logic.
Cleaner code
Let’s see if we can make the code legible again, while preserving the functionality of “why” and “why not”.
class ReasonBoo < EitherAll
def scary?
either :ghost, :zombie
end
def ghost?
all :not_alive, :regrets
end
def zombie?
all :not_alive, :hungry_for_brains
end
def alive?
false
end
def regrets?
false
end
def hungry_for_brains?
false
end
end
So far, so good. The code is very legibile, but there is a mysterious superclass EitherAnd
. Before we look at how it works, let’s look at what it allows us to do:
boo = ReasonBoo.new
boo.scary? # => false
boo.why_scary # => []
boo.why_not_scary # => [{:not_ghost=>[:not_regrets]}, {:not_zombie=>[:not_hungry_for_brains]}]
boo.ghost? # => false
boo.why_ghost # => []
boo.why_not_ghost # => [:not_regrets]
boo.zombie? # => false
boo.why_zombie # => []
boo.why_not_zombie # => [:not_hungry_for_brains]
For each predicate that uses either
or all
we can ask why or why not it’s true and the response is a chain of predicate checks.
How we get cleaner code
If you want to make your code legible, there usually has to be some dirty plumbing code. In this example we have hidden this in a superclass, but it could have been a module as well without too much effort.
In order to keep the code easier to read, I have chosen to not extract duplicate logic into helper methods.
This class implements two methods: either
and all
.
Both methods have the same structure:
- Setup the why_[predicate] and why_not_[predicate] methods.
- Evaluate each predicate until we reach a termination condition.
- Track which predicates were true/false to explain why we got the result we did.
class EitherAll
# This method mimics the behavior of "||". These two lines are functionally equivalent:
#
# ghost? || zombie? # => false
# either :ghost, :zombie # => false
#
# The bonus of `either` is that afterwards you can ask why or why not:
#
# why_not_scary # => [{:not_ghost=>[:not_regrets]}, {:not_zombie=>[:not_hungry_for_brains]}]
def either(*predicate_names)
#
# 1. Setup up the why_ and why_not_ methods
#
# Two arrays to track the why and why not reasons.
why_reasons = []
why_not_reasons = []
# This is a ruby 2.0 feature that replaces having to regexp parse the `caller` array.
# Our goal here is to determine the name of the method that called us.
# In this example it is likely to be the `scary?` method.
context_method_name = caller_locations(1, 1)[0].label
# Strip the trailing question mark
context = context_method_name.sub(/\?$/, '').to_sym
# Set instance variables for why and why not for the current context (calling method name).
# In our example, this is going to be @why_scary and @why_not_scary.
instance_variable_set("@why_#{context}", why_reasons)
instance_variable_set("@why_not_#{context}", why_not_reasons)
# Create reader methods for `why_scary` and `why_not_scary`.
self.class.class_eval do
attr_reader :"why_#{context}", :"why_not_#{context}"
end
#
# 2. Evaluate each predicate until one returns true
#
predicate_names.each do |predicate_name|
# Transform the given predicate name into a predicate method name.
# We check if the predicate needs to be negated, to support not_<predicate>.
predicate_name_string = predicate_name.to_s
if predicate_name_string.start_with?('not_')
negate = true
predicate_method_name = "#{predicate_name_string.sub(/^not_/, '')}?"
else
negate = false
predicate_method_name = "#{predicate_name_string}?"
end
# Evaluate the predicate
if negate
# Negate the return value of a negated predicate.
# This simplifies the logic for our success case.
# `value` is always true if it is what we ask for.
value = !public_send(predicate_method_name)
else
value = public_send(predicate_method_name)
end
#
# 3. Track which predicates were true/false to explain *why* we got the answer we did.
#
if value
# We have a true value, so we found what we are looking for.
# If possible, follow the chain of reasoning by asking why the predicate is true.
if respond_to?("why_#{predicate_name}")
why_reasons << { predicate_name => public_send("why_#{predicate_name}") }
else
why_reasons << predicate_name
end
# Because value is true, clear the reasons why we would not be.
# They don't matter anymore.
why_not_reasons.clear
# To ensure lazy evaluation, we stop here.
return true
else
# We have a false value, so we continue looking for a true predicate
if negate
# Our predicate negated, so we want to use the non-negated version.
# In our example, if `alive?` were true, we are not a zombie because we are not "not alive".
# Our check is for :not_alive, so the "why not" reason is :alive.
negative_predicate_name = predicate_name_string.sub(/^not_/, '').to_sym
else
# Our predicate is not negated, so we need to use the negated predicate.
# In our example, we are not scary because we are not a ghost (or a zombie).
# Our check is for :scary, so the "why not" reason is :not_ghost.
negative_predicate_name = "not_#{predicate_name_string}".to_sym
end
# If possible, follow the chain of reasoning by asking why the predicate is false.
if respond_to?("why_#{negative_predicate_name}")
why_not_reasons << { negative_predicate_name => public_send("why_#{negative_predicate_name}") }
else
why_not_reasons << negative_predicate_name
end
end
end
# We failed because we did not get a true value at all (which would have caused early termination).
# Clear all positive reasons.
why_reasons.clear
# Explicitly return false to match style with the `return true` a few lines earlier.
return false
end
# This method works very similar to `either`, which is defined above.
# I'm only commenting on the differences here.
#
# This method mimics the behavior of "&&". These two lines are functionally equivalent:
#
# !alive? && hungry_for_brains?
# all :not_alive, :hungry_for_brains
def all(*predicate_names)
context_method_name = caller_locations(1, 1)[0].label
context = context_method_name.sub(/\?$/, '').to_sym
why_reasons = []
why_not_reasons = []
instance_variable_set("@why_#{context}", why_reasons)
instance_variable_set("@why_not_#{context}", why_not_reasons)
self.class.class_eval do
attr_reader :"why_#{context}", :"why_not_#{context}"
end
predicate_names.each do |predicate_name|
predicate_name_string = predicate_name.to_s
if predicate_name_string.start_with?('not_')
negate = true
predicate_method_name = "#{predicate_name_string.sub(/^not_/, '')}?"
else
negate = false
predicate_method_name = "#{predicate_name_string}?"
end
if negate
value = !public_send(predicate_method_name)
else
value = public_send(predicate_method_name)
end
# The logic is the same as `either` until here. The difference is:
#
# * Either looks for the first true to declare success
# * And looks for the first false to declare failure
#
# This means we have to reverse our logic.
if value
if respond_to?("why_#{predicate_name}")
why_reasons << { predicate_name => public_send("why_#{predicate_name}") }
else
why_reasons << predicate_name
end
else
if negate
negative_predicate_name = predicate_name_string.sub(/^not_/, '').to_sym
else
negative_predicate_name = "not_#{predicate_name_string}".to_sym
end
if respond_to?("why_#{negative_predicate_name}")
why_not_reasons << { negative_predicate_name => public_send("why_#{negative_predicate_name}") }
else
why_not_reasons << negative_predicate_name
end
why_reasons.clear
return false
end
end
why_not_reasons.clear
return true
end
end
Conclusion
It is possible to provide traceability for why a boolean returns its value with less than 200 lines of Ruby code and minor changes to your own code.
Despite the obvious edge cases and limitations, it’s nice to know there is a potential solution to the problem of not knowing why a method returns true
or false
.
Top comments (0)