Python Inheritance

Inheritance is an OOP concept that enables creating new classes from existing ones, allowing code reuse and flexibility in design. In Python, inheritance allows a class (called the child or subclass) to inherit properties and methods from another class (called the parent or superclass). This allows specialized behavior while leveraging the functionality of existing classes.

1. Basics of Inheritance

In basic inheritance, the child class inherits all attributes and methods from the parent class. If no additional behavior is needed, we can use the parent’s functionality directly in the child class.

Basic Inheritance Syntax:
# Defining a parent class
class Animal:
    def __init__(self, species):
        self.species = species

    def make_sound(self):
        return "Some generic animal sound"

# Child class inheriting from Animal
class Dog(Animal):
    pass  # Inherits all properties and methods from Animal

# Creating an object of the child class
dog = Dog("Canine")
print(dog.species)       # Inherited attribute
print(dog.make_sound())   # Inherited method

Output:
Canine
Some generic animal sound

Explanation: The `Dog` class inherits all the properties and methods of `Animal`. Since `Dog` does not define its own `__init__` or `make_sound` methods, it uses the versions from `Animal`.

2. Overriding Methods in the Child Class

Overriding allows a child class to provide a specific implementation for a method that already exists in the parent class. This is useful for adding customized behavior.
class Animal:
    def __init__(self, species):
        self.species = species

    def make_sound(self):
        return "Some generic animal sound"

# Child class with overridden method
class Dog(Animal):
    def make_sound(self):  # Override parent method
        return "Woof!"

dog = Dog("Canine")
print(dog.make_sound())  # Calls overridden method

Output:
Woof!

Explanation: The `Dog` class overrides the `make_sound` method from `Animal`, allowing `Dog` to have its specific behavior when calling `make_sound`.

3. Using the `super()` Function

The `super()` function allows a child class to access methods or attributes of its parent class. This is particularly useful when overriding methods and you still need some behavior from the parent class.
class Animal:
    def __init__(self, species):
        self.species = species

    def make_sound(self):
        return "Some generic animal sound"

class Dog(Animal):
    def __init__(self, species, name):
        super().__init__(species)  # Call parent constructor
        self.name = name

    def make_sound(self):
        sound = super().make_sound()  # Call parent method
        return f"{sound}... But {self.name} barks 'Woof!'"

dog = Dog("Canine", "Buddy")
print(dog.make_sound())

Output:
Some generic animal sound... But Buddy barks 'Woof!'

Explanation: Here, `super()` is used to call the `__init__` method of `Animal` in `Dog`'s constructor. Additionally, `super().make_sound()` is called to get the generic sound from `Animal` and then customize it with `Dog`’s specific behavior.

4. Multiple Inheritance

Python supports multiple inheritance, where a class can inherit from more than one parent class. This can create complex relationships but can also introduce complexity and ambiguity, especially when both parents have methods with the same name.

Example of Multiple Inheritance:
class Flyer:
    def fly(self):
        return "I can fly!"

class Swimmer:
    def swim(self):
        return "I can swim!"

# Amphibian class inherits from both Flyer and Swimmer
class Duck(Flyer, Swimmer):
    pass

duck = Duck()
print(duck.fly())
print(duck.swim())

Output:
I can fly!
I can swim!

Explanation: `Duck` inherits from both `Flyer` and `Swimmer`, meaning it has access to both `fly` and `swim` methods. Multiple inheritance can be a powerful tool but requires careful design.

5. The Method Resolution Order (MRO)

In cases of multiple inheritance, Python determines the order in which methods are resolved using the Method Resolution Order (MRO). The MRO defines the sequence in which the parent classes are searched when looking for a method.

Example with MRO:
class A:
    def method(self):
        return "Method in A"

class B(A):
    def method(self):
        return "Method in B"

class C(A):
    def method(self):
        return "Method in C"

class D(B, C):
    pass

d = D()
print(d.method())  # Checks MRO to resolve which method to call
print(D.__mro__)   # Displays MRO

Output:
Method in B
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

Explanation: `D` inherits from both `B` and `C`. The MRO for `D` shows the order: `D -> B -> C -> A`. Hence, `d.method()` calls the method in `B` as it appears first in the MRO.

6. Inheritance with Property Overriding and Validation

Properties in Python allow us to add getter, setter, and deleter methods. By using inheritance, subclasses can override property methods to introduce specific validations.

class Product:
    def __init__(self, name, price):
        self._name = name
        self._price = price

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

# Subclass that overrides price validation
class DiscountedProduct(Product):
    @Product.price.setter
    def price(self, value):
        if value < 5:
            raise ValueError("Discounted price cannot be below $5")
        self._price = value

product = Product("Gadget", 50)
product.price = 30
print("Regular Product Price:", product.price)

discounted_product = DiscountedProduct("Gadget", 10)
discounted_product.price = 6
print("Discounted Product Price:", discounted_product.price)

Output:
Regular Product Price: 30
Discounted Product Price: 6

Explanation: `DiscountedProduct` inherits from `Product` but overrides the `price` setter to add a specific validation, ensuring the discounted price doesn’t fall below $5.

7. Advanced: Inheriting from Built-in Classes

In Python, we can also inherit from built-in classes like `list`, `dict`, etc. This is useful for extending built-in functionality.

Example of Extending a Built-in Class:
class CustomList(list):
    def sum(self):
        return sum(self)

custom_list = CustomList([1, 2, 3, 4])
print("Sum of elements:", custom_list.sum())

Output:
Sum of elements: 10

Explanation: `CustomList` extends the built-in `list` class by adding a `sum` method that returns the sum of its elements. This example shows how built-in functionality can be extended with custom behavior.

8. Composition vs. Inheritance

While inheritance is a powerful tool, there are cases where composition (using classes as attributes rather than parents) is more appropriate, especially when the "is-a" relationship doesn’t fit.

Example of Composition:
class Engine:
    def start(self):
        return "Engine started"

class Car:
    def __init__(self):
        self.engine = Engine()  # Composition

    def start(self):
        return self.engine.start()

car = Car()
print(car.start())

Output:
Engine started

Explanation: Here, `Car` contains an `Engine` instance instead of inheriting from it. This demonstrates composition, where `Car` can use `Engine` functionality without inheriting from it.

Summary

Inheritance in Python provides a robust way to create hierarchical relationships between classes, facilitating code reuse and logical design. Key concepts include method overriding, `super()`, multiple inheritance, MRO, and extending built-in classes.

Previous: Python Classes | Next: Python Encapsulation

<
>