Skip to content

Instantly share code, notes, and snippets.

@Narnach
Created September 18, 2014 22:08
Show Gist options
  • Save Narnach/6ee5753e0af31433fd9a to your computer and use it in GitHub Desktop.
Save Narnach/6ee5753e0af31433fd9a to your computer and use it in GitHub Desktop.
# 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:
# This implements some 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. It's simple, clear and reads well.
# The downside is that if you want to know *why* something is scary, you have to go and read the code.
# You can't ask the object *why* it arrived at its conclusion.
# The following is a logical next step: you modify your code to be able to explain itself.
class WhyNotBoo
# The object is scary if there is a reason for it to be scary.
def scary?
why_scary.any?
end
# Combine the logic of why something is scary with some bookkeeping for *why* this is the case
def why_scary
reasons = []
return reasons unless ghost? || zombie?
reasons << :ghost if ghost?
reasons << :zombie if zombie?
reasons
end
# For the reverse question we re-implement the 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_not_ghost.empty?
end
def why_not_ghost
reasons = []
reasons << :alive if alive?
reasons << :no_regrets unless regrets?
reasons
end
def zombie?
why_not_zombie.empty?
end
def why_not_zombie
reasons = []
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 "why_not_<predicate>" version.
# But now you can ask if something is scary and why (or why not).
#
# There are a few problems with this approach:
#
# 1. The logic is not in `scary?`, where you would expect it.
# 2. The logic is duplicated between `why_scary` and `why_not_scary`. Don't Repeat Yourself, or you will get logic bugs.
# 3. There is a lot more code. A lot of boilerplate code, but also multiple concerns in the same method: bookkeeping vs logic.
#
# Let's see if we can make the code even more self-explanatory.
class ReasonBoo
def scary?
# This used to be this:
#
# ghost? || zombie?
#
# We replace it with this `either` call, which is functionally equivalent, including the lazy evaluation.
# Calling `either` will evaluate the above predicates, but it also defines two new methods on this class:
#
# * `why_scary`, which returns an array of predicates when we are scary.
# * `why_not_scary`, which returns an array of predicates when we are not scary.
either :ghost, :zombie
end
def ghost?
# This used to be this:
#
# !alive? && regrets?
#
# We replace it with this `all` call, which is functionally equivalent, including the lazy evaluation.
# Calling `all` will evaluate the above predicates, but it also defines two new methods on this class:
#
# * `why_ghost`, which returns an array of predicates when we are a ghost.
# * `why_not_ghost`, which returns an array of predicates when we are not a 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
private
# Here we get to the guts that make the methods work.
# We do a number of things here:
#
# 1. Setup the why and why_not methods
# 2. Evaluate each predicate until one returns true
# 3. Track which predicates were true/false to explain *why* we got the answer we did.
#
# This method mimics the behavior of "||". These two lines are functionally equivalent:
#
# ghost? || zombie?
# either :ghost, :zombie
#
# The bonus of `either` is that afterwards you can ask why or why not.
def either(*predicate_names)
#
# 1. Setting 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`.
# I would like to do this *before* evaluating either, so the method signature does not change.
# For now this restricts you to check *why* something is scary or not, after you ask *if* it is scary or not.
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 to the predicate method
# We check if the predicate needs to be negated
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
# By negating the return value of a negated predicate, we always have a true value for our success case.
# This simplifies the logic for our success case.
if negate
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 this predicate is the reason we are successful.
# 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 we are true, clear the reasons why we would not be. They don't matter anymore.
why_not_reasons.clear
# To ensure lazy evaluation / early termination, we stop here.
return true
else
# We have a false value, so we continue looking for a true predicate
if negate
# Our predicate name is negative, but our value is false, so we want to use the positive version.
# In our example we are not scary because we are not a zombie.
# Our check is for :zombie, so the "why not" reason is :not_zombie.
negative_predicate_name = predicate_name_string.sub(/^not_/, '').to_sym
else
# Our predicate_name is positive, but our value is false, so we want to use the negative version of the predicate.
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 did not get a true value at all (which would have caused early termination), so we have failed.
# 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
#
# The bonus of `all` is that afterwards you can ask why or why not.
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 in `either` up to this point, but now we have a subtle difference:
#
# * Either looks for the first true to declare success
# * And looks for the first false to declare failure
if value
# We have a true value, so we must continue with the next
if respond_to?("why_#{predicate_name}")
why_reasons << { predicate_name => public_send("why_#{predicate_name}") }
else
why_reasons << predicate_name
end
else
# We have a false value, so we can stop here. Early termination!
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
# We fail, so clear the reasons for success.
why_reasons.clear
return false
end
end
# We did not fail, so we succeed.
why_not_reasons.clear
return true
end
end
# That's some nasty code in `either` and `all`, but it allows us to do this:
# Instantiate the object
boo = ReasonBoo.new
# We defined this method ourselves
boo.scary? # => false
# Calling `scary?` gives us these two methods:
boo.why_scary # => []
boo.why_not_scary # => [{:not_ghost=>[:not_regrets]}, {:not_zombie=>[:not_hungry_for_brains]}]
# Another method we defined
boo.ghost? # => false
# An the reasons why
boo.why_ghost # => []
# I'm not 100% happy with the inflection, :no_regrets would have been nicer, but it is consistent this way.
boo.why_not_ghost # => [:not_regrets]
# Same deal here
boo.zombie? # => false
boo.why_zombie # => []
boo.why_not_zombie # => [:not_hungry_for_brains]
# Let's define a Zombie to see a success case
class Zombie < ReasonBoo
def alive?
false
end
def hungry_for_brains?
true
end
end
zombie = Zombie.new
zombie.scary? # => true
zombie.why_scary # => [{:zombie=>[:not_alive, :hungry_for_brains]}]
zombie.why_not_scary # => []
zombie.zombie? # => true
zombie.why_zombie # => [:not_alive, :hungry_for_brains]
zombie.why_not_zombie # => []
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment