2.1.12 · D3OOP Fundamentals

Worked examples — Polymorphism — duck typing, same interface different behavior

3,257 words15 min readBack to topic

You have met polymorphism as an idea: one call, many behaviors. This page drills every corner case so that when you meet a duck-typing situation in the wild — or an exam — you have already walked through its exact shape.

We treat "polymorphism" the way an engineer treats a machine: not "does it work in the happy case?" but "does it survive every input the world can throw?" So first we list every kind of case, then we solve one example per case.

Two anti-patterns will recur below. They come from the parent topic (Polymorphism) — here is the short version so you can follow this page standalone:


The scenario matrix

Think of each row below as a cell — a distinct situation the topic can produce. A method call x.method() can land in wildly different places depending on what x is and whether the method even exists. Below, "duck" means "object with the required method"; "non-duck" means it is missing that method.

Cell Situation What could go wrong / be surprising
C1 Pure duck typing, unrelated classes People expect a shared base class — there is none
C2 Inheritance + override (subtype flavor) Which speak runs? The subclass's, via MRO
C3 Missing method (non-duck) AttributeError at the call — the degenerate/failing input
C4 EAFP recovery from the failure in C3 Catch and continue vs crash
C5 hasattr capability check (LBYL flavor) Guard behavior, never class
C6 Operator polymorphism via __add__ Same +, different meaning per type
C7 Empty / zero-length collection (degenerate input) sum([]) gives 0, no crash, but is 0 right?
C8 Partial interface — object has some methods, not all Works for one call, blows up on another
C9 Real-world word problem Payment processors: card, wallet, crypto
C10 Exam-style twist: isinstance breaks extensibility Show the anti-pattern failing on a new type
C11 Diamond multiple inheritance Which parent's method wins? C3 linearization decides

Every example below is tagged with the cell(s) it covers. Together they touch all eleven.


Forecast: guess the three lines before reading on.

  1. Dog().speak() gives "Woof". Why this step? Python looks up speak on the object, finds it on Dog, calls it. It never asks "is this an Animal?".
  2. Cat().speak() gives "Meow". Why this step? Same lookup, different class, different result — same interface, different behavior.
  3. Robot().speak() gives "Beep boop". Why this step? Robot shares zero inheritance with Dog/Cat. It fits purely because it has speak(). This is the whole point of duck typing.

Verify: The output, joined, is "Woof Meow Beep boop". Three objects, one call site, three behaviors — no base class needed.


Forecast: one of these does NOT say "...generic...". Which?

  1. Puppy().speak() gives "Yip". Why this step? Python walks the Method Resolution Order (MRO) — the ordered list of classes to search. For Puppy that is [Puppy, Animal, object]. It finds speak on Puppy first and stops. See Method Resolution Order (MRO).
  2. Kitten().speak() gives "...generic...". Why this step? Kitten defines no speak, so the search continues to Animal and finds it there. The parent's method is inherited, not copied — this is Inheritance — overriding and super().
  3. The parent is untouched. Animal().speak() still returns "...generic...". Overriding changed only Puppy's lookup, not the class for everyone — this is exactly Mistake C from the box above.

Verify: Outputs are "Yip" then "...generic...". First object resolves at Puppy, second falls through to Animal. Both are polymorphic responses to the identical call x.speak().


Forecast: does this return None, "", or crash?

  1. Python looks up speak on the Fish object. Not there. Why this step? This is the exact lookup from Example 1 — but this time it fails.
  2. Search the MRO [Fish, object]. Neither has speak. Why this step? When the search runs out of classes with no match, Python cannot invent a method.
  3. Python raises AttributeError: 'Fish' object has no attribute 'speak'. Why this step? Duck typing's rule "checks method at call time" means the failure surfaces only when you call it, not when you define Fish.

Verify: Calling raises AttributeError. This is the boundary case that motivates the next two examples — how to handle a non-duck gracefully.


