Inheritance

Written by Alex Guyer | guyera@oregonstate.edu

This lecture covers the following contents:

Inheritance

Attributes establish a sort of "has-a" relationship. If you'd like dogs to "have" ages in your program, then the Dog class should have an age attribute. Methods, in contrast, establish a sort of "can" relationship. If you'd like dogs to be able to bark in your program, then the Dog class should have a bark() method. But there's one more sort of relationship that we haven't discussed: "is-a" relationships.

An "is-a" relationship is a kind of relationship that exists between two classes. This is in contrast to "has-a" relationships, which exist between objects and their attributes, and "can" relationships, which exist between objects and their methods. Some examples of "is-a" relationships include:

"Is-a" relationships are incredibly descriptive. For example, suppose you've never heard of a husky before. If you asked me what a husky is, and I told you "a husky is a kind of dog", you'd immediately know a lot more about huskies than you did just a few seconds earlier. You'd immediately know that they have four legs, a snout, and some fur; that they give live birth; that they're kept as pets by some people; that there's probably at least a hundred of them in the world named "Fluffy" or "Bella"; and so on. Indeed, I can convey all that information to you with a single sentence: "a husky is a kind of dog". This works by drawing from your existing contextual knowledge about dogs. When I tell you that a husky is a kind of dog, you can immediately apply that contextual knowledge to huskies as well.

"Is-a" relationships are incredibly descriptive, and programming is all about description, so it'd be nice if we could express "is-a" relationships in our code. Suppose you have a Dog class with an age attribute and a bark() method. Then, suppose you want to introduce an Husky class into your program. Huskies are dogs, so huskies should also have ages and be able to bark. But do you really want to have to copy and paste the same attribute declarations and method definitions from the Dog class into the Husky class? Maybe that's not such a big deal, but what if you wanted to create thirty more classes, each corresponding to a respective species of dog? If all of them need some of the same methods and attributes, do you really want to copy and paste those methods and attributes thirty plus times? Besides, copying and pasting complex declarations and definitions introduces unnecessary coupling in your program, hence the DRY principle (e.g., if you ever wanted to change the way that dogs bark in your program, you'd now have to change it in over thirty places instead of just one).

Inheritance is an object-oriented language feature of many programming languages, and it's used to express "is-a" relationships, avoiding the need for all that copying and pasting. A class A can inherit from another class B. When this happens, all the attributes and methods declared and defined in B are inherited into A, meaning that they become automatically declared and defined in A as well. This avoids needing to manually redefine those inherited attributes and methods.

The class from which attributes and methods are being inherited is referred to as the superclass, or base class. The class that's inheriting those methods and attributes is referred to as the subclass, or derived class. The inheritance relationship is specified when defining the subclass (derived class). The syntax is as follows:

class <DerivedClassName>(<BaseClassName>):
    <class definition>

That is, define the two classes as normal, except when defining the derived class, put the name of the base class in parentheses immediately after the derived class's name (but before the colon).

For example, suppose we have a Dog class defined in dog.py:

dog.py
class Dog:
    _name: str
    _age: int
    
    def __init__(self, name: str, age: int) -> None:
        self._name = name
        self._age = age

    def bark(self) -> None:
        print(f"Bark! Bark! My name is {self._name}, and I'm "
            f"{self._age} years old")

Now suppose we want to create a Husky class that also has a _name attribute, an _age attribute, and a bark() method. We could do that like so:

husky.py
from dog import Dog # Necessary for the inheritance

# Huskies ARE a kind of dog. We establish the is-a relationship via
# inheritance
class Husky(Dog):
    # TODO define the rest of the Husky class here

    # For now, I'll leave this class empty. Python requires you to
    # use the "pass" keyword to leave a body of code empty
    pass

Because the Husky class inherits from the Dog class, it automatically has a _name attribute, an _age attribute, and a bark() method. These things don't need to be manually copied and pasted into the Husky class. In fact, even the constructor (__init__()) is inherited into the Husky classeverything is inherited. As it stands, the Husky class is basically identical to the Dog class.

Here's some proof:

main.py
from husky import Husky

def main() -> None:
    # The Husky class inherits the  __init__() method from the Dog
    # class. It accepts a name and age as arguments, storing them in
    # the _name and _age attributes, respectively
    cool_husky = Husky('Fluffy', 4)

    # The Husky class inherits the bark() method from the Dog class,
    # which prints its _name and _age attributes
    cool_husky.bark()

if __name__ == '__main__':
    main()

Running the above program produces the following output:

(env) $ python main.py 
Bark! Bark! My name is Fluffy, and I'm 4 years old

Extension

As I said, the Husky class is currently basically identical to the Dog class. That's not very useful. Indeed, inheritance by itself does not accomplish much.

However, inheritance can be combined with a couple of other techniques to make it very powerful. One simple technique is known as extension. Extension simply means to add new attribute declarations and / or method definitions to the derived class. We refer to this as extension because it essentially "extends" the base class by creating a new (derived) class with all of the same attributes and methods, but also more.

For example, huskies are sled dogs, so they can bark, but they can also pull a sled. And as they pull the sled, they lose energy. Perhaps we want to express this in our code. First, let's create a very simple Sled POD type:

sled.py
class Sled:
    distance_pulled: int
    def __init__(self) -> None:
        self.distance_pulled = 0

Now let's update our Husky class accordingly:

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    # Huskies have the same attributes and methods as the Dog class,
    # except they ALSO have the following attributes and methods:
    _energy: int

    def pull_sled(self, sled: Sled) -> None:
        # Remember: parameters are references to their arguments, so
        # modifying the parameter's distance_pulled attribute will
        # also modify the argument's distance_pulled attribute
        sled.distance_pulled += 10
        self._energy -= 10

There's one issue, though: The _energy attribute is declared, but it's never defined (i.e., assigned a value). As you know, the appropriate place to define attributes is within the class's constructor. But in this case, the Husky class inherits its constructor from the Dog class, and the Dog class's constructor doesn't define the _energy attribute.

A naive solution would be to define the _energy attribute in the Dog class's constructor, similar to the _name and _age attributes. But that wouldn't make any sense. It would imply that all dogs have an _energy attribute. That's not what we wantwe want all dogs to have _name and _age, but we want the _energy attribute to be specific to the Husky class (because huskies are sled pullers, so it's useful to track their energy).

