Wed, Dec 23, 2020
Read in 6 minutes
When first starting out with object-oriented programming (OOP), it is easy to abuse its many language features; one such feature is inheritance.
You have a working understanding of OOP like classes and methods but can’t seem to use it to structure it in a way to make sense.
You can read a bit of Ruby (it’s really easy! Reads like English.)
Without going too deep, inheritance allows us to specify a hierarchy of classes. When a child inherits from a parent, it acquires all the properties and behaviours of the parent. One such benefit is allowing us to create classes that are built upon other existing classes. Very quickly, in code it looks something like this:
class ParentClass def hello puts 'hi there!' end end class ChildClass < ParentClass end class GrandChildClass < ChildClass end # > grandchild = GrandChildClass.new # > grandchild.hello ---> 'hi there!'
Let’s imagine we run a Pizzeria. A Pizza class can be defined like so
class Pizza attr_accessor :toppings def initialize(toppings) @toppings = toppings end end
What if you wanted to introduce a different pizza, say a pepperoni pizza?
Some of us may choose to do something like this:
class PepperoniPizza < Pizza attr_accessor :toppings def initialize; end end class PepperoniPizza < Pizza def initialize super @toppings = [:pepperoni] end end
It is not exactly wrong, since PepperoniPizza is technically derived from a Pizza, but let’s consider the finer points and see if it makes sense:
HawaiianPizzaapart from just its toppings?
PepperoniPizzahandled from a
The trick to simplifying the problem is seeing how differently the siblings behave and interact with other objects around itself.
All pizzas are typically handled the same way and also behave the same way, only differing in their toppings and name. Therefore, It would make more sense to initialise the pizzas differently with their respective names and topping.
class Pizza attr_accessor :toppings def initialize(name, toppings) @name = name @toppings = toppings end end pepperoni_pizza = Pizza.new('pepperoni', [:pepperoni])
Now it reads “a pizza whose name is ‘pepperoni’ and has many toppings that are pepperoni”.
English gives us a clue to what tool to use. Attributes describe what “is” about the pizza. In this case the pizza’s name is
'pepperoni'. Suppose if we introduced a new square pizza on the menu. All we had to do is to add a new attribute to the
Pizza class (assuming square pizzas still handled the same way):
class Pizza attr_accessor :toppings def initialize(name, toppings, shape) @name = name @toppings = toppings @shape = shape end end square_pepperoni_pizza = Pizza.new('pepperoni', [:pepperoni], :square)
Composition on the other hand describes what an object “has”. A pizza has toppings. A duck has a beak. A car has wheels. It is important to distinguish attributes from composition because attributes are innate to the object, while composition allows it to be made up of other items.
It would be strange to say “the pizza has large”. A large what? Likewise, it would be strange to say “the pizza’s toppings is” (in this case, you can only use is as you are substituting the name of the would be attribute).
Some of us may propose that a Pizza can be modelled as a subclass of a Hash, after all it can be modelled as an object.
class Pizza < Hash # ... pizza methods end
This is a big NO in my opinion because it mixes concerns between the business and implementation domain. Your application classes should be composed of implementation classes, not be one of it.
Some commonly documented problems include
PepperoniPizzaa subclass of
Pizza, and later we announce that
Calzone, a type of folded pizza, is also a subclass of
Pizza. Depending on how complex the earlier classes were, adding a new class will be a nightmare because of the interaction with prior designs. This causes your design to be fragile to change.
Inheritance encourages the reuse of interfaces. In the case of Ruby, this means the reuse of methods between parent and child classes.
One clear use case is when inheriting from abstract classes,
class Shape attr_accessors :points def draw # Ruby doesn't support abstract classes raise 'Implement in subclass!' end end class Circle < Shape # has :points inherited from Shape def draw # draw a circle end end
Even so, Ruby specifically allows us to define a module as a mixin, effectively mixing in behaviours from a module to share common methods.
module Shapeable def draw raise 'Must implement!' end end class Circle attr_accessors :point attr_accessors :radius include Shapeable def draw # draw a circle end end
Shapeable module enforces that the
draw method must implemented in the
Circle class. However, it also frees Circle from how it wishes to implement draw, and the internal representations it needs to fulfil the end goal.
Modules can be used in more creative ways to impart even more behaviours in to a class, but that is a topic for another long blog post.
As we have seen, specifically when it comes to Ruby, there isn’t really a lot of reason to use inheritance. In fact, during my research for this article, I also discovered that Java and C++ folks have shifted away from inheritance as a design tool, favouring composition over inheritance.
Composition is underrated and misunderstood; yet when used properly as a design tool it can make your classes more open to changes. More on this on my next article!
Learning to write software? Subscribe now to receive tips on software engineering.