Session 26 of the Python Fundamentals series, covered in depth. It covers two ways to reuse classes: inheritance, where a Dog IS-A Animal, through subclassing, overriding methods, and super() to reuse the parent; and composition, where a Car HAS-A Engine, building objects out of other objects. It also covers isinstance and the is-a test, why you should prefer composition to inheritance, and how method resolution walks the class chain. The traps are forgetting super().__init__, so that the parent's attributes are never set and you hit an AttributeError, deep inheritance chains, and overriding a method without calling super(), which silently loses the parent's behavior. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 103 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 26
Two ways to build a class out of another class
Objectives
You can already write a class with __init__ and methods. Now you will reuse one class to build another. By the end you can:
class Dog(Animal): and inherit the parent's methods.super() to keep the parent's behavior.super().__init__() so parent attributes actually get set.isinstance.has-a) - a Car that holds an Engine.super AttributeError.Warm-up
Discussion prompt
Before we open Session 26 - Inheritance & Composition: without looking back, what was the main idea of Session 25 - Dunder Methods, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 25 of the Python Fundamentals series, in depth. Special double-underscore methods that hook Python's own syntax onto your classes: __init__ (already known), __str__ vs __repr__ (print and the shell vs containers), __eq__ for value equality, __len__ for len(), __lt__ for sorting, and __add__ for +.
Section
Part 1
Concept
You rarely build every class from scratch. Two tools let one class build on another: inheritance and composition.
Counterexample
Discussion prompt
You rarely build every class from scratch. Two tools let one class build on another: inheritance and composition.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
Use inheritance when the new class truly is a kind of the old one. A dog is an animal; a manager is an employee.
inheritance — Defining a class from a parent class so it automatically gets the parent's attributes and methods. The new class is a subclass (or child); the original is the superclass (or parent).
Analogy
Discussion prompt
Explain Inheritance is the is-a relationship by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Use inheritance when the new class truly is a kind of the old one. A dog is an animal; a manager is an employee.
Intuition
Picture a general blueprint (Animal) and a more specific one that starts from it (Dog). The Dog blueprint says 'everything an Animal has, plus these extras.'
You do not recopy the shared parts. You inherit them, then add or change only what makes a Dog special.
Explain it
Discussion prompt
Explain A subclass is a specialized version to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Picture a general blueprint (Animal) and a more specific one that starts from it (Dog). The Dog blueprint says 'everything an Animal has, plus these extras.'
Section
Part 2
Concept
Put the parent's name in parentheses after the class name: class Dog(Animal):. Now Dog starts with everything Animal has.
Without extra code, a subclass already works - it borrows the parent's __init__ and methods.
Ranking
Put in order
Put the moves of A subclass inherits everything into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. The empty body still inherits __init__ and speak from Animal.
Worked example
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return self.name + " makes a sound"
class Fish(Animal):
pass
f = Fish("Nemo")
print(f.name)
print(f.speak())Fish adds nothing but (Animal)
Why: The empty body still inherits __init__ and speak from Animal.
Creating a Fish runs Animal's __init__
Why: Fish("Nemo") uses the parent's __init__, so f.name is set.
Read the output
Why: Verified by execution: Nemo, then Nemo makes a sound - both borrowed from Animal.
| call | comes from | result |
|---|---|---|
| Fish("Nemo") | Animal.__init__ | name = Nemo |
| f.name | inherited | Nemo |
| f.speak() | Animal.speak | Nemo makes a sound |
Comparison
Comparison matrix
From A subclass inherits everything: refill the comes from column from what you know. The rest of the table is as it appeared.
| call | comes from | result |
|---|---|---|
| Fish("Nemo") | Animal.__init__ | name = Nemo |
| f.name | inherited | Nemo |
| f.speak() | Animal.speak | Nemo makes a sound |
Concept
Inheritance is a starting point, not a cage. A subclass keeps the parent's methods and can define brand-new ones of its own.
Step zero
Discussion prompt
A subclass with an extra method — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: Dog inherits speak and adds fetch
Answer:
Worked example
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return self.name + " makes a sound"
class Dog(Animal):
def fetch(self):
return self.name + " fetches the ball"
d = Dog("Rex")
print(d.speak())
print(d.fetch())Dog inherits speak and adds fetch
Why: speak comes from Animal; fetch is new and only Dogs have it.
Both methods can use self.name
Why: self.name was set by Animal's inherited __init__.
Read the output
Why: Verified by execution: Rex makes a sound, then Rex fetches the ball.
| call | defined in | result |
|---|---|---|
| d.speak() | Animal | Rex makes a sound |
| d.fetch() | Dog | Rex fetches the ball |
Trade off
Comparison matrix
From A subclass with an extra method: every row here is a choice with a cost. Fill the defined in column, then say which row you would actually pick and what you give up for it.
| call | defined in | result |
|---|---|---|
| d.speak() | Animal | Rex makes a sound |
| d.fetch() | Dog | Rex fetches the ball |
Section
Part 3
Concept
If a subclass defines a method with the same name as the parent's, the subclass version wins for its objects. This is overriding.
overriding — Defining a method in a subclass that replaces the parent's method of the same name. Objects of the subclass run the new version; the parent's own objects are unaffected.
Definition probe
Sort into buckets
Every line below is part of the definition of inheritance or of overriding — one or the other, never both. Put each where it belongs.
Fill the middle
Fill in the blanks
From Override speak in Dog — one line has had its right-hand side removed. Put it back.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return self.name + " makes a sound"
class Dog(Animal):
def speak(self):
return self.name + " says woof"
a = Animal("Generic")
d = Dog("Rex")
print(a.speak())
print(d.speak())
Why: a is what everything below it consumes, so the wrong expression here fails later and somewhere else. Same name as Animal.speak, so it overrides it for Dog objects.
Worked example
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return self.name + " makes a sound"
class Dog(Animal):
def speak(self):
return self.name + " says woof"
a = Animal("Generic")
d = Dog("Rex")
print(a.speak())
print(d.speak())Dog defines its own speak
Why: Same name as Animal.speak, so it overrides it for Dog objects.
Each object runs its class's version
Why: a is an Animal, so a.speak() uses Animal's; d is a Dog, so d.speak() uses Dog's.
Read the output
Why: Verified by execution: Generic makes a sound, then Rex says woof.
| object | class | speak() result |
|---|---|---|
| a | Animal | Generic makes a sound |
| d | Dog | Rex says woof |
Error analysis
Annotate
Walk the callouts on Override speak in Dog. Each one is a place this is easy to get subtly wrong.
Concept
By default an override fully replaces the parent method - the parent's code does not run at all.
To reuse the parent's work and add to it, call super().method() inside your override.
Sorting
Sort into buckets
These are the pieces of Session 26 - Inheritance & Composition, out of order. Put each one back under the part of the lesson it belongs to.
Faded example
Fill in the blanks
Extend the parent with super(), with the scaffolding fading: two lines are gone now — fill both.
class Greeter:
def __init__(self, name):
self.name = name
def greet(self):
return "Hi, " + self.name
class LoudGreeter(Greeter):
def greet(self):
base = super().greet()
return base + "!!!"
print(LoudGreeter("Ana").greet())
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. It returns "Hi, Ana" without recopying that logic.
Worked example
class Greeter:
def __init__(self, name):
self.name = name
def greet(self):
return "Hi, " + self.name
class LoudGreeter(Greeter):
def greet(self):
base = super().greet()
return base + "!!!"
print(LoudGreeter("Ana").greet())super().greet() runs the parent's greet
Why: It returns "Hi, Ana" without recopying that logic.
The override adds to the parent's result
Why: It appends "!!!" to whatever the parent produced.
Read the output
Why: Verified by execution: Hi, Ana!!! - parent behavior plus the extra.
| step | value |
|---|---|
| super().greet() | Hi, Ana |
| base + "!!!" | Hi, Ana!!! |
Blank canvas
Draw it
Draw what Extend the parent with super() just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Section
Part 4
Concept
When a subclass writes its own __init__, it replaces the parent's. Call super().__init__(...) first to let the parent set its attributes.
super().__init__() — A call inside a subclass's __init__ that runs the parent class's __init__, so the parent's attributes (like self.name) get set before the subclass adds its own.
Worked example
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
d = Dog("Rex", "Corgi")
print(d.name)
print(d.breed)super().__init__(name) sets self.name
Why: It runs Animal's __init__, which stores name.
Then Dog sets its own attribute
Why: self.breed is the part that is special to Dog.
Read the output
Why: Verified by execution: Rex, then Corgi - both attributes exist.
| attribute | set by | value |
|---|---|---|
| d.name | Animal.__init__ | Rex |
| d.breed | Dog.__init__ | Corgi |
Concept
You can call super().anything() to reach the parent's version of that method. It is the general way to say 'do what the parent does, then add mine.'
Step zero
Discussion prompt
A Manager whose pay adds a bonus — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: __init__ reuses the parent, then adds bonus
Answer:
Worked example
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def pay(self):
return self.salary
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self.bonus = bonus
def pay(self):
return super().pay() + self.bonus
m = Manager("Sam", 5000, 1000)
print(m.name)
print(m.pay())__init__ reuses the parent, then adds bonus
Why: super().__init__ sets name and salary; Manager adds bonus.
pay builds on the parent's pay
Why: super().pay() returns the salary; Manager adds the bonus on top.
Read the output
Why: Verified by execution: Sam, then 6000 (5000 + 1000).
| step | value |
|---|---|
| super().pay() | 5000 |
| + self.bonus | 6000 |
| m.name | Sam |
Pattern
Step through it
Step through A Manager whose pay adds a bonus one row at a time. What is driving the change, and what would the row after the last one be?
Concept
A subclass often does both at once: call super().__init__(...) to set the shared attributes, and override a method to change one behavior.
The parent supplies the common frame; the subclass fills in the one part that is different.
Fill the middle
Fill in the blanks
From A Rectangle that overrides area — one line has had its right-hand side removed. Put it back.
class Shape:
def __init__(self, color):
self.color = color
def area(self):
return 0
class Rectangle(Shape):
def __init__(self, color, w, h):
super().__init__(color)
self.w = w
self.h = h
def area(self):
return self.w * self.h
r = Rectangle("red", 3, 4)
print(r.color)
print(r.area())
Why: self.color is what everything below it consumes, so the wrong expression here fails later and somewhere else. Shape stores color; Rectangle then adds w and h.
Worked example
class Shape:
def __init__(self, color):
self.color = color
def area(self):
return 0
class Rectangle(Shape):
def __init__(self, color, w, h):
super().__init__(color)
self.w = w
self.h = h
def area(self):
return self.w * self.h
r = Rectangle("red", 3, 4)
print(r.color)
print(r.area())super().__init__ sets the shared color
Why: Shape stores color; Rectangle then adds w and h.
area is overridden with real math
Why: Shape.area returns 0; Rectangle replaces it with w * h.
Read the output
Why: Verified by execution: red, then 12 (3 * 4).
| call | comes from | result |
|---|---|---|
| r.color | Shape.__init__ | red |
| r.area() | Rectangle.area | 12 |
Section
Part 5
Anomaly
Predict first
A student writes this, and it looks reasonable:
The subclass writes its own __init__ but never calls the parent's.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: Dog's __init__ replaced Animal's and skipped super().__init__(name), so name was never stored.
Call super().__init__ so the parent sets its attributes.
Why: Dog's __init__ replaced Animal's and skipped super().__init__(name), so name was never stored. breed prints fine, then name crashes.
Trap
The subclass writes its own __init__ but never calls the parent's.
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
self.breed = breed
d = Dog("Rex", "Corgi")
print(d.breed)
print(d.name)self.name is never set
Why: Dog's __init__ replaced Animal's and skipped super().__init__(name), so name was never stored. breed prints fine, then name crashes.
| line | result |
|---|---|
| print(d.breed) | Corgi |
| print(d.name) | AttributeError: 'Dog' object has no attribute 'name' |
Call super().__init__ so the parent sets its attributes.
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
d = Dog("Rex", "Corgi")
print(d.breed)
print(d.name)Now both attributes exist
Why: super().__init__(name) stores name; Dog stores breed. Real output: Corgi, then Rex. Rule of thumb: if a subclass defines __init__, its first line is usually super().__init__(...).
| line | result |
|---|---|
| print(d.breed) | Corgi |
| print(d.name) | Rex |
Break the constraint
Discussion prompt
The rule this trap just fixed:
Call super().__init__ so the parent sets its attributes.
Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?
Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.
Answer:
Dog's __init__ replaced Animal's and skipped super().__init__(name), so name was never stored. breed prints fine, then name crashes.
Concept
The missing super().__init__ does not crash right away. The object is built fine - the crash comes the moment something reads the attribute that was never set.
So AttributeError: '...' object has no attribute 'name' often points back to a forgotten super() call.
Worked example
class Logger:
def __init__(self):
self.entries = []
def log(self, msg):
self.entries.append(msg)
class TimeLogger(Logger):
def __init__(self):
pass
t = TimeLogger()
t.log("hello")TimeLogger.__init__ does nothing useful
Why: pass means no super().__init__(), so self.entries is never created.
The crash waits until log() runs
Why: Creating t is fine; the AttributeError only fires when log tries to use self.entries.
Read the traceback
Why: Verified by execution: AttributeError: 'TimeLogger' object has no attribute 'entries'.
| step | result |
|---|---|
| TimeLogger() | object built (no entries) |
| t.log("hello") | AttributeError: 'TimeLogger' object has no attribute 'entries' |
Concept
Usually the subclass's __init__ runs first, calls super().__init__() to run the parent's, then finishes its own setup.
You control the order: put super().__init__() where the parent's attributes need to exist before your code uses them.
Ranking
Put in order
Put the moves of Trace the __init__ call order into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. Child() calls Child's __init__, which prints Child init.
Worked example
class Base:
def __init__(self):
print("Base init")
self.ok = True
class Child(Base):
def __init__(self):
print("Child init")
super().__init__()
Child()Child.__init__ starts first
Why: Child() calls Child's __init__, which prints Child init.
super().__init__() hands control to Base
Why: Then Base's __init__ runs, prints Base init, and sets self.ok.
Read the output
Why: Verified by execution: Child init, then Base init - in that order.
| order | runs | prints |
|---|---|---|
| 1 | Child.__init__ | Child init |
| 2 | Base.__init__ | Base init |
Section
Part 6
Concept
isinstance(obj, Class) is True when obj is that class or any subclass of it. It answers 'is this object a kind of Class?'
isinstance(obj, C) — A built-in that returns True if obj is an instance of class C or of any subclass of C. It reflects the is-a relationship set up by inheritance.
Hypothesis
Predict first
isinstance across the family is about to be worked. State your hypothesis first: which rule or definition decides this one, and what is the first move it forces? Then watch whether the example agrees with you.
Correct: A Dog is a Dog and an Animal
Why: Both are True - Dog inherits from Animal, so the is-a chain holds upward.
A hypothesis you wrote down is falsifiable; a vague sense of how it will go is not. If the example opens somewhere else, that gap is the thing worth chasing.
Worked example
class Animal:
pass
class Dog(Animal):
pass
d = Dog()
a = Animal()
print(isinstance(d, Dog))
print(isinstance(d, Animal))
print(isinstance(a, Dog))
print(isinstance(d, object))A Dog is a Dog and an Animal
Why: Both are True - Dog inherits from Animal, so the is-a chain holds upward.
But an Animal is not a Dog
Why: The relationship is one-way: every Dog is an Animal, not every Animal is a Dog.
Read the output
Why: Verified by execution: True, True, False, True (everything is an object).
| check | result |
|---|---|
| isinstance(d, Dog) | True |
| isinstance(d, Animal) | True |
| isinstance(a, Dog) | False |
| isinstance(d, object) | True |
Discrimination
Sort into buckets
Sort these by result, from memory, without looking back at isinstance across the family. Telling them apart on the spot is the skill; the table is only where the answer happens to be written down.
Concept
type(d) == Animal is False even when d is a Dog, because the exact type is Dog. isinstance respects subclasses; type == does not.
Pass a tuple to check several classes at once: isinstance(d, (Dog, Cat)).
Pattern
Predict first
The table runs: isinstance(d, (Dog, Cat)) | True · type(d) == Animal | False
In isinstance vs type ==, given the rows so far: what is the next one — the row where check is type(d) == Dog?
Correct: type(d) == Dog | True
| check | result |
|---|---|
| isinstance(d, (Dog, Cat)) | True |
| type(d) == Animal | False |
| type(d) == Dog | True |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. d is a Dog, and Dog is in the tuple, so it is True.
Worked example
class Animal: pass
class Dog(Animal): pass
class Cat(Animal): pass
d = Dog()
print(isinstance(d, (Dog, Cat)))
print(type(d) == Animal)
print(type(d) == Dog)A tuple asks 'is it any of these?'
Why: d is a Dog, and Dog is in the tuple, so it is True.
type == checks the exact class only
Why: d's exact type is Dog, not Animal, so type(d) == Animal is False.
Read the output
Why: Verified by execution: True, False, True.
| check | result |
|---|---|
| isinstance(d, (Dog, Cat)) | True |
| type(d) == Animal | False |
| type(d) == Dog | True |
Comparison
Comparison matrix
From isinstance vs type ==: refill the result column from what you know. The rest of the table is as it appeared.
| check | result |
|---|---|
| isinstance(d, (Dog, Cat)) | True |
| type(d) == Animal | False |
| type(d) == Dog | True |
Section
Part 7
Concept
Instead of inheriting, a class can contain another object as an attribute. A Car HAS-A Engine; the Car stores an Engine and uses it.
composition — Building a class by giving it other objects as attributes (has-a), rather than by subclassing (is-a). The container delegates work to the objects it holds.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of overriding, super().__init__(), isinstance(obj, C), composition as Session 26 - Inheritance & Composition uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Intuition
Inheritance says what an object is. Composition says what an object has. A car is not a kind of engine - it has one, plus wheels, plus seats.
When you catch yourself saying 'has a', reach for composition; save inheritance for real 'is a' cases.
Step zero
Discussion prompt
A Car HAS-A Engine — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: Car builds an Engine and stores it
Answer:
Worked example
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return "engine with " + str(self.horsepower) + " hp starts"
class Car:
def __init__(self, model, horsepower):
self.model = model
self.engine = Engine(horsepower)
def start(self):
return self.model + ": " + self.engine.start()
c = Car("Sedan", 120)
print(c.start())
print(c.engine.horsepower)Car builds an Engine and stores it
Why: self.engine = Engine(horsepower) - the Car HAS an Engine object.
Car delegates work to its engine
Why: Car.start() asks self.engine to start, then wraps the result.
Read the output
Why: Verified by execution: Sedan: engine with 120 hp starts, then 120.
| call | delegates to | result |
|---|---|---|
| c.start() | self.engine.start() | Sedan: engine with 120 hp starts |
| c.engine.horsepower | the held Engine | 120 |
Faded example
Fill in the blanks
An Order HAS-A Customer, with the scaffolding fading: two lines are gone now — fill both.
class Customer:
def __init__(self, name):
self.name = name
class Order:
def __init__(self, customer, total):
self.customer = customer
self.total = total
def summary(self):
return self.customer.name + " owes " + str(self.total)
c = Customer("Dana")
o = Order(c, 40)
print(o.summary())
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. Order stores an existing Customer object as self.customer.
Worked example
class Customer:
def __init__(self, name):
self.name = name
class Order:
def __init__(self, customer, total):
self.customer = customer
self.total = total
def summary(self):
return self.customer.name + " owes " + str(self.total)
c = Customer("Dana")
o = Order(c, 40)
print(o.summary())The Customer is passed in, not inherited
Why: Order stores an existing Customer object as self.customer.
Order reaches into its Customer
Why: summary reads self.customer.name - the held object's attribute.
Read the output
Why: Verified by execution: Dana owes 40.
| expression | value |
|---|---|
| o.customer | the Customer object |
| o.customer.name | Dana |
| o.summary() | Dana owes 40 |
Concept
Composition scales up: a class can hold a list of other objects and loop over them - a Bicycle with two Wheels, a Playlist of Songs.
Pattern
Predict first
The table runs: b.wheel_count() | 2 · b.wheels[0] | a Wheel object
In A Bicycle HAS two Wheels, given the rows so far: what is the next one — the row where expression is b.wheels[0].size?
Correct: b.wheels[0].size | 26
| expression | value |
|---|---|
| b.wheel_count() | 2 |
| b.wheels[0] | a Wheel object |
| b.wheels[0].size | 26 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. self.wheels is a list; each item is its own Wheel.
Worked example
class Wheel:
def __init__(self, size):
self.size = size
class Bicycle:
def __init__(self):
self.wheels = [Wheel(26), Wheel(26)]
def wheel_count(self):
return len(self.wheels)
b = Bicycle()
print(b.wheel_count())
print(b.wheels[0].size)Bicycle holds a list of Wheel objects
Why: self.wheels is a list; each item is its own Wheel.
Reach a held object through the list
Why: b.wheels[0] is the first Wheel; .size reads its attribute.
Read the output
Why: Verified by execution: 2, then 26.
| expression | value |
|---|---|
| b.wheel_count() | 2 |
| b.wheels[0] | a Wheel object |
| b.wheels[0].size | 26 |
Section
Part 8
Concept
A common guideline: when both could work, reach for composition. Holding an object is more flexible than being locked into a parent's whole interface.
Use inheritance only for a genuine is-a. If you would say 'has a' or 'uses a', compose instead.
Explain it
Discussion prompt
Explain Prefer composition over inheritance to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
A common guideline: when both could work, reach for composition. Holding an object is more flexible than being locked into a parent's whole interface.
Fill the middle
Fill in the blanks
From Wrong is-a: a Car is not an Engine — one line has had its right-hand side removed. Put it back.
class Engine:
def start(self):
return "vroom"
class Car(Engine):
pass
c = Car()
print(c.start())
print(isinstance(c, Engine))
Why: c is what everything below it consumes, so the wrong expression here fails later and somewhere else. Car(Engine) makes Python treat a Car AS an Engine - isinstance says True, which is nonsense in the real world.
Worked example
class Engine:
def start(self):
return "vroom"
class Car(Engine):
pass
c = Car()
print(c.start())
print(isinstance(c, Engine))It runs, but the model is wrong
Why: Car(Engine) makes Python treat a Car AS an Engine - isinstance says True, which is nonsense in the real world.
Composition would say Car HAS an Engine
Why: self.engine = Engine() keeps them separate, which matches reality.
Read the output
Why: Verified by execution: vroom, then True - it works, but forces a false is-a.
| check | result | sensible? |
|---|---|---|
| c.start() | vroom | borrowed |
| isinstance(c, Engine) | True | no - a car is not an engine |
Anomaly
Predict first
A student writes this, and it looks reasonable:
The override ignores the parent entirely, dropping the name it was built with.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: greet returns a fixed "HELLO" and never uses self.name, so Ana is lost.
Call super() to keep the parent's work, then add yours.
Why: greet returns a fixed "HELLO" and never uses self.name, so Ana is lost. No error - just wrong behavior.
Trap
The override ignores the parent entirely, dropping the name it was built with.
class Greeter:
def __init__(self, name):
self.name = name
def greet(self):
return "Hi, " + self.name
class BrokenGreeter(Greeter):
def greet(self):
return "HELLO"
print(BrokenGreeter("Ana").greet())The parent's logic silently vanishes
Why: greet returns a fixed "HELLO" and never uses self.name, so Ana is lost. No error - just wrong behavior.
| call | result |
|---|---|
| BrokenGreeter("Ana").greet() | HELLO |
Call super() to keep the parent's work, then add yours.
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("Ana").greet())super().greet() keeps the name
Why: It reuses "Hi, Ana", then adds "!!!". Real output: Hi, Ana!!!. Rule of thumb: if your override should extend the parent, start it with super().
| call | result |
|---|---|
| LoudGreeter("Ana").greet() | Hi, Ana!!! |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
Section
Part 9
Concept
Call obj.method() and Python looks in the object's class first, then its parent, then the parent's parent, up to object. The first match wins.
That search order is the method resolution order (MRO). It is why an override in the subclass beats the parent's version.
Analogy
Discussion prompt
Explain Python searches up the chain by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Call obj.method() and Python looks in the object's class first, then its parent, then the parent's parent, up to object. The first match wins.
Step zero
Discussion prompt
Trace the method lookup — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: C has no hello, so Python looks up
Answer:
Worked example
class A:
def hello(self):
return "A"
class B(A):
def hello(self):
return "B"
class C(B):
pass
c = C()
print(c.hello())
print(C.__mro__)C has no hello, so Python looks up
Why: It checks C (nothing), then B (found) - so B's hello wins.
__mro__ shows the exact search order
Why: C, then B, then A, then object - Python walks this list left to right.
Read the output
Why: Verified by execution: B, then (<class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>).
| class searched | has hello? | used? |
|---|---|---|
| C | no | keep looking |
| B | yes | used - returns B |
| A | yes | not reached |
| object | no | not reached |
Blank canvas
Draw it
Draw what Trace the method lookup just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
super() means 'start the search one step above me in the MRO.' That is why super().greet() finds the parent's version, not your own.
Ranking
Put in order
Put the moves of super() walks a three-level chain into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. C calls B, B calls A - a chain of super().__init__ calls.
Worked example
class A:
def __init__(self):
self.a = 1
class B(A):
def __init__(self):
super().__init__()
self.b = 2
class C(B):
def __init__(self):
super().__init__()
self.c = 3
c = C()
print(c.a, c.b, c.c)Each super() climbs one level
Why: C calls B, B calls A - a chain of super().__init__ calls.
Every level sets its own attribute
Why: A sets a, B sets b, C sets c - all present on the final object.
Read the output
Why: Verified by execution: 1 2 3 - all three attributes exist.
| attribute | set by | value |
|---|---|---|
| c.a | A.__init__ | 1 |
| c.b | B.__init__ | 2 |
| c.c | C.__init__ | 3 |
Error analysis
Annotate
Walk the callouts on super() walks a three-level chain. Each one is a place this is easy to get subtly wrong.
Concept
Deep inheritance (A to B to C to D...) gets hard to follow - a method could live anywhere up the chain, and one missing super() breaks the rest.
Two or three levels is usually plenty. If a hierarchy grows tall, composition is often the cleaner fix.
Counterexample
Discussion prompt
Deep inheritance (A to B to C to D...) gets hard to follow - a method could live anywhere up the chain, and one missing super() breaks the rest.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
Two or three levels is usually plenty. If a hierarchy grows tall, composition is often the cleaner fix.
Section
Part 10
Pattern
1. class Child(Parent): to inherit everything
Why: The subclass starts with the parent's attributes and methods.
2. In __init__, call super().__init__(...) first
Why: That sets the parent's attributes before you add your own; skipping it causes AttributeError later.
3. Override a method by redefining its name
Why: The subclass version wins for its objects.
4. Call super().method() to keep the parent's behavior
Why: Use it whenever your override should extend rather than replace the parent.
Pattern
Say the relationship out loud
Why: 'A Dog IS-A Animal' -> inheritance. 'A Car HAS-A Engine' -> composition.
Genuine is-a? Subclass it
Why: Use inheritance only when the child is truly a kind of the parent.
Otherwise, hold the object as an attribute
Why: Composition (self.thing = Thing()) is more flexible and avoids false is-a relationships.
When in doubt, prefer composition
Why: It keeps classes loosely coupled and hierarchies shallow.
Real world
Discussion prompt
Outside this lesson: where does Session 26 - Inheritance & Composition actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Inheritance or composition? is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 26 of the Python Fundamentals series, in depth. Two ways to reuse classes: inheritance (a Dog IS-A Animal - subclassing, overriding methods, and super() to reuse the parent) and composition (a Car HAS-A Engine - building objects out of other objects).
Check
Cow overrides sound.
class Animal:
def sound(self):
return "..."
class Cow(Animal):
def sound(self):
return "moo"
print(Cow().sound())| object | class | sound() |
|---|---|---|
| Cow() | Cow | ? |
Check your understanding
What does this print?
Answer: A
Why: Cow defines its own sound, which overrides Animal.sound for Cow objects, so it returns moo. Verified by execution.
Check
Sub calls super().__init__.
class Base:
def __init__(self, x):
self.x = x
class Sub(Base):
def __init__(self, x, y):
super().__init__(x)
self.y = y
s = Sub(1, 2)
print(s.x + s.y)| s.x | s.y | s.x + s.y |
|---|---|---|
| 1 | 2 | ? |
Check your understanding
What does this print?
Answer: A
Why: super().__init__(x) sets self.x to 1 and Sub sets self.y to 2, so s.x + s.y is 3. Verified by execution.
Check
Stopwatch skips super().__init__.
class Timer:
def __init__(self):
self.count = 0
class Stopwatch(Timer):
def __init__(self):
self.laps = []
sw = Stopwatch()
print(sw.count)| attribute | set? |
|---|---|
| sw.laps | yes |
| sw.count | ? |
Check your understanding
What does print(sw.count) do?
Answer: A
Why: Stopwatch.__init__ never calls super().__init__(), so self.count is never set and reading it raises AttributeError. Verified by execution.
Check
Square is a Shape.
class Shape:
def area(self):
return 0
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
sq = Square(5)
print(isinstance(sq, Shape))| object | class | isinstance(sq, Shape) |
|---|---|---|
| sq | Square | ? |
Check your understanding
What does this print?
Answer: A
Why: Square is a subclass of Shape, so a Square object is-a Shape and isinstance returns True. Verified by execution.
Check
Camera holds a Phone.
class Phone:
def __init__(self):
self.battery = 100
class Camera:
def __init__(self):
self.phone = Phone()
cam = Camera()
print(cam.phone.battery)
print(isinstance(cam, Phone))| expression | value |
|---|---|
| cam.phone.battery | ? |
| isinstance(cam, Phone) | ? |
Check your understanding
What are the two lines of output?
Answer: A
Why: Camera HAS-A Phone via composition, so cam.phone.battery is 100; but Camera does not inherit Phone, so isinstance(cam, Phone) is False. Verified by execution.
Trade off
Comparison matrix
From Check: composition: every row here is a choice with a cost. Fill the value column, then say which row you would actually pick and what you give up for it.
| expression | value |
|---|---|
| cam.phone.battery | ? |
| isinstance(cam, Phone) | ? |
Check
B extends A's greet.
class A:
def greet(self):
return "hello from A"
class B(A):
def greet(self):
return super().greet() + " and B"
print(B().greet())| step | value |
|---|---|
| super().greet() | hello from A |
| result | ? |
Check your understanding
What does this print?
Answer: A
Why: super().greet() returns "hello from A", and B appends " and B", giving hello from A and B. Verified by execution.
Comparison
Comparison matrix
From Check: super() in a method: refill the value column from what you know. The rest of the table is as it appeared.
| step | value |
|---|---|
| super().greet() | hello from A |
| result | ? |
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Reusing a Class · Making a Subclass · Overriding Methods · super().__init__ · The Missing super Trap · isinstance & is-a. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
You can reuse classes two ways: inheritance (a subclass IS-A parent) and composition (a class HAS-A other object as an attribute).
| You write | It means |
|---|---|
| class Dog(Animal): | Dog inherits Animal's attributes and methods |
| def speak(self): ... | override - Dog's version wins for Dog objects |
| super().__init__(name) | run the parent's __init__ so its attributes get set |
| super().greet() | reuse the parent's method, then add to it |
| isinstance(d, Animal) | True if d is an Animal or a subclass of it |
| self.engine = Engine() | composition - the object HAS an Engine |
Prefer composition unless it is a genuine is-a; always call super().__init__ or the missing attribute will crash later. Next session we build a full project using these tools together.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.