A much better solution would be to define a new constructor that's specific to the Husky class. It could then define the _energy attribute. Since it'll be specific to the Husky class, it will only execute when Husky objects are creatednot when arbitrary Dog objects are created. Hence, huskies will have an _energy attribute, but other dogs will not. And that's the objective here.

What should this Husky constructor look like? Well, huskies have three attributes: _name, _age, and _energy. All three of those attributes need to be defined for every Husky object that's ever created, so the Husky constructor should somehow define these three things when executed. It'll have three parameters, thenone for each of these three attributes:

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    _energy: int

    def __init__(self, name: str, age: int, energy: int) -> None:
        # TODO 

    def pull_sled(self, sled: Sled) -> None:
        # Remember: parameters are references to their arguments, so
        # modifying the parameter's distance_pulled attribute will
        # also modify the argument's distance_pulled attribute
        sled.distance_pulled += 10
        self._energy -= 10

Before we write the constructor's body, remember the intention of private attributes: they should not be accessed from anywhere other than within a method of the class in which they're declared. Although it's true that the Husky class "has a" _name attribute and an _age attribute, that's not where they're declared. They're declared in the Dog class. The Husky class simply inherits them. So, although Python technically allows the Husky class's constructor to access and define these attributes, it would be inappropriate for it to do so (it'd introduce unnecessary coupling and open up opportunities to accidentally break certain class invariants).

So the Husky class's constructor needs to somehow define the _name and _age attributes, assigning them the name and age parameters (respectively), but it's not allowed to do that directly since those attributes are private to the Dog class. In order to define these attributes, then, the Husky class's constructor must somehow call a method of the Dog class, passing in the name and age as arguments, so that that method can define the attributes themselves (if it's a method of the Dog class, then it's allowed to access _name and _age directly).

As a crude example, suppose the Dog class had a method named define_attributes that accepts a name and age, storing those parameters into the respective attributes:

# ... (Inside the dog class definition)
def define_attributes(self, name: str, age: int) -> None:
    self._name = name
    self._age = age

The Husky constructor could then call that method, passing in its own name and age parameters, via self.define_attributes(name, age). Again, this would work because the Husky class inherits everything from the Dog class, so if the Dog class provided a define_attributes method, then Husky objects would also have that method. And if Husky objects have such a method, then there's no reason that you couldn't call it on the self object within the Husky class's constructor.

However, that would be a rather crude solution because, as it turns out, the Dog class already has a method that, when given a name and age, stores those values within the _name and _age attributes, defining them in the process. Indeed, I'm referring to the Dog class's constructor. So if we can get the Husky class's constructor to call the Dog class's constructor, that'll solve our problem (i.e., the Husky constructor can't define the _name and _age parameters directly, but perhaps it can call the Dog constructor, which can in turn define those attributes).

But how do we call the Dog constructor from within the Husky constructor? Well, you might guess that you could do something like self.__init__(name, age), and, perhaps surprisingly, that would actually be almost correct. However, there's an issue: Husky objects actually have two methods that are both named __init__(): the one inherited from the base class (i.e., the Dog constructor), and the one defined directly in the derived class (i.e., the Husky constructor itself). If, in the Husky constructor, we simply wrote self.__init__(name, age), the Python interpreter would think that we're trying to tell the Husky constructor to call itself. But what we actually want is for the Husky constructor to call the Dog constructor. The fact that they're both named __init__() creates some ambiguity.

