Inheritance and Polymorphism
Single inheritance, super(), method overriding, real polymorphism, the method resolution order, isinstance vs type ==, and composition over inheritance.
Building One Class on Top of Another
By the end of Module 20 you can build a self-contained class with its own attributes and behavior. Real systems, though, are full of things that are variations on a theme — different kinds of employees, different kinds of payments, different kinds of shapes — that share a large amount of common structure and behavior, but differ in specific, well-defined ways. Inheritance lets one class (a subclass, or child class) automatically receive all the attributes and methods of another class (a superclass, base class, or parent class), and then add or override only what actually differs.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def describe(self):
return f"{self.name} earns ${self.salary}"
class Manager(Employee): # Manager inherits from Employee
pass
alice = Manager("Alice", 110000)
print(alice.describe()) # "Alice earns $110000" — inherited, unmodified, from Employee
print(isinstance(alice, Employee)) # True — a Manager IS an EmployeeEven with an empty body (pass), Manager already has everything Employee has — the entire point of inheritance is not writing that behavior a second time. The relationship inheritance expresses is often called an "is-a" relationship: a Manager is an Employee, with some additional specifics. This phrase will matter directly in Part 06, when it is contrasted with a different, equally important relationship: "has-a."
super() — Reaching Back Into the Parent Class
A subclass almost always needs its own additional data on top of what the parent already sets up — a Manager needs a list of direct reports; an Employee alone does not. This means the subclass typically needs its own __init__. Simply redefining __init__ from scratch would mean re-writing the parent's setup logic all over again — exactly the duplication inheritance exists to avoid. super() gives you a handle on the parent class so you can call its methods, most commonly its __init__, and let it do its own setup first.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class Manager(Employee):
def __init__(self, name, salary, direct_reports):
super().__init__(name, salary) # Employee.__init__ runs first, sets name and salary
self.direct_reports = direct_reports # Manager adds its own extra attribute
alice = Manager("Alice", 110000, ["Bob", "Carla"])
print(alice.name, alice.salary, alice.direct_reports)
# Alice 110000 ['Bob', 'Carla']super().__init__(name, salary) is exactly equivalent in effect to writing self.name = name and self.salary = salary directly inside Manager.__init__ — but written this way, if Employee.__init__ ever changes (say, a validation rule is added, exactly like Module 20's salary check), Manager automatically picks up that change for free, with zero edits needed in Manager itself.
AttributeErrors later, often far away from where the actual mistake was made.Redefining a Method to Change Its Behavior in a Subclass
A subclass can redefine any method it inherits, simply by defining a method of the same name. This is called overriding. The subclass's version takes priority whenever the method is called on an instance of that subclass — the parent's original version is not deleted, it is just no longer what gets found first.
class Employee:
def describe(self):
return f"{self.name} earns ${self.salary}"
class Manager(Employee):
def __init__(self, name, salary, direct_reports):
super().__init__(name, salary)
self.direct_reports = direct_reports
def describe(self): # OVERRIDES Employee.describe entirely
return f"{self.name} manages {len(self.direct_reports)} people and earns ${self.salary}"
alice = Manager("Alice", 110000, ["Bob", "Carla"])
print(alice.describe())
# "Alice manages 2 people and earns $110000" — the Manager version runs, not Employee'sA common, safer variant extends the parent's behavior rather than replacing it entirely — calling super().method_name() from inside the override to reuse the parent's logic, then adding to it, exactly the same idea as super().__init__() but applied to any method, not just the constructor.
class Manager(Employee):
def describe(self):
base = super().describe() # reuse Employee's version
return f"{base} and manages {len(self.direct_reports)} people"
alice = Manager("Alice", 110000, ["Bob", "Carla"])
print(alice.describe())
# "Alice earns $110000 and manages 2 people"Same Method Name, Different Behavior Per Subclass
Polymorphism ("many forms") is the ability to call the same method name on objects of different classes and have each one respond with its own correct, type-specific behavior — without the calling code needing to know or care which specific subclass it is actually holding. Here is a concrete, realistic example: a fictional fintech in Denver processing payments through several different providers, each with genuinely different internal logic, but a shared, uniform interface.
class Payment:
def __init__(self, amount):
self.amount = amount
def process(self):
raise NotImplementedError("Subclasses must implement process()")
class CreditCardPayment(Payment):
def __init__(self, amount, card_number):
super().__init__(amount)
self.card_number = card_number
def process(self):
return f"Charging ${self.amount} to card ending in {self.card_number[-4:]}"
class ACHPayment(Payment):
def __init__(self, amount, routing_number):
super().__init__(amount)
self.routing_number = routing_number
def process(self):
return f"Initiating ACH transfer of ${self.amount} via routing {self.routing_number}"
class WalletPayment(Payment):
def __init__(self, amount, wallet_id):
super().__init__(amount)
self.wallet_id = wallet_id
def process(self):
return f"Deducting ${self.amount} from wallet {self.wallet_id}"The real payoff of polymorphism shows up in code that processes a batch of mixed payment types without ever branching on type — every object simply gets asked to .process() itself, correctly, regardless of which concrete subclass it actually is:
payments = [
CreditCardPayment(49.99, "4111111111111111"),
ACHPayment(1200.00, "021000021"),
WalletPayment(15.50, "wallet_882"),
]
for payment in payments:
print(payment.process())
# Charging $49.99 to card ending in 1111
# Initiating ACH transfer of $1200.0 via routing 021000021
# Deducting $15.5 from wallet wallet_882This loop has no if isinstance(payment, CreditCardPayment): branching anywhere — it does not need to know or care what kind of payment it is holding. Each object already knows how to process itself correctly. This is genuinely the practical value polymorphism delivers: adding a new payment type later (say, CryptoPayment) means writing one new subclass — the loop above needs zero changes to support it.
The Method Resolution Order — And Why Multiple Inheritance Deserves Caution
Python supports multiple inheritance — a class can inherit from more than one parent class at once, by listing several base classes in the class definition. When multiple parents could each provide a method of the same name, Python needs a deterministic rule for deciding which one wins. That rule is called the Method Resolution Order (MRO), and you can inspect it directly on any class.
class Flyable:
def move(self):
return "Flying"
class Swimmable:
def move(self):
return "Swimming"
class Duck(Flyable, Swimmable): # inherits from BOTH
pass
d = Duck()
print(d.move()) # "Flying" — Flyable is listed first, so it wins
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)
# This is the exact left-to-right order Python searches for a methodPython computes the MRO using an algorithm called C3 linearization, which guarantees a consistent, predictable order even in fairly complex inheritance hierarchies. The practical rule of thumb: parents listed earlier in class Duck(Flyable, Swimmable): take priority when there is a naming conflict.
SerializableMixin adding a .to_json() method, or a ComparableMixin adding ordering methods), designed from the start to be combined with other classes without depending on shared state or stepping on each other's method names.Checking an Object's Type the Correct Way
There are two common ways to check what "kind of thing" an object is, and they behave differently once inheritance is in the picture. isinstance(obj, Class) checks whether obj is an instance of Class or any subclass of it. type(obj) == Class checks for an exact type match only, ignoring the entire inheritance hierarchy.
alice = Manager("Alice", 110000, ["Bob"])
print(isinstance(alice, Manager)) # True — exact type match
print(isinstance(alice, Employee)) # True — Manager IS an Employee, via inheritance
print(type(alice) == Manager) # True
print(type(alice) == Employee) # False — type() == ignores inheritance entirely, even though
# a Manager genuinely is a kind of Employeeisinstance() is almost always the correct choice, precisely because it respects inheritance. A function written to accept "any Employee" should work correctly for a Manager, a Contractor, or any other subclass — that is the entire promise of the "is-a" relationship from Part 01. type(obj) == Employee would reject a perfectly valid Manager instance simply because it is not exactly the Employee class, breaking polymorphism for no good reason.
isinstance() also accepts a tuple of types, checking whether the object matches any of them: isinstance(payment, (CreditCardPayment, ACHPayment)). This is the idiomatic way to check "is this one of these several types," rather than chaining multiple or conditions."Favor Composition Over Inheritance" — What This Actually Means
Inheritance models an "is-a" relationship. Many real design problems are actually a "has-a" relationship in disguise — and forcing a "has-a" relationship into inheritance produces fragile, awkward class hierarchies. Composition means building a class by holding an instance of another class as an attribute, rather than inheriting from it, and delegating to it as needed.
# Awkward: a Car does not genuinely "is-a" Engine — it HAS an engine.
class Engine:
def start(self):
return "Engine starting"
class Car(Engine): # modeling "has-a" as inheritance — a poor fit
def drive(self):
return f"{self.start()} — now driving"
# This forces every Car to BE an Engine in the type system, which is semantically wrong,
# and it means Car inherits every Engine method whether or not that makes sense for a car.class Engine:
def start(self):
return "Engine starting"
class Car:
def __init__(self):
self.engine = Engine() # Car HOLDS an Engine — composition, not inheritance
def drive(self):
return f"{self.engine.start()} — now driving"
my_car = Car()
print(my_car.drive()) # "Engine starting — now driving"
print(isinstance(my_car, Engine)) # False — correctly, a Car is not an EngineThe composition version is more honest about the actual relationship, and considerably more flexible: swapping in an ElectricEngine later means changing one line inside Car.__init__, with zero changes to the type hierarchy. It also avoids a real, common problem with deep inheritance chains — a subclass five levels down that has silently inherited a dozen methods that make no sense for it, purely as a side effect of the class it happened to be built on top of.
Payment hierarchy is a genuinely good use of inheritance, because CreditCardPayment really is a Payment, sharing real, meaningful structure and an intentionally uniform interface. The guidance is about defaulting to composition when the relationship is genuinely "has-a," and reaching for inheritance specifically when the relationship is genuinely "is-a" and you want that shared, substitutable interface — exactly the difference this module has now shown from both directions.A Chicago Fintech Adds a Fourth Payment Type in One Pull Request
A fintech startup built its checkout flow exactly on the pattern from Part 04 — a Payment base class with CreditCardPayment, ACHPayment, and WalletPayment subclasses, each with their own .process() implementation. Business development closes a partnership deal requiring support for a fourth payment type: Buy-Now-Pay-Later installment plans.
What the engineer actually had to change
One new subclass, following the exact same shape as the other three:
class InstallmentPayment(Payment):
def __init__(self, amount, num_installments):
super().__init__(amount)
self.num_installments = num_installments
def process(self):
per_installment = self.amount / self.num_installments
return f"Splitting ${self.amount} into {self.num_installments} payments of ${per_installment:.2f}"The checkout loop that calls payment.process() across a mixed batch of payment objects — the exact loop from Part 04 — required zero code changes. It had no branching on payment type to begin with, so a fourth type simply slotted into the existing polymorphic call site.
Why the isinstance() discipline mattered here too
A separate part of the codebase — the fraud-review queue — had, months earlier, been written with a check reading if type(payment) == CreditCardPayment or type(payment) == ACHPayment: to decide which payments needed manual review. Because it used type() == instead of isinstance(), adding InstallmentPayment silently bypassed fraud review entirely — it matched neither exact-type check, and the flawed code treated "not explicitly listed" as "does not need review." The fix, once found, was switching to isinstance(payment, RequiresReviewMixin) against a small mixin each risky payment type explicitly opted into — a pattern immune to this exact class of bug, since new subclasses have to actively declare that they need review rather than being silently excluded by an incomplete list of exact-type checks.
Four Misconceptions About Inheritance and Polymorphism
5 Interview Questions — With Complete Answers
Inheritance Mistakes Beginners Make Constantly
Errors You Will Hit With Inheritance — And Exactly Why
🎯 Key Takeaways
- ✓Inheritance (class Subclass(Parent):) lets a class receive all of another class's attributes and methods, modeling a genuine "is-a" relationship.
- ✓super().__init__() lets a subclass reuse the parent's setup logic instead of duplicating it — it should almost always be the first line of a subclass's own __init__.
- ✓Overriding a method (defining a method of the same name in the subclass) replaces the parent's version; calling super().method() from inside an override lets you extend rather than fully replace it.
- ✓Polymorphism means calling the same method name on objects of different classes and getting each one's own correct behavior — calling code never needs to branch on type.
- ✓isinstance(obj, Class) respects inheritance (True for subclasses too); type(obj) == Class checks for an exact match only and silently excludes subclasses — isinstance() is almost always the right choice.
- ✓The Method Resolution Order (MRO), visible via ClassName.__mro__, is the deterministic order Python searches for methods across multiple inheritance — earlier-listed parents win naming conflicts.
- ✓Multiple inheritance is powerful but should be used sparingly; small, focused mixins are the pattern that works well in real codebases.
- ✓Favor composition (holding another class as an attribute) over inheritance whenever the relationship is genuinely "has-a" rather than "is-a".
What comes next
Module 22 covers Python's convention-based privacy, and the dunder methods — __str__, __repr__, __eq__, __len__, and operator overloading — that let your own objects behave like Python's built-in types.
Module 22 → Encapsulation and Magic/Dunder MethodsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.