Forecast: what are the two strings in results?

  1. Dog() gives "Woof". The try succeeds; no exception. Why this step? EAFP = Easier to Ask Forgiveness than Permission: we just try the call. See EAFP vs LBYL — Pythonic error handling.
  2. Fish() raises AttributeError inside try. Why this step? Exactly the failure from Example 3, but now it is caught.
  3. The except returns "(silent)". Why this step? We recover instead of crashing — the loop continues. This is the Pythonic fix to Mistake B (don't guard with isinstance; just try and catch).

Verify: results == ["Woof", "(silent)"]. Two objects, one caught exception, program survives.


Forecast: which one says "(cannot speak)"?

  1. hasattr(Cat(), "speak") gives True. Why this step? hasattr internally tries the lookup and reports whether it worked. We test the capability speak, never the type name Cat. LBYL (Look Before You Leap) means "check the condition before acting", the opposite of EAFP's "act, then handle failure". See EAFP vs LBYL — Pythonic error handling.
  2. Cat().speak() gives "Meow". The guard passed, so we call. Why this step? This still supports any future class with speak — extensibility preserved (contrast Mistake B).
  3. hasattr(Fish(), "speak") gives False, so we return "(cannot speak)". Why this step? No speak in Fish's MRO, so we skip the call entirely — no exception ever raised.

Verify: Prints "Meow" then "(cannot speak)". Capability-based LBYL keeps polymorphism open, unlike class-based checks.


Forecast: write down each value and whether it's a number, string, or list.

  1. 1 + 2 gives 3 (int). Python calls int.__add__(1, 2). Why this step? a + b is operator overloading: the + token is translated at runtime into type(a).__add__(a, b). The token is fixed; the method it dispatches to depends on the runtime type of a. See Dunder methods — __add__, __len__, __str__.
  2. "a" + "b" gives "ab" (str). Calls str.__add__, which does concatenation. Why this step? Same operator token, but str's version glues text instead of doing arithmetic.
  3. [1] + [2] gives [1, 2] (list). Calls list.__add__, which also concatenates. Why this step? This is duck typing applied to the dispatch behind +: Python doesn't check the operands' class, it just calls whatever __add__ they define. The + symbol is syntax; the behavior is chosen dynamically per type.

Verify: Results are 3, "ab", [1, 2]. Same operator token, three behaviors selected at runtime — operator overloading powered by dynamic lookup.


Forecast: does the empty case crash, or return something? What?

  1. total_area([]): the generator yields nothing. Why this step? With no items, s.area() is never called — so a missing-method risk cannot even arise. The degenerate input is safe here.
  2. sum(<empty>) gives 0. Why this step? Python's sum uses a default start value of 0. Empty means "nothing added to zero", which is 0. No crash.
  3. total_area([Circle(2)]) gives (plain text: pi times 2 squared = 4·pi, about 12.566). Why this step? Confirms the non-empty branch: the area formula pi·r² with r = 2.

The figure below draws both branches side by side. On the left it shows a single Circle(2) with its radius r = 2 marked in violet, and the one call s.area() running the area formula pi·r² = 4·pi ≈ 12.566. On the right it shows the empty list [ ], where the same call never fires and sum falls back to its start value 0. Notice the orange "same call" arrow reaching only the left shape — the empty branch produces 0 without ever touching area. That is why the degenerate input is safe: no object means no method to be missing.

Figure — Polymorphism — duck typing, same interface different behavior

Verify: Empty gives 0; one circle of radius 2 gives 4·pi ≈ 12.566370614. The zero case is graceful, and the numeric result checks out below.


Forecast: how far does this get before failing — line a or line p?

  1. shape.area() succeeds, returning pi·1² = pi (about 3.14159). Why this step? Circle does satisfy the area part of the interface. Partial ducks quack partially.
  2. shape.perimeter() raises AttributeError. Why this step? Circle never defined perimeter; the MRO search fails at the moment of the second call, not before.
  3. Lesson: "duck typing" means the whole interface you use, method by method. Satisfying one call doesn't guarantee the next. To document the full required interface, use Abstraction — interfaces and abstract base classes (ABC).

Verify: area() returns pi (about 3.14159), then perimeter() raises AttributeError. A partial duck passes the first call and fails the second.


Forecast: estimate the three charges, then the total.

  1. CardProcessor.charge(50) gives 50 × 1.02 = 51.0. Why this step? The 2% card fee. checkout never names the class — it just calls charge.
  2. WalletProcessor.charge(50) gives 50 × 1.0 = 50.0; CryptoProcessor.charge(50) gives 50 × 1.01 = 50.5. Why this step? Same call processor.charge(50), three fee policies — the textbook same interface, different behavior.
  3. total = 51.0 + 50.0 + 50.5 = 151.5, and its type is float. Why this step? sum starts at the integer 0, then repeatedly does 0 + 51.0, + 50.0, + 50.5. Because each charge returns a float (multiplying by 1.02 etc. produces a float), the running total is promoted to float on the first addition — this is operator polymorphism from Example 6 again (int.__add__ with a float operand yields a float). Add a BankTransferProcessor tomorrow with its own charge and checkout needs zero edits — the extensibility payoff.

Verify: Individual charges 51.0, 50.0, 50.5 (units: dollars), total 151.5, type float. Units consistent (dollars in, dollars out), and the sum matches.


Forecast: Wolf clearly can speak. Does bad_speak accept it?

  1. bad_speak(Dog()) gives "Woof". isinstance(Dog(), Dog) is True, so we proceed. Why this step? The happy path — but it only works by class name, not capability.
  2. bad_speak(Wolf()) raises TypeError("not a Dog"). Why this step? Wolf is not a Dog, so the class check rejects it — even though it has a perfectly good speak(). This is Mistake B in action: the guard blocks a valid new type.
  3. The fix is Example 5's hasattr (check capability) or Example 4's EAFP (just call). Both keep the door open for Wolf, Robot, and anything else with speak. Rejecting valid substitutes also violates the Liskov Substitution Principle (SOLID) spirit.

Verify: Dog gives "Woof"; Wolf raises TypeError. The class check breaks polymorphism — the exact anti-pattern you must recognise.


Forecast: the two candidates are "B" and "A". Which prints — and in what order does Python search?

  1. Python builds D's MRO with C3 linearization, giving [D, B, C, A, object]. Why this step? This is the "diamond" shape: D reaches A through both B and C. C3 linearization is the algorithm Python uses to flatten that diamond into one unambiguous search order. Its two rules: a class comes before its parents, and if you listed class D(B, C), then B comes before C. Crucially, A (shared by both) is placed once, after both B and C — never duplicated. See Method Resolution Order (MRO).
  2. Search that order for greet. D has none, so try B; B.greet exists, so stop. Why this step? Lookup always walks the MRO left to right and takes the first match, exactly as in Example 2 — the diamond changes only the order of the list, not the lookup rule.
  3. Result: D().greet() gives "B". A's greet is never reached, even though C would have inherited it, because B sits ahead of both C and A in the MRO. Why this step? This resolves the ambiguity a naive "search all parents" scheme could not — without C3, "is it B or A?" would be undefined. C3 gives a single deterministic answer.

Verify: D().greet() == "B", and D.__mro__ names are ["D", "B", "C", "A", "object"]. The shared base A appears exactly once, after both B and C — the diamond is safely linearized.


Recall Which cell was which?

C1 duck typing no inheritance ::: Example 1 C2 override + MRO ::: Example 2 C3 missing method → AttributeError ::: Example 3 C4 EAFP recovery ::: Example 4 C5 hasattr capability check ::: Example 5 C6 operator polymorphism (+) ::: Example 6 C7 empty / zero-length input ::: Example 7 C8 partial interface ::: Example 8 C9 real-world payments ::: Example 9 C10 isinstance breaks extensibility ::: Example 10 C11 diamond inheritance / C3 linearization ::: Example 11


Connections

  • Polymorphism — duck typing, same interface different behavior (index 2.1.12)
  • Inheritance — overriding and super()
  • Abstraction — interfaces and abstract base classes (ABC)
  • Dunder methods — __add__, __len__, __str__
  • Method Resolution Order (MRO)
  • EAFP vs LBYL — Pythonic error handling
  • Liskov Substitution Principle (SOLID)