OOPBeginner 13 min Lesson 5 of 9

Inheritance — Sharing Behaviour

Build specialised classes from a general one, use super() correctly, and recognise the point where inheritance becomes a liability.

OOP · Lesson 5 of 9
0/9 done(0%)

What is it? #

Inheritance lets a class build on another. The child gets the parent's attributes and methods, and can add or replace parts.

Use it only for a real "is a" relationship. A CardPayment is a Payment. A Truck is a Vehicle. If you find yourself saying "well, sort of", stop.

Overriding means replacing a parent method. Extending means calling super() inside the override to keep the parent behaviour and add to it.

Inheritance creates the tightest coupling available between two classes. The child depends on the parent's internals and assumptions, so changing the parent can break children you have not looked at in a year.

Think of it like this #

Vehicle types share a lot: wheels, a registration, the ability to move. A truck is a vehicle with cargo capacity. A bus is a vehicle with passenger seats.

What goes wrong: a "trailer" is not a vehicle just because it has wheels and a registration. Forcing it into the hierarchy means the parent's drive() method has to be overridden into something that raises an error — a clear sign the relationship is wrong.

Simple example #

A payments system handles cards, UPI and cash on delivery. All record an amount and produce a receipt, but fees and settlement differ.

Code #

PYTHON
class Payment:
    def __init__(self, amount: float, reference: str):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.amount = amount
        self.reference = reference

    def fee(self) -> float:
        return 0.0

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

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


class CardPayment(Payment):
    def __init__(self, amount, reference, last4: str):
        super().__init__(amount, reference)        # parent setup first
        self.last4 = last4

    def fee(self) -> float:                        # override
        return round(self.amount * 0.02, 2)

    def receipt(self) -> str:                      # extend, not replace
        return f"{super().receipt()} (card ****{self.last4})"


class UpiPayment(Payment):
    pass                                           # inherits everything


class CashOnDelivery(Payment):
    def fee(self) -> float:
        return 25.0                                # flat handling charge


payments = [
    CardPayment(1000, "ORD-1", "4321"),
    UpiPayment(1000, "ORD-2"),
    CashOnDelivery(1000, "ORD-3"),
]

for payment in payments:
    print(payment.receipt())
# CardPayment ORD-1: 1020.0 (card ****4321)
# UpiPayment ORD-2: 1000.0
# CashOnDelivery ORD-3: 1025.0


# A sign the hierarchy is wrong
class Refund(Payment):
    def total(self):
        raise NotImplementedError("a refund is not a payment")   # smell

How it works #

Payment holds everything common: validation, the amount, the reference, and a total built from amount plus fee.

super().__init__(amount, reference) runs the parent initialiser, including its validation, before the child adds last4. Skipping this leaves the object half-built.

CardPayment.fee overrides the parent's zero fee. Notice that Payment.total calls self.fee(), and because self is a CardPayment, the child's version runs. The parent never needs to know which subclasses exist.

CardPayment.receipt extends rather than replaces. Calling super().receipt() keeps the shared format and appends the card detail, so a change to the base format flows through automatically.

UpiPayment adds nothing. It exists as a distinct type so the rest of the system can tell payment methods apart, and it inherits the zero fee.

The loop shows the practical benefit: one piece of code handles all three types without checking what each one is.

The Refund class at the bottom is the warning sign. When a subclass has to disable a parent method, the "is a" relationship is false. Code written against Payment would break when handed a Refund — a direct violation of the Liskov Substitution Principle.

Real-world use #

Framework classes are designed for inheritance: base views, base models, base test cases. They document which methods you are expected to override, which makes the coupling manageable.

In application code, keep hierarchies shallow — one or two levels. Deep hierarchies make it genuinely hard to answer "where does this method come from", and changing a base class becomes risky.

The common failure is inheriting to reuse a helper method. If ReportGenerator inherits from FileUtils just to get write_file, the relationship is invented. Importing a function or holding a collaborator object is clearer.

Many teams have moved towards composition as the default, reaching for inheritance only when several types genuinely share both identity and behaviour. That trade-off is the subject of the last lesson in this track.

Common mistakes #

  • Forgetting super().__init__(...), leaving the object partly initialised.
  • Inheriting purely to reuse a method rather than because of an "is a" relationship.
  • Subclasses that disable or contradict parent behaviour.
  • Hierarchies three or more levels deep.
  • Reaching into parent internals from a child instead of using its public methods.

Practice #

Model a Notification base class with send() and format(). Add EmailNotification, SmsNotification (enforcing a 160-character limit) and PushNotification. Use super() in at least one override. Then describe in one sentence why ScheduledReport should not inherit from Notification.

Quick quiz

  1. 1. When is inheritance appropriate?

  2. 2. What does `super().method()` inside an override do?

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

  4. 4. What does a subclass raising NotImplementedError for a parent method suggest?

  5. 5. What is the main risk of inheritance?

Summary

  • Inheritance shares behaviour for genuine "is a" relationships.
  • Always call `super().__init__()` when the child adds its own setup.
  • Extend with `super()` rather than replacing shared behaviour wholesale.
  • A subclass that disables parent behaviour signals a broken hierarchy.
  • Keep hierarchies shallow — inheritance is the tightest coupling there is.