PythonIntermediate 14 min Lesson 17 of 30

Day 17 — Inheritance

Share behaviour between related classes with inheritance, call the parent with super(), override methods safely, and know when composition is better.

Python · Lesson 17 of 30
0/30 done(0%)

What is it? #

Inheritance lets a class start from another class and add or change parts of it. The new class gets everything the parent has without repeating the code.

You use it when there is a genuine "is a" relationship. A SavingsAccount is a kind of Account. A PdfReport is a kind of Report.

Overriding means redefining a parent method in the child. super() calls the parent version, which is how you extend behaviour instead of replacing it entirely.

Inheritance is easy to overuse. If you find yourself inheriting just to reuse one helper method, or overriding most of the parent, the relationship is not really "is a" — reach for composition instead.

Think of it like this #

Think of job roles. Every employee gets an ID card and can log in. A manager is an employee who can also approve leave. You do not rewrite the ID card system for managers; you start from employee and add the extra ability.

Where it goes wrong: a contractor is not a kind of manager just because both can approve something. Forcing that relationship makes an organisation chart nobody can read.

Simple example #

A payments system supports several methods. All of them record an amount and produce a receipt, but each charges differently and some add a processing fee.

Code #

PYTHON
class Payment:
    def __init__(self, amount, reference):
        self.amount = amount
        self.reference = reference

    def fee(self):
        return 0.0

    def total(self):
        return round(self.amount + self.fee(), 2)

    def receipt(self):
        return f"{self.__class__.__name__}: {self.total()} ({self.reference})"


class CardPayment(Payment):
    def __init__(self, amount, reference, last4):
        super().__init__(amount, reference)     # let the parent set up first
        self.last4 = last4

    def fee(self):                              # override
        return self.amount * 0.02

    def receipt(self):
        base = super().receipt()                # extend, do not replace
        return f"{base} card ****{self.last4}"


class UpiPayment(Payment):
    pass                                        # no fee, no changes needed


card = CardPayment(1000, "ORD-1", "4321")
upi = UpiPayment(1000, "ORD-2")

print(card.total(), upi.total())     # 1020.0 1000
print(card.receipt())                # CardPayment: 1020.0 (ORD-1) card ****4321
print(isinstance(card, Payment))     # True

How it works #

class CardPayment(Payment): means "start from Payment". CardPayment immediately has total and receipt without writing them.

super().__init__(amount, reference) runs the parent's initialiser so the shared attributes get set, then the child adds last4. Skipping this call is a common bug — the object ends up half-initialised.

fee is overridden. Notice that total in the parent calls self.fee(), and because self is a CardPayment, Python uses the child's version. That is dynamic dispatch, and it is what makes the parent's total work correctly for every subclass without knowing about them.

receipt shows extending rather than replacing: call super().receipt() for the shared part, then add what is specific.

UpiPayment overrides nothing. It exists as a distinct type so the rest of the system can distinguish it, and it inherits the zero fee.

isinstance(card, Payment) is true, which is the practical meaning of inheritance: a CardPayment can be used anywhere a Payment is expected. That promise is what the Liskov Substitution Principle formalises in the SOLID track.

Real-world use #

Framework code is full of inheritance: you subclass a base view, a base model, a base test case, and override the two methods you care about. This works because the base class was designed as an extension point.

In your own code, the sweet spot is a small, stable base with a handful of subclasses that differ in well-defined ways — payment types, export formats, notification channels.

The failure mode is a deep hierarchy where behaviour is scattered across five levels and you cannot tell which class a method actually comes from. When that happens, teams usually flatten it and pass behaviour in as objects instead, which is composition. The design patterns track covers Strategy, which is the usual replacement.

Common mistakes #

  • Forgetting super().__init__(...), leaving the object partly set up.
  • Inheriting to reuse one helper method. Import a function or compose instead.
  • Overriding a method with different behaviour that breaks the parent’s promise, so code written against the parent fails.
  • Building hierarchies more than two or three levels deep.
  • Using inheritance when the relationship is "has a" rather than "is a" — a Car has an Engine, it is not an Engine.

Practice #

Create a Notification base class with send() and a summary() method. Add EmailNotification and SmsNotification subclasses that override send(), with the SMS one also enforcing a 160-character limit. Use super() in at least one override, and print a summary for each.

Quick quiz

  1. 1. What does `super().__init__(...)` do?

  2. 2. Why does the parent `total()` use the child’s `fee()`?

  3. 3. When is inheritance a poor fit?

  4. 4. What does `isinstance(card, Payment)` return for a CardPayment?

  5. 5. What is the difference between overriding and extending a method?

Summary

  • Inheritance shares behaviour where a genuine "is a" relationship exists.
  • `super()` calls the parent version so you can extend rather than replace.
  • Parent methods automatically use overridden child methods through `self`.
  • Keep hierarchies shallow; deep trees become hard to follow.
  • When the relationship is "has a", use composition instead.