POODR Notes: Acquiring Behavior Through Inheritance (Chapter 6)
Article Table of Contents
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:
- Clean up the initialization methods, so the sub-classes have sensible defaults
- Clean up the
spares
method, so sub-classes have sensible defaults - 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. AsBicycle
is written, subclasses must implementdefault_tire_size
. Innocent and well-meaning subclasses likeRecumbentBike
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 ofpost_initialization
method, rather than the subclasses callingsuper
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.