These notes discuss the second chapter of Sandi Metz’s book with the code snippets in Ruby. If you’re new to the Ruby language, you may want to check out this simple tutorial .
If you missed chapter 1, here it is .
I’ve included quotes from the book and important notes. All Ruby codes here are taken from the book or slightly changed.
Let’s start chapter 2 now…
Your goal is to model your application, using classes, such that it does what it is supposed to do right now and it is also easy to change later .
Sometimes you know how to write code but not where to put it. Once you know where to put that code, you can decide what belongs in a class.
Organizing Code to Allow for Easy Changes
The idea of easy is too broad; you need concrete definitions of easiness and specific criteria by which to judge code. If you define easy to change as:
- Changes have no unexpected side effects
- Small changes in requirements require correspondingly small changes in code
- Existing code is easy to reuse
- The easiest way to make a change is to add code that in itself is easy to change
Then the code you write should have the following qualities. Code should be:
- Transparent The consequences of change should be obvious in the code that is changing and in distant code that relies upon it
- Reasonable The cost of any change should be proportional to the benefits the change achieves
- Usable Existing code should be usable in new and unexpected contexts
- Exemplary The code itself should encourage those who change it to perpetuate these qualities
Code that is Transparent, Reasonable, Usable, and Exemplary (TRUE) not only meets today’s needs but can also be changed to meet the needs of the future. The first step in creating code that is TRUE is to ensure that each class has a single, well-defined responsibility.
Creating Classes That Have a Single Responsibility
When you create a class, put this in your mind: Class always does the smallest possible useful thing. It shouldn’t do two things. That is to say, it should have a single responsibility.
An Example Application: Bicycles and Gears
Let’s design a class and try to apply single responsibility by solving a problem. Say, you want to buy a bike and you’d like to make sure what bike would be a better choice. That choice can depend on how long the bike will move once you push the pedal. Bicyclists compare that by comparing the teeth of two gears; the one at the front (chainring) and the rear one (cog).
The gear combining a 52-teeth chainring and an 11-teeth cog has a ratio of ~ 4.73 which means the rotational motion is converted to translational 5 times that round your feet do when they push the pedal.
Here is the simple class design quoted from the book:
class Gear
attr_reader :chainring, :cog
def initialize chainring, cog
@chainring = chainring
@cog = cog
end
def ratio
chainring / cog.to_f
end
end
puts Gear.new(52, 11).ratio # 4.7272727272727275
puts Gear.new(30, 27).ratio # 1.1111111111111112
Since the Gear
class is a subclass of Object
it inherits all its
methods like new
so we pass the arguments of chainring and cog teeth
to it. calling the ratio method calculates the ratio of both teeth
dividing chainring by the cog.
Let’s iterate on this class if you want to consider the wheel size.
Cyclists (at least in the US) use something called gear inches to compare bicycles that differ in both gearing and wheel size. The formula follows: gear inches = wheel diameter * gear ratio
where wheel diameter = rim diameter + twice tire diameter
Let’s add this to our Gear
class:
class Gear
attr_reader :chainring, :cog, :rim, :tire
def initialize chainring, cog, rim, tire
@chainring = chainring
@cog = cog
@rim = rim
@tire = tire
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * (rim + (2 * tire))
end
end
puts Gear.new(52, 11, 26, 1.5).gear_inches # 137.0909090909091
puts Gear.new(52, 11, 24, 1.25).gear_inches # 125.27272727272728
There is a problem with this design because ratio
does not work
anymore:
puts Gear.new(52, 11).ratio
# gear_wheel_sz.rb:3:in `initialize': wrong number of arguments (given 2, expected 4) (ArgumentError)
# from gear_wheel_sz.rb:23:in `new'
# from gear_wheel_sz.rb:23:in `<main>'
Let’s ignore this bug for now and see if this class has a single responsibility or not.
Determining if a Class has a Single Responsibility
One way to determine if a class has a single responsibility or not is to ask the methods questions. For example, a question like Please Mr. Gear, what is your ratio? makes perfect sense, while Please Mr. Gear, what is your gear_inches? is on shaky ground, and Please Mr. Gear, what is your tire (size)? seems ridiculous.
Another way is to be able to describe the class in one sentence. When you use the word ‘and’ or ‘or’, the class likely has more than one responsibility and you need to avoid that.
Determining When to Make Design Decisions
Ask yourself, is this class really a Gear ? How it has rims and tires?! Perhaps Gear should be Bicycle ? or maybe there is a Wheel in here?
This “improve it now” versus “improve it later” tension always exists. Applications are never perfectly designed. Every choice has a price. A good designer understands this tension and minimizes costs by making informed tradeoffs between the needs of the present and the possibilities of the future.
Writing Code That Embraces Change
You want to create a code that depends on behavior, not data. A behavior is captured by methods. The “Don’t Repeat Yourself” (DRY) phrase is a shortcut to writing a class that has a single responsibility.
One way to make your code changeable is to make hide instance variables
just like we did with cog
for example. We used attr_reader
which
causes Ruby to create a wrapper method for the variables. It virtually
created this method for cog
:
def cog
@cog
end
so that you don’t have to call the instance variable @cog
each time
you need it.
Also, to make your code changeable you should hide data structure as in the following example:
class ObscuringReferences
attr_reader :data
def initialize data
@data = data
end
def diameters
# 0 is rim, 1 is tire
data.collect {|diameter|
diameter[0] + (diameter[1] * 2)}
end
end
puts ObscuringReferences.new([[662, 20], [662, 23]]).diameters
Remember when we had the equation inside the gear_inches
which has rim + (2 * tire)
. The above example shows that we can do that by
defining a data structure of 2d array data
which explicitly shows
that if you iterate over data
, you’ll find rims are at the index 0
and tires are at the index 1.
This simple example is bad enough; imagine the consequences if
data
returned an array of hashes that were referenced in many places. A change to its structure would cascade through your code; each change represents an opportunity to create a bug so stealthy that your attempts to find it will make you cry.
We can solve this issue by applying the following:
class RevealingReferences
attr_reader wheels
def initialize data
@wheels = wheelify data
end
def diameters
wheels.collect {|wheel|
wheel.rim + (2 * wheel.tire)}
end
Wheel = Struct.new(:rim, :tire)
def wheelify data
data.collect {|diameter|
Wheel.new(diameter[0], diameter[1])}
end
end
The wheelify
method contains the only bit of code that understands
the structure of the incoming array and the diameters
method only
knows that the message wheels
returns an enumerable and that each
enumerated thing responds to rim
and tire
.
Enforce Single Responsibility Everywhere
Single responsibility is not only applied to classes but also methods.
Looking at the diameters
method, we can see it has two
responsibilities: it iterates over the wheel
and calculates the
diameters:
def diameters
wheels.collect {|wheel|
wheel.rim + (2 * wheel.tire)}
end
What we need to do is to separate the two responsibilities into two different methods:
# first method - iterates over the array
def diameters
wheels.collect {|wheel| diameter(wheel)}
end
# second method - calculates the diameter of one wheel
def diameter wheel
wheel.rim + (wheel.tire * 2)
end
This way, you can use the diameter
method in other places inside
your code. It’s not overdesigned, it’s just simplifying things for the
future.
Remember the gear_inches
method:
def gear_inches
ratio * (rim + (2 * tire))
end
this method has two responsibilities that enforce us to isolate them:
def gear_inches
ratio * diameter
end
def diameter
rim + (2 * tire)
end
here we calculated the wheel diameter
in a separate method and then
the output of it is used to determine gear_inches
.
This simple refactoring makes the problem obvious.
Gear
is responsible for calculatinggear_inches
butGear
should not be calculating wheel diameter.
Extending the Gear
class code further by adding diameter
method:
class Gear
attr_reader :chainring, :cog, :wheel
def initialize chainring, cog, rim, tire
@chainring = chainring
@cog = cog
@wheel = Wheel.new(rim, tire)
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
Wheel = Struct.new(:rim, :tire) do
def diameter
rim + (tire * 2)
end
end
end
Finally, the Real Wheel
There is a feature request coming your way to calculate the
circumference of the wheel. It’s simple, right? It is, but the real
change here is that little change forces us to make a new design
decision which is to make a separate class for Wheel
independent of
Gear
.
Adding a new class in our case is simple because what you just need to do is to convert the structure data type that you made previously and add a circumference method to it.
class Gear
attr_reader :chainring, :cog, :wheel
def initialize chainring, cog, wheel=nil
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
chainring / cog .to_f end
def gear_inches ratio *
wheel .diameter end end
class Wheel attr_reader
:rim , :tire def initialize rim, tire
@rim = rim @tire = tire
end def diameter
rim + (tire * 2 ) end
def circumference diameter
* Math :: PI end end
CHAINRING = 26 COG =
1.5 RIM = 52 TIRE = 11
@wheel = Wheel .new ( CHAINRING ,
COG ) puts @wheel .diameter #
29.0 puts @wheel .circumference
# 91.106186954104 puts
Gear .new ( RIM , TIRE , @wheel ) .gear_inches
# 137.0909090909091 puts
Gear .new ( RIM , TIRE ) .ratio #
4.7272727272727275
Each class now has a single responsibility. The code is not perfect though, but it is good enough.
Final Thoughts
In this chapter, we’ve been introduced to the single responsibility and how changeable and maintainable object-oriented software is applied through classes that do one thing . Each class should be isolated from the rest of your application to make your code easy to change.
Note: Please note that these notes are not sufficient to get all the info from the book, these are my notes on the book. Sometimes I skip stuff and sometimes I rephrase statements based on my understandings and other times I write my own thoughts. If you want to get the complete knowledge behind the book, you’ve got to get the book and read it :)
See you in chapter 3 notes!
Get Practical Object-Oriented Design in Ruby now! (if you haven’t already)
or this second edition (both links are affiliates)
By the author