Abstract vs. DRY
We've talked about this before, including on our podcast and blog, but it bears repeating: DRY is the worst programming advice ever. It will almost always make your code harder to change as requirements evolve.
For example, say you have some code that uses a driver to manipulate a vending machine to prepare either coffee or tea. Like this:
class VendingMachine
attr_reader :driver
def initialize(driver: Driver.new)
@driver = driver
end
def vend(beverage:, options: {})
case beverage
when :coffee
dispense_cup
heat_water
prepare_grounds
dispense_water
dispense_sweetener if options[:sweet]
dispense_cream if options[:creamy]
dispense_whipped_cream if options[:fluffy]
dispose_of_grounds
when :tea
dispense_cup
heat_water
dispense_tea_bag
dispense_water
dispense_sweetener if options[:sweet]
dispense_cream if options[:creamy]
end
end
private
def dispense_cup
driver.dispense_cup
end
def heat_water
driver.fill_tank
driver.pressurize_tank
end
# ...
end
A small vending machine application
Each of the methods called from the case
statement in the vend
method interacts with a hardware driver to prepare the recipes.
We can probably all agree on a few things about this code:
- The code is fairly readable.
- There's quite a bit of duplication between the two recipes.
- Future developers will likely add more beverages by adding more
when
clauses to thecase
statement. - At 25.1, the flog score for the
vend
method is already a little too high.
At this point, there are a couple of different paths forward:
- We could DRY the code.
- Or, we could introduce a layer of abstraction.
DRYing the code will simplify it on one axis. The Assignments, Branches, and Conditionals algorithm used by flog will produce a smaller number. Like this:
class VendingMachine
attr_reader :driver
def initialize(driver: Driver.new)
@driver = driver
end
def vend(beverage:, options: {})
dispense_cup
heat_water
case beverage
when :coffee
prepare_grounds
when :tea
dispense_tea_bag
end
dispense_water
dispense_sweetener if options[:sweet]
dispense_cream if options[:creamy]
if beverage == :coffee
dispense_whipped_cream if options[:fluffy]
dispose_of_grounds
end
end
private
def dispense_cup
driver.dispense_cup
end
def heat_water
driver.fill_tank
driver.pressurize_tank
end
# ...
end
A DRY version of the small vending machine application
The vend
method now has a flog score of 16.1, which is nearly 10 points lower. Quite an improvement. But in choosing to DRY this code, we've tied the two recipes together, making it difficult to change one without affecting the other; and, we've made extending this code with additional recipes more difficult since we'll have to weave them together with the existing DRY recipes. Plus, there are at least 12 different code paths through the vend
method as it currently exists making the method difficult to test.
Or, we could go with adding a layer of abstraction, like this:
class VendingMachine
attr_reader :driver
def initialize(driver:)
@driver = driver
end
def vend(beverage:, options: {})
case beverage
when :coffee
Coffee.new(driver:, options:).prepare
when :tea
Tea.new(driver:, options:).prepare
end
end
end
class Coffee < Recipe
def prepare
dispense_cup
heat_water
prepare_grounds
dispense_water
dispense_sweetener if options[:sweet]
dispense_cream if options[:creamy]
dispense_whipped_cream if options[:fluffy]
dispose_of_grounds
end
end
class Tea < Recipe
def prepare
dispense_cup
heat_water
dispense_tea_bag
dispense_water
dispense_sweetener if options[:sweet]
dispense_cream if options[:creamy]
end
end
class Recipe
attr_reader :driver
def initialize(driver:)
@driver = driver
end
private
def dispense_cup
driver.dispense_cup
end
def heat_water
driver.fill_tank
driver.pressurize_tank
end
# ...
end
An abstract version of the small vending machine application
Now, there are more lines of code, and as such, the flog score for what used to be the vend
method is 35.0. However, the flog scores of the vend
method and the two recipe classes are 6.1, 15.2, and 10.8 respectively. So, since we can only work on one thing at a time, we're always working with less complexity. And, we've not commingled the recipes. So, they're still as easy to understand as the original implementation.
Furthermore, future developers are likely to add new recipes by creating new classes instead of interleaving new recipes into the existing DRY code. This separates the recipes and allows each to be tested independently making each portion of the system easier to test.
The astute observer might point out that the prepare
methods in the Coffee
and Tea
classes still contain duplication. These observers might even be tempted to DRY that duplication using something like the Template pattern. But, this would again intermingle the recipes. And, any new beverage would either have to comply with the template, or cause all of the algorithms to be rewritten unnecessarily.
This is the difference between DRY code and abstract code. DRY code might seem easier to write, but makes changing or extending an application harder. Abstract code may seem more difficult to write, but makes changing and extending the application simpler.
DRY is the easy solution. Abstraction is the simple solution.
Prefer simple over easy.
No spam, no sharing to third party. Only you and me.
Member discussion