Python Lesson 16 of 71

OOP Part 2 — Inheritance, Method Overriding, Polymorphism & Abstraction

In OOP Part 1 you learned to build a class: attributes, methods, self, __init__, __repr__. One class, standing alone, doing its job. That’s genuinely useful, and for a lot of code it’s where the story should end.

This lesson is about what happens when you have more than one class and they overlap. You’ve written EmailNotifier. Now you need SlackNotifier, and it turns out 80% of the code is identical. The obvious move is to make one inherit from the other. Sometimes that’s exactly right. Very often it’s a trap that takes two years to spring.

So we’re going to do this honestly. You’ll learn inheritance properly — what a subclass really gets, how overriding actually works (it’s less magical than you think), and what super() is actually doing, which is almost certainly not what you’ve been told. You’ll learn polymorphism and why Python needs no interface keyword to have it. You’ll learn to build abstract bases that refuse to be instantiated. And you’ll learn the MRO well enough to read one out loud and predict a diamond.

You’ll also learn, early and repeatedly, that the best inheritance hierarchy is usually the one you didn’t write. That’s not a paradox — it’s the most valuable thing in this lesson, and every senior engineer learned it the expensive way.

Everything below was run on Python 3.12. Type it. Watching an MRO print on your own screen is what turns a rule into an instinct.


Why this matters

Here’s the situation that creates every hierarchy ever written. You have a class that sends email notifications. It validates the recipient, formats the message, keeps a log of what it sent, and has a nice __repr__. Then someone asks for Slack notifications. You copy the file, change send(), and now you have two classes that are 80% identical.

Two weeks later you fix a bug in the recipient validation. You fix it in one file. The other one keeps the bug, because you forgot it existed. This is the DRY problem from the functions lesson, wearing a bigger coat — and inheritance is the tool that’s supposed to solve it.

Inheritance says: put the shared 80% in a Notifier base class, and let EmailNotifier and SlackNotifier inherit it. One home for the shared rule. Fix it there, both are fixed. That’s the pitch, and when the shape genuinely fits, it delivers.

But inheritance charges rent, and beginners are never shown the bill. When EmailNotifier inherits from Notifier, it doesn’t import a few methods — it welds itself to the base class’s entire surface: every method, every attribute, every assumption, forever. Change the base and you change every subclass at once, including the three you forgot about and the one a colleague wrote in another repo. That’s the same lever that makes inheritance powerful and makes it dangerous: there’s no such thing as inheriting a little bit.

The tool What it gives you What it costs
Inheritance Shared code + a shared type — subclasses pass isinstance Permanent coupling to the base’s whole surface; changes ripple everywhere
Composition Shared code, no coupling — you hold an object and call it You forward the calls you want (a few extra lines)
A plain function Shared code, no types, no ceremony Nothing — this is why it’s usually right
A Protocol A shared shape, checked by your type checker No runtime enforcement; static checking only

Notice what that table is quietly saying. Inheritance is the only row that costs something permanent, and it’s the row beginners reach for first — because it’s the one the tutorials teach. So let’s set the mental model now, before any syntax:

Inheritance is a lookup chain, not a family tree. When you call obj.method(), Python doesn’t consult a taxonomy or reason about what things “are”. It checks the object’s own __dict__, then walks a flat list of classes in a fixed order and stops at the first one that has a method by that name. That list is the MRO. Everything in this lesson — overriding, super(), polymorphism, the diamond problem, mixins — is a consequence of that one sentence. Once you see the list, all of it becomes obvious at the same time.

Hold that, and use inheritance when it earns its rent. The rest of the time, hold an object instead of becoming one.


Inheritance: what a subclass actually gets

The syntax is one pair of parentheses on the class line:

class Account:
    bank = "KloudBank"                      # class attribute — shared by all

    def __init__(self, owner: str, balance: float = 0) -> None:
        self.owner = owner
        self.balance = balance

    def deposit(self, amount: float) -> float:
        self.balance += amount
        return self.balance

    def __repr__(self) -> str:
        return f"{type(self).__name__}(owner={self.owner!r}, balance={self.balance})"


class SavingsAccount(Account):              # <- SavingsAccount inherits from Account
    def add_interest(self, rate: float = 0.04) -> float:
        return self.deposit(round(self.balance * rate, 2))

SavingsAccount defines exactly one method. Watch what it can do:

s = SavingsAccount("Vinod", 1000)     # Account.__init__ — inherited
print(s)                              # Account.__repr__ — inherited
print(s.deposit(500))                 # Account.deposit  — inherited
print(s.add_interest())               # its own
print(s.bank)                         # class attribute — inherited
print(isinstance(s, Account))
print(SavingsAccount.__bases__)
SavingsAccount(owner='Vinod', balance=1000)
1500
1560.0
KloudBank
True
(<class '__main__.Account'>,)

Four things to notice, because each is a real idea rather than a syntax detail.

SavingsAccount("Vinod", 1000) worked without a __init__ of its own — the lookup walked past SavingsAccount and found Account.__init__. add_interest calls self.deposit(...), a method it never defined, and that resolves the same way. __repr__ printed SavingsAccount, not Account, because it was written as type(self).__name__ rather than a hardcoded string — a small habit that makes a base class’s __repr__ correct for every subclass forever. And isinstance(s, Account) is True: inheritance creates a type relationship, not just a code-sharing arrangement. That last one is the part composition can’t give you, and it’s the only honest reason to prefer inheritance.

The vocabulary, because interviewers use all of it interchangeably and you should be able to follow either dialect:

Term Means In our example
Base class / superclass / parent The class being inherited from Account
Derived class / subclass / child The class doing the inheriting SavingsAccount
__bases__ The direct bases you wrote — one level only (Account,)
__mro__ The full flat lookup chain (SavingsAccount, Account, object)
Override Subclass defines a name the base already has
Extend Override that calls super() and adds to it UrgentEmailNotifier.send
Mixin A small class adding one capability, never used alone TimestampMixin
object The implicit base of everything Last in every MRO
What you write in a subclass Inherited from the base? Notes
Instance methods ✅ Yes Found by walking the MRO
Class attributes (bank = "...") ✅ Yes Shared object — see the mutable trap below
__init__ ✅ Yes Unless you define your own — then you must call super().__init__()
Dunder methods (__repr__, __eq__, __len__) ✅ Yes Write them with type(self).__name__, not a literal
@property, @staticmethod, @classmethod ✅ Yes classmethod receives the subclass as cls
Instance attributes (self.owner) ⚠️ Only if __init__ runs This is the super().__init__() bug
Names starting _single ✅ Yes Convention only — Python doesn’t enforce privacy
Names starting __double ⚠️ Name-mangled __x becomes _Account__x — deliberately hard to override

The classmethod row is worth a second. A @classmethod on the base receives the actual subclass as cls, which is why alternative constructors like Account.from_json(...) automatically return a SavingsAccount when called as SavingsAccount.from_json(...). That’s inheritance doing something genuinely useful and hard to replicate.

The is-a test, and when it’s a lie

The standard advice is the is-a test: use inheritance when the subclass genuinely is a kind of the base. A SavingsAccount is an Account. A Dog is an Animal. If you can’t say it out loud without wincing, don’t inherit.

That advice is correct and almost useless, because “is-a” is a sentence in English and English is a liar. The real test is harsher, and it has a name: the Liskov Substitution Principle. Anywhere your code accepts a base, it must be able to accept any subclass without knowing, and nothing may break. Not “the sentence sounds fine” — substitutable in every context the base is used in.

Here’s the classic demonstration that “is-a” fails. A square is a rectangle. Everyone agrees; it’s literally true in geometry. So:

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height

    def set_width(self, w: float) -> None:
        self.width = w

    def area(self) -> float:
        return self.width * self.height


class Square(Rectangle):                  # "a square IS A rectangle" — true in maths!
    def set_width(self, w: float) -> None:
        self.width = w
        self.height = w                   # a square must stay square

