An Introduction to Object Oriented Programming (OOP) in Python: Part 2

Niels IJpelaar
7 min readDec 15, 2023

--

Co-authored by Marije van Haeringen and René Flohil

Introduction

Welcome back to the exciting world of Object-Oriented Programming (OOP) in Python! In the first part, we covered OOP fundamentals, including classes and encapsulation. If you have not read it, we recommend you start there! In this second part of our series, we’ll explore inheritance, polymorphism, and abstraction to expand your OOP knowledge and take your Python programming skills to the next level. So, fasten your seatbelts and get ready to delve into this!

Inheritance

Class inheritance enables you to create new classes based on existing ones. This inheritance system facilitates the construction of complex systems by allowing you to establish relationships between classes, resulting in a clear hierarchy of parent and child classes.

Inheritance starts with a superclass (also known as a parent class), which serves as a foundation for other classes. To create a new class with inheritance, you define a subclass (or child class) that inherits attributes and methods from the superclass. In the example below, our class ‘Dog’ is adjusted and is now created from the superclass ‘Animal’. Notice that in the constructor of the subclass, the method super() is used to call the constructor of the superclass so that its parent’s functionality can be used in the subclass without repeating code. Calling super() can be done optionally here, because it is done automatically when you don’t explicitly call it.

class Animal():
"""Class Animal"""

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

def eat(self) -> None:
print("I can eat")

def drink(self) -> None:
print("I can drink")
from animal import Animal

class Dog(Animal):

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

def __str__(self) -> None:
return f"{self.name} is a dog and is {self.age} years old"

def bark(self) -> None:
print("Woof!")

Inheritance provides a systematic approach to adding new features to existing classes. Instead of modifying the original class directly, you can create a new subclass that extends the functionality of the superclass. By letting our Dog class inherit from the superclass Animal, it now has two extra methods: eat and drink. So, without changing the subclass, we introduced new functionality!

The subclass can override methods to create a more specific implementation. The example below shows how to override methods in a subclass. In the method ‘eat()’ we only print a new sentence, but in the ‘drink()’ method we first called ‘super().drink()’. In this case, we override the parent method while still using the original functionality. This way, overriding can be used to replace or extend the superclass’ functionality.

from animal import Animal

class Dog(Animal):

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

def __str__(self) -> None:
return f"{self.name} is a dog and is {self.age} years old"

def bark(self) -> None:
print("Woof!")

# Override eat() method
def eat(self) -> None:
print("I like to eat bones")

# Override (extend) drink() method
def drink(self) -> None:
super().drink()
print("I like to drink water")
# Script demonstrating overriding

from animal import Animal
from dog import Dog

dog_max = Dog('Max', 7)
dog_max.bark()
print(dog_max)

print(f"Max is an animal: {isinstance(dog_max, Animal)}")

dog_max.eat() # Override superclass functionality
dog_max.drink() # Extend superclass functionality
Output of the script demonstrating overriding.

You avoid duplicating code across multiple classes by centralizing common features and behaviors in a superclass. Subclasses can focus on implementing unique functionalities without the need to recreate shared functionalities, resulting in more maintainable and efficient code. Furthermore, inheritance enables polymorphism, allowing different classes to be treated as instances of a common parent class. Finally, inheritance models an “is-a” relationship between classes. This helps developers intuitively understand the structure of the codebase, as it mirrors the relationships between real-world concepts.

Polymorphism

Polymorphism allows different objects to be treated as instances of a common superclass or interface. It allows you to write code that doesn’t need to know the exact class of the objects it’s working with, but only their shared behaviors or attributes. Objects of different classes can be treated as interchangeable entities as long as they adhere to a common interface.

Interfaces, which specify a set of methods that must be implemented by classes that adopt them, play a vital role in achieving polymorphism. Subclasses that inherit from a common superclass or implement the same interface can be used interchangeably, even if they have different implementations for the same methods. This allows for the creation of generic code that can work with multiple implementations.

We adjusted our code a little bit and added a method ‘make_sound()’ to the Animal class. In our Dog class we replaced the method ‘bark()’ with the new method ‘make_sound()’ so it overrides the parent class functionality. Next to that, we extended our project with a new class called ‘Cat’. Now both classes (Dog and Cat) implement a common class Animal, as can be seen in the example below.

