2.1.12 · D4OOP Fundamentals

Exercises — Polymorphism — duck typing, same interface different behavior

3,215 words15 min readBack to topic
Figure — Polymorphism — duck typing, same interface different behavior

The figure above is our compass: one call site (the amber button) fans out to many objects, and each object supplies its own method body. Keep this picture in mind — every exercise is a variation on it.


Level 1 — Recognition

Goal: spot polymorphism, and spot when it is NOT happening.

Recall Solution L1.1

(a) YES — operator polymorphism. The same name + dispatches to str.__add__ (concatenate) and list.__add__ (concatenate lists). One symbol, two method bodies chosen by type(left).

(b) YES — duck typing. greet never names a class. It only needs x to have .speak(). Dog and Robot share no base class, yet both fit the implicit interface.

(c) YES — operator polymorphism (subtle!). * calls int.__mul__ on 3*2 → 6 and str.__mul__ on "hi"*2 → "hihi". Same operator, different behavior per type. Many students say "NO, it's just one function f" — but the polymorphism lives inside *, not in f.

Recall Solution L1.2

Line A works"Quack". Line B raises AttributeError.

Why: at runtime noise executes a.quack(). Python searches quack along type(a).__mro__. For Dog, the MRO is [Dog, object] — no quack anywhere → AttributeError: 'Dog' object has no attribute 'quack'. Dog does have bark, but we never call bark, so it doesn't save us. The interface required is quack, and Dog doesn't satisfy it.


Level 2 — Application

Goal: write small polymorphic code and predict its output.

Recall Solution L2.1

Each shape answers area() in its own way — same call, different body:

  • Circle(2) (here math.pi )
  • Square(3)
  • Rectangle(2,5)

Sum . Rounded to 4 places: .

Recall Solution L2.2
class Triangle:
    def __init__(self, b, h): self.b, self.h = b, h
    def area(self): return 0.5 * self.b * self.h

Triangle needs no inheritance — it only satisfies the implicit interface "has area()". That is exactly why total_area never changes.

  • Triangle(6,4). So total_area([Triangle(6,4)]) = 12.0.
  • total_area([Circle(1), Triangle(6,4)]) (again math.pi).
Figure — Polymorphism — duck typing, same interface different behavior

The figure above shows why total_area never changes: each shape holds its own area() body (cyan boxes), yet the loop calls them through the one shared interface. The amber labels are the different formulas — different maths, identical call s.area().

Recall Solution L2.3

Sum of .price attributes: . Prints 869.

total codes an interface (has .price), not a type — Book and Coffee are unrelated yet both fit. Add object() and the generator hits i.price on a plain object → AttributeError: 'object' object has no attribute 'price', raised the moment that item is reached. The lookup failed along type(i).__mro__ = [object].


Level 3 — Analysis

Goal: reason about method lookup, MRO, and where things go wrong.

Recall Solution L3.1
  • Dog().speak()"Woof". MRO [Dog, Animal, object]; speak found on Dog first → its override runs.
  • Cat().speak()"...". MRO [Cat, Animal, object]; Cat has no speak, so lookup walks to Animal and finds the base version.
  • Animal().speak()"...". Animal itself defines speak.

Key insight: overriding in Dog changes only Dog's lookup. Cat and Animal are untouched. Each object resolves speak along its own Method Resolution Order (MRO).

Recall Solution L3.2

Assume Dog, Cat, Robot each define speak.

  • Version A (LBYL) returns 2 items (Dog, Cat). Robot fails the isinstance check and is silently dropped — even though it can speak! The type-check rejected a perfectly valid duck.
  • Version B (EAFP) returns 3 items (Dog, Cat, Robot). It just asks each object to speak; every object that has speak succeeds.

Why it matters: Version A must be edited every time a new speaker appears. Version B needs zero edits — this is EAFP preserving extensibility, the whole payoff of polymorphism.

Recall Solution L3.3
  • Dog() → has speak → appended. ✅
  • Cat() → has speak → appended. ✅
  • Robot() → has speak → appended. ✅
  • object() → no speakAttributeError caught → skipped. ❌
  • 42 (int) → no speakAttributeError caught → skipped. ❌

Total appended = 3. EAFP lets the good ducks through and quietly declines the rest.


Level 4 — Synthesis

Goal: design polymorphic systems, including operator/dunder polymorphism.

Recall Solution L4.1

Step by step:

  1. Money(150) + Money(275) → Python sees +, calls Money.__add__(left, right). This returns Money(150 + 275) = Money(425).
  2. str(...) → Python calls Money.__str__, giving f"${425/100:.2f}" = "$4.25".

Output: "$4.25". This is operator polymorphism you authored: the same + symbol now has meaning for Money because you supplied its __add__ body. See Dunder methods — __add__, __len__, __str__.

Recall Solution L4.2

len(obj) is polymorphic: it simply calls type(obj).__len__(obj). So:

  • len(Playlist([...3...]))3
  • len(Team([...2...]))2
  • Sum → 5.

Rule: len(x)type(x).__len__(x); any object supplying __len__ becomes "measurable", no inheritance needed.

Recall Solution L4.3
def render_all(nodes):
    return sum(len(n.render()) for n in nodes)

It relies only on the render() interface — pure duck typing. Now the pieces:

  • Text("hi").render()"hi" → length 2
  • Bold("go").render()"**go**" → length 6 (2 stars + go + 2 stars = 6)
  • Link("A","u").render()"[A](u)" → length 6 ([ A ] ( u ))

Total = 14. Add a Heading class tomorrow with its own render()render_all never changes.


Level 5 — Mastery

Goal: combine inheritance, override, super(), and duck typing under one roof.

Recall Solution L5.1

MRO of Square = [Square, Polygon, Shape, object].

  1. Square.describe runs → "square: " + super().describe(). super() in Square means "next after Square in the MRO" = Polygon.
  2. Polygon.describe runs → "polygon: " + super().describe(). super() here = Shape.
  3. Shape.describe runs → "shape".

Unwinding: "polygon: " + "shape" = "polygon: shape", then "square: " + "polygon: shape". Final: "square: polygon: shape". This is override + super() cooperating along the MRO.

Recall Solution L5.2

chorus uses only the speak() interface, so Horn (no inheritance) joins the Animal subclasses seamlessly: "Woof Meow Beep".

If Animal() itself is included, Animal.speak runs raise NotImplementedError → the whole chorus blows up with NotImplementedError. To prevent this at design time, make Animal a true abstract base class so it cannot be instantiated:

from abc import ABC, abstractmethod
class Animal(ABC):
    @abstractmethod
    def speak(self): ...

Now Animal() raises TypeError immediately — you catch the mistake at construction, not deep in the pipeline. See Abstraction — interfaces and abstract base classes (ABC).

Recall Solution L5.3

Bundle.cost calls p.cost() on each part. Thanks to polymorphism it doesn't care if p is an Item (returns its price) or a Bundle (recurses). Same call cost(), different body — a recursive composite tree.

Evaluate inside out:

  • Inner Bundle([Item(30), Item(20)])
  • Outer Bundle([Item(100), 50, Item(5)])

Printed: 155. This composite pattern is polymorphism's endgame: uniform interface across a whole tree of different node types.


Recall One-line summary of the whole page

Every exercise reduces to one truth ::: x.method() resolves method along type(x).__mro__ at runtime — same call site, object-chosen body — so any object honoring the interface plugs in with zero caller changes.

Connections