Now a function that only ever heard of Rectangle:

def stretch_and_measure(rect: Rectangle) -> float:
    rect.set_width(10)                    # perfectly reasonable
    return rect.area()

print(stretch_and_measure(Rectangle(2, 5)))   # => 50
print(stretch_and_measure(Square(2, 2)))      # => 100   <- expected 50!
50
100

stretch_and_measure did nothing wrong. It set a width and asked for an area, exactly as Rectangle promised. But Square broke the promise: changing the width silently changed the height. The English sentence was true and the code is still wrong, because Square is not substitutable for Rectangle — a mutable rectangle’s contract includes “width and height move independently”, and a square can’t honour it.

That’s the whole lesson about is-a in one example. The relationship isn’t about what things are; it’s about what callers are allowed to assume.

The proposed hierarchy Sounds fine? Actually?
SavingsAccount(Account) ✅ Fine — every Account operation still means what it meant
Square(Rectangle) (mutable) ✅ “a square is a rectangle” ❌ Breaks set_width — the classic LSP violation
AdminUser(User) ⚠️ Usually a role, not a type. Roles change; classes don’t
Stack(list) ✅ “a stack is a list” ❌ You also inherit insert, sort, __getitem__ — it’s not a stack any more
RetryingEmailNotifier(EmailNotifier) ❌ Retry is a policy, not a kind of notifier. See the combinatorial explosion below
Employee(Person) ⚠️ People stop being employees. Objects don’t change class
TimestampMixin in front of a notifier ✅ Fine — a mixin is a deliberate, narrow tweak

Prefer composition — and here’s the honest reason

Let’s say it plainly now rather than at the end: when in doubt, use composition. Not because inheritance is bad, but because inheritance’s failure mode is expensive and invisible for months, and composition’s failure mode is “you wrote four extra lines”.

The concrete reason is combinatorial explosion, and it’s easiest to see with a policy. Suppose you want retries. The inheritance instinct:

class RetryingEmailNotifier(EmailNotifier): ...
class RetryingSlackNotifier(SlackNotifier): ...     # copy-paste the retry loop

Now add SMS. Now add “log every send”. Now add “rate limit”. You need one class per combinationRetryingLoggingSlackNotifier — and the count is transports × policies. Four transports and three policies is 32 classes, most of which exist only to re-glue the same two behaviours in a different order. Everyone who has worked in a mature codebase has met this hierarchy, and it is always someone else’s fault.

Composition collapses it to addition. One retry wrapper that holds any notifier and forwards to it:

class RetryingNotifier:
    def __init__(self, inner, attempts: int = 3) -> None:
        self.inner = inner                 # HOLD one, don't BE one
        self.attempts = attempts

    def notify(self, message: str) -> str:
        for _ in range(self.attempts):
            try:
                return self.inner.notify(message)
            except ConnectionError:
                continue
        raise ConnectionError(f"gave up after {self.attempts} attempts")

Four transports and three policies is now seven classes, not 32 — and they compose in any order at runtime: RetryingNotifier(LoggingNotifier(SlackNotifier("#alerts"))). Better still, RetryingNotifier works with a transport that doesn’t exist yet, written by someone who has never heard of it. An inheritance hierarchy can never do that; it has to be edited to accept a new member.

The distinction that actually predicts which one you want:

Retry isn’t a kind of notifier. It’s something you do to a notifier. That’s the tell. You’ll build both versions in the lab and watch the difference.


Overriding, and what super() really does

Overriding is when a subclass defines a method the base already has. It sounds like a feature. It isn’t — it’s a side effect of the lookup rule from the top of this lesson:

class Notifier:
    def send(self, message: str) -> str:
        return f"generic → {message}"

class SlackNotifier(Notifier):
    def send(self, message: str) -> str:       # same name — this one wins
        return f"slack → {message}"

print(SlackNotifier().send("deploy done"))
# => slack → deploy done

Python walks the chain, finds send on SlackNotifier first, stops. Notifier.send was never consulted. There is no “override” mechanism, no @override requirement, no registration step — an earlier entry in the list simply shadows a later one. That’s it.

Which immediately raises the interesting question: what if you don’t want to replace the base’s work, but to add to it? That’s super().

Replace vs extend

class UrgentEmailNotifier(EmailNotifier):
    def send(self, message: str) -> str:
        base = super().send(message.upper())   # run the base's version first
        return f"{base}  [URGENT]"             # then add to it
Pattern What it does Use when
No method at all Inherits the base’s The base is already right
def send(...) — new body, no super() Replaces entirely The base’s version is irrelevant to you
super().send(...) then add Extends — base first, then you You want the base’s work plus a tweak
Modify args, then super().send(new) Extends — you pre-process Normalising/validating input before the real work
super().__init__(...) first, then self.x = ... Required for __init__ Almost always — see below
raise NotImplementedError Refuses You’re forbidding an inherited operation — a design smell (LSP!)

The __init__ bug everybody writes once

Define __init__ in a subclass and the base’s __init__ stops running — same shadowing rule, no exception for dunders. And since instance attributes are created by __init__, they simply never exist:

class Notifier:
    def __init__(self, recipient: str) -> None:
        self.recipient = recipient
        self.sent: list[str] = []

class EmailNotifier(Notifier):
    def __init__(self, recipient: str, subject: str) -> None:
        self.subject = subject             # BUG: never called super().__init__()

e = EmailNotifier("ops@example.com", "Deploy")
print(e.subject)          # => Deploy      — this works fine
print(e.recipient)        # boom
Traceback (most recent call last):
  File "demo.py", line 12, in <module>
    print(e.recipient)
          ^^^^^^^^^^^
AttributeError: 'EmailNotifier' object has no attribute 'recipient'

Read the shape of that failure, because it’s what makes it nasty: the object was constructed successfully. No error at EmailNotifier(...). The AttributeError appears later — maybe much later, in a different function, on the one code path that touches .recipient. The traceback points at the innocent reader, not at the guilty __init__.

The fix is one line, and it goes first:

class EmailNotifier(Notifier):
    def __init__(self, recipient: str, subject: str) -> None:
        super().__init__(recipient)        # base sets up ITS attributes first
        self.subject = subject             # then yours

Call super().__init__() first as a habit. Your own attributes may depend on the base’s; the base’s never depend on yours. If you skip it deliberately, that’s a comment, not a silence.

What super() actually does

Now the important part, and the reason most people’s mental model is subtly broken. Nearly every tutorial says super() means “the parent class”. That is wrong, and the wrongness is invisible until it costs you an afternoon.

super() means: continue the MRO walk from the entry after the class I am written in — in type(self).__mro__. It’s a cursor into the instance’s chain, not a pointer to a base.

For single inheritance those descriptions coincide, which is why the myth survives. Break the tie with a diamond and the truth is unmissable:

class A:
    def who(self) -> str:
        return "A"

class B(A):
    def who(self) -> str:
        return "B → " + super().who()     # "the parent" would mean A...

class C(A):
    def who(self) -> str:
        return "C → " + super().who()

class D(B, C):
    pass

print("D().who():", D().who())
print("B().who():", B().who())
D().who(): B → C → A
B().who(): B → A

Stop and look at that. The same super().who() line inside B ran C.who() in one case and A.who() in the other. B’s only base is AB has never heard of C, and C was possibly written years later by someone else. If super() meant “my parent”, D().who() would print B → A and C would be skipped.

It didn’t, because super() asked type(self).__mro__ — which for a D instance is (D, B, C, A, object) — found B in it, and took the next entry: C. The chain is a property of the object, not of the class the code is sitting in.

This is why super() is not a convenience for typing A.who(self). They are different operations:

class A2:
    def go(self): return "A2"
class B2(A2):
    def go(self): return "B2 → " + A2.go(self)    # hardcoded base — skips the chain
