2.5 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:We put an underscore at the end of the attribute id_ in order to distinguish it from the built-in function id.

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: Python has a much more powerful mechanism for accessing the superclass without naming it directly. It involves the built-in super function, but this is beyond the scope of this course.

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. In this course, we also include type annotations from the parent class. For a technical reason, the current version of python_ta sometimes complains when these type annotations are missing. Here are the complete subclasses:

class SalariedEmployee(Employee):
    """
    === Attributes ===
    salary: This employee's annual salary

    === Representation invariants ===
    - 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 ===
    - hourly_wage >= 0
    - 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 classIn many other languages, instance attributes are inherited.. 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.