OOP in Python, part 11: Inheritance

MP 52: Why is inheritance so important in OOP?

Note: This is the eleventh post in a series about OOP in Python. The previous post showed how you can keep your classes organized, especially as they grow in size and complexity. The next post shows how to enforce constraints on subclasses.


We’ve worked through all the different kinds of methods you should know about, so it’s time to tackle one of the most important topics in object-oriented programming: inheritance.

Inheritance allows you to take advantage of the behaviors defined in one class, by writing a more specialized version of that class with little or no repetition. This post doesn’t aim to teach inheritance. Instead we’ll look at a short example, discuss some important aspects of inheritance, and then look at a real-world example of inheritance.

Modeling accounts

A lot of basic OOP examples focus on tangible things such as cars, animals, and plants. This helps people learning about classes for the very first time, but many of the classes we work with as programmers end up modeling abstract things. To make for a more meaningful discussion we’ll model something abstract, but keep the implementation really simple.

Let’s consider a user account. Most sites and apps that use accounts have several kinds of accounts. For example a common approach is to have three types of accounts:

  • Free accounts: These let people try out a site or app before committing to share personal information or make a payment.
  • Standard accounts: These accounts have the basic privileges that go along with a full account, such as saving a reasonable amount of data, and accessing the full set of features.
  • Premium accounts: These accounts have more privileges, higher usage levels, or just show extra support for the project.

This is a nice example for discussing inheritance because we can readily identify some information and actions common to all of these accounts, and some information and actions that are unique to each kind of account.

We’ll write a very minimalistic implementation of these accounts, so the code shown in this post will run as written. In the following posts we’ll look at how the concepts involved in inheritance play out in real-world projects where the code isn’t so straightforward.

Inheritance terminology

One thing that confuses people when learning about inheritance is the variety of words that are used to describe the same thing. In the specific example of one class inheriting from another, the first class can be called the parent class, the base class, or the superclass. The class that inherits from the parent class can be called a child class, or a subclass.

Some of these words can also be used as a verb. When you write a child class, you are subclassing the parent class. We also say that the child class extends the behavior of the parent class.

Screenshot of the Dropbox pricing page.
Dropbox offers four different account levels. One way to model this in code would be to write a base Account class, and then write four subclasses that each extend the Account class.

The parent class

Sometimes you know ahead of time that you’re going to use inheritance. Other times you’ll work on a class only to discover later that you’d like to make a specialized version of it. Since we know we’re going to use inheritance, we can go straight to the step of identifying what goes in the parent class.

Here’s a parent Account class, which contains information and actions common to all account types:

# account.py

class Account:
    """Model a user account on a site or app."""

    def __init__(self, username, password):
        self.username = username
        self._store_password(password)

    def welcome_user(self):
        print(f"Welcome to the site, {self.username}!")

    def close_account(self):
        print(f"Closed account for {self.username}.")

    def _store_password(self, password):
        print(f"Stored password for {self.username}.")

if __name__ == "__main__":
    account = Account("eric")
    account.welcome_user()

Creating any kind of account requires a username. A password may be set immediately, or that may happen in a followup step, so password is an optional argument. There are two public methods that apply to all account types: welcome_user() and close_account().

If you’re not familiar with the conditional test used here, it checks whether this code is being run directly or being imported into another module. If account.py is being run directly, this block executes and we make an instance of Account. If it’s being imported, the conditional test will fail and no instance will be created. This is a nice way to use the code in a module, while not interfering with any code that imports the module. In a mature project, this class would probably be moved to its own file and then be imported anywhere an instance needs to be created.

Here’s the output:

Stored password for eric.
Welcome to the site, eric!

Free accounts

Now that the generic Account class works, we can focus on specific kinds of accounts. Here’s a minimal FreeAccount class:

class Account:
    ...

class FreeAccount(Account):

    def welcome_user(self):
        super().welcome_user()
        print("You'll have limited access on a free account.")

if __name__ == "__main__":
    account = FreeAccount("eric")
    account.welcome_user()

In this example, all we’re doing is customizing the message that’s displayed when we welcome a user. While not the main point of this example, this shows that a child class doesn’t always need its own __init__() method. If a child class doesn’t implement __init__(), the __init__() method from the parent class is automatically called.

In welcome_user(), we first call the version of welcome_user() from the parent class. The use of super() can seem like a bit of magic; we’ll come back to that in a moment. We then add a specific message for free users letting them know that their account is limited:

Stored password for eric.
Welcome to the site, eric!
You'll have limited access on a free account.

This is a classic example of inheritance; we use the existing behavior of the parent class, and add a little more specialized behavior in the child class. We did this by overriding the welcome_user() method defined in the parent Account class.

More about super()

People new to OOP tend to use super() without really understanding it, which is quite reasonable. Let’s take a closer look at super() for a moment, by focusing on welcome_user() in FreeAccount:

