Inheritance in Python, like in most other Object Oriented languages, allows us to re-use shared behavior, and allow us to abstract it away so that we don’t have to think about the implementation of certain behavior.
This is awesome! Developers spend a lot of time thinking about how to avoid repeating themselves. But as with everything in software, there are tradeoffs. Abstracting implementations away can mean that it’s not immediately obvious what behavior a class needs to define for itself, vs. what the parent class handles.
All of the examples in this post function when implemented properly, and will throw an error when they’re not. Which to follow in your code may be a matter of personal preference or following established patterns in a codebase, but let’s look at a few and talk through the benefits and downsides of each!
Let’s say, for example, we’re building a system that relies on user events, and we need to trigger certain types events at the relevant places in the application. We might build a base Event
class that has behavior on how to generate its data and send it, from which specific event types can inherit. The base class might look something like this:
class Event():
def generate_data(self):
data = {
'user': {
'first_name': self.user.first_name
}
}
return data
def send(self):
...
Now, if we’re building banking software, we may need to track specific types of events, such as a deposit. This doesn’t have any custom behavior yet, but the barest implementations might look something like this:
class DepositEvent(Event):
def __init__(self, deposit):
self.deposit = deposit
Now, you might have noticed that these won’t actually work as is. The base Event
class calls self.user
in its generate_data
method, but none of our classes implement that. However, the deposit object that our event is instantiated with has a reference to a user, so we can go ahead and add it:
class DepositEvent(Event):
def __init__(self, deposit):
self.deposit = deposit
self.user = deposit.user
This works swimmingly! The problem is, it’s a pretty easy thing to forget, and just looking at the base class doesn’t make it very obvious that we need to remember to do this. If we add a new event type, say a withdrawal, and we forget to add the self.user
attribute when we build it, any time we try to call generate_data on a WithdrawalEvent
, we’ll see: AttributeError: ‘WithdrawalEvent’ object has no attribute ‘user’
. Oops!
Ok, what if we require that events are instantiated with a user, and the base class is instantiated with self.user
? That would look something like this:
class Event():
def __init__(self, user):
self.user = user
def generate_data(self):
data = {
'user': {
'first_name': self.user.first_name
}
}
return data
class DepositEvent(Event):
def __init__(self, user, deposit):
super(DepositEvent, self).__init__(user)
self.deposit = deposit
class WithdrawalEvent(Event):
def __init__(self, user, withdrawal):
super(WithdrawalEvent, self).__init__(user)
self.withdrawal = withdrawal
This is better! We can look at the base Event class and see that it needs to have a user instance passed in, and most text editors and IDEs are smart enough to tell us when things are missing arguments, so if we create a new base class and forget to provide it as an argument on __init__
, it’ll let us know.
Things could still go wrong though. First, if we forget our call to super, we’ll end up with the same problem we had in our first example. It’s also kind of a bummer that we have to pass user in as a specific argument to each event type, even though usually another relevant instance the event needs anyway will have a reference to it.
Let’s look at one other way to accomplish this, using Python’s built-in NotImplementedError:
class Event():
def generate_data(self):
data = {
'user': {
'first_name': self.user.first_name
}
}
return data
@property
def user(self):
raise NotImplementedError
class DepositEvent(Event):
def __init__(self, deposit):
self.deposit = deposit
@property
def user(self):
return self.deposit.user
class WithdrawalEvent(Event):
def __init__(self, withdrawal):
self.withdrawal = withdrawal
@property
def user(self):
return self.withdrawal.user
That’s a lot more code! And we still get an error if we forget to implement the user property. (Tangentially, properties are usually used to give you access to getters and setters, but they can also be used in cases like this where we want to add some custom behavior to our properties). However, now if we forget, instead of seeing an AttributeError
, we will see a NotImplementedError
. Hopefully with either it would be fairly simple to track down and fix the issue, but my favorite thing about NotImplementedError is this:
It allows our code to serve as documentation. Even a quick glance at the base Event class makes it very clear to any developer who might want to implement a sub-class that they’ll need to add a user
property to it. This kind of self-documentation can make the developer experience that much better.