CODE WITH MARTIN

13. Class Inheritance


Overview

There is one more thing we can do with classes, and we call this class inheritance. For this chapter, we'll include a useful example that shows why you might want to use this in your own projects. You'll even get to see how we can write to files and get the current system time.

Class inheritance allows us to use a previously defined class as a starting point for a new class, building on top of what the previously defined class has. It's best to dive straight in to some code to show what I mean.

Inheriting Classes

class Animal:
    def __init__(self):
        self.name = "Animal"

    def eat(self):
        print(self.name + " says \"Nom nom nom...\"")

class Dog(Animal):
    def speak(self):
        print(self.name + " says \"WOOF!\"")

pet = Dog()
pet.speak()
pet.eat()

Lines 1-6 is a class named 'Animal' and inside that we define a class variable named 'name' and a function named 'eat'. A very basic class.

Line 8, we're declaring another class named 'Dog', but after that name we've got the text '(Animal)'. This part of the line is what is telling Python that this class is to inherit from the class named 'Animal' when it gets created. We call the class we inherit from the base class, and the class that is inheriting the base class is called a derived class, as it's deriving behavior from the base class. Another way to put it is, the class Dog is derived from the base class Animal.

Lines 9-10 shows a function named 'speak' that is part of the 'Dog' class.

When Python creates an instance of 'Dog', it will allow all the functions and variables inside 'Animal' to be used inside the 'Dog' class.

To demonstrate that, lets take a look at lines 12-14. On line 12, the variable named 'pet' is being assigned to an instance of 'Dog'. If we look at the definition of 'Dog', it only has the function 'speak' function. But on line 14, we're calling a function named 'eat', which only exists in the Animal class. We're able to use this function because our Dog inherits from Animal.

Overriding Functions

You can override functions defined in the classes that we inherit if you use the same name as those functions. For example:

class Animal:
    def __init__(self):
        self.name = "Animal"

    def eat(self):
        print(self.name + " says \"Nom nom nom...\"")

class Dog(Animal):
    def speak(self):
        print(self.name + " says \"WOOF!\"")

    def eat(self):
        print(self.name + " says \"Woof woof, nom nom nom, woof!...\"")

pet = Dog()
pet.speak()
pet.eat()

In this example, we've defined the function 'eat' inside of the Dog class. So now there is a function named eat in both the Dog and Animal classes. Python chooses the function in the class you have instanced, which is Dog on line 15. Even though the base class Animal has an eat function, on line 17, the call to the function is executed inside Dog instead. Dog has overridden the base class function eat.

Challenge: Can you change the Dog class that so that it sets the name variable in the base class to be something different? Don't change the speak or eat functions. The output in the terminal should show the new name.

Accessing Base Classes

When inheriting classes and overriding functions, sometimes you still want to execute the base classes version of those functions. This is especially true when your derived classes have their own '__init__' functions.

To demonstrate a problem, run the following code (it should throw an error):

class Animal:
    def __init__(self):
        self._required_sleep_hours = 8

class Dog(Animal):
    def __init__(self):
        self._num_legs = 4
        
    def info(self):
        print("This dog has " + str(self._num_legs) + " legs.")
        print("This dog needs " + str(self._required_sleep_hours) + " hours of sleep.")

pet = Dog()
pet.info()

What we're expecting is that inside the function 'info' in the 'Dog' class, we should see two lines in the terminal telling us how many legs the dog has and how many hours of sleep it needs. But the error we get in the terminal tells us that the variable '_required_sleep_hours' does not exist.

The reason it doesn't exist is because this variable is created in the '__init__' function in the 'Animal' class on line 3. This function is never called in the base class because we have overridden it in the 'Dog' class on line 6.

To solve this problem, Python has a special function called 'super'. It allows us to call the base class version of a function. In the 'Dog' class, we need to let the '__init__' function run in the 'Animal' class as well as our overridden function in the 'Dog' class. Let's amend the code using the special 'super' function to solve this:

class Animal:
    def __init__(self):
        self._required_sleep_hours = 8

class Dog(Animal):
    def __init__(self):
        super().__init__()
        self._num_legs = 4
        
    def info(self):
        print("This dog has " + str(self._num_legs) + " legs.")
        print("This dog needs " + str(self._required_sleep_hours) + " hours of sleep.")

