Inheritance and Composition
Inheritance and Composition
Composition means an object has another object and asks it to do work. Inheritance means an object is a more specific kind of another object. The boring default is composition. Inherit when the English sentence “X is a Y” stays true as the code grows, and call super() to run the parent’s setup.
Mental model
Desk has a Register and a Clock. That is composition: attributes that are objects. When the register’s rules change, the desk still looks like a desk.
PaidTicket is a Ticket with an amount. That is inheritance: the child can sit anywhere a ticket sits, plus it knows cents. class PaidTicket(Ticket): puts Ticket in the child’s method search. super().__init__(...) runs the parent __init__ so you do not copy self.id = id.
If you inherit only to reuse a couple of methods, you are borrowing with a bad contract. Put the shared helper on a third object, or make it a function.
Worked examples
Case 1: Composition (the default)
Save as desk_parts.py. The desk does not is-a register. It has one.
# desk_parts.py
class Register:
def __init__(self):
self.cents = 0
def take(self, n):
self.cents += n
return self.cents
class Clock:
def __init__(self, hour):
self.hour = hour
def stamp(self):
return f"{self.hour:02d}:00"
class Desk:
def __init__(self, register, clock):
self.register = register
self.clock = clock
def ring_up(self, cents):
total = self.register.take(cents)
return f"{self.clock.stamp()} took {cents} now {total}"
def main():
desk = Desk(Register(), Clock(9))
print(desk.ring_up(400))
print(desk.ring_up(250))
print("in register", desk.register.cents)
if __name__ == "__main__":
main()Run:
uv run python desk_parts.pyOutput:
09:00 took 400 now 400
09:00 took 250 now 650
in register 650
You can pass a fake register in a test. That is the payoff. Inheritance would glue the desk to one register class.
Case 2: Inheritance when it is a true is-a
Save as paid_ticket.py. A paid ticket is a ticket. It still has id and table. It also has cents. label extends the parent’s label.
# paid_ticket.py
class Ticket:
def __init__(self, id, table):
self.id = id
self.table = table
def label(self):
return f"ticket {self.id} → table {self.table}"
class PaidTicket(Ticket):
def __init__(self, id, table, cents):
super().__init__(id, table)
self.cents = cents
def label(self):
return f"{super().label()} paid {self.cents}c"
def main():
unpaid = Ticket(7, 12)
paid = PaidTicket(8, 4, 650)
print(unpaid.label())
print(paid.label())
print(isinstance(paid, Ticket))
print(paid.table)
if __name__ == "__main__":
main()Run:
uv run python paid_ticket.pyOutput:
ticket 7 → table 12
ticket 8 → table 4 paid 650c
True
4
isinstance(paid, Ticket) is true. Code that accepts a ticket can accept a paid ticket if it only needs label, id, and table.
Case 3: super() in a short chain
Save as shift_ticket.py. ShiftTicket is still a ticket. It adds a shift name. super() is the parent, not a copy-paste of __init__.
# shift_ticket.py
class Ticket:
def __init__(self, id, table):
self.id = id
self.table = table
def label(self):
return f"ticket {self.id} → table {self.table}"
class ShiftTicket(Ticket):
def __init__(self, id, table, shift):
super().__init__(id, table)
self.shift = shift
def label(self):
return f"{self.shift} {super().label()}"
def main():
t = ShiftTicket(3, 11, "morning")
print(t.label())
print(t.id, t.table, t.shift)
if __name__ == "__main__":
main()Run:
uv run python shift_ticket.pyOutput:
morning ticket 3 → table 11
3 11 morning
If Ticket.__init__ later grows a notes list, ShiftTicket still gets it. That is why you call super() instead of setting self.id again.
The trap
Inheriting for reuse when the relationship is has-a. Desk(Register) makes every desk a register. Callers can desk.take(400) and skip the clock. Tests cannot swap the register.
Save as desk_is_register.py:
# desk_is_register.py
class Register:
def __init__(self):
self.cents = 0
def take(self, n):
self.cents += n
return self.cents
class Desk(Register):
def __init__(self, hour):
super().__init__()
self.hour = hour
def ring_up(self, cents):
total = self.take(cents)
return f"{self.hour:02d}:00 took {cents} now {total}"
def main():
desk = Desk(9)
print(desk.ring_up(400))
print("also a register:", isinstance(desk, Register))
desk.take(50)
print("sneaky total", desk.cents)
if __name__ == "__main__":
main()Run:
uv run python desk_is_register.pyOutput:
09:00 took 400 now 400
also a register: True
sneaky total 450
It runs. It is the wrong shape. Use desk_parts.py: a desk has a register. take stays on the register. The desk’s public method is ring_up.
The boring rule
- Default to composition: store the helper on
selfand call it. - Inherit only for a real is-a that you are willing to keep.
- Call
super().__init__(...)in the child. Do not copy parent attributes by hand. - Override a method to extend behaviour; call
super().method(...)when the parent’s work still applies. isinstanceon your own classes is a smell if you need it in many places. Prefer a method on the object.- Do not build a diamond of mixins to save ten lines.
Try this
- In
desk_parts.py, addPrinterwithdef emit(self, text): return text. Compose it onDeskand printring_upthrough the printer. - In
paid_ticket.py, addVoidedTicket(Ticket)withlabelreturning the parent label plusvoided. Keepsuper().__init__. - In
shift_ticket.py, dropsuper().__init__and setself.idby hand. Then addself.notes = []toTicket.__init__and noticeShiftTicketdoes not get it. Putsuper()back. - Rewrite
desk_is_register.pyas composition (copy the shape of Case 1). Confirmisinstance(desk, Register)isFalse.