Inheritance and Composition

Updated

September 8, 2026

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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 self and 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.
  • isinstance on 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

  1. In desk_parts.py, add Printer with def emit(self, text): return text. Compose it on Desk and print ring_up through the printer.
  2. In paid_ticket.py, add VoidedTicket(Ticket) with label returning the parent label plus voided. Keep super().__init__.
  3. In shift_ticket.py, drop super().__init__ and set self.id by hand. Then add self.notes = [] to Ticket.__init__ and notice ShiftTicket does not get it. Put super() back.
  4. Rewrite desk_is_register.py as composition (copy the shape of Case 1). Confirm isinstance(desk, Register) is False.