Practical Object-Oriented Design

Chapter 2 - Design Classes with a Single Responsibility

Having a Single Responsibility is a powerful way to make Classes/Methods easy to change.

Here are some important techniques I learned.

Depend on behavior, not data

Hide instance variables

Found this interesting:

class Gear
  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
  end

  def ratio
    @chainring / @cog.to_f # <-- road to ruin
  end
end

We shouldn't be dependent on @cog instance variable. Instead, we should be using a getter method and if we need to add logic to the getter, it will be better (what we're doing here is to "make it easy to change").

Better design (easier to change):

class Gear
  attr_reader :chainring, :cog   # <-- create getters

  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
  end

  def ratio
    chainring / cog.to_f         # <-- use the getters
  end
end

Now we're able to easily change, for example, the behavior of cog, like this:

def cog
  @cog * unanticipated_adjustment_factor
end

Hide data structures

In this example, let's assume data is a 2D array with pairs of integers representing the size of the rim and the tire (of a bike's wheel):

# rim and tire sizes in millimeters, in a 2D array
@data = [
  [622, 20],
  [622, 23],
  [559, 30],
  [559, 40],
]

The method to calculate diameter of all these wheels:

def diameters
  # 0 is rim, 1 is tire
  data.collect { |cell| cell[0] + (cell[1] * 2) }
end

# ... many other that use the index to access the array

Imagine you have other methods handling this data accessing the rim+tire dimensions via the array's index. If you change the way your data is structured, you'll need all those methods that "know" about how the data is structured.

Instead, let's create a Struct:

class RevealingReferences
  attr_reader :wheels
  def initialize(data)
    @wheels = wheelify(data)
  end

  def diameters
    wheels.collect { |wheel| wheel.rim + (wheel.tire * 2) }
  end

  # Class Struct provides a convenient way to create
  # a simple class that can store and fetch values.
  Wheel = Struct.new(:rim, :tire)

  def wheelify(data)
    data.collect { |cell| Wheel.new(cell[0], cell[1]) }
  end
end

Enforce Single Responsibility Principle Everywhere

Separating iteration from the action that's being performed on each element is a common case of multiple responsibility that is easy to recognize.

def diameters
  wheels.collect do |wheel|
    wheel.rim + (wheel.tire * 2)
  end
end

In the method above, we're iterating over the wheels and then calculating the diameter of each wheel, two responsibilities (iterate & calculate) for a single method.

Let's refactor that to apply the SRP:

# first: iterate over the array
def diameters
  wheels.collect { |wheel| diameter(wheel) }
end

# second: calculate diameter of ONE wheel
def diameter(wheel)
  wheel.rim + (wheel.tire * 2)
end

The impact of a single refactoring like this is small, but the cumulative effect of this coding style is huge. Methods that have a single responsibility conver the following benefits:

Because you are writing changeable code, you are best served by postponing decisions until you are absolutely forced to make them. Any decision you make in advance of an explicit requirement is just a guess. Don't decide; preserve your ability to make a decision later.