py4u guide

Deep Dive into Python's Inheritance and Polymorphism

Object-Oriented Programming (OOP) is a paradigm centered around "objects"—entities that bundle data (attributes) and behavior (methods). Two of its core pillars, **inheritance** and **polymorphism**, enable code reuse, flexibility, and scalability. In Python, these concepts are not just theoretical; they are practical tools that simplify complex systems, reduce redundancy, and make code easier to maintain. In this blog, we’ll explore inheritance and polymorphism in Python in depth. We’ll start with inheritance, understanding how classes can inherit and extend functionality from other classes. Then, we’ll dive into polymorphism, which allows objects of different types to be treated uniformly. By the end, you’ll have a solid grasp of how to leverage these concepts to write clean, efficient, and maintainable Python code.

Table of Contents

  1. Inheritance in Python

  2. Polymorphism in Python

  3. Real-World Applications and Best Practices

  4. Conclusion

  5. References

Inheritance in Python

What is Inheritance?

Inheritance is a mechanism where a new class (called a child or subclass) inherits attributes and methods from an existing class (called a parent or superclass). This promotes code reuse: instead of rewriting code, the child class can extend or modify the parent’s behavior.

Key benefits:

  • Reduces code duplication.
  • Establishes a hierarchical relationship between classes.
  • Enables extensibility (e.g., adding new features to child classes without altering the parent).

Basic Syntax

In Python, inheritance is defined by passing the parent class(es) as arguments to the child class definition:

class ParentClass:
    # Parent attributes and methods

class ChildClass(ParentClass):  # Child inherits from ParentClass
    # Child-specific attributes and methods

Types of Inheritance

Python supports several types of inheritance, each suited to different scenarios.

Single Inheritance

A child class inherits from one parent class. This is the simplest and most common form.

Example:
Let’s define a Vehicle parent class with basic attributes (e.g., color) and methods (e.g., start_engine). A Car child class will inherit from Vehicle and add car-specific features (e.g., num_doors).

class Vehicle:
    def __init__(self, color):
        self.color = color  # Attribute inherited by child classes

    def start_engine(self):
        print(f"{self.color} vehicle engine started.")

# Child class inheriting from Vehicle
class Car(Vehicle):
    def __init__(self, color, num_doors):
        super().__init__(color)  # Initialize parent class
        self.num_doors = num_doors  # Child-specific attribute

    def honk(self):  # Child-specific method
        print("Beep beep!")

# Usage
my_car = Car("red", 4)
my_car.start_engine()  # Inherited method: "red vehicle engine started."
my_car.honk()          # Child method: "Beep beep!"
print(my_car.num_doors)  # Child attribute: 4

Multiple Inheritance and Method Resolution Order (MRO)

A child class inherits from two or more parent classes. Python uniquely supports multiple inheritance, but it introduces complexity: what if parent classes have methods with the same name?

To resolve conflicts, Python uses the Method Resolution Order (MRO)—a predefined order in which parent classes are checked for methods. You can view the MRO with ClassName.mro() or help(ClassName).

Example:

class Engine:
    def start(self):
        print("Engine starting...")

class Radio:
    def start(self):
        print("Radio turning on...")

# Child class inherits from both Engine and Radio
class Car(Engine, Radio):
    pass

my_car = Car()
my_car.start()  # Which parent's start() is called?
print(Car.mro())  # Output: [Car, Engine, Radio, object]

Why Engine first? MRO follows the “C3 linearization” algorithm, which prioritizes the order of parent classes in the child’s definition (Engine is listed before Radio). Thus, Car uses Engine.start().

Multilevel Inheritance

A child class inherits from a parent class, which in turn inherits from another class (forming a chain).

Example:

class Vehicle:  # Grandparent
    def move(self):
        print("Moving...")

class Car(Vehicle):  # Parent (inherits from Vehicle)
    def wheels(self):
        print("4 wheels")

class SportsCar(Car):  # Child (inherits from Car)
    def speed(self):
        print("Max speed: 300 km/h")

# Usage
ferrari = SportsCar()
ferrari.move()   # Inherited from Vehicle: "Moving..."
ferrari.wheels() # Inherited from Car: "4 wheels"
ferrari.speed()  # Child method: "Max speed: 300 km/h"

Hierarchical Inheritance

One parent class is inherited by multiple child classes.

Example:

class Animal:
    def breathe(self):
        print("Breathing...")

class Dog(Animal):
    def bark(self):
        print("Woof!")

class Cat(Animal):
    def meow(self):
        print("Meow!")

# Usage
buddy = Dog()
whiskers = Cat()

buddy.breathe()  # Inherited: "Breathing..."
buddy.bark()     # Dog-specific: "Woof!"

whiskers.breathe()  # Inherited: "Breathing..."
whiskers.meow()     # Cat-specific: "Meow!"

Hybrid Inheritance

A combination of two or more types of inheritance (e.g., multilevel + multiple).

Example:

class A:
    def method_a(self):
        print("Method A")

class B(A):  # Multilevel: B inherits from A
    def method_b(self):
        print("Method B")

class C(A):  # Hierarchical: C also inherits from A
    def method_c(self):
        print("Method C")

class D(B, C):  # Multiple: D inherits from B and C
    pass

# MRO for D: [D, B, C, A, object]
d = D()
d.method_a()  # Inherited from A via B/C

The super() Function

The super() function allows a child class to call methods from its parent class. It’s especially useful in __init__ to initialize parent attributes before adding child-specific ones.

Example:

class Parent:
    def __init__(self, name):
        self.name = name
        print(f"Parent initialized with name: {self.name}")

class Child(Parent):
    def __init__(self, name, age):
        super().__init__(name)  # Call Parent's __init__
        self.age = age
        print(f"Child initialized with age: {self.age}")

