Josh Thompson     about     archive     turing     office hours

POODR Notes: Acquiring Behavior Through Inheritance (Chapter 6)

I’m reading through Practical Object Oriented Design in Ruby .

These are some notes from chapter 6, Acquiring Behavior Through Inheritance; mostly these are for me, and they don’t intend to stand on their own. Read the book, work through chapter six, and then come back and read through this.

The current state of the code is we have this RoadBike object and MountainBike object that share significant code.

We’re going to extract common functionality into the Bycycle superclass.

Step one, according to Sandi Metz, is always move code from the base class to super class, never start with code in the super class.

class Bicycle
  # Please note how empty this class is.
end

class MountainBike < Bicycle
  attr_reader :size, :front_shock, :rear_shock
  def initialize(args)
    @size         = args[:size]
    @front_shock  = args[:front_shock]
    @rear_shock   = args[:rear_shock]
  end
  
  def spares
    { chain:      '10-speed',
      tire_size:  '2.1',
      rear_shock: rear_shock
    }
  end
end

class RoadBike < Bicycle
  attr_reader :size, :tape_color
  def initialize(args)
    @size       = args[:size]
    @tape_color = args[:tape_color]
  end
  
  def spares
    { chain:      '10-speed',
      tire_size:  '23',
      tape_color: tape_color }
  end
end

mtb = MountainBike.new(
  size:        'small',
  front_shock: 'manitou',
  rear_shock: 'fox'
)
road = RoadBike.new(
  size:       'large',
  tape_color: "Red",
)

We have three broad goals:

  1. Clean up the initialization methods, so the sub-classes have sensible defaults
  2. Clean up the spares method, so sub-classes have sensible defaults
  3. Accomplish 1 and 2 in such a way that the next developer who adds a sub-class doesn’t have to inspect the existing classes in great detail to figure out subtle nuance.

First, a sub-optimal refactor for #initialize

First with adding @tire_size to the Bicycle superclass:

class Bicycle
  attr_reader :size
  def initialize(args)
    @size = args[:size]
  end
end

and we have to call super from the sub-class initialize method, in order to get all of our instance variables initialized:

class MountainBike < Bicycle
  attr_reader :front_shock, :rear_shock
  def initialize(args)
    @front_shock  = args[:front_shock]
    @rear_shock   = args[:rear_shock]
    super(args) # we need this to get size from the superclass, otherwise we lose the `@size` instance variable
  end
end
  
class RoadBike < Bicycle
  attr_reader :tape_color
  def initialize(args)
    @tape_color  = args[:tape_color]
    super(args)
  end
end

We’ve created a problem, now.

Every new subclass of Bicycle that we might ever make needs to call super in the initialization method. That’s a subtle requirement, and not one that will be immediately apparent to future developers.

Use post_initialize instead of initialize

To navigate this requirement that subclasses never call super, we’ll strip our entire initialize method out of our subclass.

Here’s what we end up with next:

class Bicycle
  attr_reader :tire_size
  def initialize(args)
    @tire_size = args[:tire_size] || default_tire_size
    post_initialize(args)
  end
  
  def post_initialize(args)
    nil
  end
end

class MountainBike < Bicycle
  attr_reader :front_shock, :rear_shock
  def post_initialize(args)
    @front_shock  = args[:front_shock]
    @rear_shock   = args[:rear_shock]
  end
  .
end

No calls to super, and future developers, when looking at either the Bicycle class or one of its existing sub-classes immediately see what’s going on, and follow the existing pattern.

Future subclasses can now follow the pattern of existing subclasses. They won’t create an initialize method, but instead will implement a hook that responds to post_initialize.

Setting sensible defaults

Lets step back to the state of the class, pre-refactor, when we had a spares method in both sub-classes, which contained values we’d rather set as defaults, like chain, which is currently the same value across all Bicycle subclasses, and tire_size which differs by sub-class

class Bicycle
  # empty again
end

class MountainBike < Bicycle
  .
  def spares
    { chain:      '10-speed',
      tire_size:  '2.1',
      rear_shock: rear_shock }
  end
end

class RoadBike < Bicycle
  .
  def spares
    { chain:      '10-speed',
      tire_size:  '23',
      tape_color: tape_color }
  end