class C2(A2):
    def go(self): return "C2 → " + super().go()   # cooperative
class D2(B2, C2): pass

print(D2().go())
print([c.__name__ for c in D2.__mro__])
B2 → A2
['D2', 'B2', 'C2', 'A2', 'object']

C2 is in the MRO and C2.go never ran. No error. No warning. Its contribution just silently vanished, because B2 jumped straight to A2 instead of taking the next step in the chain. If C2 were a mixin that adds an audit log, you’ve just lost your audit log and nothing will ever tell you.

Form Meaning Verdict
super().m(...) Next class after me in type(self).__mro__ ✅ The one to use
super().__init__(...) Same, for construction ✅ Call it first
Base.m(self, ...) Hardcoded jump to Baseskips the chain ❌ Silently breaks mixins
super(B, self).m() The explicit Python 2 form — what super() compiles to ⚠️ Only needed outside a class body
super(type(self), self) Looks clever ❌ Infinite recursion when subclassed. Never
super() outside a method No __class__ cell to read RuntimeError: super(): __class__ cell not found

Two failure modes worth recognising on sight. super().__init__(x) when nothing above you takes an argument reaches object.__init__ and gives you TypeError: object.__init__() takes exactly one argument (the instance to initialize) — usually meaning you passed an argument up a chain that doesn’t want it. And calling super().send() from a mixin used alone raises AttributeError: 'super' object has no attribute 'send', which is the chain telling you it ran out of classes: a mixin is only valid in front of something that supplies the method.


Polymorphism, duck typing, and checking types

Polymorphism — “many shapes” — is the payoff. It means one piece of code works across many types, because each type responds to the same call in its own way.

You have already been using it for the whole course. len("abc"), len([1, 2]) and len({"a": 1}) all work, and len contains no branching over types: it just asks each object for its __len__. That’s polymorphism.

notifiers = [
    EmailNotifier("dev@example.com", subject="FYI"),
    SlackNotifier("#alerts"),
    UrgentEmailNotifier("oncall@example.com", subject="PAGE"),
]

for n in notifiers:            # one loop, three types
    print(n.notify("disk 91% full"))
email → dev@example.com | FYI | disk 91% full
slack → #alerts | disk 91% full
email → oncall@example.com | PAGE | DISK 91% FULL  [URGENT]

The loop has no idea what it’s holding, and — this is the point — you can add a fourth notifier type without touching the loop. Compare it to the version this replaces:

for n in notifiers:                       # the anti-pattern
    if isinstance(n, EmailNotifier):
        n.send_email(message)
    elif isinstance(n, SlackNotifier):
        n.post_to_slack(message)
    # ...and edit this file every single time a type is added

That’s the smell polymorphism exists to remove. A chain of isinstance checks that all end up calling a different method name is a missing common method. Give them the same name and delete the chain.

Polymorphism arrives in Python in four flavours, and only one of them needs a base class at all:

Flavour How it works Example Needs inheritance?
Duck typing The object has the method anything with .notify() ❌ No
Inheritance-based Subclasses override a base’s method NotifierEmailNotifier ✅ Yes
Operator / dunder Python calls __len__, __eq__, __add__ len(x), a + b, for x in y ❌ No
Structural (Protocol) Shape checked statically def f(s: Sender) ❌ No

Three of four rows say no. That ratio is the point: in Python, polymorphism is the default state of things, and inheritance is one narrow way to get it — not the price of admission.

Duck typing: Python needs no interface

Here’s what surprises people arriving from Java or C#: the loop above doesn’t require a common base class at all. Delete Notifier entirely and it still works, as long as each object has .notify().

That’s duck typing — “if it walks like a duck and quacks like a duck, it’s a duck.” Python doesn’t ask what an object is; it asks what it can do, at the moment you ask it. There’s no declaration, no implements, no registration:

class CarrierPigeon:                       # inherits from nothing
    def notify(self, message: str) -> str:
        return f"🐦 coo! {message}"

broadcast([EmailNotifier("a@b.c"), CarrierPigeon()], "hello")   # just works

A CarrierPigeon is not a Notifier by any nominal measure. It fits anyway, because fitting means “has the method”.

Python — duck / structural Java / C# — nominal
What makes a type “fit” It has the methods It declares implements Notifier
Interface keyword None — there is no interface interface + implements required
Declared in advance? No — the class needn’t know you exist Yes — the class must name the interface
Checked when At the call, at runtime At compile time
Failure mode AttributeError: 'Rock' object has no attribute 'send' Won’t compile
Use a 3rd-party class you can’t edit Just pass it Impossible without an adapter
Two unrelated libraries agreeing Free — same method name is enough Needs a shared interface both import
Static checking available? Opt-in — typing.Protocol + mypy Built in, mandatory

Both models are defensible; they’re trading the same coin. Nominal typing catches mismatches at compile time and makes intent explicit — at the cost of ceremony, and of being unable to retrofit an interface onto a class you don’t own. Duck typing is frictionless and open to extension — at the cost of finding out at runtime, in production, that something lacked a method.

Python’s modern answer is to have both: duck typing by default, and typing.Protocol when you want the checker to verify the shape before you ship. More on that in a moment.

isinstance, issubclass, and why type(x) == C is wrong

Sometimes you do need to ask about a type. Ask correctly:

class Animal: pass
class Dog(Animal): pass

d = Dog()
print(isinstance(d, Animal))        # => True    "is it a Dog or any subclass?"
print(type(d) == Animal)            # => False   "is it EXACTLY an Animal?"
print(issubclass(Dog, Animal))      # => True    class-to-class
print(isinstance(d, (Animal, str))) # => True    tuple = "any of these"

type(x) == C is an exact identity check — it deliberately fails for every subclass. That’s almost never what you mean, and it’s a bug with a delay fuse: it works perfectly until someone subclasses your class, then quietly takes the wrong branch. It breaks the substitution principle by construction — the whole point of a subclass is that code accepting the base accepts it too, and == says no.

The trap is that type(x) == C reads like the innocent question. It isn’t. Unless you are genuinely writing a serialiser that must distinguish bool from int, or dispatching on exact type for performance in a hot loop, use isinstance.

print(isinstance(True, int))        # => True     bool IS a subclass of int!
print(type(True) == int)            # => False

That’s not a curiosity — bool really is a subclass of int in Python, so isinstance(True, int) is True and True + True == 2. It’s the one place type(x) == int occasionally earns its keep.

Check Asks Subclasses pass? Use for
isinstance(x, C) Is x a C or a subclass? ✅ Yes ✅ The default. Guard clauses, validation
isinstance(x, (A, B)) Any of several ✅ Yes ✅ Cheaper and clearer than or
issubclass(D, B) Is the class D under B? ✅ Yes Class objects, not instances
type(x) == C Is x exactly a C? No ❌ Almost always a bug
type(x) is C Same, but correct identity form ❌ No Rare: serialisers, bool vs int
hasattr(x, "send") Can it do the thing? n/a Duck typing, when one method is all you need
isinstance(x, SomeProtocol) Does it have the shape? n/a Needs @runtime_checkable
no check at all Usually the right answer. Just call the method

That last row is not a joke. The most Pythonic type check is the one you deleted. If you’re about to write if isinstance(x, Notifier): x.notify(m), ask what you’d do in the else — if the answer is “crash”, then just call x.notify(m) and let it crash with a clearer message.


Abstraction: abc.ABC and typing.Protocol

Abstraction means defining what must exist without saying how. A Notifier base knows every notifier must send(), but has no opinion on how — that’s the subclass’s job.

The naive attempt looks reasonable and is quietly broken:

class Notifier:
    def send(self, message: str) -> str:
        raise NotImplementedError("subclasses must implement send()")

class BrokenNotifier(Notifier):
    pass                                   # oops — forgot to implement send()