pet = Dog()
pet.info()

On line 7, you'll notice how we use the 'super' function to access the class we're inheriting from, which is the 'Animal' class. We can then call the '__init__' function manually. Doing so, means that the variable declaration on line 3 gets executed. Now that variable is created, using that variable on line 12 works. Run the code to see the program now running successfully.

When deriving classes and overriding the '__init__' function, it's best practice to call the base class version first.

To further demonstrate how this works with other functions, let's move the 'info' function in to the base class that reports things about the animal only:

class Animal:
    def __init__(self):
        self._required_sleep_hours = 8
        
    def info(self):
        print("This animal needs " + str(self._required_sleep_hours) + " hours of sleep.")

class Dog(Animal):
    def __init__(self):
        super().__init__()
        self._num_legs = 4
        
    def info(self):
        print("This dog has " + str(self._num_legs) + " legs.")
        
pet = Dog()
pet.info()

Running this, you'll notice in the terminal that we only see the information about the dogs number of legs. Can you guess what we need to do to make sure we also execute the 'Animal' classes version of the 'info' function when it's ran in the 'Dog' class?...

class Animal:
    def __init__(self):
        self._required_sleep_hours = 8
        
    def info(self):
        print("This animal needs " + str(self._required_sleep_hours) + " hours of sleep.")

class Dog(Animal):
    def __init__(self):
        super().__init__()
        self._num_legs = 4
        
    def info(self):
        super().info()
        print("This dog has " + str(self._num_legs) + " legs.")
        
pet = Dog()
pet.info()

Yep, we need to use 'super' as we now do on line 14. Executing the 'info' function in the 'Dog' class will now ensure the 'info' function in the inherited class 'Animal' is also ran.

Private Functions / Variables

Sometimes there are functions and variables we want to create inside a class that we don't want anyone to call or use when they have an instance of the class. There is a standard practice in the Python community to prefix your function or variable names with the underscore character '_' to indicate that they are private. For example:

class Animal:
    def __init__(self):
        self.name = "Animal"
        self._times_eaten = 0
        self._total_food_cost = 0

    def eat(self):
        print(self.name + " says \"Nom nom nom...\"")
        self._record_eaten()

    def _record_eaten(self):
        self._times_eaten += 1
        self._total_food_cost += 5

pet = Animal()
pet.eat()

In this example, we track how many times the animal has eaten. Every time the 'eat' function is called, it also calls a class function on line 9 called '_record_eaten'. This is a private function because the name has the underscore prefix '_'.

We also have two private variables '_times_eaten' and '_total_food_cost' that should only be used inside the class, as we can see, they are used on lines 12-13 which is inside the class. These variables shouldn't be used or modified outside of the class.

It would be bad if someone was to call the function '_record_eaten' themselves because that would increase the variables '_times_eaten' and '_total_food_cost' even though the 'eat' function was not called.

So if you see functions or variables in a class with an underscore prefix, don't use these when you have an instance of that class.

Hidden Functions

Like private functions and variables, there is a feature in Python that allows us to hide them away from derived classes. In the previous example, nothing stops a derived class from inheriting the Animal class and calling the '_record_eaten'. This is as bad as someone calling the function using an instance variable.

To prevent this, we can prefix functions or variables with two underscore characters '__'. Let's look at the previous example again with a derived class that tries to call a hidden function:

class Animal:
    def __init__(self):
        self.name = "Animal"
        self.__times_eaten = 0
        self.__total_food_cost = 0

    def eat(self):
        print(self.name + " says \"Nom nom nom...\"")
        self.__record_eaten()

    def __record_eaten(self):
        self.__times_eaten += 1
        self.__total_food_cost += 5

class Dog(Animal):
    def speak(self):
        self.__record_eaten()
        print("Woof.")

pet = Dog()
pet.speak()

You'll see how on lines 4-5, we use two underscores for the variable names that we want to hide. We also hide the function '__record_eaten' with two underscores on line 11.

If you run this code, it will produce an error. It errors because on line 17, the class 'Dog' that inherits the 'Animal' class tries to call the hidden function '__record_eaten'. Python successfully stopped the derived class from calling that function.

