OOP in Python, part 12: Enforcing constraints on subclasses
MP 53: How can a base class ensure that subclasses implement certain behaviors?
Note: This is the twelfth post in a series about OOP in Python. The previous post discussed inheritance, and why it’s important in OOP. The next post shows the use of abstract base classes in a real-world project.
The most basic version of inheritance, where the child class is free to extend and override the behavior of the parent class in any way it wants, covers a lot of use cases. But there are many situations where that freedom can lead to problems. For example what if the base class needs each subclass to implement certain behaviors, and they don’t?
In this post we’ll look at a situation where this issue arises. We’ll then rewrite the parent class as an abstract base class, which enforces certain constraints against the subclasses.
Closing accounts in a simulation
Let’s continue with the Account
example from the previous post. We had a base class called Account
, and three subclasses representing particular kinds of accounts: FreeAccount
, StandardAccount
, and PremiumAccount
. Each account class has two public methods: welcome_user()
and close_account()
.
As we extend this example we’ll keep the code simple so it runs, but we’ll think more seriously about what a real-world implementation of these classes would look like. We’ll focus specifically on the close_account()
method.
Here’s the current implementation, showing only the base Account
class and PremiumAccount
:
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}.")
...
class PremiumAccount(Account):
def welcome_user(self):
super().welcome_user()
print("Thank you for supporting us at this level!")
if __name__ == "__main__":
account = PremiumAccount("birdie")
account.welcome_user()
account.close_account()
We make a premium account, welcome the user, and then close the account.
This works:
Stored password for birdie.
Welcome to the site, birdie!
Thank you for supporting us at this level!
Closed account for birdie.
Everything works, because we’re just using print()
calls to simulate closing an account. Let’s see what happens if we move beyond this overly simplistic implementation.
Closing accounts in the real world
In the real world, closing accounts means different things for different users. For example a free account may expire after a certain period of time. You don’t necessarily want to delete this user’s data, because they might want to upgrade to a standard account without losing anything they’ve created during the free trial.
For users with a standard account closing could mean their account has expired, or they may have requested that their account be deleted. If they’re closing their account early, you may have to process a refund before fully closing the account.
Closing a premium account is probably similar to closing a standard account, but it depends what the premium features were. If the premium involved more features, there may be some granularity to the closing and deletion process. For example closing the account may mean transitioning to a standard account for some users, and fully deleting an account for others.1
When thinking through each of these scenarios, and the many other considerations that come up in a real-world implementation of an Account
class and its subclasses, it’s clear that the base Account
class can’t take care of closing accounts on its own. This is something that must be taken care of by each subclass.
A well-behaved subclass
Inheritance sets up a relationship between two classes. If both classes in the relationship do what’s expected of them, everything tends to work well.
Let’s remove close_account()
from the parent class and place it in the child class, where it needs to be in this context:
class Account:
"""Model a user account on a site or app."""
def __init__(self, username, password=""):
...
def welcome_user(self):
...
def _store_password(self, password):
...
...
class PremiumAccount(Account):
def welcome_user(self):
...
def close_account(self):
# Query user about exactly what they want.
# Flag appropriate data for deletion.
# Flag appropriate data for preservation.
# Take care of refunds appropriately.
# Confirm account closure.
print("We have closed your Premium account.")
print(" Thank you for your involvement with us.")
if __name__ == "__main__":
account = PremiumAccount("birdie")
account.welcome_user()
account.close_account()
We’re still not doing the actual work of closing an account in this code. However, in a real-world implementation of an account system, this would be the appropriate place to coordinate all of these steps.
The code runs, and in a full implementation all of the commented tasks would have been completed as well:
Stored password for birdie.
Welcome to the site, birdie!
Thank you for supporting us at this level!
We have closed your Premium account.
Thank you for your involvement with us.
If everyone plays nicely when using inheritance, things tend to work well.
A not-so-well-behaved subclass
Now let’s look at the same example, but this time we’ll see what happens if the subclass doesn’t implement close_account()
:
class Account:
...
...
class PremiumAccount(Account):
def welcome_user(self):
...
if __name__ == "__main__":
account = PremiumAccount("birdie")
account.welcome_user()
account.close_account()
There’s no new code here, we’ve just removed the close_account()
method from PremiumAccount
.
As you’d probably expect, this results in an error:
...
Thank you for supporting us at this level!
Traceback (most recent call last):
File "account.py", line 39, in <module>
account.close_account()
^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'PremiumAccount' object has no attribute 'close_account'
The call to account.close_account()
fails, because there is no close_account()
method associated with the account
object.
This might seem like it’s not a big deal. You’d run this code, see the error, and probably recognize that you need to write a close_account()
method in the PremiumAccount
class. However, it’s quite easy to write code where this error doesn’t show up. Let’s do that.
An invisible error
Here’s a situation where the error is still there, but it doesn’t show up when you run the code:
class Account:
...
...
class PremiumAccount(Account):
def welcome_user(self):
super().welcome_user()
print("Thank you for supporting us at this level!")
if __name__ == "__main__":
account = PremiumAccount("birdie")
account.welcome_user()
We haven’t changed anything about the two classes. All we’ve done is remove the account.close_account()
at the end of this listing.
This code runs without any errors, because we’re not trying to close the account:
Stored password for birdie.
Welcome to the site, birdie!
Thank you for supporting us at this level!
This is a very realistic scenario. Imagine a library that offers a base Account
class, and expects you to implement your own close_account()
method. If this expectation isn’t enforced in any way, you can write a custom Account
subclass that works really well for creating and using accounts. But the first time someone tries to close their account, your system will crash.
The first time someone tries to close their account, your system will crash.
In the messiness of the real world, where there can be many behaviors that need to be handled by a subclass, this kind of bug can appear quite easily. Fortunately, there’s a way for a base class to enforce some constraints on all subclasses.
Abstract base classes
The name “abstract base class” might sound intimidating. Programming is abstract already; something that’s literally called abstract must be really abstract! But, as is often the case in programming, the core idea isn’t necessarily as complicated as it sounds.
An abstract base class is a class that isn’t meant to be instantiated directly. That is, you can’t make an instance directly from an abstract base class. Instead, you have to write a subclass that extends the abstract base class, and then make instances of the subclass. The important part is that the base class can set rules for what kind of functionality the subclass has to implement.
What does this mean for our example? This means we can rewrite Account
as an abstract base class, so it requires every subclass to define a close_account()
method. If we do this correctly, end users will know they’re missing a close_account()
method even if they’re not yet calling it. Let’s see how this works in practice.
A more assertive Account
class
We’d like to have a version of Account
which declares that all subclasses must implement a close_account()
method. Here’s a version of Account
that does exactly that:
from abc import ABC, abstractmethod
class Account(ABC):
"""Model a user account on a site or app."""
def __init__(self, username, password=""):
...
def welcome_user(self):
...
@abstractmethod
def close_account(self):
pass
def _store_password(self, password):
...
...
class PremiumAccount(Account):
def welcome_user(self):
super().welcome_user()
print("Thank you for supporting us at this level!")
if __name__ == "__main__":
account = PremiumAccount("birdie")
account.welcome_user()
In the Python standard library, the abc
module includes resources for implementing abstract classes. Here we import the class ABC
, which is used to make abstract base classes. We also import the @abstractmethod
decorator.2
We update Account
so that it inherits from ABC
; this makes Account
an abstract class. Any class that inherits from ABC
can no longer be instantiated directly. If you try to make an instance from a class that inherits from ABC
, you’ll get an error.
Finally, we add an empty close_account()
method back into Account
. The @abstractmethod
decorator tells Python to verify that any subclass of Account
has defined its own close_account()
method. If it doesn’t have one, Python will raise an error as soon as someone tries to make an instance of the subclass.
In the current listing, we have a version of PremiumAccount
that doesn’t define a close_account()
method. At the end of the program, we’re still trying to make an instance of PremiumAccount
without calling close_account()
. Recall that this code ran without generating an error earlier.
Here’s the output now:
Traceback (most recent call last):
File "account.py", line 43, in <module>
account = PremiumAccount("birdie")
^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Can't instantiate abstract class PremiumAccount
with abstract method close_account
Even without trying to call close_account()
, we get an error. The error is raised as soon as we try to create an instance of PremiumAccount
. The error message is telling us that Python can’t make an instance of PremiumAccount
, because all it currently has is an abstract version of the method close_account()
.3
To make this error go away, we need to provide an implementation of close_account()
in PremiumAccount
, even if we’re not yet calling that method.
Here’s a corrected PremiumAccount
class:
from abc import ABC, abstractmethod
class Account(ABC):
...
...
class PremiumAccount(Account):
def welcome_user(self):
...
def close_account(self):
# Query user about exactly what they want.
# Flag appropriate data for deletion.
# Flag appropriate data for preservation.
# Take care of refunds appropriately.
# Confirm account closure.
print("We have closed your Premium account.")
print(" Thank you for your involvement with us.")
if __name__ == "__main__":
account = PremiumAccount("birdie")
account.welcome_user()
This code runs without errors, whether or not we call close_account()
.
Conclusions
Inheritance is a powerful concept, and the relationship defined in a simple implementation of inheritance covers a lot of use cases. But there are also a wide variety of situations where the inheritance relationship is more complex, and the base class must impose constraints on all subclasses in order for things to function correctly. Abstract base classes make these kinds of constraints possible.
In the example we’ve been discussing, the abc
module lets the Account
class declare exactly what behavior each account subclass must implement. This example involves a two-layer hierarchy where specific types of accounts all inherit from Account
. When modeling more complex situations, it’s common to have three layers:
BaseAccount
: An abstract base class, which defines behaviors that must be implemented by all classes that model an account.Account
: A class that inherits fromBaseAccount
. This may be an abstract class, or a regular class that can be instantiated. This class implements functionality that’s common to all account types.SpecificAccount
subclasses: These classes all inherit fromAccount
. They inherit the behaviors that are implemented inAccount
, but they also must follow the constraints placed on them byBaseAccount
.
Part of the power of object-oriented programming lies in the ability to write simple classes to model simple situations, while retaining the ability to model complex relationships when you need to. Abstract base classes give you tremendous power and flexibility in setting up rules that other classes must follow.
In the next post we’ll look at some real-world examples of abstract base classes. If you’ve been writing Python code for a while, you’ve probably been inheriting from abstract classes without fully realizing it.
Resources
You can find the code files from this post in the mostly_python GitHub repository.
Managing user data is simple in concept, but somewhat complicated when it comes to implementation. Ignoring legal issues for a moment, you shouldn’t permanently delete a user’s data the moment a deletion request appears. Consider the following kinds of questions:
What if the deletion request is accidental?
What if the request is intentional, but the user has years of data associated with your project?
What if the user writes a short time later saying they’re regretful, and asks to have their data restored?
What if the account has been compromised, and the deletion request is malicious?
These are all very real situations for projects that have more than a handful of users. Throw in legal requirements for protecting user privacy, and handling account closure and deletion requests becomes much more complicated than many people realize.
A decorator is a special kind of function that runs before another function is called. For example, consider this pseudocode example:
@my_decorator
def my_function():
...
Whenever my_function()
is called, the function my_decorator()
will be called first, and then the code in my_function()
will be executed. A later post will cover decorators in more detail.
There has been a lot of work recently toward creating “friendlier” error messages in Python. Many of the error messages in tracebacks were written decades ago, and haven’t been revisited often. Clearer error messages are better for beginners, but they’re also quite helpful to experienced programmers as well.
I wonder if this error message could be rewritten, from:
TypeError: Can't instantiate abstract class PremiumAccount
with abstract method close_account
to something along the lines of:
TypeError: PremiumAccount must implement the close_account() method.
I don’t know if this version of the error message would be accurate for all situations where it could be raised.