b = BrokenNotifier()                       # constructs FINE. The bug ships.
b.send("hi")
Traceback (most recent call last):
  File "demo.py", line 9, in <module>
    b.send("hi")
  File "demo.py", line 3, in send
    raise NotImplementedError("subclasses must implement send()")
NotImplementedError: subclasses must implement send()

The class was created, the object was constructed, everything looked healthy — and the failure arrives at 3am when something finally calls send(). The error is late: it fires on use, not on construction.

abc.ABC + @abstractmethod

An Abstract Base Class moves that failure forward to the earliest possible moment — you can’t even build the object:

from abc import ABC, abstractmethod

class Notifier(ABC):                        # inherit from ABC
    def __init__(self, recipient: str) -> None:
        self.recipient = recipient          # an ABC may have __init__

    @abstractmethod
    def send(self, message: str) -> str:
        """Deliver message. Subclasses MUST implement this."""

    def notify(self, message: str) -> str:  # concrete methods are fine here!
        return f"[log] {self.send(message)}"

Notifier("ops@example.com")
Traceback (most recent call last):
  File "demo.py", line 14, in <module>
    Notifier("ops@example.com")
TypeError: Can't instantiate abstract class Notifier without an implementation for abstract method 'send'

Version note: Python 3.11 and older word this as TypeError: Can't instantiate abstract class Notifier with abstract method send. 3.12 rewrote it to “without an implementation for abstract method ‘send’” and quotes the names. Same rule, clearer sentence. If you see the older phrasing you’re on an older interpreter, not a different bug.

A subclass that forgets send is blocked too, at its first instantiation — and the message names exactly what’s missing:

TypeError: Can't instantiate abstract class Partial without an implementation for abstract method 'close'

An ABC is not “all abstract”. The notify method above is fully concrete and inherited by everyone — this is the template method pattern: the base owns the shared workflow and delegates the one variable step. That mixture is exactly what an ABC is for and what a Protocol cannot do.

Feature Behaviour
class C(ABC) Marks the class abstract (sets metaclass=ABCMeta)
@abstractmethod Marks one method as required; enforced at instantiation
Instantiating with any unimplemented TypeErrorat construction, not at call
Concrete methods in the ABC ✅ Allowed and inherited — the point of a template method
__init__ in the ABC ✅ Allowed — subclasses call super().__init__()
@property + @abstractmethod ✅ Stack them — abstract property (@property on top)
@classmethod/@staticmethod + abstract ✅ Stack them (@classmethod on top)
Abstract class with no abstract methods Instantiates fine — nothing to enforce
Notifier.register(SomeClass) Virtual subclass — passes isinstance, no enforcement
NotImplementedError @abstractmethod
Fails when The method is called The object is constructed
Catches a missing method Only if that line runs Always
Can you build a broken object? ✅ Yes — the bug ships ❌ No
Tooling sees the requirement ❌ No ✅ Yes — IDEs and checkers flag it
Needs a base class No Yes — must inherit ABC
Right use An optional hook, or an inherited op you’re refusing Declaring a required method

The rule: if a subclass must implement it, @abstractmethod. NotImplementedError is for the rarer case of an optional hook whose default is “nobody implemented me”, and for the design-smell case of an inherited method you’re refusing — which is usually the code telling you the hierarchy is wrong.

typing.Protocol — the structural alternative

An ABC is nominal: to count as a Notifier, you must inherit from Notifier. That’s a real cost. If a third-party library hands you a perfectly good object with a .send() method, it will never be a Notifier, because it can’t retroactively inherit from a class it’s never heard of. You’d have to write an adapter for no reason but bookkeeping.

typing.Protocol (Python 3.8+) makes duck typing checkable without demanding inheritance. It describes a shape:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Sender(Protocol):
    def send(self, message: str) -> str: ...     # shape only — no body

class SlackClient:                                # inherits from NOTHING
    def send(self, message: str) -> str:
        return f"slack: {message}"

class Rock:
    pass

def broadcast(sender: Sender, message: str) -> str:   # mypy checks this
    return sender.send(message)

print(broadcast(SlackClient(), "hi"))
print(isinstance(SlackClient(), Sender))
print(isinstance(Rock(), Sender))
slack: hi
True
False

SlackClient satisfies Sender by having the method. It never mentions Sender; Sender never mentions it. And mypy will still reject broadcast(Rock(), "hi") before you run anything — static safety with zero coupling. That’s duck typing with a seatbelt, and it’s why Protocol is the modern default for “I depend on an interface I don’t own”.

Two sharp edges. isinstance against a Protocol requires the @runtime_checkable decorator, or:

TypeError: Instance and class checks can only be used with @runtime_checkable protocols

And even then, a runtime isinstance check against a Protocol only verifies that the method names exist — not their signatures, not their types. isinstance(x, Sender) is True for an object whose send() takes six arguments. The real checking is static; the runtime check is a shallow courtesy.

abc.ABC typing.Protocol
Typing style Nominal — you must inherit Structural — shape is enough
Subclass must declare the relationship ✅ Yes: class E(Notifier) ❌ No — it needn’t know it exists
Blocks instantiation of the incomplete TypeError at construction ❌ Never — no runtime power
Enforced when Runtime, at instantiation Static — mypy/pyright, before you run
Can share concrete code / __init__ ✅ Yes — template methods ❌ No — bodies are ignored
Works on third-party classes ❌ Only via .register() ✅ Yes, automatically
isinstance support ✅ Always ⚠️ Needs @runtime_checkable; names only
Cost to the implementer Coupling to your base Zero
Best for Your own hierarchy that shares real code Depending on an interface you don’t own

They’re complements, not rivals, and the choice is usually easy. Writing a family of your own classes with genuinely shared implementation? ABC. Declaring what you need from something a caller hands you? Protocol — and note it belongs on the consumer side, which is exactly backwards from how Java interfaces are usually taught.


Multiple inheritance, the MRO, and mixins

Python lets a class have several bases:

class TimestampedSlack(TimestampMixin, SlackNotifier):
    pass

Which raises the question every other language has answered by banning it: if two bases define the same method, who wins?

Python answers with the MRO — Method Resolution Order — a single flat tuple computed once at class-creation time by the C3 linearisation algorithm. Every attribute lookup walks that tuple, left to right, and stops at the first hit. You can read it:

print([c.__name__ for c in TimestampedSlack.__mro__])
# => ['TimestampedSlack', 'TimestampMixin', 'SlackNotifier', 'Notifier', 'ABC', 'object']

That tuple is the answer to “who wins”. There is no other rule.

Here is the real lookup, end to end. Read it left to right in the order Python actually performs it: the instance’s own __dict__ first, then a walk down the flat MRO tuple until a class’s namespace holds the name, and — the part that matters — super() resuming that same walk from wherever the running method sits, which is why a diamond sends super() sideways to a class the current one never declared.

Python method lookup shown left to right: a call on an instance checks the instance dict first and misses, then walks the flat MRO tuple produced by C3 linearisation of the diamond D-B-C-A-object, stops at the first class whose namespace holds the method, and super() resumes the same walk from the position after the current class so it reaches C rather than A, with a hardcoded base call shown silently skipping C and an exhausted chain raising AttributeError

The six badges mark where this bites: the instance dict is checked before any class (1); the MRO is one flat list, not a tree (2); overriding is just an earlier entry shadowing a later one (3); super() is a cursor into the instance’s chain, so it can reach a class the current one never named (4); hardcoding Base.method(self) silently skips whatever sat between you (5); and running off the end is an AttributeError (6).

The diamond, worked through

The diamond problem is the reason C3 exists. Two classes inherit from a common base, and a fourth inherits from both:

      A            D.__mro__ = (D, B, C, A, object)
     / \
    B   C          class A: ...
     \ /           class B(A): ...
      D            class C(A): ...
                   class D(B, C): ...

