|||

Rake Routes

by Stephen Ball

Every if” statement is an object waiting to be extracted

The problem

Consider the following Ruby code.

class Beverage
  attr_reader :kind

  def initialize(kind)
    @kind = kind
  end

  def typical_ounces
    if :coffee
      6
    else
      8
    end
  end

  def calories_per_ounce
    if :coffee
      0
    else
      :unknown
    end
  end
end

So we’re modeling beverages. Great! Our class can accept a kind of beverage and then respond to some messages about it.

coffee = Beverage.new(:coffee)
coffee.container # => "mug"
coffee.typical_ounces # => 6
coffee.calories_per_ounce # => 0

oj = Beverage.new(:orange_juice)
oj.container # => "glass"
oj.typical_ounces # => 8
oj.calories_per_ounce # => :unknown

Wonderful. But those if blocks aren’t great right? If we drop more kinds of beverage in that model then they’re going to get annoying pretty quickly. But no problem we can use some case statements to make a nice pattern!

class Beverage
  attr_reader :kind

  def initialize(kind)
    @kind = kind
  end

  def typical_ounces
    case kind
    when :coffee
      4
    when :orange_juice
      8
    else
      8
    end
  end

  def calories_per_ounce
    case kind
    when :coffee
      0
    when :orange_juice
      14
    else
      :unknown
    end
  end
end

Lovely. But there’s a problem here. As we add beverage after beverage the repetition of the conditionals becomes arduous.

class Beverage
  attr_reader :kind

  def initialize(kind)
    @kind = kind
  end

  def typical_ounces
    case kind
    when :coffee
      4
    when :orange_juice
      8
    when :rum
      1.5
    when :whole_milk
      8
    when :skim_milk
      8
    else
      8
    end
  end

  def calories_per_ounce
    case kind
    when :coffee
      0
    when :orange_juice
      14
    when :rum
      64
    when :whole_milk
      19
    when :skim_milk
      12
    else
      :unknown
    end
  end
end

Sure we could allow a lot of the typical_ounces fall through to the default case but then our data structures don’t line up with the calories_per_ounce and future devs won’t know if they really are the default or if we forgot to declare the correct data.

The tests are similarly convoluted and each new beverage is a data slog to sort through and easy to get wrong.

This isn’t fun! Let’s make it fun!

What we have here is a failure to utilize the full power of object oriented design.

As an OO practitioner, when you see a conditional, the hairs on your neck should stand up. Its very presence ought to offend your sensibilities. You should feel entitled to send messages to objects, and look for a way to write code that allows you to do so.”

Excerpt From: Sandi Metz, Katrina Owen. 99 Bottles of OOP.” Apple Books.

Sandi and Katrina go on to explain that of course not every conditional is bad. The problem is having conditionals controlling the individual pieces of behavior in our class. A better object oriented design would pull together all the like behaviors into their own classes. Then have singular top level conditional that decides which behavior to use.

It’s like having a car repair manual peppered with conditionals. If you have an Outback then do X, but if you have a Forester then do Y.” Not a great experience! A much nicer alternative is only making a decision about which car model you have once and then using its dedicated manual.

Replacing low level conditionals with objects

We can make our beverage class nicer to work with by not requiring it to be the only class we have. We can make a class for each individual beverage that all answer the same messages that we want to send them.

class Coffee
  def typical_ounces
    6
  end

  def calories_per_ounce
    0
  end
end

class OrangeJuice
  def typical_ounces
    8
  end

  def calories_per_ounce
    14
  end
end

class Rum
  def typical_ounces
    1.5
  end

  def calories_per_ounce
    64
  end
end

class WholeMilk
  def typical_ounces
    8
  end

  def calories_per_ounce
    19
  end
end

class SkimMilk
  def typical_ounces
    8
  end

  def calories_per_ounce
    12
  end
end

Check that out! Those classes are completely focused and minimal. Testing them becomes a simple exercise that they respond to the right messages.

But what happened to Beverage and where are the default values? Let’s take those in reverse order.

The default case

Before the default values fell out of our huge case statements in the final else path where we give up and say 8” for typical ounces and :unknown for calories per ounce. Unknown is not an actual beverage that we directly modeled before, but it’s absolutely behavior that we can extract into a name.

