Video transcript & code
Separation of concerns is something we usually strive for in our programs. We try to keep different concerns separate at the project level using libraries or service-oriented architecture; at the program level using modules and classes; and at the class level using methods. But today I want to talk about separating concerns at a really low level: the level of individual lines.
The example I'm going to use today is loosely based on part of the "Gilded Rose" code kata. If you've never gone through this kata before on your own, you might want to go look it up and work through it before continuing. It's a great programming exercise, and it's worthwhile to tackle it without any prior solution bias this video might give you.
Consider a class representing a cheese on a shelf.
class Cheese attr_reader :value def initialize(value) @value = value end end
Cheese has value. And since this is the sort of cheese that adds value the longer it ages, we have an
#age method which increments its value.
class Cheese attr_reader :value def initialize(value) @value = value end def age @value += 1 end end
However, there is a ceiling to what people will pay for cheese. So the value of cheese is capped at 50.
To model this, we make the increment conditional on whether the current value is less than 50.
class Cheese attr_reader :value def initialize(value) @value = value end def age @value += 1 if value < 50 end end
Let's just play with this for a moment.
We'll make a cheese with a value of 48.
When we age it once, it gains a value of 49.
When we age it again, the value goes up to 50.
But when we age it a third time, the value stays at 50.
c = Cheese.new(48) c.age c.value # => 49 c.age c.value # => 50 c.age c.value # => 50
So far so good. But as we flesh out our requirements, we find we have to deal with more than one kind of cheese. Green cheese from the moon gains 2 points of value for every unit of aging.
We deal with this complication by first encapsulating the increment unit into a separate, private method.
This then enables us to inherit a
GreenCheese class, with an overridden increment value of 2.
class Cheese attr_reader :value def initialize(value) @value = value end def age @value += increment if value < 50 end private def increment 1 end end class GreenCheese < Cheese private def increment 2 end end
If we instantiate a
GreenCheese object with a value of 48 and age it once, we get a value of 50.
If we age it again, the value is still 50.
gc = GreenCheese.new(48) gc.age gc.value # => 50 gc.age gc.value # => 50
But what if we instead start the cheese at value 49.
When we age it once, the resulting value is 51!
class GreenCheese < Cheese private def increment 2 end end gc = GreenCheese.new(49) gc.age gc.value # => 51
This violates the constraint that the value will always be capped at 50.
This happened because 49 is less than 50, and so our code permitted the increment.
To fix this, we update our original
#age method to increment the value by the either the increment unit, or the difference between the current value and 50, whichever is smaller.
class Cheese attr_reader :value def initialize(value) @value = value end def age @value += [increment, 50-value].min if value < 50 end private def increment 1 end end
Now when we age a value-49 green cheese, its value is correctly held down to 50.
gc = GreenCheese.new(49) gc.age gc.value # => 50 gc.age gc.value # => 50
This code works, but the algorithm is getting kind of awful. Especially for a one-liner.
We could extract out the calculation of an adjusted increment value into a separate variable.
def age adjusted_increment = [increment, 50-value].min @value += adjusted_increment if value < 50 end
But that doesn't really get at the root of the problem. The real issue here is that the line which increments the value has acquired two responsibilities.
The first responsibility is incrementing value. The second responsibility is constraining the value.
And it may not be immediately obvious, but after factoring out the
adjusted_increment variable, we've actually forced both of those responsibilities onto this new extracted line as well!
After all, this line is concerned with both an increment, and the limits imposed on that increment by the overall value cap. This is an interesting case of a refactoring that doesn't actually separate any concerns.
It may seem strange to talk about separation of concerns between individual lines in a method. But I believe that this is where good encapsulation and cohesion begins. To have well-factored, composable methods, objects, and modules, we need to start with good separation of concerns at the lowest and smallest of levels.
Let's undo that change.
def age @value += [increment, 50-value].min if value < 50 end
Now, how can we refactor this in a way that separates concerns?
Here's one way. On line one, we can increment.
Then on line two, we can limit, using the lesser of the current value of the value cap.
def age @value += increment @value = [@value, 50].min end
These lines have total separation of concerns. Line one only knows about incrementing value, and doesn't know anything about constraining the value. Line two knows only how to constrain the value, and doesn't care at all about increments or anything else we might have done on the lines before it. These are two self-contained operations.
In fact, they are so neatly self contained that we could easily factor them out into their own meaningfully-named private methods, leaving the original
#age method as what Kent Beck refers to as a composed method.
Now we have one method concerned with incrementing, one concerned with capping, and one method whose only job is to make sure those other operations happen in the right order.
class Cheese attr_reader :value def initialize(value) @value = value end def age increment_value constrain_value end private def increment_value @value += increment end def constrain_value @value = [@value, 50].min end def increment 1 end end
This is a neat, elegant separation of concerns, and I really like how it has come out.
Although, if you're anything like me, you might at the same time be feeling like there's something very wrong with this solution. It's a feeling that stems from the possibility that for a brief moment between the
constrain_value steps, this object may contain invalid instance data.
That's a legitimate worry, and we'll talk about it at length in the next episode. Until then: happy hacking!