class Animal():
"""Class Animal"""

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

def make_sound(self) -> None:
print("I can make a sound")

# Other code omitted for brevity...
from animal import Animal

class Dog(Animal):

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

def __str__(self) -> None:
return f"{self.name} is a dog and is {self.age} years old"

# Override
def make_sound(self) -> None:
print("Woof!")

# Other code omitted for brevity...
from animal import Animal

class Cat(Animal):

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

def __str__(self) -> None:
return f"{self.name} is a cat and is {self.age} years old"

Despite their distinct behaviors, they all share certain characteristics like eating, drinking, and making sounds. Through polymorphism, you could write a function that takes an Animal object and invokes its ‘make_sound()‘ method without knowing the exact class of the object or the exact implementation of the method. This encourages flexible code that can be reused with new animal classes in the future.

# Function demonstrating polymorphism
def animal_sound(animal: Animal) -> None:
animal.make_sound()

# Create animals
dog = Dog('Max', 7)
cat = Cat('Whiskers', 4)

animal_sound(dog) # Outputs: Woof!
animal_sound(cat) # Outputs: I can make a sound

Abstraction

Abstraction allows us to create a simplified representation of real-world objects and their behaviors by hiding the complex implementation details while exposing a clean and understandable interface. In Python, abstraction is achieved by abstract classes and abstract methods.

An abstract class is a class that cannot be instantiated directly. Instead, it serves as a blueprint for other classes that inherit from it. In Python, you create an abstract class by importing the abc module and using the ABC (Abstract Base Class) metaclass. We adjusted our Animal class a bit, so it became an abstract class:

from abc import ABC, abstractmethod

# We inherit from the Abstract Base Class
class Animal(ABC):
"""Class Animal"""

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

def eat(self) -> None:
print("I can eat")

def drink(self) -> None:
print("I can drink")

@abstractmethod
def make_sound(self) -> None:
pass

We also adjusted the ‘make_sound()’ method to become an abstract method. An abstract method is a method declared within an abstract class that lacks an implementation. Abstract methods are meant to be overridden by subclasses to provide specific functionality. So, any class that inherits from Animal must provide a concrete implementation for this abstract method. In other words; we say that an animal can make a sound, but we do not know what sound that might be. It is up to the subclass (also known as concrete class) to define the actual sound.

Here, we’ve adjusted our Cat class so it provides a concrete implementation of the make sound method specific to a cat.

from animal import Animal

class Cat(Animal):

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

def __str__(self) -> None:
return f"{self.name} is a cat and is {self.age} years old"

# We are forced to implement this method since we inherit from Animal
def make_sound(self) -> None:
print("Meow!")

The beauty of abstraction lies in its ability to define a common interface (in this case, make sound) that multiple classes can adhere to, ensuring a consistent and predictable structure. This simplifies code maintenance, promotes code reuse, and makes it easier to understand and work with complex systems. By using abstract classes and methods in Python, you can enforce a contract that subclasses must follow, making your code more reliable and maintainable in the long run. Abstraction is a powerful tool for creating organized and extensible object-oriented programs.

Take-aways

Object-Oriented programming provides a set of principles and features that allow you to write efficient and maintainable code. It allows you to write shorter code, that is more manageable, and easier to expand on as codebases grow.

Inheritance allows for the creation of new classes based on existing ones making it easier to create complex systems. By using subclasses, you can avoid code duplication. Tools: superclasses, subclasses, overriding methods.

Polymorphism allows interaction of objects through a common interface. Objects of different classes can be treated as interchangeable entities, promoting flexible and reusable code. Tools: interfaces, accessing methods through superclass.

Abstraction allows for the creation of class blueprints. This provides a consistent and predictable structure for subclasses, promoting code reuse and maintenance. Tools: abstract class (ABC), class templates.

OOP in Python: The Neverending Journey?

As we wrap up this second part of our journey into Object-Oriented Programming (OOP) in Python, we hope you’ve gained a deeper understanding of the concepts. Remember that OOP is not just a set of abstract principles; it’s a practical way to design and structure your code to make it more modular, maintainable, and scalable. Your journey doesn’t end here; it’s just the beginning. Keep practicing and exploring, and may your OOP adventures in Python be fruitful. Happy coding!

--

--