The naive answers are both wrong. Depth-first (D, B, A, C, object) reaches A before C, so a method C overrides from A would be shadowed by A itself — nonsense. Plain breadth-first breaks other guarantees. C3 gives the one order satisfying all three sane rules at once:

  1. A class always precedes its own bases.
  2. Bases keep the left-to-right order you wrote them in.
  3. That ordering is consistent for every class in the hierarchy (monotonicity).

The algorithm: take the head of the first list that doesn’t appear in the tail of any other list; repeat. For D(B, C):

L[B] = [B, A, object]        L[C] = [C, A, object]

L[D] = D + merge([B, A, object], [C, A, object], [B, C])

  1. head B  — in no tail          → TAKE B    → merge([A, object], [C, A, object], [C])
  2. head A  — in tail of [C,A,object]  ✗ REJECT
     head C  — in no tail          → TAKE C    → merge([A, object], [A, object], [])
  3. head A  — in no tail now      → TAKE A    → merge([object], [object], [])
  4. object                        → TAKE object

L[D] = [D, B, C, A, object]

Step 2 is the whole diamond problem being solved. A was rejected because C still needs to come firstA appears in C’s tail, so taking A now would put a base ahead of its own subclass. That single rule is why A runs last and exactly once.

And now the payoff, which is the single most useful thing to understand about super():

class B(A):
    def who(self): return "B → " + super().who()
class C(A):
    def who(self): return "C → " + super().who()
class D(B, C): pass

print(D().who())     # => B → C → A
print(B().who())     # => B → A

super() inside B reached C — a class B does not inherit from and has never heard of. And A.who() ran once, not twice, even though both B and C inherit from it. That’s cooperative multiple inheritance: every class calls super(), the MRO threads them into a single chain, and each runs exactly once. It only works if everyone uses super() — one hardcoded A.who(self) and the chain breaks silently.

Concept What it is
C.__mro__ The tuple Python walks. Computed once at class creation
C.mro() Same thing as a list — the method form
C3 linearisation The algorithm. Guarantees the three rules above
Monotonicity A subclass’s MRO never contradicts its bases’ MROs
C.__bases__ Only the direct bases you wrote — not the chain
super() Cursor: next entry after this class in type(self).__mro__
MRO conflict No valid order exists → TypeError at class definition
Depth-first (old Python 2) Legacy, broken for diamonds. C3 since 2.3

When no consistent order exists, Python refuses to create the class at all — at import time, which is the good outcome:

class X: pass
class Y(X): pass
class Bad(X, Y): pass          # X before Y, but Y must precede its base X
TypeError: Cannot create a consistent method resolution
order (MRO) for bases X, Y

You asked for X before Y (base order), while rule 1 demands Y before its base X. Both can’t hold. The fix is nearly always to flip the bases — class Good(Y, X) — which is consistent, and usually what you meant: most-specific first, left to right.

Mixins: the sane use of multiple inheritance

A mixin is a small class that adds one focused capability, is never instantiated alone, and isn’t part of the is-a story. It’s the one form of multiple inheritance that reliably stays readable:

class TimestampMixin:
    """Prefixes a timestamp. Not a Notifier — it only sits in FRONT of one."""
    def send(self, message: str) -> str:
        return f"[2026-07-15T09:00:00Z] {super().send(message)}"

class TimestampedSlack(TimestampMixin, SlackNotifier):
    pass

print(TimestampedSlack("#audit").notify("disk 91% full"))
# => [2026-07-15T09:00:00Z] slack → #audit | disk 91% full

Look closely at TimestampMixin.send: it calls super().send(message) — but TimestampMixin inherits from object, which has no send. On its own, that line is guaranteed to fail:

TimestampMixin().send("hi")
# AttributeError: 'super' object has no attribute 'send'

It works inside TimestampedSlack because super() reads the instance’s MRO, where SlackNotifier sits right after TimestampMixin. The mixin is a deliberately incomplete class that only makes sense when threaded into a chain that completes it. Once you can explain that sentence, you understand super().

Mixin rule Why
Name it ...Mixin Tells readers it’s not a standalone type
Give it one capability TimestampMixin, not UtilsMixin
No __init__ if you can help it Keeps the cooperative chain simple
Always call super() It’s mid-chain; hardcoding a base breaks everyone downstream
List mixins first class C(Mixin, Base) — leftmost wins, so the mixin can intercept
Never instantiate it alone AttributeError: 'super' object has no attribute ...
No state of its own, ideally State belongs to the real class
Stop at two or three Past that, nobody can predict the MRO — use composition

When is multiple inheritance actually fine? Narrow list: mixins that add one orthogonal capability; ABCs/Protocols with no implementation to collide; and frameworks that were designed for it (Django’s class-based views). Everywhere else — especially two concrete classes with real state — you are building a puzzle for whoever is on call. Reach for composition.


Inheritance vs composition vs a plain function

The decision, in one table. Read the middle column first — it’s the question that actually resolves it:

Situation The question to ask Use
Shared behaviour, no shared state, no types needed “Is this just a calculation?” A plain function
Subclass genuinely substitutable everywhere the base is used “Would an LSP violation be impossible?” Inheritance
You want to add a policy (retry, cache, log, rate-limit) “Is it something you do to it, not something it is?” Composition (wrapper)
One orthogonal cross-cutting tweak “Does it need to sit in front of an existing method?” A mixin
Need the type relationship (isinstance, framework hooks) “Does anything check the type?” Inheritance
Shared interface, unrelated implementations, you own them “Must every one of them implement send()?” ABC
Shared interface, classes you don’t own “Do I just need the shape?” Protocol
Behaviour must change at runtime “Can an object’s class change? (No.)” Composition
Combining N behaviours × M types “Does the class count multiply?” Composition
You want the base’s code but not its interface “Would I be embarrassed by the inherited methods?” Composition — this is the Stack(list) mistake
“It’s mostly the same but with a few differences” “Am I about to override half the base?” Composition — this is the classic wrong turn
Framework says to (django.db.models.Model) “Does the framework require it?” Inheritance — obviously

Two rows deserve a word.

“You want the base’s code but not its interface” is the Stack(list) mistake. Inheriting from list to build a Stack gives you push/pop and also insert, sort, reverse, __getitem__ and slicing — so your “stack” can be modified in the middle, which makes it not a stack. You inherited to reuse an implementation and accidentally published an interface. Hold a list instead: self._items = []. Four lines, and the type means what it says.

“It’s mostly the same but with a few differences” is the sentence to be suspicious of. If you’re overriding half the base’s methods, the base isn’t a generalisation of your class — it’s a different class you’re fighting. Every override is a small admission that the is-a relationship was aspirational.

There’s also a third option the table’s first row is pointing at, and it’s the one most often forgotten in an OOP lesson: not a class at all. Because functions are first-class objects, a behaviour you’d model as a one-method subclass hierarchy is frequently just a function you pass in. Where Java needs a SortStrategy interface and three implementing classes, Python writes sorted(rows, key=by_age). If your “abstract base” has exactly one abstract method and no shared state, you have re-invented the function — with extra steps and a TypeError.

And the honest summary: start with a function. If you need state, use a class. If you need many classes with a shared interface, use an ABC or a Protocol. Only reach for inheritance when the type relationship is real and permanent — and even then, ask once more whether composition would do.


Hands-on lab

You’ll build a notification system that exercises every idea in this lesson — and then you’ll refactor the one class that shouldn’t have been a subclass.

Everything is standard library. No pip install, but a venv is the right habit:

mkdir notifiers && cd notifiers
python3 -m venv .venv
source .venv/bin/activate       # Windows: .venv\Scripts\activate
python3 --version               # want 3.12+ (Windows users: use `python`)

Step 1 — an ABC that refuses to be instantiated. Create notify.py:

"""notify.py - one hierarchy, four ideas: inherit, override, dispatch, compose."""

from abc import ABC, abstractmethod