end

Here’s the first round of refactors.

We pull chain into the superclass, fetching the value of default_chain if Bicycle isn’t explicitly given a chain value.

default_tire_size is trickier; we require every Bicycle subclass to implement default_tire_size, since it’s not implemented in the superclass:

class Bicycle
  attr_reader :chain, :tire_size
  def initialize(args={})
    @chain     = args[:chain]     || default_chain
    @tire_size = args[:tire_size] || default_tire_size
  end
  def default_chain
    '10-speed'
  end    
  
  def spares
    { chain: chain,
      tire_size: tire_size}
  end
end

class MountainBike < Bicycle
  .
  def default_tire_size
    '2.1'
  end
end

class RoadBike < Bicycle
  .
  def default_tire_size
    '23'
  end
end

When we give RoadBike and MountainBike a default_tire_size method, the superclass may now call default_tire_size and set it appropriately.

We’ve created a trap, however. From the book:

The root of the problem is that Bicycle imposes a requirement upon its subclasses that is not obvious from a glance at the code. As Bicycle is written, subclasses must implement default_tire_size. Innocent and well-meaning subclasses like RecumbentBike may fail because they do not fulfill requirements of which they are unaware.

We can remove this trap, though, in the superclass, by telling all future developers that they need to implement certain methods.

class Bicycle
  .
  def default_tire_size
    raise NotImplementedError, "this #{self.class} cannot respond to 'default_tire_size'"
  end
end

Now when the new class is initialized, rather than simply failing to set a tire_size value, we’ll explicitly halt execution and tell them exactly what method they need to add.


Phew. All this work, and we’ve still not fully fixed our spares method.

We’ve got this spares method in the superclass, now using our handy sensible defaults:

class Bicycle
  attr_reader :chain, :tire_size
  def initialize(args={})
    @chain     = args[:chain]     || default_chain
    @tire_size = args[:tire_size] || default_tire_size
  end
  
  def default_chain
    '10-speed'
  end    
  
  def spares
    { chain: chain,
      tire_size: tire_size}
  end
  
  def default_tire_size
    raise NotImplementedError, "this #{self.class} cannot respond to 'default_tire_size'"
  end
end

class MountainBike < Bicycle
  .
  def spares
    super.merge(
      front_shock: front_shock,
      rear_shock: rear_shock
      )
    end
end

class RoadBike < Bicycle
  .
  def spares
    super.merge(tape_color: tape_color)
  end
end

By now, you should feel skeptical of calls to super. We want to avoid causing confusion with future developers about what classes should implement the spares method, and what it should do.

The fix is easy, once you see it written out. We’re going to expect our classes to implement local_spares, and we’ll simply ask our superclass to merge those local_spares into its own hash, like so:

class Bicycle
  .  
  def spares
    { chain: chain,
      tire_size: tire_size}.merge(local_spares)
  end
  
  def local_spares
    {}
  end
end

class MountainBike < Bicycle
  def local_spares
    { front_shock: front_shock,
      rear_shock: rear_shock }
  end
end

The Template Method Pattern

I had to read this chapter four or five times before it all sunk in, then I had to manually convert the classes from “tightly coupled and bad” to “appropriately abstract” three or four times for this to all sink in, at the very basic level of this example.

Anyway, this Template Method Pattern seems to mean:

Where abstract superclasses and their sub-classes share methods, the superclass must be explicit about what the subclasses must do.

If the superclass provides a globally-available method (like some default value) that can be overridden (or ‘specialized’) by the sub-class, no further action need be taken by the superclass

If the superclass requires a method value (like the subclass providing some specific default value or specialization) it ought to raise an error if that default value is not specialized.

Is it a rule of thumb that the subclasses never have an initialize method? it seems the superclass will call the subclasses with some sort of post_initialization method, rather than the subclasses calling super after initializing themselves.

Subclasses ought to provide specializations on the superclass, rather than ever overriding specific superclass attributes.

I’d love to come up with a repository of drills or exercises to practice refactoring code according to this Template Method design pattern.

Some day, later.

Subscribe via Email

When I write a new post, you'll get an email the next Friday, straight to your inbox.