Using hidden functions and variables, we can better protect functions and variables from being misused in derived classes.

Checking Variable Types

When working with classes and inheritance, you might find yourself in situations where you need to know the type of a variable and whether or not a variable is inheriting a class type.

We can check if a variable is a specific class type using the following:

class Animal:
    def __init__(self):
        self.name = "Animal"

    def eat(self):
        print(self.name + " says \"Nom nom nom...\"")

class Dog(Animal):
    def speak(self):
        print(self.name + " says \"WOOF!\"")

    def eat(self):
        print(self.name + " says \"Woof woof, nom nom nom, woof!...\"")

pet = Dog()

result = type(pet) is Dog

print(result)

Line 17 shows how we're testing our variable named 'pet' to see if it's a 'Dog' class type. You should see in the terminal the text 'True' from the print statement on line 19.

There is also a function named 'issubclass' that's used for checking if a variable type is inheriting from a specific class. For example:

class Animal:
    def __init__(self):
        self.name = "Animal"

    def eat(self):
        print(self.name + " says \"Nom nom nom...\"")

class Dog(Animal):
    def speak(self):
        print(self.name + " says \"WOOF!\"")

    def eat(self):
        print(self.name + " says \"Woof woof, nom nom nom, woof!...\"")

pet = Dog()

result = issubclass(type(pet), Animal)

print(result)

We'll see an example of why this is helpful in the practical example below.

Practical Example

To demonstrate the usefulness of inheritance, we're going to create a program that sends some text to a log. The log is going to be either the terminal, or a file.

Note: This example exercise will not work with the online python tools. You will need to be using Visual Studio Code as described in the Setup chapter earlier in the course.

We're going to develop this in an object oriented way, meaning, we will make use of classes.

First of all, we're going to define a logger class that knows how to produce a nice log line using the current time. But this class doesn't know where to save that line, it's a bit of a dumb class.

from datetime import datetime

class Logger:
    def log(self, msg):
        now = datetime.now()
        time = now.strftime("%H:%M:%S")
        line = "({}): {}".format(time, msg)

        self._write_line(line)

    def _write_line(self, line):
        # Do nothing in this base class.
        pass

Lines 5 to 7 are producing a nicely formatted log string using the current time and the string that has been passed in the argument 'msg'. It then passes the final formatted string to a private function called '_write_line'.

You'll see on line 13 how we use a keyword called 'pass' inside the '_write_line' function. What this means in Python is - do nothing. It indicates that this function is completely empty. This class is not going to be responsible for knowing where to send this log message. We will use inheritance to define more classes that know how to achieve that behavior.

So next up, our terminal logger:

from datetime import datetime

class Logger:
    def log(self, msg):
        now = datetime.now()
        time = now.strftime("%H:%M:%S")
        line = "({}): {}".format(time, msg)

        self._write_line(line)

    def _write_line(self, line):
        # Do nothing in this base class.
        pass

# This TerminalLogger class knows how to write messages to the terminal.
class TerminalLogger(Logger):
    def _write_line(self, line):
        print(line)

On line 16, we've introduced the TerminalLogger class that inherits from the Logger class. This class also overrides the empty function _write_line that is in the base class Logger. We make use of the print function on line 18 inside the overridden _write_line function because this class will be responsible for writing the log lines to the terminal.

Now we need a class that knows how to write our logs to a file:

from datetime import datetime

class Logger:
    def log(self, msg):
        now = datetime.now()
        time = now.strftime("%H:%M:%S")
        line = "({}): {}".format(time, msg)

        self._write_line(line)

    def _write_line(self, line):
        # Do nothing in this base class.
        pass

# This TerminalLogger class knows how to write messages to the terminal.
class TerminalLogger(Logger):
    def _write_line(self, line):
        print(line)

# This FileLogger class knows how to write messages to a file named "log.txt".
class FileLogger(Logger):
    def _write_line(self, line):
        file = open("log.txt", "a+")
        file.write(line + "\n")
        file.close()

On line 21 is our newly introduced FileLogger class. Similar to the TerminalLogger, this class overrides the _write_line function.