class Notifier(ABC):
    """Abstract base. Every notifier knows a recipient and must implement send()."""

    def __init__(self, recipient: str) -> None:
        self.recipient = recipient
        self.sent: list[str] = []          # per-INSTANCE, not a class attribute

    @abstractmethod
    def send(self, message: str) -> str:
        """Deliver message. Subclasses MUST implement this."""

    def notify(self, message: str) -> str:
        """Shared logic every subclass inherits, then the subclass's own send()."""
        receipt = self.send(message)
        self.sent.append(message)
        return receipt

    def __repr__(self) -> str:
        return f"{type(self).__name__}(recipient={self.recipient!r})"

What just happened: send is abstract — required. notify and __repr__ are concrete and inherited by everyone (the template method pattern). self.sent is built in __init__, so each instance gets its own list — a class-level sent = [] would be shared by the entire family.

Step 2 — two concrete subclasses that call super().__init__(). Append:

class EmailNotifier(Notifier):
    def __init__(self, recipient: str, subject: str = "(no subject)") -> None:
        super().__init__(recipient)        # the line everyone forgets
        self.subject = subject

    def send(self, message: str) -> str:
        return f"email → {self.recipient} | {self.subject} | {message}"


class SlackNotifier(Notifier):
    def __init__(self, recipient: str, webhook: str = "https://hooks.example/T000") -> None:
        super().__init__(recipient)
        self.webhook = webhook

    def send(self, message: str) -> str:
        return f"slack → {self.recipient} | {message}"

What just happened: each implements the abstract send, so each is instantiable. super().__init__(recipient) goes first, so self.recipient and self.sent exist before the subclass adds its own.

Step 3 — an override that extends with super(). Append:

class UrgentEmailNotifier(EmailNotifier):
    """Override that EXTENDS rather than replaces."""

    def send(self, message: str) -> str:
        base = super().send(message.upper())
        return f"{base}  [URGENT]"

What just happened: it doesn’t reimplement email formatting — it pre-processes the message, delegates to EmailNotifier.send via super(), then decorates the result.

Step 4 — a mixin and a true diamond. Append:

class TimestampMixin:
    """Cross-cutting tweak. Not a Notifier - it only sits in front of one."""

    def send(self, message: str) -> str:
        return f"[2026-07-15T09:00:00Z] {super().send(message)}"


class TimestampedSlack(TimestampMixin, SlackNotifier):
    pass


def broadcast(notifiers: list[Notifier], message: str) -> None:
    for n in notifiers:
        print("   ", n.notify(message))


# ---- the diamond -------------------------------------------------
class A:
    def who(self) -> str: return "A"
class B(A):
    def who(self) -> str: return "B → " + super().who()
class C(A):
    def who(self) -> str: return "C → " + super().who()
class D(B, C):
    pass

What just happened: TimestampMixin.send calls super().send() even though its only base is object. That only resolves because the MRO of TimestampedSlack puts SlackNotifier right after it. broadcast is polymorphism: one loop, no isinstance.

Step 5 — the inheritance misuse, then the composition fix. Append:

class FlakySlackNotifier(SlackNotifier):
    """Fails the first `fail_times` sends, then succeeds."""

    def __init__(self, recipient: str, fail_times: int = 2) -> None:
        super().__init__(recipient)
        self.fail_times = fail_times
        self.attempts = 0

    def send(self, message: str) -> str:
        self.attempts += 1
        if self.attempts <= self.fail_times:
            raise ConnectionError(f"slack 503 (attempt {self.attempts})")
        return super().send(message)


class RetryingNotifier:
    """COMPOSITION: wraps ANY notifier. One class, every transport."""

    def __init__(self, inner: Notifier, attempts: int = 3) -> None:
        self.inner = inner
        self.attempts = attempts

    def notify(self, message: str) -> str:
        for i in range(1, self.attempts + 1):
            try:
                return self.inner.notify(message)
            except ConnectionError as exc:
                print(f"      retry {i}/{self.attempts} after {exc}")
        raise ConnectionError(f"gave up after {self.attempts} attempts")

What just happened: this is the refactor. The inheritance version would have been RetryingSlackNotifier(SlackNotifier) — plus RetryingEmailNotifier, plus one per transport forever. RetryingNotifier holds a notifier instead of being one, so it works with all of them, including transports written next year. Note it isn’t a Notifier and doesn’t need to be: it has .notify(), and duck typing asks for nothing more.

Step 6 — wire it up. Append:

def main() -> None:
    print("1. ABC refuses to be instantiated")
    try:
        Notifier("ops@example.com")
    except TypeError as exc:
        print("   TypeError:", exc)

    print("\n2. Inherited __init__ / notify / __repr__")
    email = EmailNotifier("ops@example.com", subject="Deploy")
    print("   ", repr(email))
    print("   ", email.notify("build 412 shipped"))
    print("    inherited .sent →", email.sent)

    print("\n3. Override + super() extends the parent")
    urgent = UrgentEmailNotifier("oncall@example.com", subject="PAGE")
    print("   ", urgent.notify("db failover"))

    print("\n4. Polymorphic dispatch over mixed types")
    broadcast([
        EmailNotifier("dev@example.com", subject="FYI"),
        SlackNotifier("#alerts"),
        UrgentEmailNotifier("oncall@example.com", subject="PAGE"),
        TimestampedSlack("#audit"),
    ], "disk 91% full")

    print("\n5. MRO")
    print("    TimestampedSlack:", [c.__name__ for c in TimestampedSlack.__mro__])
    print("    diamond D(B, C) :", [c.__name__ for c in D.__mro__])
    print("    D().who()       :", D().who(), "  ← super() in B went to C, not A")
    print("    B().who()       :", B().who(), "      ← same line, different chain")

    print("\n6. Inheritance misuse → composition")
    flaky = FlakySlackNotifier("#alerts", fail_times=2)
    resilient = RetryingNotifier(flaky, attempts=3)
    print("   ", resilient.notify("nightly backup done"))
    print("    attempts made:", flaky.attempts)


if __name__ == "__main__":
    main()

Step 7 — run it.

python3 notify.py
1. ABC refuses to be instantiated
   TypeError: Can't instantiate abstract class Notifier without an implementation for abstract method 'send'

2. Inherited __init__ / notify / __repr__
    EmailNotifier(recipient='ops@example.com')
    email → ops@example.com | Deploy | build 412 shipped
    inherited .sent → ['build 412 shipped']

3. Override + super() extends the parent
    email → oncall@example.com | PAGE | DB FAILOVER  [URGENT]

4. Polymorphic dispatch over mixed types
    email → dev@example.com | FYI | disk 91% full
    slack → #alerts | disk 91% full
    email → oncall@example.com | PAGE | DISK 91% FULL  [URGENT]
    [2026-07-15T09:00:00Z] slack → #audit | disk 91% full

5. MRO
    TimestampedSlack: ['TimestampedSlack', 'TimestampMixin', 'SlackNotifier', 'Notifier', 'ABC', 'object']
    diamond D(B, C) : ['D', 'B', 'C', 'A', 'object']
    D().who()       : B → C → A   ← super() in B went to C, not A
    B().who()       : B → A       ← same line, different chain

6. Inheritance misuse → composition
      retry 1/3 after slack 503 (attempt 1)
      retry 2/3 after slack 503 (attempt 2)
    slack → #alerts | nightly backup done
    attempts made: 3

Block 5 is the lesson in four lines. TimestampedSlack’s MRO is a flat list with the mixin in front — that’s why the timestamp lands outside the Slack formatting in block 4. The diamond linearises to D, B, C, A, object. And then the two who() calls: the same super().who() line inside B produced B → C → A for a D and B → A for a B. If super() meant “the parent”, both would print B → A. It doesn’t, so they don’t.

Block 6 is the refactor paying off: three attempts, two failures, one success — and RetryingNotifier never inherited from anything.

