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.
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.
classWhyNotBoo# The object is scary if there is a reason for it to be scary.defscary?why_scary.any?end# Why is this object scary?defwhy_scaryreasons=[]# Early termination if this object is *not* scary.returnreasonsunlessghost?||zombie?# Recursively determine why this object is scary.reasons.concat([:ghost=>why_ghost])ifghost?reasons.concat([:zombie=>why_zombie])ifzombie?reasonsend# For the "why not" question we re-implement the "why" logic in reverse.defwhy_not_scaryreasons=[]returnreasonsifghost?||zombie?reasons.concat([:not_ghost=>why_not_ghost])unlessghost?reasons.concat([:not_zombie=>why_not_zombie])unlesszombie?reasonsenddefghost?why_ghost.any?enddefwhy_ghostreturn[]unless!alive?&®rets?[:not_alive,:regrets]enddefwhy_not_ghostreasons=[]returnreasonsifghost?reasons<<:aliveifalive?reasons<<:no_regretsunlessregrets?reasonsenddefzombie?why_zombie.any?enddefwhy_zombiereturn[]unless!alive?&&hungry_for_brains?[:not_alive,:hungry_for_brains]enddefwhy_not_zombiereasons=[]returnreasonsifzombie?reasons<<:aliveifalive?reasons<<:not_hungry_for_brainsunlesshungry_for_brains?reasonsenddefalive?trueenddefregrets?falseenddefhungry_for_brains?falseendend
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 and why_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”.
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:
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.
classEitherAll# 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]}]defeither(*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 markcontext=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_evaldoattr_reader:"why_#{context}",:"why_not_#{context}"end## 2. Evaluate each predicate until one returns true#predicate_names.eachdo|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_sifpredicate_name_string.start_with?('not_')negate=truepredicate_method_name="#{predicate_name_string.sub(/^not_/,'')}?"elsenegate=falsepredicate_method_name="#{predicate_name_string}?"end# Evaluate the predicateifnegate# 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)elsevalue=public_send(predicate_method_name)end## 3. Track which predicates were true/false to explain *why* we got the answer we did.#ifvalue# 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.ifrespond_to?("why_#{predicate_name}")why_reasons<<{predicate_name=>public_send("why_#{predicate_name}")}elsewhy_reasons<<predicate_nameend# 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.returntrueelse# We have a false value, so we continue looking for a true predicateifnegate# 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_symelse# 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_symend# If possible, follow the chain of reasoning by asking why the predicate is false.ifrespond_to?("why_#{negative_predicate_name}")why_not_reasons<<{negative_predicate_name=>public_send("why_#{negative_predicate_name}")}elsewhy_not_reasons<<negative_predicate_nameendendend# 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.returnfalseend# 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_brainsdefall(*predicate_names)context_method_name=caller_locations(1,1)[0].labelcontext=context_method_name.sub(/\?$/,'').to_symwhy_reasons=[]why_not_reasons=[]instance_variable_set("@why_#{context}",why_reasons)instance_variable_set("@why_not_#{context}",why_not_reasons)self.class.class_evaldoattr_reader:"why_#{context}",:"why_not_#{context}"endpredicate_names.eachdo|predicate_name|predicate_name_string=predicate_name.to_sifpredicate_name_string.start_with?('not_')negate=truepredicate_method_name="#{predicate_name_string.sub(/^not_/,'')}?"elsenegate=falsepredicate_method_name="#{predicate_name_string}?"endifnegatevalue=!public_send(predicate_method_name)elsevalue=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.ifvalueifrespond_to?("why_#{predicate_name}")why_reasons<<{predicate_name=>public_send("why_#{predicate_name}")}elsewhy_reasons<<predicate_nameendelseifnegatenegative_predicate_name=predicate_name_string.sub(/^not_/,'').to_symelsenegative_predicate_name="not_#{predicate_name_string}".to_symendifrespond_to?("why_#{negative_predicate_name}")why_not_reasons<<{negative_predicate_name=>public_send("why_#{negative_predicate_name}")}elsewhy_not_reasons<<negative_predicate_nameendwhy_reasons.clearreturnfalseendendwhy_not_reasons.clearreturntrueendend
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.