3.6 Inheritance: Attributes and Initializers#
Let’s return to the payroll code we wrote
and generalize it from hard-coded values to instance attributes.
This will allow us to customize individual employees with their own annual salaries or hourly wages.
Documenting attributes#
Just as the base class contains methods (even abstract ones!) that all subclasses need to have in common, the base class also documents attributes that all subclasses need to have in common. Both are a fundamental part of the public interface of a class.
We decided earlier that the application would need to record an id and a name for all employees. Here’s how we document that in the base class:[1]
class Employee:
"""An employee of a company.
Attributes:
id_: This employee's ID number.
name: This employee's name.
"""
id_: int
name: str
Defining an initializer in the abstract superclass#
Even though abstract classes should not be instantiated directly, we provide an initializer in the superclass to initialize the common attributes.
class Employee:
def __init__(self, id_: int, name: str) -> None:
"""Initialize this employee.
"""
self.id_ = id_
self.name = name
def get_monthly_payment(self) -> float:
"""Return the amount that this Employee should be paid in one month.
Round the amount to the nearest cent.
"""
raise NotImplementedError
def pay(self, date: str) -> None:
"""Pay this Employee on the given date and record the payment.
(Assume this is called once per month.)
"""
payment = self.get_monthly_payment()
print(f'An employee was paid {payment} on {date}.')
Inheriting the initializer in a subclass#
Because the initializer is a method,
it is automatically inherited by all Employee
subclasses
just as, for instance, pay
is.
>>> # Assuming SalariedEmployee does not override Employee.__init__,
>>> # that method is called when we construct a SalariedEmployee.
>>> fred = SalariedEmployee(99, 'Fred Flintstone')
>>> # We can see that Employee.__init__ was called,
>>> # and the two instance attributes have been initialized.
>>> fred.name
'Fred Flintstone'
>>> fred.id_
99
Just as with all other methods, for each subclass, we must decide whether the inherited implementation is suitable for our class, or whether we want to override it.
In this case, the inherited initializer is not suitable, because each subclass requires that additional instance attributes be initialized:
For each SalariedEmployee
we need to keep track of the employee’s salary,
and for each HourlyEmployee
we need to keep track of their number of work hours per week and their hourly wage.
Certainly we could override and replace the inherited initializer,
and in its body copy the code from Employee.__init__
:
class SalariedEmployee(Employee):
def __init__(self, id_: int, name: str, salary: float) -> None:
self.id_ = id_ # Copied from Employee.__init__
self.name = name # Copied from Employee.__init__
self.salary = salary # Specific to SalariedEmployee
class HourlyEmployee(Employee):
def __init__(self, id_: int, name: str, hourly_wage: float,
hours_per_month: float) -> None:
self.id_ = id_ # Copied from Employee.__init__
self.name = name # Copied from Employee.__init__
self.hourly_wage = hourly_wage # Specific to HourlyEmployee
self.hours_per_month = hours_per_month # Specific to HourlyEmployee
This is not a very satisfying solution because the first two lines of each initializer are duplicated—and for more complex abstract base classes, the problem would be even worse!
Since the inherited initializer does part of the work by initializing the attributes that all employees have in common, we can instead use Employee.__init__
as a helper method.
In other words, rather than override and replace this method,
we will override and extend it.
As we saw briefly last week, we use the superclass name to access
its method:[2]
class SalariedEmployee(Employee):
def __init__(self, id_: int, name: str, salary: float) -> None:
# Note that to call the superclass initializer, we need to use the
# full method name '__init__'. This is the only time you should write
# '__init__' explicitly.
Employee.__init__(self, id_, name)
self.salary = salary
In the subclasses, we also need to document each instance attribute and declare its type.[3] Here are the complete subclasses:
class SalariedEmployee(Employee):
"""
Attributes:
salary: This employee's annual salary
Representation Invariants:
- self.salary >= 0
"""
id_: int
name: str
salary: float
def __init__(self, id_: int, name: str, salary: float) -> None:
# Note that to call the superclass initializer, we need to use the
# full method name '__init__'. This is the only time you should write
# '__init__' explicitly.
Employee.__init__(self, id_, name)
self.salary = salary
def get_monthly_payment(self) -> float:
return round(self.salary / 12, 2)
class HourlyEmployee(Employee):
"""An employee whose pay is computed based on an hourly rate.
Attributes:
hourly_wage:
This employee's hourly rate of pay.
hours_per_month:
The number of hours this employee works each month.
Representation Invariants:
- self.hourly_wage >= 0
- self.hours_per_month >= 0
"""
id_: int
name: str
hourly_wage: float
hours_per_month: float
def __init__(self, id_: int, name: str, hourly_wage: float,
hours_per_month: float) -> None:
Employee.__init__(self, id_, name)
self.hourly_wage = hourly_wage
self.hours_per_month = hours_per_month
def get_monthly_payment(self) -> float:
return round(self.hours_per_month * self.hourly_wage, 2)
We can see that when we construct an instance of either subclass,
both the common instance attributes
(name
and id_
)
and the subclass-specific attributes are initialized:
>>> fred = SalariedEmployee(99, 'Fred Flintstone', 60000.0)
>>> fred.name
'Fred Flintstone'
>>> fred.salary
60000
>>> barney = HourlyEmployee(23, 'Barney Rubble', 1.25, 50.0)
>>> barney.name
'Barney Rubble'
>>> barney.hourly_wage
1.25
>>> barney.hours_per_month
50.0
We have now completed the second version of the code
.
Download it so that you can experiment with it as you continue reading.
Subclasses inherit methods, not attributes#
It may seem that our two subclasses have “inherited” the attributes documented in the Employee
class.[4]
But remember that a type annotation does not create a variable.
Consider this example:
>>> fred = SalariedEmployee(99, 'Fred Flintstone', 60000.0)
>>> fred.name
'Fred Flintstone'
The only reason that fred
has a name
attribute is because the SalariedEmployee
initializer explicitly calls the Employee
initializer, which initializes this attribute.
A superclass initializer is not called automatically when a subclass instance is created.
If we remove this call from our example, we see that the two attributes name
and id_
are missing:
class SalariedEmployee(Employee):
def __init__(self, id_: int, name: str, salary: float) -> None:
# Superclass call commented out:
# Employee.__init__(self, id_, name)
self.salary = salary
>>> fred = SalariedEmployee('Fred Flintstone')
>>> fred.name
AttributeError
Initializers with different signatures#
Notice that the signatures for Employee.__init__
and SalariedEmployee.__init__
are different.
SalariedEmployee.__init__
has an additional parameter for the salary.
This makes sense.
We should be able to configure each salaried employee with their own salary,
but it is irrelevant to other types of employee, who don’t have a salary.
Because abstract classes aren’t meant to be instantiated directly, their initializers are considered private, and so can be freely overridden and have their signatures changed in each subclass. This offers flexibility in specifying how subclasses are created, and in fact it is often the case that different subclasses of the same abstract class will have different initializer signatures. However, subclass initializers should always call the initializer of their superclass!
It turns out that Python allows us to change the signature of any method we override, not just __init__
.
However, as we’ll discuss in the next section, in this course we’ll use inheritance to define interfaces that your subclasses should implement.
Because a function signature is a crucial part of its interface, you should not do this for uses of inheritance in this course.