Skillversitas

Method Overriding and Polymorphism

Polymorphism is the property that lets one piece of code work with many types: a function that takes "anything with a read method" does not care which class provides it. Python's polymorphism is behavior-driven rather than type-declared - the language checks for the method, not the class - which is duck typing, and it is the mechanism that makes overriding (the child's version of a method) useful beyond a single hierarchy. This chapter covers the three faces of the feature: overriding within a hierarchy, structural (duck-typed) polymorphism across unrelated classes, and the explicit contract layer (isinstance with ABCs, typing.Protocol) for when you want the guarantee checked.

Overriding in Action

class Shape:
    def area(self):
        raise NotImplementedError

    def describe(self):
        # calls self.area() - resolved on the ACTUAL instance
        return f"area={self.area():.2f}"

class Circle(Shape):
    def __init__(self, r):
        self.r = r

    def area(self):
        import math
        return math.pi * self.r ** 2

class Square(Shape):
    def __init__(self, s):
        self.s = s

    def area(self):
        return self.s ** 2

shapes = [Circle(2), Square(3), Circle(1)]
for s in shapes:
    print(type(s).__name__, s.describe())
# Circle area=12.57
# Square area=9.00
# Circle area=3.14

The loop never asks "what type is this?" - it calls s.describe(), and inside describe the call self.area() resolves on each object's actual class. That single dynamic dispatch is polymorphism: the Shape code was written once against the base, and every subclass plugs in by defining area. The base's area raising NotImplementedError is the standard way to say "abstract without the ABC machinery" - a concrete subclass that forgets to override fails loudly at the first call. The pattern generalizes to any operation that varies by type: __str__, save, validate, render - the hierarchy provides the shape, the override provides the behavior.

Duck Typing: No Shared Base Required

class FileSource:
    def __init__(self, path):
        self._f = open(path)

    def read(self, n=-1):
        return self._f.read(n)

    def close(self):
        self._f.close()

class MemorySource:
    def __init__(self, data):
        self._data = data
        self._pos = 0

    def read(self, n=-1):
        chunk = self._data[self._pos:self._pos + n] if n > 0 else self._data[self._pos:]
        self._pos += len(chunk)
        return chunk

    def close(self):
        pass

# one function, two unrelated classes - no common ancestor
def first_line(src):
    line = src.read(1024).split("\n", 1)[0]
    src.close()
    return line

print(first_line(MemorySource("hello\nworld")))   # 'hello'

first_line works on FileSource and MemorySource because both have read and close - the classes share no base at all. This is duck typing, and it is the default mode of Python: the interface is the set of methods a caller uses, and any class providing them qualifies. The design consequence is that "interfaces" in Python are usually implicit contracts, and the two explicit tools (below) exist precisely for when you want to state the contract and check membership against it. The trade is the classic one: duck typing is flexible and fast to write, but a missing method is a AttributeError at the call site, in production, instead of a type error at definition time.

Checking Membership: isinstance and ABC

from abc import ABC, abstractmethod

class Drawable(ABC):
    @abstractmethod
    def draw(self):
        """Render to the current surface."""

    @abstractmethod
    def bounds(self):
        """Return (x, y, w, h)."""

class Rect(Drawable):
    def __init__(self, x, y, w, h):
        self.x, self.y, self.w, self.h = x, y, w, h

    def draw(self):
        return f"rect at {self.x},{self.y}"

    def bounds(self):
        return (self.x, self.y, self.w, self.h)

print(isinstance(Rect(0, 0, 5, 5), Drawable))   # True

class Text(Drawable):
    def draw(self):
        return "text"

    def bounds(self):
        return (0, 0, 10, 2)

# a class that forgets an abstract method cannot be instantiated
try:
    class Broken(Drawable):
        def draw(self):
            return "x"
    Broken()
except TypeError as e:
    print(e)   # Can't instantiate abstract class Broken with abstract method bounds

An ABC (abc.ABC with @abstractmethod) makes the contract explicit: subclasses must implement the marked methods or they cannot be instantiated, and isinstance(x, Drawable) becomes a meaningful test. This is the "nominal" side of Python's polymorphism - a declared hierarchy, checked at construction and available for runtime tests. Use it when the interface is a real product of the codebase (a plugin system, a public API) and the cost of a missing method is high; skip it for internal helpers where duck typing is lighter. The ABC chapter (16) covers the full toolkit; here the point is that isinstance is the bridge between the structural and the nominal.

Structural Contracts: typing.Protocol

from typing import Protocol, runtime_checkable

@runtime_checkable
class HasArea(Protocol):
    def area(self) -> float: ...
    @property
    def label(self) -> str: ...

class Triangle:
    label = "triangle"
    def __init__(self, b, h):
        self.b, self.h = b, h
    def area(self) -> float:
        return 0.5 * self.b * self.h

class Box:
    label = "box"
    def __init__(self, w, d, h):
        self.w, self.d, self.h = w, d, h
    def area(self) -> float:      # surface area
        return 2 * (self.w * self.d + self.d * self.h + self.w * self.h)

def report(obj: HasArea) -> str:
    # the annotation documents the contract; the call works on anything
    # that structurally matches - no inheritance from HasArea needed
    return f"{obj.label}: {obj.area():.2f}"

print(report(Triangle(4, 6)))    # triangle: 12.00
print(report(Box(1, 2, 3)))      # box: 22.00
print(isinstance(Triangle(1, 1), HasArea))   # True - runtime-checkable

A Protocol defines the contract structurally: any class with the matching methods satisfies it, inheritance or not. With @runtime_checkable, isinstance tests work (checking only the presence of the members, not their signatures), and the annotation obj: HasArea documents and type-checks the requirement. This is the modern answer to "duck typing, but said out loud" - the contract is visible in the signature, the checker enforces it statically, and no artificial base class is required. It is the right tool for libraries that accept many third-party types against one interface.

Choosing the Mechanism

SituationTool
Behavior varies within one hierarchyoverride + dynamic dispatch
Internal code, few types, speed of writing mattersplain duck typing
Public API / plugin boundary, need checked membershipABC + isinstance
Contract over types you do not own (third-party, no base)typing.Protocol

Summary

Polymorphism in Python is dynamic dispatch: code written against a name (s.area()) runs each object's own version, whether the variation comes from overriding in a hierarchy or from two unrelated classes that happen to share the interface - duck typing, the language's default. When the contract must be explicit and checkable, ABCs add a declared interface with isinstance support and construction-time enforcement, and typing.Protocol states the same idea structurally for types you cannot make inherit from a common base. The choice is a boundary question: inside a module, duck typing; at a public or plugin boundary, an ABC; across ownership lines, a Protocol. The dispatch mechanism is the same in all three - that is the point.