class FreeAccount(Account):
    ...
    
    def welcome_user(self):
        super().welcome_user()
        print("You'll have limited access on a free account.")

The bold line here is really saying, Find the superclass of FreeAccount, and call the welcome_user() method from that class.

I’m always a fan of playing around in code to see what we’re actually working with. Let’s add a couple print() calls to see what super() really is:

    def welcome_user(self):
        super().welcome_user()
        print("You'll have limited access on a free account.")
        print(super)
        print(super())

Here we’re printing the super object itself, and we’re printing what’s returned by super() as well. Here’s the relevant part of the output:

<class 'super'>
<super: <class 'FreeAccount'>, <FreeAccount object>>

It looks like a function, but super is actually a built-in class.

When we call super() without any arguments, we get back an instance of super; that’s what <super: in the second line indicates. This instance of super acts as a reference to the parent class—in this case FreeAccount. The super object is like a proxy instance of FreeAccount that lets us have access to the methods in that class, without actually making a new instance of that class.

You might ask why we need something so magical as this. For example, in this simplified case the following code also works:

    def welcome_user(self):
        Account.welcome_user(self)    # Don't do this!
        print("You'll have limited access on a free account.")

Here we’ve replaced the call to super() with an explicit reference to the parent class, Account. Note, however, that we also had to explicitly include the self argument that welcome_user() expects to receive.

It turns out that it’s quite helpful to have super() do this work for us. Here’s a brief list of reasons why it’s so useful:

We can change the name of the parent class, and all of the calls involving super() still work. If we had to use hard-coded references to the parent class, changes like this would make for a lot of clerical work. This is especially true when a parent class has multiple subclasses.

It handles details like passing the self argument to methods in the parent class. This means we never have to pass self explicitly in a method call.

Most importantly, a child class in Python can have more than one parent class. In this case, you can include arguments in the call to super() specifying how those parent classes should be searched for the method you want to call.1

Two more account classes

Let’s finish the example by implementing StandardAccount and PremiumAccount:

class Account:
    ...

class FreeAccount(Account):
    ...

class StandardAccount(Account):

    def welcome_user(self):
        super().welcome_user()
        print("You have access to all features on the site.")

class PremiumAccount(Account):

    def welcome_user(self):
        super().welcome_user()
        print("Thank you for supporting us at this level!")

if __name__ == "__main__":
    account = FreeAccount("eric")
    account.welcome_user()

    account = StandardAccount("william")
    account.welcome_user()

    account = PremiumAccount("birdie")
    account.welcome_user()

We define two more classes, both of which inherit from Account. These two classes have the same structure as FreeAccount. This shows that inheritance isn’t just about letting one class extend another; a parent class can be extended by as many child classes as you need. In the case of library code, a single parent class can be extended by anyone using that library.

We then make one instance of each account type. The output shows that all three classes are working:

Stored password for eric.
Welcome to the site, eric!
You'll have limited access on a free account.

Stored password for william.
Welcome to the site, william!
You have access to all features on the site.

Stored password for birdie.
Welcome to the site, birdie!
Thank you for supporting us at this level!

Real-world example: unittest.TestCase

There are many standard library modules and third-party libraries that expect end users to inherit from a class defined in the library. For example, in the unittest module people are expected to build their test cases by inheriting from the TestCase class:

Test authors should subclass TestCase for their own tests. Construction and deconstruction of the test's environment ('fixture') can be implemented by overriding the setUp() and tearDown() methods respectively.

The source code for this class is over 1,000 lines long! That’s a lot of functionality you get for free by just writing a single line of code like this:

import unittest

class MyTestCase(unittest.TestCase):
    ...

Many of the methods in TestCase help you make assertions in your test suite. There are methods like assertEqual(), assertNotEqual(), assertIn(), assertListEqual(), and many more. There’s also a setUp() method, which sets up resources needed for multiple test methods.

Inheritance provides a clear structure for writing all the messy code needed to implement complex testing behavior, while letting end users only write the code needed to test their specific project.2

Conclusions

The concept of inheritance is straightforward at its core; a parent class defines some behavior and attributes, and all child classes can take on and extend the behavior of the original class. The syntax can be confusing, because things like super() are doing a lot of work under the hood that’s not obvious at all. It can look like needless complexity, but when you understand what’s being done for you the syntax starts to look more reasonable.

In the next post we’ll look at a common problem that comes up when using inheritance, and what you can do to address that issue.

Resources

You can find the code files from this post in the mostly_python GitHub repository.


  1. If you’re curious to read more about super(), start with the official documentation. Here’s a taste of what you’ll see there:

    class super(type, object_or_type=None)

    Return a proxy object that delegates method calls to a parent or sibling class of type. This is useful for accessing inherited methods that have been overridden in a class.

    Also, see Raymond Hettinger’s excellent discussion, Python’s super() considered super!

  2. I don’t actually recommend using unittest for testing these days. Instead, I recommend everyone use pytest, whether you’re writing a small set of simple tests or a large, complex test suite.