Now try these, and predict before you run:

  1. Delete super().__init__(recipient) from EmailNotifier.__init__ and run. Which line fails, and how far is it from the line you broke?
  2. Wrap the email notifier instead: RetryingNotifier(EmailNotifier("a@b.c")). It works with zero changes. Count how many classes the inheritance version would have needed for 4 transports × 3 policies.
  3. Flip the bases to class TimestampedSlack(SlackNotifier, TimestampMixin). Print the MRO and explain — from the tuple alone — why the timestamp disappears.
  4. Add class CarrierPigeon: with only a notify() method (inheriting from nothing) and pass it to broadcast. Does it work? What does that tell you about the list[Notifier] hint?
  5. Add @abstractmethod def close(self) to Notifier and run. Read the new TypeError — how many names does it list?

Common mistakes and troubleshooting

Symptom / traceback Cause Fix
AttributeError: 'EmailNotifier' object has no attribute 'recipient' — but construction worked Subclass __init__ never called super().__init__(), so base attributes were never created Call super().__init__(...) first in every subclass __init__
TypeError: Can't instantiate abstract class Notifier without an implementation for abstract method 'send' Instantiating an ABC, or a subclass that didn’t implement every @abstractmethod Implement the named method(s), or instantiate a concrete subclass. (≤3.11 wording: “with abstract method send”)
TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y Base order contradicts the hierarchy — class Bad(X, Y) where Y(X) Flip the bases: most-specific first — class Good(Y, X)
TypeError: Ch.send() missing 1 required positional argument: 'urgent' when looping over mixed types Override changed the signature — an LSP violation Keep the base’s signature. Add optional params with defaults, or *args, **kwargs
AttributeError: 'super' object has no attribute 'send' A mixin used alone, or super() past the end of the MRO Mixins go in front of a class that provides the method: class C(Mixin, Base)
TypeError: object.__init__() takes exactly one argument (the instance to initialize) super().__init__(x) reached object, which takes no args Something in the chain doesn’t forward args — check every __init__ in C.__mro__
RuntimeError: super(): __class__ cell not found Zero-arg super() outside a class body (e.g. in a nested/reassigned function) Use the explicit form super(MyClass, self), or move the call into the method
Subclass takes the wrong branch / is silently ignored type(x) == C — exact check, fails for every subclass Use isinstance(x, C)
Method on a mixin/base never runs, no error at all Someone wrote Base.method(self) instead of super().method() — it jumped the chain Use super() everywhere. Verify with print(C.__mro__)
One subclass’s data appears in another’s Mutable class attribute (sent = []) — one object shared by the whole family Create it per instance in __init__: self.sent = []
TypeError: Instance and class checks can only be used with @runtime_checkable protocols isinstance(x, SomeProtocol) without the decorator Add @runtime_checkable — and remember it checks names only
NotImplementedError at 3am from a class that constructed fine hours earlier Used raise NotImplementedError instead of @abstractmethod Make the base an ABC and mark it @abstractmethod — fail at construction
Adding a 5th type means editing a 40-line if isinstance(...) chain Missing polymorphism Give every type the same method name and delete the chain
Changing the base breaks three subclasses in ways nobody predicted Deep hierarchy — behaviour is spread over 5 files Flatten to 2 levels; convert the rest to composition

Four of these cost the most hours.

1. The forgotten super().__init__(). The reason this one hurts isn’t the fix — it’s one line — but the distance between cause and symptom. The constructor succeeds. The object looks fine. repr() might even work. Then, an hour of runtime later, one code path touches self.recipient and raises AttributeError in a function that is entirely innocent. You’ll debug the reader for twenty minutes before you suspect the writer. Rule: every subclass __init__ starts with super().__init__(...), and if it doesn’t, that’s a deliberate comment.

2. Mutable class attributes shared across the family. This is the mutable default argument trap wearing OOP clothes, and it has the same root cause: the object is created once, when the class statement runs.

class Notifier:
    sent = []                          # ONE list for the whole family
    def notify(self, message):
        self.sent.append(message)      # .append MUTATES the shared object

class EmailNotifier(Notifier): pass
class SlackNotifier(Notifier): pass

e, s = EmailNotifier(), SlackNotifier()
e.notify("email one")
s.notify("slack one")
print("e.sent        :", e.sent)
print("s.sent        :", s.sent)
print("same object?  :", e.sent is s.sent is Notifier.sent)
print("in e.__dict__?:", "sent" in e.__dict__)
e.sent        : ['email one', 'slack one']
s.sent        : ['email one', 'slack one']
same object?  : True
in e.__dict__?: False

Two unrelated subclasses, two separate instances, one list. self.sent.append(...) never created an instance attribute — it looked sent up (missing on the instance, found on Notifier) and mutated the object it found. Note "sent" in e.__dict__ is False: the data isn’t on the instance at all.

The tell is that last check. If self.x.append(...) works but x isn’t in self.__dict__, you’re mutating class state. Fix: build it per-instance in __init__ (self.sent = []).

Class attribute (x = [] in the body) Instance attribute (self.x = [] in __init__)
Created Once, when the class statement runs Per object, on every construction
Lives on The class — shared by every subclass and instance The instance’s __dict__
self.x.append(1) Mutates the shared object ⚠️ Mutates this object’s own
self.x = [1] Creates an instance attribute that shadows it Normal rebinding
Shows in obj.__dict__ ❌ No ✅ Yes
Right for Constants: bank = "KloudBank", MAX_RETRIES = 3 All mutable per-object state
The bug it causes One list for the whole family, growing forever

If that “created once” line feels familiar, it should — it’s the same rule that makes def f(x=[]) share one list across every call. The class body runs once, exactly like def runs once. One rule, two famous traps.

3. Overriding with a different signature. Python won’t stop you, and the damage shows up only in the polymorphic loop that was the whole point:

class Notifier:
    def send(self, message): ...
class SmsNotifier(Notifier):
    def send(self, message, urgent):     # added a REQUIRED parameter
        ...

for n in [Notifier(), SmsNotifier()]:
    n.send("hi")
# TypeError: SmsNotifier.send() missing 1 required positional argument: 'urgent'

SmsNotifier is no longer substitutable for Notifier — it demands more than the base promised, so code written against the base breaks on it. This is the Square/Rectangle problem in miniature. The rule is asymmetric, and it’s worth memorising in this shape:

An override may… Verdict Example
Add a parameter with a default ✅ Safe send(self, message, urgent=False)
Accept *args, **kwargs and forward ✅ Safe Wrappers and mixins
Return a more specific type ✅ Safe Base returns Notifier, you return EmailNotifier
Accept a broader input type ✅ Safe Base takes list, you take any Iterable
Add a required parameter ❌ Breaks callers send(self, message, urgent) ← the bug above
Rename a parameter ❌ Breaks keyword callers send(self, text) vs send(message=...)
Return less / None where a value was promised ❌ Breaks callers Silent AttributeError downstream
Raise a new exception type the base never did ❌ Breaks callers Nobody upstream catches it
Tighten a precondition ❌ Breaks callers Base accepts any string; you reject empty ones

The pattern: accept more, promise no less. If you genuinely need a different signature, you needed a different method — or composition.

4. Deep hierarchies nobody can follow. Five levels of inheritance means the behaviour of obj.send() is determined by code in five files, and to predict it you must hold the whole MRO in your head. Every change to a middle class is a change to everything below it. There’s no error message for this one — just a codebase where nobody can answer “what does this call do?” without twenty minutes of reading. Two levels is comfortable. Three needs a reason. Past that, print(C.__mro__) and refactor to composition — the wrapper you write today is one file someone can read in isolation, forever.


Cheat-sheet

