Design Techniques: Classical Inheritance
My previous post about duck typing showed duck-typed objects responding to the same message. But what if objects share not only the same message, but also the same behaviour? In order to keep our code DRY, we should extract this common behaviour to a single place in our code. One way to do this is with inheritance.
What is inheritance?
In Ruby, classical inheritance and sharing behaviour via modules are both mechanisms for inheritance, which, at its core, is an implementation of automatic message delegation. Inheritance allows us to define a relationship between two objects, where if the first object receives a messge it doesn’t understand, it will automatically forward (or delegate) that message to the second object. In this scenario the first object is the subclass while the second is the superclass. In Ruby, while an object can have multiple subclasses, each object may only have one superclass. This is known as single inheritance.
An object can inherit from another object that inherits from another object, and so on, culminating in a chain of classes that can receive forwarded messages from any objects below them. Methods defined at the top of this look-up chain have widespread influence over the objects that sit below them—changes to these methods immediately ripple down the inheritance hierarchy, meaning you can accomplish big changes with small changes to the code; however, this also means that small changes can potentially break things. Using inheritance properly, therefore, is of the highest priority and requires adhering to specific coding techniques.
Classical inheritance best practices
I have some code in my Ruby Battleship game that is responsible for returning an array of coordinates that represent a ship’s placement on the board. It calculates these coordinates by taking the ship’s starting position and adding the ship’s length to it. If the ship is being placed vertically, it adds the length down the rows. And if it’s being placed horizontally, it adds the length across the columns. I started out with two classes, one for vertical placements and another for horizontal ones.
class VerticalCoordinatesBuilder
attr_reader :first_row, :first_column, :length
def initialize(first_row:, first_column:, length:)
@first_row = first_row
@first_column = first_column
@length = length
end
def build
array_of_rows.map do |row|
{ row: row, column: first_column }
end
end
def array_of_rows
Array (first_row..last_row)
end
def last_row
(first_row + length) - 1
end
end
class HorizontalCoordinatesBuilder
attr_reader :first_row, :first_column, :length
def initialize(first_row:, first_column:, length:)
@first_row = first_row
@first_column = first_column
@length = length
end
def build
array_of_columns.map do |column|
{ row: first_row, column: column }
end
end
def array_of_columns
Array (first_column..last_column)
end
def last_column
(first_column + length) - 1
end
end
These two classes obviously share an interface—they both respond to the build
message and are even initialized in the same way. If we look closer, we see that the two build
methods are also very similar. You get the sense that these two objects are strongly related with common behaviour, but they differ along a single dimension (vertical vs horizontal). This is the exact problem that inheritance solves—let’s extract the common behaviour above to a single CoordinatesBuilder
class.
class CoordinatesBuilder
attr_reader :first_row, :first_column, :length
def initialize(first_row:, first_column:, length:)
@first_row = first_row
@first_column = first_column
@length = length
end
def build
array.map(&build_coordinate_pair)
end
def array
raise "Implement `#array` in the #{self.class} class!"
end
def build_coordinate_pair
raise "Implement `#build_coordinate_pair` in the #{self.class} class!"
end
def last(start)
(start + length) - 1
end
end
class HorizontalCoordinatesBuilder < CoordinatesBuilder
def array
Array (first_column..last(first_column))
end
def build_coordinate_pair
Proc.new { |column| { row: first_row, column: column }}
end
end
class VerticalCoordinatesBuilder < CoordinatesBuilder
def array
Array (first_row..last(first_row))
end
def build_coordinate_pair
Proc.new { |row| { row: row, column: first_column }}
end
end
Note: It is recommended that you wait until you have at least three potential subclasses before you try to extract a common superclass, but for the purpose of this post I’ve gone ahead with only the two.
Separate abstractions and specializations
When using inheritance, ensure that your objects have a generalization-specialization relationship. The CoordinatesBuilder
above provides the general behaviour for iterating over an array of rows and columns and returning an array of coordinate hashes. The subclasses that inherit from it are responsible for providing the specializations; namely, which array to loop over, and what function to apply to each element in the array.
One way to ensure your inherited code maintains this separation of abstractions and specializations is to use the template method pattern. We see this pattern in the code above—CoordinatesBuilder
provides the general algorithm for building the array of coordinates, but it allows its subclasses to influence this algorithm by expecting them to implement the array
and build_coordinate_pair
methods. These are the template methods, and as a rule, the superclass should implement every template method it calls, even if it expects the subclasses to override them.
Defining every template method in the superclass may feel redundant, but it provides valuable documentation of the subclass-superclass relationship requirements. If the methods were missing from both the superclass and a subclass, the developer would only see a potentially confusing NameError: undefined local variable or method...
error when a subclass object received those messages. Raising specific error messages in the superclass methods makes it clear to the developer that the subclasses are expected to implement these methods themselves.
Decoupling subclasses from superclasses
Part of keeping your subclasses and superclasses loosely coupled is avoiding the use of super
.
class CoordinatesBuilder
attr_reader :first_row, :first_column, :length
def initialize(first_row:, first_column:, length:)
@first_row = first_row
@first_column = first_column
@length = length
end
def build(array, block)
array.map(&block)
end
# nothing else has changed
end
class HorizontalCoordinatesBuilder < CoordinatesBuilder
def build
super(array_of_columns, build_coordinate_pair)
end
def array_of_columns
Array (first_column..last(first_column))
end
def build_coordinate_pair
Proc.new { |column| { row: first_row, column: column }}
end
end
super
adds an additional, unneccessary dependency by forcing a subclass to know something about how its superclass works. All subclasses will now know how the algorithm in CoordinatesBuilder
works. This use of super
also leads to code duplication by requiring all current and future subclasses to call super
in the same place and in the same way.
Instead of super
, use hook messages as we did in the previous example with our array
and a build_coordinate_pair
methods. These methods remove knowledge of the coordinate-building algorithm from the subclasses and hand control back to the parent CoordinatesBuilder
class.
Recognizing where to use inheritence
Your code might resemble the example at the beginning of this post, where the HorizontalCoordinatesBuilder
and VerticalCoordinatesBuilder
classes were essentially the same thing but they differed along a single dimension. This indicates that inheritance may be a good solution.
Another indication that you should consider inheritance is if you have a single class that is trying to account for multiple specializations.
class CoordinatesBuilder
attr_reader :first_row, :first_column, :length, :direction
def initialize(first_row:, first_column:, length:, direction:)
@first_row = first_row
@first_column = first_column
@length = length
@direction = direction
end
def build
case direction
when 'vertical'
build_vertical_coordinates
when 'horizontal'
build_horizontal_coordinates
end
end
def build_vertical_coordinates
array_of_rows.map { |row| { row: row, column: first_column }}
end
def build_horizontal_coordinates
array_of_columns.map { |column| { row: first_row, column: column }}
end
def array_of_rows
Array (first_row..last_row)
end
def array_of_columns
Array (first_column..last_column)
end
end
This case statement should remind you of the antipattern we saw in the previous post on duck typing; however, instead of asking an object what class it belongs to, here we are checking the value of an attribute on self
to determine which message to send to self
. Checking for the value of direction
inside the build
method implies that CoordinatesBuilder
has too many responsibilities—it’s essentially made up of two objects that should be broken out into their own subtypes. With this case statement, the CoordinatesBuilder
class must be changed every time you add a new direction (maybe we’ll want to place ships diagonally in the future), instead of simply creating a new subclass that would extend the inheritance hierarchy.
If you think that inheritance might the right solution for your problem, think about the relationships you’d be creating and ensure that every subtype is a type of its parent (e.g. a HorizontalCoordinatesBuilder
is a CoordinatesBuilder
). If the relationship is better described as behaves-like-a, you should instead look into behaviour sharing via modules; if it is a has-a relationship, you would be better off using composition.