class UnknownBeverage
  def typical_ounces
    8
  end

  def calories_per_ounce
    :unknown
  end
end

Choosing the right behavior

The Beverage concept is still useful. Before we’d initialize it with a kind and then expect the resulting instance to have the correct behavior.

oj = Beverage.new(:orange_juice)
oj.calories_per_ounce # => 14

Here we can either adjust our API or add a little complexity to the Beverage class.

Adjusting the Beverage API

First, let’s see how adjusting our API would look.

In Ruby it’s idiomatic to add a class method called for to choose between different object behaviors e.g. Beverage.for(:coffee). We can call the decision method anything we like. Its job will be to pick the right beverage class so we can use whatever makes sense.

oj = Beverage.for(:orange_juice)
oj = Beverage.given(:orange_juice)
oj = Beverage.from(:orange_juice)
oj = Beverage.build(:orange_juice)

In our example let’s stick with for. Here’s what that top level decision behavior could look like.

class Beverage
  def self.for(kind)
    case kind
    when :coffee
      Coffee.new
    when :orange_juice
      OrangeJuice.new
    when :rum
      Rum.new
    when :whole_milk
      WholeMilk.new
    when :skim_milk
      SkimMilk.new
    else
      UnknownBeverage.new
    end
  end
end

Keeping the original Beverage behavior

Second, let’s see how we could keep the original Beverage.new behavior by making Beverage a little more complex. It can hold the specific beverage class chosen from kind and delegate the calls to the appropriate data class.

class Beverage
  def initialize(kind)
    @dataObject = case kind
                  when :coffee
                    Coffee.new
                  when :orange_juice
                    OrangeJuice.new
                  when :rum
                    Rum.new
                  when :whole_milk
                    WholeMilk.new
                  when :skim_milk
                    SkimMilk.new
                  else
                    UnknownBeverage.new
                  end
  end

  def typical_ounces
    @dataObject.typical_ounces
  end

  def calories_per_ounce
    @dataObject.calories_per_ounce
  end
end

Even better! Ruby has a stdlib for that: SimpleDelegator! With SimpleDelegator we give it the class we want our object to delegate methods calls to. Nice!

require "delegate"

class Beverage < SimpleDelegator
  def initialize(kind)
    dataObject = case kind
                 when :coffee
                   Coffee.new
                 when :orange_juice
                   OrangeJuice.new
                 when :rum
                   Rum.new
                 when :whole_milk
                   WholeMilk.new
                 when :skim_milk
                   SkimMilk.new
                 else
                   UnknownBeverage.new
                 end
    super(dataObject)
  end
end

Conditionals? Managed

Yes in all of these cases there’s still a conditional. But the key difference is that now we have a single top level conditional choosing a behavior rather than multiple low level conditionals choosing raw data. We have little to no chance of introducing a bug in the data due to a mismatch of logic. Before we could have easily introduced bug while trying to keep those large, parallel case statements consistent.

Up next Choose Generic Tools Upstream your tooling instead of rolling your own. The more you push upstream to gems or Rails, the less logic you need in your application. Save Shrink your data into bitfields (and out again) Some applications want to save every byte. Every last byte! Maybe they have memory constraints, maybe they’re sending data over the network, maybe
Latest posts A simple language spec isn’t a feature when you’re building applications The Fastest Possible Tests Shrink your data into bitfields (and out again) Every “if” statement is an object waiting to be extracted Choose Generic Tools Hyperlinks you might find interesting — #4 Running bundle install on rails master Use tldr for command line examples Friday Lunch Links — #3 Friday Lunch Links — #2 Logical Solver: Turn facts into conclusions Programming with jq Command line tools - jq Friday Lunch Links — #1 Why diversity matters Music for coding - October 2019 Code puzzles are a poor way to gauge technical candidates Add vim to a pipeline with vipe Connecting Objects with Observable Let’s write a shell script What’s a $PATH anyway? Let’s Use Hwacha to Scan URLs Deliberate Git Customize Your IRB Program Like a Videogamer Gem Spotlight: interactive_editor Things Most Interviewees Fail to Discover Rails isn’t for beginners How to use bundler instead of rvm gemsets How to write (and test) a gem to serve static files on the Rails asset pipeline A Taste of Metaprogramming