Python and Inheritance – When objects are similar

Python - Inheritance

In this article we will see one of the main concepts of Object-Oriented programming: inheritance. Inheritance in Python is a fundamental concept of object-oriented programming, allowing the creation of class hierarchies. When objects share similar attributes and behavior, you can use inheritance to create a base class (superclass) from which more specific classes (subclasses) are derived. This approach promotes code reusability and allows subclasses to inherit and/or override superclass members. Through practical examples, we’ll explore how Python handles inheritance, overriding, and Method Resolution Order (MRO), providing a foundation for creating flexible, organized class structures.

Basic Inheritance

Inheritance is a powerful concept that allows you to structure your code in a hierarchical way, making it easy to create specialized classes that inherit the characteristics of more general classes. This approach promotes modularity and ease of maintenance of the code.

One of the keys to inheritance is the concept of the is-a relationship. For example, we can say that a Dog is a type of Animal, and the same goes for the Cat. This concept reflects the natural hierarchy found in the real world and makes class design more intuitive. Let’s see it with a simple example, in which we will define an Animal class and two Dog and Cat classes which will inherit some characteristics from the first, such as the emit_sound method.

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

    def make_sound(self):
        pass

class Dog(Animal):
    def make_sound(self):
        return "Woof!"

class Cat(Animal):
    def make_sound(self):
        return "Meow!"

# Using the classes
fido = Dog("Fido")
whiskers = Cat("Whiskers")

print(fido.name)            
print(fido.make_sound())   

print(whiskers.name)        
print(whiskers.make_sound())  

In this example, we have a base class called Animal that has an init constructor and an emit_sound method that is declared as abstract (with pass). The Dog and Cat derived classes inherit from the Animal class and provide a specific implementation of the emit_sound method.

Running the code we will get:

Fido
Woof!
Whiskers
Meow!

When we create an instance of Dog or Cat, we can access both specific attributes of the derived class (such as name) and methods inherited from the base class.

Inheritance in Python follows the syntax class Child(ParentClass), where Child is the class that inherits from ParentClass. The child class can extend or override the methods of the parent class, thus providing a specific implementation.

However, it is important to use inheritance carefully. Overuse or misuse of inheritance can lead to a complex and difficult-to-understand hierarchical structure. In some cases, composition (using objects from other classes without inheriting) may be a better choice.

Another important consideration is method overriding. When a child class inherits a method from a parent class, it can override that method to provide its own specific implementation. This allows greater flexibility in adapting the behavior of child classes to the specific needs of the program.

Class, Superclass and Subclass

Here are some additional definitions to recognize the role of classes in inheritance:

Class: A class in object-oriented programming is a template or prototype for creating objects. Classes define attributes (instance variables) and methods (functions) that represent the behavior of objects created by the class.

Superclass (or Parent Class): A superclass is a more general class from which other classes (subclasses) can inherit attributes and methods. The superclass represents broader, more general concepts.

Subclass (or Child Class): A subclass is a class that inherits attributes and methods from a superclass. The subclass can also extend or override the superclass’s methods to tailor the behavior to its specific needs.

Now, a more detailed example:

class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    def description(self):
        return f"{self.brand} {self.model}"

class Car(Vehicle):
    def __init__(self, brand, model, doors):
        # Call the superclass constructor using super()
        super().__init__(brand, model)
        self.doors = doors

    # Override the description method
    def description(self):
        return f"{super().description()}, {self.doors}-doors"

# Create an instance of Car
my_car = Car("Toyota", "Corolla", 4)

# Use the description method of the subclass
print(my_car.description())

In this example:

  • Vehicle is the superclass that has a constructor and a description method.
  • Car is the subclass that inherits from Vehicle and has an additional doors attribute. The subclass also overridden the description method to add car-specific information.

The relationship is clear: a Car is a type of Vehicle. Therefore, Car is the subclass and Vehicle is the superclass.

Executing we will obtain the following result:

Toyota Corolla, 4-doors

Multiple inheritance

In Python, it is also possible to inherit from multiple classes, which is known as multiple inheritance. This functionality offers many possibilities, but must be handled with care to avoid confusion and ambiguity in the code.

Here is an example showing multiple inheritance:

class Engine:
    def start(self):
        print("Engine started")

    def stop(self):
        print("Engine stopped")

class Electronics:
    def turn_on(self):
        print("Electronic system turned on")

    def turn_off(self):
        print("Electronic system turned off")

class Car(Engine, Electronics):
    def start_car(self):
        print("Car started")

# Create an instance of Car
my_car = Car()

# Call methods inherited from both parent classes
my_car.start()
my_car.stop()
my_car.start_car()

In this example, we have two parent classes, Motor and Electronics, each with its own on and off methods. The Car class inherits from both Motor and Electronics, thus taking advantage of multiple inheritance.

When we call the power on and off methods on the my_car object, Python uses the method of the first parent class specified in the class declaration. Therefore, my_car.turn-on() calls the turn-on method of the Engine class, while my_car.turn-off() calls the turn-off method of the Electronics class.

Executing we will obtain the following result:

Engine running
Engine off
Car started

Multiple inheritance can lead to situations where ambiguities exist, especially if the parent classes have methods with the same name. In these cases, it is important to handle multiple inheritance carefully to avoid confusion in your code.

Multiple inheritance with the Mixin technique

Mixin is a technique in object-oriented programming that involves the use of lightweight, specialized classes to add functionality to another main class. The main goal of mixins is to provide a flexible and modular way to extend the behavior of a class without having to use multiple inheritance heavily.

Mixin classes, in fact, are classes that provide specific functionality or additional behaviors. They are designed to be lightweight and independent, focusing on a single responsibility.