Lines 23 shows how we open a file called "log.txt" using the Python function 'open'. We append a new line to the file on line 24, and then close the file on line 25.

So, we've now got these 3 classes and we're not sure why we've created them in the way we have yet. Let's start using them and see why inheritance really helps. We'll now add some code that pretends to do things, but makes use of the logging classes:

from datetime import datetime
import sys

class Logger:
    def log(self, msg):
        now = datetime.now()
        time = now.strftime("%H:%M:%S")
        line = "({}): {}".format(time, msg)

        self._write_line(line)

    def _write_line(self, line):
        # Do nothing in this base class.
        pass

# This TerminalLogger class knows how to write messages to the terminal.
class TerminalLogger(Logger):
    def _write_line(self, line):
        print(line)

# This FileLogger class knows how to write messages to a file named "log.txt".
class FileLogger(Logger):
    def _write_line(self, line):
        file = open("log.txt", "a+")
        file.write(line + "\n")
        file.close()

# Create a logger.
logger = TerminalLogger()

# Check if we're using a logger class.
if issubclass(type(logger), Logger) == False:
    print("The logger variable is not set to a class that inherits from Logger.")
    sys.exit()

# Pretend to do some cool things.
logger.log("Im starting a really big application now.")
logger.log("Im now doing some really cool things...")
logger.log("Ok, I've finished, everything worked fine.")

Line 2, we've added an import statement which lets us use the 'sys.exit()' on line 34. This function causes the Python program to end.

Line 29, we create a logger instance using the TerminalLogger class type and store that in the variable 'logger'.

Line 32, we're checking that the logger variable is using a class that inherits from the Logger class. If it wasn't, then the 'logger.log' function calls on lines 37-39 would fail, so instead we show an error message and exit the program on line 34. This is a safety check to reduce the possibility of errors.

Line 37 to 39 show how we're making use of that logger by calling the function 'log'. Now because our logger is a TerminalLogger, the behavior of that class will make sure our log lines arrive in the terminal. So go ahead and run this, and you'll see the log lines nicely formatted with the time in the terminal window.

Now, seeing all these log lines in the terminal is great when you're working on developing a product. But when you want to release this and run it on another computer, the terminal isn't as easily accessed, and the terminal goes away when the program ends. It would be much nicer to see a log file to see what the program is doing.

To do this with the way we have designed our code with classes, is to simply change a single line of code. Instead of using the TerminalLogger on line 29, let's change it to use FileLogger:

from datetime import datetime
import sys

class Logger:
    def log(self, msg):
        now = datetime.now()
        time = now.strftime("%H:%M:%S")
        line = "({}): {}".format(time, msg)

        self._write_line(line)

    def _write_line(self, line):
        # Do nothing in this base class.
        pass

# This TerminalLogger class knows how to write messages to the terminal.
class TerminalLogger(Logger):
    def _write_line(self, line):
        print(line)

# This FileLogger class knows how to write messages to a file named "log.txt".
class FileLogger(Logger):
    def _write_line(self, line):
        file = open("log.txt", "a+")
        file.write(line + "\n")
        file.close()

# Create a logger.
logger = FileLogger()

# Check if we're using a logger class.
if issubclass(type(logger), Logger) == False:
    print("The logger variable is not set to a class that inherits from Logger.")
    sys.exit()

# Pretend to do some cool things.
logger.log("Im starting a really big application now.")
logger.log("Im now doing some really cool things...")
logger.log("Ok, I've finished, everything worked fine.")

Run this, and you will see that a file named 'log.txt' is created in the folder you ran the Python code file in. Open it and you will see the logged lines of text, and there is the magic. We simply changed a single line of code and the logging lines on lines 37-39 are now magically logging to the file, instead of the terminal!

Imagine if we had hundreds of log lines throughout a project. How easy is it now that we can change the behavior so easily by just changing the one line where we create the logger - very powerful.

What is even more incredible is the way this program can be extended. If there was a new way of storing the log lines, for example, by email, then all you would need to do is introduce a class that inherits 'Logger' and overrides the '_write_line' function to send an email. Then you can change your logger variable to this new log class and magically your entire software now uses this new email logger.

What We Learned