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.
Top comments (0)