Unlike classical multiple inheritance, where a class can inherit from multiple parent classes, the mixin technique favors composition. The main classes incorporate the behavior of mixins through inheritance, but without creating a complex hierarchy.

Here’s an example of what using mixins in Python might look like:

# Mixin class
class RegistrableMixin:
    def register(self):
        print(f"Registration: {self}")

# Main class using the mixin
class User:
    def __init__(self, name):
        self.name = name

    def greet(self):
        print(f"Hello, I am {self.name}")

# Class inheriting from User and using the RegistrableMixin mixin
class RegisterableUser(User, RegistrableMixin):
    pass

# Create an instance of RegisterableUser
registerable_user = RegisterableUser("Alice")

# Use methods from the main class and the mixin
registerable_user.greet()   
registerable_user.register()  

In this example, RegistrabileMixin is a mixin that provides the register method. The RegisterableUser class inherits from both User and RegistrableMixin, thus obtaining both the greeting behavior and the registration capability.

Running the code you will get the following result:

Hello, I am Alice
Registration: <__main__.UserRegistrable object at 0x0000021B52504090>

Using mixins offers a flexible way to add functionality to existing classes without having to create complex inheritance hierarchies. This makes the code more modular and easier to maintain.

The Diamond Problem in multiple inheritance

The “diamond problem” is a situation that can occur in programming languages that support multiple inheritance, such as Python. This problem is also sometimes called “ambiguous inheritance” or “death diamond”. Occurs when a class inherits from two classes that have the same common parent class.

To better understand the problem, consider the following scenario:

class A:
    def method(self):
        print("Method of class A")

class B(A):
    def method(self):
        print("Method of class B")

class C(A):
    def method(self):
        print("Method of class C")

class D(B, C):
    pass

# Create an instance of class D
instance_d = D()

# Call the method of class D
instance_d.method()

In this case, class D inherits from both B and C, and both of these classes inherit from A. If we call the method method on the instance of D, the diamond problem occurs. In Python, attribute resolution occurs following Method Resolution Order (MRO), which determines the order in which base classes are examined when searching for an attribute.

The output of this example will be:

Class B method

This is the default resolution of the diamond problem in Python. The resolution order is determined by the sequence in which the classes are listed in the parentheses in the class D declaration (class D(B, C)). In this case, B comes before C, so B’s method takes precedence.

To handle the diamond problem, Python uses a mechanism called C3 Linearization (or C3 algorithm) to define the solving order of methods. This algorithm ensures a consistent and predictable order for attribute resolution during multiple inheritance.

If you need to manually influence the solve order, you can use the mro method or the mro() function. For example:

print(D.__mro__)

What you get:

(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

This shows the order in which classes are considered when resolving attributes.

Extend built-in classes

A really interesting aspect of inheritance is adding functionality to built-in classes. You can extend built-in classes in Python using inheritance to create custom subclasses. This allows you to add specific functionality or change the behavior of the built-in classes to suit your needs.

Here’s an example where we extend the class

class CustomStack(list):
    def push(self, element):
        self.append(element)

    def pop(self):
        if not self:
            raise IndexError("The stack is empty")
        return super().pop()

# Create an instance of our custom class
my_stack = CustomStack()

# Use methods from the list class and those added by our class
my_stack.push(1)
my_stack.push(2)
my_stack.push(3)

print("Stack:", my_stack)  
removed_element = my_stack.pop()
print("Removed element:", removed_element)  

print("Updated stack:", my_stack)  

In this example, CustomStack is a subclass of list that adds two methods, push and pop, for adding and removing items from the stack. The CustomStack class inherits all the methods and attributes of the list class and adds new ones.

Running the code you get the following result:

Stack: [1, 2, 3]
Item removed: 3
Stack updated: [1, 2]

You can extend other built-in classes in a similar way, such as dict, str, or any other built-in class in Python. Simply inherit from the desired class and override or add the necessary methods.

It’s important to note that when you extend built-in classes, you can take advantage of many of the built-in features and operators that are already defined for those classes. For example, our Custom Stack can be used with slicing operators and other list operations as if it were a common list.

Overriding and the super() function

Overriding is a key concept in inheritance, which allows a subclass to provide its own implementation of a method that is already defined in its superclass. In other words, a subclass can “override” the behavior of a method inherited from its superclass, providing a new implementation more specific to its needs.

Here is an example of overriding in Python:

class Vehicle:
     def description(self):
         return "This is a generic vehicle."

class Car(Vehicle):
     def description(self):
         return "This is a car."

# Let's create an instance of the Car class
my_car = Car()

# We call the overridden method
print(my_car.description()) # Output: This is a car.

In this example, the Car class inherits from the Vehicle class and overrides the description method. When we call my_car.description(), Python uses the version of the method defined in the Car class, ignoring the version of the Vehicle class.

Running the code you get:

This is a car.

Overriding is useful when you want to customize the behavior of an inherited method to fit the specific needs of the subclass. However, it is important to respect the method signature, i.e. the name and number of parameters, to ensure that overriding is correct and that subclasses can be used consistently with superclasses.

Another important point is the use of the super() function to call the superclass method inside a subclass. This allows you to extend the behavior of the superclass without having to completely rewrite it. For example:

class Vehicle:
     def description(self):
         return "This is a generic vehicle."

class Car(Vehicle):
     def description(self):
         # We call the superclass method using super()
         return super().description() + "But it's also a car."

# Let's create an instance of the Car class
my_car = Car()

# We call the overridden method with the superclass method call
print(my_car.description())

In this way, the Car subclass can extend the behavior of the Vehicle superclass without completely repeating its implementation. Executing you get the following result:

This is a generic vehicle. But it's also a car.

Leave a Reply