Syntax What it does
class Child(Parent): Inherit — Child gets Parent’s methods and class attributes
class C(A, B): Multiple inheritance — leftmost wins
super().method(args) Next class after me in type(self).__mro__not “the parent”
super().__init__(args) Run the base’s __init__. First line of your __init__
Parent.method(self) Hardcoded jump — skips the chain. Avoid
C.__mro__ The tuple Python walks. Read this to debug any lookup
C.mro() Same, as a list
C.__bases__ Only the direct bases you wrote
type(self).__name__ The actual class name — use in a base’s __repr__
isinstance(x, C) Is x a C or subclass? ✅ The default check
isinstance(x, (A, B)) Any of several
issubclass(D, B) Class-to-class check
type(x) == C Exact type — fails for subclasses. Usually a bug
hasattr(x, "send") Duck-typing check — “can it do the thing?”
from abc import ABC, abstractmethod The abstract-base toolkit
class C(ABC): Make it abstract
@abstractmethod Required in subclasses — TypeError at construction
@property + @abstractmethod Abstract property (@property on top)
C.register(Other) Virtual subclass — passes isinstance, no enforcement
raise NotImplementedError Fails at call time. For optional hooks only
from typing import Protocol Structural typing — shape, not ancestry
class P(Protocol): Declares required methods; implementers needn’t know
@runtime_checkable Lets isinstance work on a Protocol (names only)
class C(Mixin, Base): Mixin first so it can intercept
self.x = [] in __init__ Per-instance state ✅
x = [] in the class body Shared by the whole family ⚠️
__x (two underscores) Name-mangled to _C__x — hard to override on purpose

Interview and exam questions

Q: What does super() actually do? Is it “the parent class”? A: No — that’s the most common misconception. super() returns a proxy that continues the lookup from the next class after the current one in type(self).__mro__. For single inheritance that happens to be the parent, which is why the myth survives. In a diamond D(B, C) where both B and C inherit from A, super().who() written inside B runs C.who() for a D instance — even though B’s only base is A and B has never heard of C. The chain belongs to the object’s type, not to the class the code sits in.

Q: What’s the MRO, and what algorithm builds it? A: The Method Resolution Order — a flat tuple (C.__mro__) that Python walks left to right on every attribute lookup, stopping at the first class whose namespace has the name. It’s built once at class creation by C3 linearisation, which guarantees three things: a class precedes its bases; base order is preserved left to right; and the result is monotonic (consistent with every base’s own MRO). If no such order exists, Python raises TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y at class-definition time.

Q: Work out the MRO of D where class A, class B(A), class C(A), class D(B, C). A: (D, B, C, A, object). Merge [B, A, object], [C, A, object] and [B, C]: take B (in no tail); reject A (it’s in C’s tail — C must come first); take C; then A; then object. The key step is rejecting A early — that’s what stops a base preceding its own subclass, and it’s why A runs exactly once even though two classes inherit from it.

Q: What’s the difference between @abstractmethod and raise NotImplementedError? A: Timing. @abstractmethod (with ABC) fails at constructionTypeError: Can't instantiate abstract class X without an implementation for abstract method 'y' — so a broken subclass can’t exist. NotImplementedError only fires when that method is called, so the object builds fine and the bug ships to production, surfacing on whichever code path finally touches it. Use @abstractmethod for required methods; NotImplementedError for optional hooks.

Q: ABC vs Protocol — when would you pick each? A: ABC is nominal — implementers must inherit from it — and it’s enforced at runtime, can block instantiation, and can share concrete code (__init__, template methods). Protocol is structural — anything with the right methods satisfies it, with no inheritance and no cooperation — and it’s checked statically by mypy/pyright. Use an ABC for your own family of classes that share real implementation. Use a Protocol to declare what you need from objects you don’t own; it goes on the consumer side, which is the opposite of how Java interfaces are usually taught.

Q: Why is type(x) == C usually wrong? A: It’s an exact-type check, so it’s False for every subclass — which breaks the substitution principle that makes subclassing worth anything. It works fine until someone subclasses your class, then silently takes the wrong branch with no error. isinstance(x, C) accepts C and its subclasses, which is what you almost always mean. The rare legitimate uses are serialisers and hot-loop dispatch that must distinguish exact types — e.g. bool from int, since isinstance(True, int) is True.

Q: What is duck typing, and how does it differ from Java/C# interfaces? A: Duck typing means an object’s suitability is decided by the methods it has, not the classes it inherits — “if it quacks like a duck.” No interface keyword, no implements, no declaration: pass anything with a .notify() to code that calls .notify(). Java/C# use nominal typing — a class must declare it implements a named interface, checked at compile time. The trade: Python is frictionless and works with classes you can’t edit, but fails at runtime with AttributeError; Java catches it at compile time but can’t retrofit an interface onto third-party code. typing.Protocol gives Python the static checking without the coupling.

Q: What happens if a subclass __init__ doesn’t call super().__init__()? A: The base’s __init__ never runs, so every attribute it would have created doesn’t exist. The object constructs successfully — no error — and later you get AttributeError: 'Child' object has no attribute 'name' from whichever line first touches it, potentially far away in both code and time. Fix: call super().__init__(...) as the first statement, before your own attributes, since yours may depend on the base’s and never the reverse.

Q: What does this print, and why?

class Notifier:
    sent = []
    def notify(self, m):
        self.sent.append(m)

class Email(Notifier): pass
class Slack(Notifier): pass

e, s = Email(), Slack()
e.notify("a"); s.notify("b")
print(e.sent)

A: ['a', 'b']. sent = [] is a class attribute — one list created once when the class body ran, shared by Notifier and every subclass and instance. self.sent.append(m) looks sent up (missing on the instance, found on Notifier) and mutates the shared object; it never creates an instance attribute, which you can prove with "sent" in e.__dict__False. Fix: self.sent = [] in __init__.

Q: When should you use inheritance instead of composition? A: When the subclass is genuinely substitutable everywhere the base is used (Liskov), the relationship is permanent, and you need the type — something calls isinstance, or a framework requires it. Prefer composition when you’re adding a policy (retry, caching, logging), when behaviour must change at runtime, when the class count would multiply (N transports × M policies is N+M with composition and N×M with inheritance), or when you want the base’s code but not its interface. Rule of thumb: inheritance for what a thing is; composition for what it has or does.

Q: Explain the diamond problem and how Python solves it. A: D(B, C) where both B and C inherit from A — if both define a method, which runs, and does A’s version run twice? Python computes one flat MRO with C3 (D, B, C, A, object) and walks it in order, so B wins, and if every class cooperates by calling super(), each runs exactly once in that order. Depth-first (D, B, A, C) would be wrong because it reaches A before C, letting a base shadow its own subclass. The catch: cooperation is voluntary — one class hardcoding A.method(self) instead of super().method() silently skips C, with no error.

Q (coding): Refactor this into something that doesn’t multiply.

class RetryingEmailNotifier(EmailNotifier): ...
class RetryingSlackNotifier(SlackNotifier): ...
class LoggingEmailNotifier(EmailNotifier): ...
# ...and 4 transports × 3 policies = 12 more

A: The policies are things you do to a notifier, not kinds of notifier — so wrap, don’t subclass:

class RetryingNotifier:
    def __init__(self, inner, attempts: int = 3) -> None:
        self.inner = inner                 # HOLD one, don't BE one
        self.attempts = attempts

    def notify(self, message: str) -> str:
        for _ in range(self.attempts):
            try:
                return self.inner.notify(message)
            except ConnectionError:
                continue
        raise ConnectionError(f"gave up after {self.attempts} attempts")

n = RetryingNotifier(LoggingNotifier(SlackNotifier("#alerts")))

4 transports × 3 policies drops from 12 classes to 7, they compose in any order at runtime, and each works with transports that don’t exist yet. What’s being tested is whether you can spot that “retry” is a policy rather than a type — and that a wrapper only needs .notify(), so duck typing means it needn’t inherit from anything.


Key takeaways

pythonoopinheritancepolymorphismabstractionsupermroabcprotocolduck-typingmixinscompositionmethod-overridingisinstance
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments