Inheritance & composition
Extend a base class with inheritance, and build objects from other objects.
Two ways to reuse a class
Every real codebase reuses behavior. The question an interviewer cares about is how you reuse it, because the wrong choice locks a system into a rigid shape that is painful to change later. There are two tools: inheritance (an "is-a" relationship) and composition (a "has-a" relationship). Reaching for inheritance by reflex is one of the most common junior mistakes, so knowing when each fits is the actual skill here.
Inheritance: a subclass is a kind of its parent
When you write class LoudGreeter(Greeter), a LoudGreeter is a Greeter. It automatically gets every attribute and method defined on Greeter, and you only write the parts that differ. When Python looks up obj.greet, it walks the class chain (the method resolution order) from the subclass upward and uses the first match it finds. Defining greet on LoudGreeter therefore overrides the parent version.
To extend the parent instead of replacing it, call super(). super() returns a proxy that dispatches to the next class up the chain, so super().greet() runs Greeter.greet(self) and hands you its return value to build on:
class Greeter:
def __init__(self, name):
self.name = name
def greet(self):
return "Hi, " + self.name
class LoudGreeter(Greeter):
def greet(self):
return super().greet() + "!!!"
print(LoudGreeter("Ada").greet()) # Hi, Ada!!!
Notice LoudGreeter never redefines __init__. Because it inherits the parent's, LoudGreeter("Ada") still stores self.name = "Ada". That is exactly the Apply exercise: override greet, call super().greet(), and append "!!!".
What super() actually resolves to
super() does not mean "my parent class". It means "the next class after me in the method
resolution order", and that distinction only shows up once more than one base is involved. Every
class carries its MRO as a list you can read:
class A:
def who(self):
return "A"
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([cls.__name__ for cls in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']
Read that output carefully: inside B.who, super() reached C, not A. B does not inherit from
C at all. The MRO is a property of D, the object the call started from, so B's super() resolves
against D's ordering:
Stage 1 of 4: The call starts here, and D's MRO is what every super() in the chain will follow.
This is the diamond problem, and cooperative super() is how Python solves it: the shared base A
executes once rather than once per path. It also explains why every class in a cooperative hierarchy
should call super(). One class that skips the call silently truncates the chain for everyone below it.
Composition: an object has another object
Composition means an object holds other objects as attributes instead of inheriting from them. A Person is not a kind of Wallet, so inheritance is wrong here. A Person has a Wallet:
class Person:
def __init__(self, name):
self.name = name
self.wallet = Wallet() # a fresh Wallet, owned by this person
p = Person("Ada")
p.wallet.add(50)
print(p.wallet.balance) # 50
The Person delegates money handling to the Wallet and stays small. That is the Practice exercise: set both self.name and a brand-new self.wallet = Wallet().
Sort each pair by the relationship that fits it. Ask whether the first thing IS a kind of the second, or merely HAS one.
Which to use
Prefer composition when one thing contains or uses another. Reserve inheritance for a genuine "is-a" relationship where the subclass is fully substitutable for its parent. Composition keeps classes small and lets you swap a part out without touching a whole hierarchy.
Pitfalls
The classic trap: a subclass defines its own __init__ but forgets to call super().__init__(...). The parent's setup never runs, so attributes like self.name are silently missing and you get an AttributeError only later, when some method reads them. If you override __init__, call the parent's inside it. (LoudGreeter sidesteps this by not defining __init__ at all.)
Interview nuance: know why "prefer composition over inheritance" is standard advice. Inheritance couples a subclass to the parent's internals, so a change in the base class can quietly break every subclass (the fragile base class problem), and deep chains make method lookup hard to trace through the MRO. Composition swaps that inheritance coupling for a narrow "uses" boundary you can test and replace in isolation.
class Greeter:
def __init__(self, name):
self.name = name
def greet(self):
return "Hi, " + self.name
class LoudGreeter(Greeter):
def greet(self):
return super().greet() + "!!!"
print(LoudGreeter("Ada").greet()) # Hi, Ada!!!Apply
Your turn
The task this lesson builds to.
Greeter is provided. Implement LoudGreeter(Greeter) so its greet calls the parent's
greet via super() and appends "!!!".
run("Ada") should return "Hi, Ada!!!".
3 hints and 2 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Wallet is provided. Finish Person.__init__ so each person stores their name and owns
a fresh Wallet as self.wallet (composition).
run(50) should return 50 (the wallet's balance after adding 50).
2 hints and 3 automated checks are waiting in the workspace.