Python — Inheritance & Polymorphism
Inheritance is a core pillar of object-oriented programming that allows a class to derive behaviour from another class. Python supports single, multiple, and multilevel inheritance with a sophisticated method resolution order (MRO) powered by the C3 linearization algorithm.
This section covers everything from basic single inheritance through advanced topics such as the diamond problem, mixins, abstract base classes, and the composition-over-inheritance principle.
A child class inherits attributes and methods from a single parent class. The child can override or extend the parent's behaviour.
| 1 | class Animal: |
| 2 | def __init__(self, name: str): |
| 3 | self.name = name |
| 4 | |
| 5 | def speak(self) -> str: |
| 6 | return f"{self.name} makes a sound." |
| 7 | |
| 8 | class Dog(Animal): |
| 9 | def speak(self) -> str: |
| 10 | return f"{self.name} barks." |
| 11 | |
| 12 | class Cat(Animal): |
| 13 | def speak(self) -> str: |
| 14 | return f"{self.name} meows." |
| 15 | |
| 16 | rex = Dog("Rex") |
| 17 | whiskers = Cat("Whiskers") |
| 18 | |
| 19 | print(rex.speak()) # → Rex barks. |
| 20 | print(whiskers.speak()) # → Whiskers meows. |
| 21 | |
| 22 | # isinstance check |
| 23 | print(isinstance(rex, Dog)) # True |
| 24 | print(isinstance(rex, Animal)) # True |
info
The super() built-in returns a proxy object that delegates method calls to the next class in the MRO. It is essential for cooperative multiple inheritance and for calling the parent implementation from an overridden method.
| 1 | class Animal: |
| 2 | def __init__(self, name: str): |
| 3 | self.name = name |
| 4 | |
| 5 | class Dog(Animal): |
| 6 | def __init__(self, name: str, breed: str): |
| 7 | super().__init__(name) # delegate to Animal.__init__ |
| 8 | self.breed = breed |
| 9 | |
| 10 | def __repr__(self) -> str: |
| 11 | return f"Dog(name={self.name}, breed={self.breed})" |
| 12 | |
| 13 | rex = Dog("Rex", "Golden Retriever") |
| 14 | print(rex) # → Dog(name=Rex, breed=Golden Retriever) |
| 15 | |
| 16 | # What super() actually resolves to |
| 17 | print(Dog.__mro__) |
| 18 | # → (<class 'Dog'>, <class 'Animal'>, <class 'object'>) |
best practice
A child class can override any method from its parent by defining a method with the same name. The overridden method can optionally call the parent version via super().
| 1 | class Worker: |
| 2 | def work(self) -> str: |
| 3 | return "Working..." |
| 4 | |
| 5 | def break_time(self) -> str: |
| 6 | return "Taking a break." |
| 7 | |
| 8 | class Developer(Worker): |
| 9 | def work(self) -> str: |
| 10 | # extend parent behaviour |
| 11 | base = super().work() |
| 12 | return f"{base} Writing code." |
| 13 | |
| 14 | def debug(self) -> str: |
| 15 | return "Fixing bugs." |
| 16 | |
| 17 | dev = Developer() |
| 18 | print(dev.work()) # → Working... Writing code. |
| 19 | print(dev.break_time()) # → Taking a break. (inherited) |
| 20 | print(dev.debug()) # → Fixing bugs. (new method) |
| 21 | |
| 22 | # isinstance / issubclass |
| 23 | print(issubclass(Developer, Worker)) # True |
| 24 | print(issubclass(Worker, object)) # True |
Python does not have true method overloading (multiple methods with the same name but different signatures). The last definition wins. Use default arguments or *args / **kwargs instead.
A class can inherit from multiple parent classes. This gives great flexibility but introduces complexity when two parents define the same method.
| 1 | class Flyer: |
| 2 | def move(self) -> str: |
| 3 | return "Flying through the air." |
| 4 | |
| 5 | def fuel_type(self) -> str: |
| 6 | return "Aviation fuel." |
| 7 | |
| 8 | class Swimmer: |
| 9 | def move(self) -> str: |
| 10 | return "Swimming through water." |
| 11 | |
| 12 | def fuel_type(self) -> str: |
| 13 | return "Fish and plankton." |
| 14 | |
| 15 | class Duck(Flyer, Swimmer): |
| 16 | pass |
| 17 | |
| 18 | duck = Duck() |
| 19 | print(duck.move()) # → Flying through the air. |
| 20 | print(duck.fuel_type()) # → Aviation fuel. |
| 21 | |
| 22 | # The first parent in the list wins by default |
| 23 | print(Duck.__mro__) |
| 24 | # → Duck -> Flyer -> Swimmer -> object |
warning
The diamond problem arises when a class inherits from two classes that share a common ancestor. Python resolves this using the C3 linearization algorithm, which produces a consistent, monotonic MRO.
| 1 | class A: |
| 2 | def whoami(self) -> str: |
| 3 | return "A" |
| 4 | |
| 5 | class B(A): |
| 6 | def whoami(self) -> str: |
| 7 | return "B" |
| 8 | |
| 9 | class C(A): |
| 10 | def whoami(self) -> str: |
| 11 | return "C" |
| 12 | |
| 13 | class D(B, C): |
| 14 | pass |
| 15 | |
| 16 | d = D() |
| 17 | print(d.whoami()) # → B |
| 18 | |
| 19 | # MRO: D -> B -> C -> A -> object |
| 20 | print(D.__mro__) |
| 21 | |
| 22 | # The C3 algorithm guarantees: |
| 23 | # 1. Children come before parents |
| 24 | # 2. Order of bases in class declaration is preserved |
| 25 | # 3. Monotonicity — consistent across subclasses |
Access D.__mro__ or call help(D) to inspect the resolution order at runtime. The super() proxy follows this order, which makes cooperative multiple inheritance predictable.
| 1 | # Cooperative diamond with super() |
| 2 | class A: |
| 3 | def __init__(self): |
| 4 | print("A.__init__") |
| 5 | |
| 6 | class B(A): |
| 7 | def __init__(self): |
| 8 | print("B.__init__") |
| 9 | super().__init__() |
| 10 | |
| 11 | class C(A): |
| 12 | def __init__(self): |
| 13 | print("C.__init__") |
| 14 | super().__init__() |
| 15 | |
| 16 | class D(B, C): |
| 17 | def __init__(self): |
| 18 | print("D.__init__") |
| 19 | super().__init__() |
| 20 | |
| 21 | D() |
| 22 | # Output: |
| 23 | # D.__init__ |
| 24 | # B.__init__ |
| 25 | # C.__init__ |
| 26 | # A.__init__ |
info
A mixin is a class that provides reusable behaviour but is not meant to stand alone. Mixins follow naming conventions (often suffixed with Mixin) and work via multiple inheritance to compose functionality.
| 1 | class LogMixin: |
| 2 | """Add logging capability to any class.""" |
| 3 | def log(self, message: str) -> None: |
| 4 | print(f"[{self.__class__.__name__}] {message}") |
| 5 | |
| 6 | class SerializeMixin: |
| 7 | """Add JSON serialisation to any class.""" |
| 8 | def to_dict(self) -> dict: |
| 9 | return self.__dict__ |
| 10 | |
| 11 | def to_json(self) -> str: |
| 12 | import json |
| 13 | return json.dumps(self.to_dict()) |
| 14 | |
| 15 | class User(LogMixin, SerializeMixin): |
| 16 | def __init__(self, name: str, email: str): |
| 17 | self.name = name |
| 18 | self.email = email |
| 19 | self.log(f"User created: {name}") |
| 20 | |
| 21 | user = User("Alice", "alice@example.com") |
| 22 | print(user.to_json()) |
| 23 | # → {"name": "Alice", "email": "alice@example.com"} |
| 24 | |
| 25 | # Mixins should be small, focused, and stateless |
best practice
Abstract Base Classes define an interface that subclasses must implement. Python provides the abc module with ABC and abstractmethod to enforce contracts at instantiation time.
| 1 | from abc import ABC, abstractmethod |
| 2 | |
| 3 | class Shape(ABC): |
| 4 | """Abstract base class for all shapes.""" |
| 5 | |
| 6 | @abstractmethod |
| 7 | def area(self) -> float: |
| 8 | """Calculate area — must be implemented by subclass.""" |
| 9 | ... |
| 10 | |
| 11 | @abstractmethod |
| 12 | def perimeter(self) -> float: |
| 13 | ... |
| 14 | |
| 15 | def describe(self) -> str: |
| 16 | """Concrete method — available to all subclasses.""" |
| 17 | return f"{self.__class__.__name__} — area: {self.area():.2f}" |
| 18 | |
| 19 | class Circle(Shape): |
| 20 | def __init__(self, radius: float): |
| 21 | self.radius = radius |
| 22 | |
| 23 | def area(self) -> float: |
| 24 | return 3.14159 * self.radius ** 2 |
| 25 | |
| 26 | def perimeter(self) -> float: |
| 27 | return 2 * 3.14159 * self.radius |
| 28 | |
| 29 | c = Circle(5) |
| 30 | print(c.describe()) # → Circle — area: 78.54 |
| 31 | |
| 32 | # Shape() would raise TypeError: Can't instantiate abstract class |
Static methods, class methods, and properties can also be declared abstract:
| 1 | from abc import ABC, abstractmethod, abstractproperty |
| 2 | from typing import final |
| 3 | |
| 4 | class Document(ABC): |
| 5 | @abstractmethod |
| 6 | def render(self) -> str: |
| 7 | ... |
| 8 | |
| 9 | @abstractmethod |
| 10 | @classmethod |
| 11 | def from_file(cls, path: str) -> "Document": |
| 12 | """Factory — must be implemented by subclass.""" |
| 13 | ... |
| 14 | |
| 15 | @abstractmethod |
| 16 | @staticmethod |
| 17 | def supported_extensions() -> set[str]: |
| 18 | ... |
| 19 | |
| 20 | @property |
| 21 | @abstractmethod |
| 22 | def file_size(self) -> int: |
| 23 | """Subclasses must provide this property.""" |
| 24 | ... |
| 25 | |
| 26 | class PDFDocument(Document): |
| 27 | def __init__(self, path: str, size: int): |
| 28 | self.path = path |
| 29 | self._size = size |
| 30 | |
| 31 | def render(self) -> str: |
| 32 | return f"Rendering PDF: {self.path}" |
| 33 | |
| 34 | @classmethod |
| 35 | def from_file(cls, path: str) -> "PDFDocument": |
| 36 | return cls(path, 1024) |
| 37 | |
| 38 | @staticmethod |
| 39 | def supported_extensions() -> set[str]: |
| 40 | return {".pdf"} |
| 41 | |
| 42 | @property |
| 43 | def file_size(self) -> int: |
| 44 | return self._size |
info
Composition — building classes by embedding objects rather than inheriting from them — often leads to more flexible, testable designs. The Gang of Four principle "favour composition over inheritance" remains relevant in Python.
| 1 | # Inheritance approach — rigid |
| 2 | class EmailNotifier: |
| 3 | def send(self, msg: str): |
| 4 | print(f"Sending email: {msg}") |
| 5 | |
| 6 | class UserWithEmail(EmailNotifier): |
| 7 | def __init__(self, name: str): |
| 8 | self.name = name |
| 9 | self.send(f"Welcome {name}!") |
| 10 | |
| 11 | # Composition approach — flexible |
| 12 | class Notifier: |
| 13 | def __init__(self, channels: list): |
| 14 | self.channels = channels |
| 15 | |
| 16 | def send(self, message: str): |
| 17 | for channel in self.channels: |
| 18 | channel.send(message) |
| 19 | |
| 20 | class EmailChannel: |
| 21 | def send(self, msg: str): |
| 22 | print(f"Email: {msg}") |
| 23 | |
| 24 | class SMSChannel: |
| 25 | def send(self, msg: str): |
| 26 | print(f"SMS: {msg}") |
| 27 | |
| 28 | class User: |
| 29 | def __init__(self, name: str, notifier: Notifier): |
| 30 | self.name = name |
| 31 | self.notifier = notifier |
| 32 | |
| 33 | def welcome(self): |
| 34 | self.notifier.send(f"Welcome {self.name}!") |
| 35 | |
| 36 | # Compose different behaviours at runtime |
| 37 | n = Notifier([EmailChannel(), SMSChannel()]) |
| 38 | u = User("Alice", n) |
| 39 | u.welcome() |
| 40 | # → Email: Welcome Alice! |
| 41 | # → SMS: Welcome Alice! |
Use inheritance for is-a relationships (a Dog is an Animal). Use composition for has-a relationships (a User has a Notifier). Composition supports dependency injection, easier testing, and runtime reconfiguration.
best practice
| 1 | # isinstance / issubclass — runtime type checks |
| 2 | from collections.abc import Iterable, Mapping |
| 3 | |
| 4 | class CustomList: |
| 5 | def __getitem__(self, index): |
| 6 | return index |
| 7 | def __len__(self): |
| 8 | return 0 |
| 9 | |
| 10 | # Register as virtual subclass without inheritance |
| 11 | Iterable.register(CustomList) |
| 12 | |
| 13 | print(isinstance(CustomList(), Iterable)) # True |
| 14 | print(issubclass(CustomList, Iterable)) # True |
| 15 | |
| 16 | # Virtual subclass registration works with ABCs |
| 17 | # but does NOT affect MRO or super() |
1. Keep inheritance hierarchies shallow (ideally no deeper than 2-3 levels). Deep hierarchies are hard to debug and refactor.
2. Use super() consistently — never bypass it by calling parent constructors directly. This ensures cooperative inheritance works correctly.
3. Prefer ABCs and @abstractmethod over duck-typing-only contracts when you need to enforce an interface across a codebase.
4. Use mixins for reusable, orthogonal behaviour. Keep them stateless and avoid __init__ in mixin classes.
5. Favour composition over inheritance for most real-world scenarios. Ask "has-a?" before "is-a?".
6. Understand self.__class__ vs type(self). In inheritance chains, self.__class__ always refers to the actual runtime class of the instance.
7. Use @staticmethod when the method does not depend on class or instance state; use @classmethod for polymorphic factory methods. Both are inherited normally but are not overridden in the traditional sense.
| 1 | # Static method vs class method in inheritance |
| 2 | class Base: |
| 3 | @staticmethod |
| 4 | def info() -> str: |
| 5 | return "Base info" |
| 6 | |
| 7 | @classmethod |
| 8 | def create(cls) -> "Base": |
| 9 | return cls() |
| 10 | |
| 11 | class Derived(Base): |
| 12 | @staticmethod |
| 13 | def info() -> str: |
| 14 | return "Derived info" |
| 15 | |
| 16 | # classmethod create is inherited — cls will be Derived |
| 17 | pass |
| 18 | |
| 19 | print(Base.info()) # → Base info |
| 20 | print(Derived.info()) # → Derived info |
| 21 | print(Derived.create()) # → <Derived object> (cls=Derived) |
| 22 | |
| 23 | # Property inheritance |
| 24 | class Rectangle: |
| 25 | def __init__(self, w: float, h: float): |
| 26 | self._w = w |
| 27 | self._h = h |
| 28 | |
| 29 | @property |
| 30 | def area(self) -> float: |
| 31 | return self._w * self._h |
| 32 | |
| 33 | class Square(Rectangle): |
| 34 | def __init__(self, side: float): |
| 35 | super().__init__(side, side) |
| 36 | |
| 37 | @property |
| 38 | def area(self) -> float: |
| 39 | # Override property behaviour |
| 40 | return f"Square area: {self._w * self._h}" |
| 41 | |
| 42 | sq = Square(4) |
| 43 | print(sq.area) # → Square area: 16 |