# Usage
child = Child("Alice", 10)
# Output:
# Parent initialized with name: Alice
# Child initialized with age: 10

In multiple inheritance, super() follows the MRO to determine which parent method to call.

Method Overriding

Method overriding occurs when a child class redefines a method inherited from the parent. This allows the child to provide custom behavior while retaining the method name.

Example:

class Bird:
    def fly(self):
        print("Bird flies.")

class Penguin(Bird):
    def fly(self):  # Override parent's fly()
        print("Penguin cannot fly.")

# Usage
tweety = Bird()
tweety.fly()  # "Bird flies."

penguin = Penguin()
penguin.fly()  # "Penguin cannot fly." (overridden)

Access Modifiers and Inheritance

Python uses naming conventions to denote access levels for attributes/methods:

  • Public: No leading underscores (e.g., name). Inherited freely.
  • Protected: Single leading underscore (e.g., _age). Intended for use within the class and its subclasses (not enforced, but a convention).
  • Private: Double leading underscores (e.g., __ssn). “Name-mangled” to _ClassName__ssn, making them hard to access from outside or subclasses.

Example:

class Parent:
    def __init__(self):
        self.public = "Public"
        self._protected = "Protected"
        self.__private = "Private"

class Child(Parent):
    def access(self):
        print(self.public)       # OK (public)
        print(self._protected)   # OK (protected, by convention)
        # print(self.__private)  # Error: AttributeError (private)

child = Child()
child.access()

Polymorphism in Python

What is Polymorphism?

Polymorphism (from Greek: “many forms”) allows objects of different types to be treated as objects of a common type. For example, a function can work with multiple object types as long as they share a common method.

Types of Polymorphism

Runtime Polymorphism (Method Overriding)

We already saw this with method overriding! When a child class overrides a parent method, calling the method on a child object executes the child’s version. This is “runtime” because the method to call is determined at runtime based on the object’s type.

Example with a common interface:

class Shape:
    def area(self):
        pass  # To be overridden by subclasses

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):  # Override area()
        return 3.14 * self.radius **2

class Square(Shape):
    def __init__(self, side):
        self.side = side
    def area(self):  # Override area()
        return self.side** 2

# Polymorphic function: works with any Shape subclass
def print_area(shape):
    print(f"Area: {shape.area()}")

# Usage
circle = Circle(5)
square = Square(4)

print_area(circle)  # "Area: 78.5" (calls Circle.area())
print_area(square)  # "Area: 16" (calls Square.area())

Compile-Time Polymorphism (Method Overloading)

Method overloading refers to defining multiple methods with the same name but different parameters (e.g., different number or types of arguments). Unlike languages like Java, Python does not natively support method overloading (due to dynamic typing). However, we can simulate it using:

  • Default arguments
  • Variable-length arguments (*args/**kwargs)

Example with default arguments:

class Math:
    def add(self, a, b, c=None):
        if c is None:
            return a + b
        else:
            return a + b + c

math = Math()
print(math.add(2, 3))      # 5 (uses 2 args)
print(math.add(2, 3, 4))   # 9 (uses 3 args)

Example with *args:

class Math:
    def sum(self, *args):
        return sum(args)

math = Math()
print(math.sum(1, 2))       # 3
print(math.sum(1, 2, 3, 4)) # 10

Duck Typing

Python follows the “duck typing” principle: “If it walks like a duck and quacks like a duck, it’s a duck.” This means an object’s suitability is determined by its methods/attributes, not its type.

Example:

class Duck:
    def quack(self):
        print("Quack!")

class RubberDuck:
    def quack(self):
        print("Squeak!")

class Dog:
    def bark(self):  # No quack() method
        print("Woof!")

# Polymorphic function: works with any object that has quack()
def make_quack(duck):
    duck.quack()

# Usage
make_quack(Duck())        # "Quack!" (Duck has quack())
make_quack(RubberDuck())  # "Squeak!" (RubberDuck has quack())
# make_quack(Dog())       # Error: AttributeError (Dog has no quack())

Abstract Base Classes (ABCs) and Interfaces

To enforce that subclasses implement specific methods (e.g., defining a common interface), use Python’s abc module to create abstract base classes (ABCs). An ABC contains one or more abstract methods (decorated with @abstractmethod), which subclasses must implement.

Example:

from abc import ABC, abstractmethod

class Shape(ABC):  # Abstract base class
    @abstractmethod
    def area(self):  # Abstract method (no implementation)
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):  # Must implement area()
        return 3.14 * self.radius **2

class Square(Shape):
    def __init__(self, side):
        self.side = side
    # def area(self):  # Uncomment to see error (missing implementation)
    #     return self.side** 2

# Usage
circle = Circle(5)
print(circle.area())  # 78.5

# square = Square(4)  # Error: Can't instantiate abstract class Square with abstract method area

Real-World Applications and Best Practices

Inheritance Best Practices

  • Favor composition over inheritance when possible. Inheritance creates tight coupling; composition (e.g., including objects of other classes as attributes) is more flexible.
  • Avoid deep inheritance hierarchies (e.g., great-grandparent → grandparent → parent → child). They become hard to maintain.
  • Use ABCs to define clear interfaces for subclasses.

Polymorphism Best Practices

  • Use polymorphism to write generic code (e.g., a function that works with all Shape subclasses).
  • Leverage duck typing for flexibility, but document expected methods/attributes.
  • Avoid overloading when a single method with default arguments suffices.

Conclusion

Inheritance and polymorphism are foundational to Python OOP, enabling code reuse, flexibility, and scalability. Inheritance lets you build hierarchical class relationships, while polymorphism lets you treat objects of different types uniformly. By mastering these concepts, you’ll write cleaner, more maintainable code that adapts to changing requirements.

References