Created
September 18, 2014 22:08
-
-
Save Narnach/6ee5753e0af31433fd9a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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