2.1.12 · D5OOP Fundamentals

Question bank — Polymorphism — duck typing, same interface different behavior

1,895 words9 min readBack to topic

True or false — justify

Polymorphism in Python requires a shared base class.
False. When you call x.speak(), Python only walks type(x).__mro__ looking for a speak attribute — it never verifies a common ancestor. So a Robot unrelated to Animal still fits make_it_speak; inheritance is one way to organise it, not a requirement.
Overriding speak() in Dog changes the speak() that Animal and Cat see.
False. Each object resolves the name along its own Method Resolution Order (MRO). Dog's MRO finds Dog.speak first, but Animal's and Cat's MRO lists never include Dog, so their lookups are untouched — override is per-class, not global mutation.
If two unrelated classes both define .area(), an object of one can be passed anywhere the other is expected.
True, as far as the call mechanism cares — the MRO walk finds area on both, so s.area() succeeds for either. Python never compares class names; only the presence of the method at call time matters.
isinstance(x, Animal) before calling x.speak() makes your code more polymorphic.
False. That guard is LBYL (Look Before You Leap) keyed on ancestry, so any new compatible type that isn't an Animal subclass is rejected before its perfectly-good speak is ever tried — shrinking, not widening, the set of objects that work.
The operator + behaving differently for int, str, and list is polymorphism.
True. a + b is compiled to a lookup of __add__ on type(a)'s MRO, so one operator name dispatches to each type's own method — the same call site, different behavior, which is the definition.
Duck typing means Python checks the object's type is a "Duck" class.
False. It is the opposite: Python ignores the class name entirely and, via the MRO walk, checks only whether the object provides the method you invoke at the moment you invoke it. "Quacks like a duck" refers to behavior, not birth.
A method call x.foo() is resolved when the class is defined.
False. The name is not bound to any specific function until the call executes; only then does Python run the MRO search on type(x). This runtime (late) binding is precisely what lets one call site hit different implementations.
Two objects can share the same interface (method names) yet be Liskov-unsafe substitutes.
True. Matching method names guarantee the MRO walk finds something to call; they say nothing about whether that something honours the expected contract. The LSP governs behavioral compatibility, which the lookup mechanism cannot enforce.

Spot the error

def total(items): return sum(i.price for i in items) — someone says "this only works if every item is a Book."
Wrong claim. total never names Book; the comprehension only reads each item's .price attribute. A Toy, Subscription, or any object exposing price works — the function codes an interface, not a type.
if type(x) == Animal: x.speak() used to "safely" call speak.
Two errors. type(x) == Animal is exact-type matching, so it rejects Dog (whose type() is Dog, not Animal) even though Dog is a valid speaker, and it hard-codes a class instead of asking for the capability. Prefer just calling x.speak() (EAFP) or hasattr(x, "speak").
A Robot class with no speak() is put in for t in (Dog(), Cat(), Robot()): make_it_speak(t). Where and why does it fail?
It fails at runtime on the Robot() iteration: the MRO walk over Robot's classes finds no speak and falls off the end, raising AttributeError. It cannot fail earlier because lookup is dynamic and evaluated per object.
class Dog(Animal): def speak(): return "Woof" (note: no self).
The method is missing the self parameter. When called as dog.speak() Python passes the instance automatically, so the found function receives one argument it can't accept — a "takes 0 positional arguments but 1 was given" TypeError. The override is broken.
Base class uses def speak(self): pass to "promise" the interface.
Weak promise. pass returns None silently, so a subclass that forgets to override produces wrong-but-silent behavior instead of a clear failure. Raising NotImplementedError (or using an ABC) makes the missing override fail loudly.

Why questions

Why does polymorphism let you write code that works with objects invented tomorrow?
Because the code depends only on an interface (e.g. .area()), and the MRO lookup happens at runtime — any future object whose class provides that method is found and called with zero changes to the caller.
Why is EAFP preferred over checking isinstance before every call?
EAFP (Easier to Ask Forgiveness than Permission — call it, catch failures) welcomes any object that actually works, while LBYL type checks pre-emptively exclude valid new types. EAFP keeps the door open; ancestry guards nail it shut. See EAFP vs LBYL — Pythonic error handling.
Why is Python's polymorphism described as something that "falls out" rather than a bolted-on feature?
Because x.method() is always a runtime search along type(x).__mro__. There is no separate polymorphism engine — different objects giving different results is just the natural consequence of that one lookup rule.
Why can inheritance still be useful if duck typing already gives polymorphism?
Inheritance adds organisation, not the polymorphism itself: sharing common code, documenting the expected interface, enabling super() reuse, and optional isinstance checks. See Inheritance — overriding and super().
Why does the + operator "know" to concatenate strings but add integers?
It doesn't decide anything — a + b calls a.__add__(b), and each type ships its own [[Dunder methods — add, len, str|__add__]]. The dispatch is driven by the left operand's type, so the behavior lives in the object, not the operator.
Why does exact-type matching (type(x) == Base) break inheritance-based polymorphism specifically?
type(x) returns the most-derived class, so a Dog instance's type is Dog, not Animal, making the equality False even though a Dog is an Animal. isinstance walks the MRO and would say True, so use it if you must check at all.
Why can the left operand's __add__ fail and yet a + b still succeed?
If a.__add__(b) returns the special sentinel NotImplemented, Python does not give up — it tries the right operand's ==__radd__== (b.__radd__(a)). Only if that also returns NotImplemented does TypeError fire. This two-sided fallback is how 1 + myvector can work even though int.__add__ doesn't understand your class.

Edge cases

What happens if an object defines speak as a plain attribute (a string) instead of a method, then you call x.speak()?
The MRO walk succeeds (the name exists) but the call fails: a string isn't callable, so Python raises TypeError. "Name found" and "callable" are separate requirements.
An object has .area but it's set to None. Does duck typing consider it "having the interface"?
hasattr returns True because the name resolves along the MRO, but calling x.area() raises TypeError (None isn't callable). "Has the attribute" and "has a working method" are not the same guarantee.
Two classes both define speak() with identical signatures but one returns a sound and the other silently deletes a file. Is that valid polymorphism?
Mechanically yes — the MRO finds speak and the call site works. But it violates the spirit of a shared interface and the Liskov Substitution Principle, since callers expecting a harmless answer get a destructive side effect.
What does x.speak() do when x's class defines nothing but its parent defines speak?
The MRO search skips the empty subclass, finds speak on the parent, and calls it — inherited (not overridden) behavior. No error, because "not found here" continues up the list, only reaching AttributeError if every class in the MRO lacks it.
Can a class satisfy an ABC's interface without inheriting from it?
Yes — plain duck typing means a class can act like the ABC's interface (provide its methods) without registering, so calls work; but isinstance against the ABC may still return False. Capability is separate from declared type. See Abstraction — interfaces and abstract base classes (ABC).
Two parents in class C(A, B) both define greet() — whose runs?
A's, because C's MRO lists A before B (left-to-right, C3 linearization). The same call c.greet() resolves deterministically to the first match along that order. See Method Resolution Order (MRO).
Degenerate case: total([]) on an empty list. What comes back and why isn't it an error?
sum(...) over an empty generator returns 0. No object's .price is ever read, so the missing-attribute question never arises — the polymorphism is simply never exercised.

Recall One-line summary of the whole bank

Python asks “can you do it?” (does the method resolve along the MRO and run), not “what are you?” (your class) — and every trap on this page is some version of confusing those two questions.


Connections