(By the way, the term method overriding describes the case wherein a base class and derived class each define a method with the same name. We'll discuss this further shortly.)

Luckily, there's a solution. Python provides a special built-in function named super(). It accepts no arguments and returns a sort of base-class "version" of the calling object. You can then proceed to call methods on that object, and it will specifically call the base-class methods, even if the derived class has methods with the same name (i.e., overrides). That is, you can call a base class constructor from within a derived class constructor like so:

super().__init__(<argument list>)

Let's tell the Husky constructor to call the Dog constructor, passing in the name and age parameters as arguments, accordingly:

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    _energy: int

    def __init__(self, name: str, age: int, energy: int) -> None:
        # This method, being a method of the Husky class, is not
        # supposed to access the _name and _age attributes since they
        # were declared in the Dog class. But, somehow, we need
        # 'self._name' to be assigned the value of the 'name' parameter
        # and 'self._age' to be assigned the value of 'age'. To
        # accomplish this, we pass 'name' and 'age' up to the base
        # class (Dog) constructor, which in turn stores those values in
        # the appropriate attributes:
        super().__init__(name, age)

    def pull_sled(self, sled: Sled) -> None:
        # Remember: parameters are references to their arguments, so
        # modifying the parameter's distance_pulled attribute will
        # also modify the argument's distance_pulled attribute
        sled.distance_pulled += 10
        self._energy -= 10

Indeed, this is an extremely common pattern. In fact, any time you create a derived class and implement a constructor for it, the very first thing in that constructor's body should basically always be a call to the base class's constructor, passing in the appropriate arguments.

Our Husky constructor is still missing one step, though: it needs to define the _energy attribute, assigning it the value of the energy parameter. Because the _energy attribute is private to the Husky class, the Husky class's constructor is allowed to access and define it directly:

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    _energy: int

    def __init__(self, name: str, age: int, energy: int) -> None:
        # This method, being a method of the Husky class, is not
        # supposed to access the _name and _age attributes since they
        # were declared in the Dog class. But, somehow, we need
        # 'self._name' to be assigned the value of the 'name' parameter
        # and 'self._age' to be assigned the value of 'age'. To
        # accomplish this, we pass 'name' and 'age' up to the base
        # class (Dog) constructor, which in turn stores those values in
        # the appropriate attributes:
        super().__init__(name, age)
        self._energy = energy

    def pull_sled(self, sled: Sled) -> None:
        # Remember: parameters are references to their arguments, so
        # modifying the parameter's distance_pulled attribute will
        # also modify the argument's distance_pulled attribute
        sled.distance_pulled += 10
        self._energy -= 10

Finally, we need to update our program itself. When we create a Husky, we now need to pass three arguments into its constructor instead of two (because now we'll be calling the Husky constructor instead of the inherited Dog constructor). We'll test out the pull_sled() method as well while we're at it:

main.py
from husky import Husky
from sled import Sled

def main() -> None:
    # The Husky class now has its own constructor, so this line
    # of code will call that constructor as opposed to the inherited
    # Dog() constructor (however, the Husky constructor, in turn,
    # calls the Dog constructor via the super().__init__(...) line).
    # The Husky constructor requires three arguments: the name, the
    # age, and the energy.
    cool_husky = Husky('Fluffy', 4, 100)

    # The Husky class inherits the bark() method from the Dog class,
    # which prints its _name and _age attributes
    cool_husky.bark()

    # Create a sled for the husky to pull
    fast_sled = Sled()

    # Tell the husky to pull the sled
    cool_husky.pull_sled(fast_sled)

    # See how far the husky pulled the sled (prints 10)
    print(f'Distance sled pulled: {fast_sled.distance_pulled}')

    # (The Husky's energy should have also reduced from 100 to 90,
    # but that's not shown here. We'll revisit this shortly)

if __name__ == '__main__':
    main()

Running the above program produces the following output:

(env) $ python main.py 
Bark! Bark! My name is Fluffy, and I'm 4 years old
Distance sled pulled: 10

Overriding

In Python, it is not permissible to define two methods with the same name in the same class (some languages support this through so-called method overloadswhich are different from method overridesbut Python does not support such things). However, it is permissible to define a method in a base class, create a derived class that inherits from that base class (and therefore inherits the aforementioned method), and then define another method in that derived class with the same name as the method in the base class. This derived-class method is referred to as a method override.

The implication of a method override is that the derived class has two (or more) methods with the same name: the one that it inherits from its base class, and the one that it defines directly (there may be more than two in the case of deep inheritance hierarchies, such as classes inheriting from classes that inherit from other classes, or multiple inheritance, which is beyond the scope of this course).

I mentioned this offhandedly earlier in my example about derived-class constructors. As you know, a constructor must be named __init__(). Constructors are methods, and, like all methods, they are inherited into derived classes. Hence, if a class A inherits from another class B, and B defines a constructor, then A inherits that constructor as well. But if A also defines a constructor, then both of those constructors will be present in class A. Indeed, class A will have two methods with the same name: __init__.

The reason that the derived-class method is referred to as the "override" is that it overrides the method with the same name inherited from the base class. What I mean by that is this: if a class has two methods with the same name due to an inherited base-class method being overridden by a derived-class method, then when you attempt to call the method by its name, the Python interpreter will assume that you're referring to the derived-class methodnot the base-class method. In essence, the derived-class method takes precedence over the base-class method; it "overrides" the base-class method.

We've seen this already. When we constructed a Husky object earlier via cool_husky = Husky('Fluffy', 4, 100), that line of code called the Husky class constructornot the Dog class constructor (though the Husky constructor calls the Dog constructor in turn, but that's besides the point). This is an important detail because the Husky class actually has two constructors (the Husky constructor and the Dog constructor), but the interpreter automatically calls the derived-class one rather than the base-class one.

To demonstrate another example of method overrides, I'm going to rewrite our Dog and Husky classes. Here's the new version of the Dog class:

dog.py
class Dog:
    _name: str
    _age: int
    
    def __init__(self, name: str, age: int) -> None:
        self._name = name
        self._age = age

    def vocalize(self) -> None:
        print('Bark! Bark!')

    def print(self) -> None:
        print(f'Name: {self._name}')
        print(f'Age: {self._age}')

I've basically separated the bark() method into two methods: vocalize() and print(). The vocalize() method prints 'Bark! Bark!' to the terminal, and the print() method prints the dog's name and age to the terminal.

However, as a husky owner would tell you, huskies don't just barkthey howl. So, suppose that when I create a Husky object and tell it to vocalize(), rather than printing 'Bark! Bark!' to the terminal, I'd like it to print 'Awooo!' to the terminal. I can accomplish this by overriding the vocalize() method in the Husky class:

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    _energy: int

    def __init__(self, name: str, age: int, energy: int) -> None:
        super().__init__(name, age)
        self._energy = energy

    def pull_sled(self, sled: Sled) -> None:
        sled.distance_pulled += 10
        self._energy -= 10

    # Override the vocalize() method. When a husky vocalizes, it says
    # 'Awooo!' instead of 'Bark! Bark!'
    def vocalize(self) -> None:
        print('Awooo!')

Let's make use of our new methods. Here's an updated main() function:

main.py
from husky import Husky
from sled import Sled

def main() -> None:
    # Calls the Husky constructor, which calls the Dog constructor.
    # The Dog constructor defines the name (_name) and age (_age). The
    # Husky constructor defines the energy value (_energy).
    cool_husky = Husky('Fluffy', 4, 100)

    # Calls the Husky vocalize() method, NOT the Dog vocalize()
    # method (the interpreter automatically calls the derived-class
    # override instead of the original, overridden base-class method)
    cool_husky.vocalize()

    fast_sled = Sled()
    cool_husky.pull_sled(fast_sled)
    print(f'Distance sled pulled: {fast_sled.distance_pulled}')

    # Calls the Dog print() method, as inherited by the Husky
    # class, printing cool_husky's name and age to the terminal.
    cool_husky.print()

if __name__ == '__main__':
    main()

Again, because derived-class methods override base-class methods, cool_husky.vocalize() calls the Husky class's vocalize() methodnot the Dog class's vocalize() method. Running the above program produces the following output:

(env) $ python main.py 
Awooo!
Distance sled pulled: 10
Name: Fluffy
Age: 4

There's at least one thing that we could improve about the above program: when we call cool_husky.print(), it'd be nice if it printed all of the husky's information to the terminal, but it currently only prints the husky's name and age. Indeed, the husky's remaining energy value (i.e., the _energy attribute) is omitted from the printout.

Of course, the _energy attribute is defined in the Husky class, not the Dog class. And because of the direction of inheritance, this means that dogs, in general, do not have an energy value. Huskies do, but arbitrary dogs do not. It wouldn't make any sense, then, try to print the _energy attribute from within the Dog class's print() method. Dogs simply do not have an _energy attribute.

Again, this is the perfect use case for an override. If we override the print() method in the Husky class, we can have it print all of the Husky's information to the terminal, including its _energy attribute. But we'll run into the exact same problem that we did when defining the Husky class's constructor: because _name and _age are declared as private attributes in the Dog class, methods of the Husky class should not access them directly. This means that the Husky class's print() method should not directly access the husky's name and age. But, somehow, it needs to print the husky's name and age to the terminal.

The solution is the same as that of the constructor problem: if we can tell the Husky class's print() override to call the Dog class's print() method on the calling object (self), that would in turn print the calling object's name and age to the terminal. Again, you might think that you can do this via self.print(), and again, that would be almost correct, except it would tell the Husky class's print() method to call itself rather than to call the inherited base-class method with the same name. And, again, this ambiguity can be resolved via the super() function.

In the constructor case, we wrote super().__init__(name, age) to tell the Husky constructor to call the Dog constructor. In this case, we'll write super().print() to tell the Husky class's print() method to call the Dog class's print() method:

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    _energy: int

    def __init__(self, name: str, age: int, energy: int) -> None:
        super().__init__(name, age)
        self._energy = energy

    def pull_sled(self, sled: Sled) -> None:
        sled.distance_pulled += 10
        self._energy -= 10

    # Override the vocalize() method. When a husky vocalizes, it says
    # 'Awooo!' instead of 'Bark! Bark!'
    def vocalize(self) -> None:
        print('Awooo!')

    # Override the print() method. When a husky is printed to the
    # terminal, its energy needs to be printed in addition to its
    # name and age
    def print(self) -> None:
        # This method is defined in the Husky class, so it's not
        # supposed to access the inherited _name and _age attributes
        # (they're private to the Dog class). But we need to print
        # them. To that end, we call the Dog class's print() method
        # on the calling object (self), which it indeed has due
        # to inheritance. But that method has the same name as this
        # one, so to avoid this method calling itself, we need to use
        # the super() function to specify that we want to call the
        # base class's print() method.
        super().print()

        # Print the energy value to the terminal
        print(f'Remaining energy: {self._energy}')

Now, when we run our program, cool_husky.print() calls the Husky class's print() method rather than the Dog class's print() method. The Husky class's print() method in turn calls the Dog class's print() method via super().print() (which prints the husky's name and age to the terminal) before directly printing the husky's energy to the terminal. Here's the new program output:

(env) $ python main.py 
Awooo!
Distance sled pulled: 10
Name: Fluffy
Age: 4
Remaining energy: 90

In both my examples of the super() function, I've used it to call a base-class method from within the derived-class method that overrides said base-class method. However, it's actually more flexible than thatit can be used within any derived-class method to call any base-class method that has been overridden in the derived class. For example, if I wanted to (for some reason), I could write super().vocalize() within the body of the Husky class's pull_sled() method:

# ... In the Husky class definition
def pull_sled(sled: Sled) -> None:
    # ... (existing code omitted for brevity)
    super().vocalize() # Calls the Dog class's vocalize() method

That would make the text 'Bark! Bark!' appear in the terminal whenever a Husky object's pull_sled() method is called. In contrast, suppose I wrote self.vocalize() instead:

# ... In the Husky class definition
def pull_sled(sled: Sled) -> None:
    # ... (existing code omitted for brevity)
    self.vocalize() # Calls the Husky class's vocalize() method

That would make the text 'Awooo!' appear in the terminal whenever a Husky object's pull_sled() method is called.

It's also possible to use the super() function outside of a class method (e.g., from within the main() function). This allows you to call overridden base-class methods on arbitrary objects. But that's beyond the scope of this course.

(For the curious reader, an example of the correct syntax would be to write super(Husky, cool_husky).vocalize() in our main() function, which would call the Dog class's vocalize() method instead of the Husky class's vocalize() method. Indeed, when used outside a class method, the super() function must be given two arguments, the first being the derived class itself, and the second being the object whose overridden base-class methods you'd like to call).

The power of overrides becomes more apparent in larger demonstrations with more classes. For example, we could have thirty different species-specific classes that all inherit from the Dog class, and many of them could override the vocalize() method, each in their own way. Perhaps a husky says "Awooo!", but a yorkshire terrier says "yip!". Also, suppose that many of those classes don't override the vocalize() method. That's perfectly okaythey'd still have the inherited vocalize() method from the Dog class, meaning the would simply print "Bark! Bark!" when their vocalize() method is called (indeed, the base-class method sort of serves as a "default" behavior if it's not overridden in a given derived class). I could write out such an example, but this lecture is already getting quite long, so I'll leave that as an exercise to the reader.

(And the true power of overrides is showcased by polymorphism. But that's a different lecture.)

Before we move on, here are some rules of thumb to keep in mind:

Grandchildren

A base class may also be a derived class. That's to say, it's possible to create inheritance hierarchies that are deeper than two layers, wherein a class inherits from another class that, in turn, inherits from another class (and so on). For various reasons, extremely deep inheritance hierarchies are often a sign of poor code design. But in some cases, it's perfectly fine (and useful) to have more than just two layers.

I've told you that base classes are also called superclasses, and derived classes are also called subclasses. But there's another pair of terms to describe these things: base classes can also be called parent classes, and derived classes can also be called child classes. This is because inheritance hierarchies are often diagrammed as trees; each class is a node, and derived classes are represented as child nodes of their respective base classes' nodes.

When three layers are present in an inheritance hierarchy, this sort of establishes a "grandparent / grandchild" relationship: the class at the top of the inheritance tree is the grandparent, and the classes at the bottom of the tree are the grandchildren.

Anything that's inherited into a child class is also inherited into the grandchild class(es). So if class A inherits from class B, which inherits from class C, then anything declared or defined within class C is inherited into B, but it's also inherited all the way down into A (because A inherits it from B, which inherits it from C).

For example, we could create a Pet class, from which the Dog class is derived (establishing the "is-a" relationship that dogs are pets in the context of our program):

pet.py
class Pet:
    _owners_name: str

    def __init__(self, owners_name: str) -> None:
        self._owners_name = owners_name

    def print(self) -> None:
        print(f"Owner's name: {self._owners_name}")

All pets have an owner, and the owner's name is stored in the Pet object's private _owners_name attribute.

Next, we'll update the Dog class to establish the inheritance relationship. Since dogs are pets, and pets have an _owners_name attribute, dogs will also have an _owners_name attribute. Whenever a Dog object is constructed, then, we need to be able to specify the name of its owner, and we need to store that name in the dog's _owners_name attribute. We can do this by updating our Dog constructor, giving it an owners_name parameter and having it pass that parameter as an argument up to the Pet class constructor. Finally, whenever a Dog object is printed to the terminal, we want its owner's name to be printed as well, which we can accomplish by updating the Dog class's print() method to have it call the Pet class's print() method. Here's the updated Dog class:

dog.py
from pet import Pet

class Dog(Pet):
    _name: str
    _age: int
    
    def __init__(self, owners_name: str, name: str, age: int) -> None:
        # Call the Pet class's constructor, passing the owner's name
        # up to it as an argument to be stored in the _owners_name
        # attribute
        super().__init__(owners_name)

        self._name = name
        self._age = age

    def vocalize(self) -> None:
        print('Bark! Bark!')

    def print(self) -> None:
        # Call the Pet class's print() method, printing the name
        # of the dog's owner
        super().print()

        print(f'Name: {self._name}')
        print(f'Age: {self._age}')

Now that the Dog class's constructor has an additional parameter for the owner's name, we need to update the Husky constructor accordingly. We'll give it a fourth parameter to specify the owner's name, and we'll have it pass that parameter as an argument up to the Dog class's constructor (along with the name and age):

husky.py
from dog import Dog
from sled import Sled

class Husky(Dog):
    _energy: int

    def __init__(
            self,
            owners_name: str,
            name: str,
            age: int,
            energy: int) -> None:
        super().__init__(owners_name, name, age)
        self._energy = energy

    def pull_sled(self, sled: Sled) -> None:
        sled.distance_pulled += 10
        self._energy -= 10

    # Override the vocalize() method. When a husky vocalizes, it says
    # 'Awooo!' instead of 'Bark! Bark!'
    def vocalize(self) -> None:
        print('Awooo!')

    # Override the print() method. When a husky is printed to the
    # terminal, its energy needs to be printed in addition to its
    # name and age
    def print(self) -> None:
        # This method is defined in the Husky class, so it's not
        # supposed to access the inherited _name and _age attributes
        # (they're private to the Dog class). But we need to print
        # them. To that end, we call the Dog class's print() method
        # on the calling object (self), which it indeed has due
        # to inheritance. But that method has the same name as this
        # one, so to avoid this method calling itself, we need to use
        # the super() function to specify that we want to call the
        # base class's print() method.
        super().print()

        # Print the energy value to the terminal
        print(f'Remaining energy: {self._energy}')

Finally, let's update our main() function so that, when we construct cool_husky, we pass in a fourth argument for the owner's name:

main.py
from husky import Husky
from sled import Sled

def main() -> None:
    # Calls the Husky constructor, which calls the Dog constructor,
    # which calls the Pet constructor. The Pet constructor defines
    # the husky's owner's name (_owners_name). The Dog constructor
    # defines the name (_name) and age (_age). The Husky constructor
    # defines the energy value (_energy).
    cool_husky = Husky('Steve', 'Fluffy', 4, 100)

    # Calls the Husky vocalize() method, NOT the Dog vocalize()
    # method (the interpreter automatically calls the derived-class
    # override instead of the original, overridden base-class method)
    cool_husky.vocalize()

    fast_sled = Sled()
    cool_husky.pull_sled(fast_sled)
    print(f'Distance sled pulled: {fast_sled.distance_pulled}')

    # Calls the Husky print() method, which calls the Dog print()
    # method, which calls the Pet print() method. The Pet print()
    # method prints the husky's owner's name. The Dog print() method
    # additionally prints the husky's name and age. The Husky
    # print() method prints the husky's energy value.
    cool_husky.print()

if __name__ == '__main__':
    main()

Running the above program produces the following output:

(env) $ python main.py 
Awooo!
Distance sled pulled: 10
Owner's name: Steve
Name: Fluffy
Age: 4
Remaining energy: 90

Let's make sure you understand what's going on.

If you were to construct a Pet object (e.g., via my_cool_pet = Pet('Sally')), then that object would have a single print() method and no vocalize() methods. If you called its print() method (e.g., my_cool_pet.print()), that would simply execute the Pet class's print() method. If you tried to call its vocalize() method (e.g., my_cool_pet.vocalize()), that would produce a runtime error (and Mypy errors) since Pet objects do not have a vocalize() method.

If you were to construct a Dog object (e.g., via my_cool_dog = Dog('Sally', 'Spot', 4)), then that object would have two print() methods (the one inherited from the Pet class, and the override defined in the Dog class) and one vocalize() method (the one defined in the Dog class). If you were to call its print() method (e.g., via my_cool_dog.print()), that would execute the Dog class's print() method, which would in turn execute the Pet class's print() method via super().print(). If you were to call its vocalize() method, that would simply execute the Dog class's vocalize() method, printing 'Bark! Bark!' to the terminal.

If you were to construct a Husky object (e.g., via cool_husky = Husky('Steve', 'Fluffy', 4, 100), as above), then that object would have three print() methods (the two inherited from the Dog class, and the override defined in the Husky class) and two vocalize() methods (the one inherited from the Dog class, and the override defined in the Husky class). If you were to call its print() method, that would execute the Husky class's print() method, which would in turn call the Dog class's print() method, which would in turn call the Pet class's print() method. If you were to call its vocalize() method, that would execute the Husky class's vocalize() method, printing 'Awooo!' to the terminal.

(Optional content) When is a square not a rectangle?

Although it's commonly said that inheritance establishes "is-a" relationships, that's a bit of an oversimplified analogy, , depending on your class designand there are cases where it falls apart. A classic counterexample is the "square-is-a-rectangle" conundrum.

In real life, a square is of course a rectangle (it's a quadrilateral consisting of four right angles, and that's the definition of a rectangle). However, depending on your class design, it might not be appropriate to represent this real-life is-a relationship with an inheritance relationship in your codebase.

Suppose the Rectangle class looks like this:

rectangle.py
class Rectangle:
    _length: float
    _width: float

    def __init__(self, length: float, width: float) -> None:
        self._length = length
        self._width = width

    # Getters and setters
    def get_length(self) -> float:
        return self._length

    def get_width(self) -> float:
        return self._width

    def set_length(self, length: float) -> None:
        self._length = length

    def set_width(self, width: float) -> None:
        self._width = width

Now suppose the derived Square class looks like this:

square.py
from rectangle import Rectangle

class Square(Rectangle):
    def __init__(self, side_length: float):
        # A square does not have a separate length or width, just
        # a single side_length. So that's what this constructor
        # receives as a parameter. We then pass it up as both the
        # length AND the width to the Rectangle constructor, storing
        # it in both the _length AND _width attributes
        super().__init__(side_length, side_length)

Finally, consider the following program:

main.py
from rectangle import Rectangle
from square import Square

def main() -> None:
    # Create a 4x3 rectangle
    r = Rectangle(4.0, 3.0)

    # Change its length to 5 (so that it's a 5x3 rectangle)
    r.set_length(5.0)

    # Print its length and width
    print(f'Length: {r.get_length()}') # Prints 5.0
    print(f'Width: {r.get_width()}') # Prints 3.0

    # Create a 4x4 square
    s = Square(4.0)

    # Fine, but suppose we call its set_length() function, changing
    # its length
    s.set_length(5.0)

    # Notice: it's no longer a square! Its length and width are are
    # different (length is 5, width is still 4)
    print(f'Length: {s.get_length()}') # Prints 5.0
    print(f'Width: {s.get_width()}') # Prints 4.0

if __name__ == '__main__':
    main()

Indeed, because squares "are" rectangles, and rectangles have set_length() and set_width() methods, squares also have set_length() and set_width() methods. That's hugely problematic; it allows us to independently modify a square's length and width. If we do thatif we modify a square's length but not its width (or vice-versa)then it's definitionally not a square anymore even though its type is still Square. If we then passed it to a function that expects its Square parameter to have equal side lengths, that function would be sorely mistaken, and bugs could ensue. This is also a fairly subtle mistake. Mypy is not going to give us any errors or warnings.

Put another way, the Square class should have a class invariant enforcing that its length and width are always the same, but it doesn't. And, in this case, so long as the Square class inherits from the Rectangle class, there's no way that we could ever create that class invariant because the setters inherited from the Rectangle class inherently violate it.

You could try to create the invariant by overriding the setters in the Square class. For example:

square.py
from rectangle import Rectangle

class Square(Rectangle):
    def __init__(self, side_length: float):
        # A square does not have a separate length or width, just
        # a single side_length. So that's what this constructor
        # receives as a parameter. We then pass it up as both the
        # length AND the width to the Rectangle constructor, storing
        # it in both the _length AND _width attributes
        super().__init__(side_length, side_length)

    # Setter overrides. When modifying a square's length or width, it
    # actually modifies BOTH, forcing them to remain the equal to
    # each other.
    def set_length(self, side_length: float) -> None:
        super().set_length(side_length)
        super().set_width(side_length)

    def set_width(self, side_length: float) -> None:
        super().set_length(side_length)
        super().set_width(side_length)

And, indeed, this does fix the problem in the previous example:

main.py
from rectangle import Rectangle
from square import Square

def main() -> None:
    # Create a 4x3 rectangle
    r = Rectangle(4.0, 3.0)

    # Change its length to 5 (so that it's a 5x3 rectangle)
    r.set_length(5.0)

    # Print its length and width
    print(f'Length: {r.get_length()}') # Prints 5.0
    print(f'Width: {r.get_width()}') # Prints 3.0

    # Create a 4x4 square
    s = Square(4.0)

    # This now calls the Square set_length() method, which in turn
    # calls the Rectangle class's set_length() AND set_width() methods,
    # setting the square's length AND width to 5. So it's still a
    # square!
    s.set_length(5.0)

    print(f'Length: {s.get_length()}') # Prints 5.0
    print(f'Width: {s.get_width()}') # Prints 5.0

if __name__ == '__main__':
    main()

However, as I mentioned in a note earlier, it's possible to use the super() function call overridden base-class methods on objects from anywhere; it doesn't have to be done from within a derived-class method. That's to say, even though s is a Square, and the Square class overrides the setters inherited from the Rectangle class, that doesn't change the fact that squares are still rectangles, so s still has those overridden setters. And if it has those setters, it's still technically possible to call them. It just requires using a slightly different syntax with the super() function:

main.py
from rectangle import Rectangle
from square import Square

def main() -> None:
    # Create a 4x3 rectangle
    r = Rectangle(4.0, 3.0)

    # Change its length to 5 (so that it's a 5x3 rectangle)
    r.set_length(5.0)

    # Print its length and width
    print(f'Length: {r.get_length()}') # Prints 5.0
    print(f'Width: {r.get_width()}') # Prints 3.0

    # Create a 4x4 square
    s = Square(4.0)

    # This now calls the Square set_length() method, which in turn
    # calls the Rectangle class's set_length() AND set_width() methods,
    # setting the square's length AND width to 5. So it's still a
    # square!
    s.set_length(5.0)

    print(f'Length: {s.get_length()}') # Prints 5.0
    print(f'Width: {s.get_width()}') # Prints 5.0

    # Uh oh! It's still possible to call the Rectangle class's setters
    # on s, which it has because the Square class inherits from the
    # Rectangle class (overriding an inherited method does not get rid
    # of it!). This sets s's length to 1.0, but its width is still 5.0.
    # So it's not a valid square anymore :(
    super(Square, s).set_length(1.0)

    print(f'Length: {s.get_length()}') # Prints 1.0
    print(f'Width: {s.get_width()}') # Prints 5.0

if __name__ == '__main__':
    main()

Again, so long as the Square class inherits from the Rectangle class, and the Rectangle class continues to provide setters for the length and width, there is no way around this problemit will always be possible to create a Square object whose length is not equal to its width. And, again, that's a huge problem. Many functions will expect squares to have equal lengths and widths, and if that assumption is violated, various problems could occur.

There are two solutions, then:

  1. Remove the inheritance relationship. That is, make the Square class not inherit from the Rectangle class. Whatever attributes and methods a Square needs to have can be declared / defined directly in the Square class.

  2. Remove the setters from the Rectangle class. To be clear, you would not have to remove the gettersjust the setters. Technically, it'd still be possible to modify the length and width of a Square or Rectangle object independently, but it'd require directly accessing _length or _width from somewhere other than within a method of the Rectangle class. And you're not supposed to do that anyways (if someone messes with private attributes and accidentally violates a class invariant, that's their fault).

In some cases, either option could work. But in other cases, you might really want to keep those setters around, which would make option b) non-viable.

(Though, if you went with option b and needed to modify the length or width of a Rectangle object, a relatively simple alternative would be to simply create a new Rectangle object. This is in line with how things are typically done in a functional paradigm. For example, if you need to modify the _length attribute of a Rectangle called r, changing its value to 12.0, you could simulate that effect via r = Rectangle(12.0, r.get_width()). Of course, if there are any other references to the object that r references, this would not update those objects. So this isn't exactly the same thing as r.set_length(12.0). But it's sufficient in many cases.)

That's just one example of how the "inheritance represents is-a relationships" mindset is a bit oversimplified and can get you into trouble. Yes, in real life, squares "are" rectangles, but that doesn't necessarily mean that it's appropriate to represent that idea using inheritance. Other problematic examples abound.

A much better mindset is provided by the Liskov Substitution Principle (LSP). It's the L in SOLID, meaning that it's one of the five major principles of object-oriented software design. It states that derived-class objects should always be substitutable for base-class objects, without breaking any intended class invariants or other program properties. That is, in any situation where a base-class object is expected, it should be sensible to provide a derived-class object instead. If you can think of a counterexample wherein providing a derived-class object in substitution for a base-class object does not make sense (e.g., where it would break an intended class invariant), then that inidicates an LSP violation. In such a case, either the inheritance should be removed altogether, or the base class's interface should be modified to resolve the counterexample(s).

For example, if a function has a Rectangle parameter r and calls r.set_length(5.0), then it would not make sense to pass in a Square object as a substitute since that would modify the square's length but not its width (violating the definition of a Square). This is a clear counterexample in that it indicates that Square objects are not always substitutable for Rectangle objects, which means that the Square class should not inherit from the Rectangle class (or the setters should be removed to resolve this counterexample and establish the substitutability).

The LSP basically suggests that we should treat inheritance as establishing "substitutable-for" relationships rather than "is-a" relationships. Although these are very similar concepts, the former is a bit more rigorous, and it helps avoid common design mistakes like the square-is-a-rectangle conundrum.