OOP in Python, part 18: Composition and inheritance
MP 64: A mix of the two approaches is often the most practical solution.
Note: This post is part of a series about OOP in Python. The previous post introduced composition. The next post discusses when to use OOP, and when OOP is not needed.
In the last post we discussed how composition can be used to model has a relationships, such as a library that has a collection of books. But what happens when there are more relationships in the overall scenario you’re trying to model? What if you have a mix of has a and is a relationships?
In real-world situations you often need to use a mix of inheritance and composition. In this post we’ll extend the library example to include a mix of inheritance where appropriate, and composition where appropriate.
Books and magazines
A library that only lends books is a little limiting. Let’s expand the library to include a collection of magazines as well.
To do this, we need to think about two kinds of relationships:
What’s the relationship between a library and a magazine?
What’s the relationship between a magazine and a book?
If the word relationship isn’t helping you think clearly about this, try using the word connection instead.
Libraries and magazines
The relationship (or connection) between a library and a magazine is easier to think about, so let’s start there. A library has a magazine, or a collection of magazines. That steers us toward composition—the Library
class will have a collection of Magazine
instances.
Books and magazines: conceptual thinking
The relationship between books and magazines is less clear. A book doesn’t have a magazine, and a magazine doesn’t have a book. So we’re probably not going to be using composition between them.1
Let’s consider the is a relationship. A book is not a magazine, and a magazine is not a book. They seem related somehow though, especially in the context of a library. We’ll do some common things with them. We’ll catalog them, we’ll loan them out, and people will return them. So they’ll have some common attributes and behaviors, which leads us towards inheritance. Is there any way we can fill in the following blanks with the same word?
A book is a _____.
A magazine is a _____.
I’ll pause a moment here before sharing my thoughts on how to approach this. If you can come up with a word that works for both of these examples, you could probably write code to match your mental model. Even if your model is different than mine, you’d be on your way toward a reasonable implementation of a library.
Two words come to mind for me: publication and resource. I was inclined to go with publication because I’ve been thinking about what books and magazines are. But as I write all this out I find myself thinking more about what a library is, and what a book and a magazine mean to a library. In this context, I think resource might be the better term. We loan and catalog resources, and this line of thinking will extend to a number of other things: CDs, DVDs, board games, headphones, and more.
Also, it’s worth noting before we write any code that we’re not committing ourselves too much by choosing a specific way of thinking at this point. If we choose publication as the parent of books and magazines, we can always go back and insert another layer in the hierarchy called resource. If we start with resource, we can always go back and add a publication layer into the hierarchy. If you recognize the need for these layers right away, it’s easier to put them in now. But you can also refactor these layers in later if the need arises.
Books and magazines: representation in code
I’m considering books and magazines to both be examples of library resources, so let’s write a Resource
class. It will contain the information that’s common to every resource a library might have, and behavior that’s common to all resources as well.
Here’s a first pass at writing the Resource
class. We’re working with a number of classes, so let’s put all these in a file that will only contain the classes. We’ll call it library_models.py:
class Resource:
def __init__(self, resource_type):
self.resource_type = resource_type
self.current_borrower = None
def lend(self, patron):
self.current_borrower = patron
def return_resource(self):
self.current_borrower = None
This class defines two attributes: what kind of resource each instance is, and a reference to a borrower. When you create a resource, it’s not checked out to anyone initially.
The Resource
class needs a lend()
method; writing the lend()
method made me realize we need a model for the library’s patrons. The lend()
method sets the value of current_borrower
to a specific patron. The return_resource()
method sets the value of current_borrower
back to None
. You might have noticed a discrepancy between the method names here. We can’t have a function named return()
, because that would override the built-in return
statement.
Here’s the Patron
class:
class Patron:
def __init__(self, name):
self.name = name
Now we can update the Book
class. It’s mostly the same as the version we used in the last post, except it inherits from Resource
:
class Book(Resource):
def __init__(self, title="", author=""):
self.title = title
self.author = author
super().__init__(resource_type="book")
def get_info(self):
return f"{self.title}, by {self.author}"
This new version only needs to pass a resource_type
argument to the parent __init__()
method.
The show_status()
method
As I was updating Book
, I started to add code to the get_info()
method that would show whether the book was available or not. Then I realized this functionality should apply to all resources. So I added a get_status()
method to Resource
:
class Resource:
...
def get_status(self):
if self.current_borrower:
return "on loan"
else:
return "available"
If there’s a value set for current_borrower
, the resource is on loan to someone. Otherwise, it’s available. Even in a small project like the example library we’re building, I think it’s important to show that not all code and functionality is clear from the start. Often times code for one part of a project becomes clear as you’re working on a different part of the project.
The updated Library
class
The Library
class has the same functionality as it did in the last post, but it’s expanded to work with various resources, not just books:
class Library:
def __init__(self, name="", resources=None):
self.name = name
if resources is None:
self.resources = []
else:
self.resources = resources
def get_books(self):
books = [
r for r in self.resources
if r.resource_type == "book"
]
return books
def show_books(self):
print(f"All books in {self.name}:")
for book in self.get_books():
info = book.get_info()
print(f"- {info} ({book.get_status()})")
A library has a collection of resources
now, rather than just a collection of books. When you want to work with only books, you can call the method get_books()
. This method uses a comprehension to generate a list of resources that are designated as books.
The method show_books()
now calls get_books()
when iterating over the collection of books. It also adds a note about whether the book is currently available, or out on loan.
Making a library
With these classes defined, we can build a library. We’ll do this in a separate file called climbing_library.py:
from library_models import Library, Book, Patron
# Create a library.
library = Library(name="The Climber's Library")
# Create some books.
book = Book(
title="Freedom of the Hills",
author="The Mountaineers",
)
library.resources.append(book)
book = Book(
title="Learning to Fly",
author="Steph Davis",
)
library.resources.append(book)
# Show all books.
library.show_books()
Before adding patrons and magazines, let’s make sure the functionality we had in the last post still works. Here we create a library, and then create two books just as we did before. Notice that the code for creating an instance of Book
hasn’t changed. The fact that Book
inherits from Resource
is an implementation detail, invisible to the end user.
We import the necessary classes, and append to library.resources
instead of library.books
.2
The output is the same as it was in the last post, with the additional information about whether each book is available or not:
$ python climbing_library.py
All books in The Climber's Library:
- Freedom of the Hills, by The Mountaineers (available)
- Learning to Fly, by Steph Davis (available)
This is good; it means the new functionality we’ve introduced hasn’t broken any existing functionality.
Adding patrons and borrowing books
Now we can make a patron, loan them a book, and verify that the book is on loan:
...
# Show all books.
library.show_books()
# Lend a book.
birdie = Patron("Birdie")
freedom_hills = library.get_books()[0]
freedom_hills.lend(birdie)
# Show all books.
library.show_books()
We create a patron named Birdie. We pull out the first book in the collection, and call its lend()
method. We then show all the books in the library again:
...
All books in The Climber's Library:
- Freedom of the Hills, by The Mountaineers (on loan)
- Learning to Fly, by Steph Davis (available)
The second listing of books shows that Freedom of the Hills is currently out on loan. The system is working. :)
Loaning books and magazines
Now we can create some magazines as well as some books. Here’s the Magazine
class:
class Magazine(Resource):
def __init__(self, title="", issue=""):
self.title = title
self.issue = issue
super().__init__(resource_type="magazine")
def get_info(self):
return f"{self.title}, issue {self.issue}"
This is structured just like Book
. All this code belongs in Magazine
and not Resource
, because the information and format is specific to a magazine.
Now we need to update the Library
class to work with magazines:
class Library:
...
def get_magazines(self):
magazines = [
r for r in self.resources
if r.resource_type == "magazine"
]
return magazines
def show_magazines(self):
print(f"All magazines in {self.name}:")
for magazine in self.get_magazines():
info = magazine.get_info()
print(f"- {info} ({magazine.get_status()})")
We add two methods to Library
, to get all the magazines in the library’s collection and to show them.3
Now we can add some magazines to the library’s resources, and loan one out:
...
# Show all books.
library.show_books()
# Create some magazines.
for issue_num in range(70, 84):
magazine = Magazine(title="Alpinist", issue=issue_num)
library.resources.append(magazine)
# Loan the latest issue.
current_issue = library.get_magazines()[-1]
current_issue.lend(birdie)
# Show magazines.
library.show_magazines()
We create the most recent issues of Alpinist magazine, and add them to library.resources
. We then get the current issue, and lend it to birdie
. Finally, we show the state of the magazine collection.
This works, just as it does for books:
$ python climbing_library.py
...
All magazines in The Climber's Library:
- Alpinist, issue 70 (available)
- Alpinist, issue 71 (available)
...
- Alpinist, issue 82 (available)
- Alpinist, issue 83 (on loan)
All the issues of the magazine show up, and the latest issue is currently on loan.
Conclusions
In most real-world projects, a practical implementation won’t involve purely inheritance or purely composition. Many times you’ll need to mix the two approaches to come up with a solution that makes sense for what you’re trying to accomplish.
If we were writing code for a real-world library project, there are a number of ways we’d extend the ideas that were introduced here:
Information about the library’s resources would be stored in a database. We’d still have OOP code that acts on the data we’re currently working with.
We could make
Resource
an abstract base class. People should make instances of specific resource subclasses, but in the current implementation there’s no reason to make an instance ofResource
. We could then define specific methods that all resource types need to implement.We’d consider building out methods that might seem redundant, but would create a more natural and intuitive API. For example we currently have
resource.lend(patron)
, but it might be helpful to havepatron.borrow(resource)
as well. The functionality of lending resources already exists, but additional methods would support multiple ways of conceptualizing the relationship between patrons and resources. The implementation would be simple:patron.borrow()
would be a one-line wrapper aroundresource.lend()
.The
lend()
method would set a due date. Due dates are central to libraries, so we might write our own class to represent them. The class could have methods likeextend()
, and have properties likeoverdue
.
Throughout all this work, a clear understanding of composition and inheritance would help you build a model that’s understandable, flexible, and maintainable. A reasonable implementation would allow you to extend individual parts of the system, without too much concern that your new work will negatively impact existing functionality.
Resources
You can find the code files from this post in the mostly_python GitHub repository.
You might recognize at this point that a magazine does have articles. If we extend the model further we might want to use composition to connect Magazine
instances with Article
instances.
A more complete implementation of the Library
class would probably include an add_book()
method. Again, the change making Book
a subclass of Resource
would be invisible to end users.
We now have two pairs of similar methods: get_books()
and show_books()
, and get_magazines()
and show_magazines()
. These could be refactored into two more general methods, get_resources()
and show_resources()
. The method get_resources()
would require a resource_type
argument, and then it would return resources of that specific type.
If you do this refactoring work, you wouldn’t need to add two new methods every time the library adds a new type of resource to its collection.