When building software, we often come across special cases, specializations, and shared logic. In object-oriented languages, inheritance is commonly used to deal with these.
Building an API client
Let’s say you are writing a client to interact with a third-party API that lists movies. It might look like:
module MovieFacts
class Client
def initialize(client_id, client_secret)
@client_id = client_id
@client_secret = client_secret
end
def directors
fetch_data("/directors").map { |director| Director.new(director) }
end
def director(name)
Director.new(fetch_data("/directors/#{name}"))
end
private
def fetch_data
# fetch directors from API
end
end
end
The client returns a lightweight Director
object based on the JSON response.
module MovieFacts
class Director
def initialize(json)
@raw_data = JSON.parse(json)
end
def name
@raw_data.fetch("name")
end
def id
@raw_data.fetch("id")
end
end
end
Inheritance
A new set of requirements come in. movie-facts.com
is rate limiting your
service so you need to watch how many requests you make in a day. The good news
is that you know that movie-facts.com
only updates its systems once a day so
it should be trivial to cache the data in memory and only fetch from the API
once a day. Not only does this fix your rate-limiting issues but it also speeds
up performance of your own system.
There are now two ways of doing the same task (and it’s not too hard to imagine others coming along down the road). Creating a separate class would result in a lot of duplicated code.
The intro to “Design Patterns in Ruby” describes an additional principle:
Separate things that change from those that stay the same
The logic for returning the list of director objects remains the same, but the way we fetch the data changes based on context. We need a way to separate the fetching logic (which changes), from the director logic (which doesn’t).
The most commonly used solution for this problem is inheritance. Inheritance is a way of creating a specialized form of an object. Because of this, child classes have access to all the methods and private state of their parent. They are their parent, with a few modifications.
You start by extracting the shared logic into a ClientBase
class
module MovieFacts
class ClientBase
NotImplementedError = Class.new(StandardError)
def initialize(client_id, client_secret)
@client_id = client_id
@client_secret = client_secret
end
def directors
fetch_data("/directors").map { |director| Director.new(director) }
end
def director(name)
Director.new(fetch_data("/directors/#{name}"))
end
private
def fetch_data
raise NotImplementedError
end
end
end
You then derive a class for handling fetches via HTTP:
module MovieFacts
class HttpClient < ClientBase
private
def fetch_data
# make HTTP request
# cache response
end
end
end
and one for fetches from cache.
module MovieFacts
class CacheClient < ClientBase
private
def fetch_data
# read data from cache
end
end
end
Now if you ever want to add a third way to fetch the data in the future, all you need to do is add a new sub-class. Great use of the Open/Closed principle!
Limitations of inheritance
Simple inheritance solved our duplicated logic problem. It also made it easy for us to extend the system to fetch data from other sources if necessary. However, it does have a few critical weaknesses when it comes to building reusable OO components:
- it is vulnerable to combinatorial explosion when there are multiple independent parts of the code that vary.
- there is no encapsulation between parents and descendants.
We dig into how to solve this problems in the next posts on modules and composition.
Further Reading
This article is part 1 of 4 in a series on building reusable object-oriented software.