DEV Community

Wes Oldenbeuving
Wes Oldenbeuving

Posted on • Originally published at blog.narnach.com on

Boolean Externalities

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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:

  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 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

Enter fullscreen mode Exit fullscreen mode

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]

Enter fullscreen mode Exit fullscreen mode

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:

  1. Setup the why_[predicate] and why_not_[predicate] methods.
  2. Evaluate each predicate until we reach a termination condition.
  3. 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

Enter fullscreen mode Exit fullscreen mode

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)