Back to Home
Live Project · Duck Typing

Image Filter
Plugin Pipeline.

Grayscale, Sepia, Invert, Brighten, Contrast, Posterize and Threshold share no base class. They only share one method name: apply(pixel). The pipeline doesn't import them by name — it just calls .apply() on whatever the plugin registry hands it.

That is duck typing at work. Add a new filter file, drop it into the registry, and the pipeline works unchanged. This live project makes the idea tangible: real pixel math, six runnable Python programs, five hands-on exercises, a runnable playground, and an interactive demo where you can chain filters and watch the trace.

📅 October 20, 2026 ⏱ 45 min 🦆 Duck Typing 🐍 Python 3.11+
Back to Home Start the Course Live Demo Polymorphism Guide

Progress Tracker

Tick off each milestone as you complete it. Progress is saved in this browser — leave and come back anytime.

🎯 Your Journey Through the Pipeline

0%

    Why Duck Typing Fits Image Filters

    🦆 "If It Has apply(pixel), It's a Filter"

    A conventional design would create a Filter base class and force every filter to inherit it. That works — until you want to plug in a filter written by a third party, or test with a fake filter in one line, or treat a plain function as a filter.

    Duck typing frees the pipeline from any ancestor requirement: anything with an apply(pixel) method is a filter. The registry decides what's in play; the pipeline just calls.

    This page is a complete tutorial. You'll meet duck typing through six progressive programs, solve five exercises, explore seven filter classes in the live lab, and test yourself with a five-question quiz. Every Python code block is runnable in-browser via Pyodide — no installation.

    6 runnable programs 5 exercises with solutions Interactive pixel lab (7 filters) Progress tracker Cheat sheet & best practices Quick quiz

    The Problem This Solves

    Without duck typing, a naive filter pipeline usually looks like this:

    def apply_filter(image, filter_name):
        if filter_name == "grayscale":
            return apply_grayscale(image)
        elif filter_name == "sepia":
            return apply_sepia(image)
        elif filter_name == "invert":
            return apply_invert(image)
        raise ValueError("unknown filter")

    Smell: every new filter means another elif here — and in every other function that lists filters. Behavior that belongs to the filter leaked into the caller.

    Goal: write the loop once — for f in filters: pixel = f.apply(pixel) — and add new filters by writing new classes. That's what this page builds.

    Key Ideas Before the Code

    Skim this glossary now and come back whenever a term feels unfamiliar.

    Glossary

    TermPlain-English meaningWhere you'll see it
    Duck typing"If it has the method I call, it's good enough." No shared base class required.Every filter on this page
    Structural typingThe formal name: an object's type is determined by its structure (methods), not its declared ancestry.The concept duck typing implements
    Nominal typingThe opposite: an object's type is determined by its declared inheritance.Java, C#, most static languages
    ProtocolA typing tool (typing.Protocol) that describes a duck-typed interface.Program 3
    runtime_checkableDecorator that lets isinstance(x, Protocol) work by checking method presence.Program 3
    EAFP"Easier to Ask Forgiveness than Permission" — try the call, catch errors.Program 5
    LBYL"Look Before You Leap" — check first, then call.Program 5
    Registry patternA dict mapping names to objects. One lookup, no if/elif ladders.Program 1
    PipelineA chain of transformations applied in order: output of step 1 is input to step 2.The whole page
    PixelA 3-tuple (r, g, b) with each channel in [0, 255].Every filter
    Pure functionA function that returns a new value and does not mutate its inputs.Every apply should be pure

    Duck Typing vs Inheritance at a Glance

    Inheritance-basedDuck typing
    Question asked"Are you a Filter?""Can you .apply()?"
    Needs a shared parentYesNo
    Third-party typesWon't work unless they subclass yoursWork if they have the method
    Checked whenAt class definition (ABC) or at callOnly when called
    Great forFamilies with shared codePlugin systems, mocks, adapters
    RiskDeep hierarchies get rigidFailures appear late, at runtime

    Best of both worlds: use duck typing at runtime (nothing checks classes) and use a Protocol at type-check time (mypy catches typos). You get flexibility and safety.

    The Contract, Written Down

    Even in a duck-typed system, you can — and should — document the contract. A Protocol does this without imposing inheritance.

    from typing import Protocol, runtime_checkable
    
    @runtime_checkable
    class Filter(Protocol):
        def apply(self, pixel: tuple[int, int, int]) -> tuple[int, int, int]:
            ...

    Nothing inherits from Filter. It exists only so isinstance(x, Filter) can verify method presence, and so type checkers warn you when apply is missing or misspelled.

    How the Pipeline Works

    The Three Layers

    LayerResponsibilityKnows about…
    RegistryHolds a dict {name: filter_instance}. Filters are registered here.Every filter class — but only once.
    PipelineReceives (pixel, filter_list) and calls apply(pixel) for each filter, chained.Only the method name apply.
    FiltersEach is a class with apply(pixel) returning a new pixel.Nothing about each other or the pipeline.

    The payoff: adding VintageFilter requires one line in the registry. The pipeline never changes. That's the "open for extension, closed for modification" idea — enforced by duck typing.

    Data Flow Through the Pipeline

    Follow a single pixel through three chained filters:

    1. Start: pixel (120, 80, 200).
    2. Apply Grayscale: luminance = 0.299·120 + 0.587·80 + 0.114·200 ≈ 106 → (106, 106, 106).
    3. Apply Brighten(30): each channel + 30 → (136, 136, 136).
    4. Apply Invert: each channel 255 − c → (119, 119, 119).
    5. Result: a darker-gray pixel. Notice the order matters — Invert → Grayscale gives a different result than Grayscale → Invert.

    Order dependence: filter chains are not commutative. The pipeline must preserve user order — and this page's live demo does exactly that.

    Six Programs, Explained Line by Line

    Each program teaches one aspect of duck typing. Predict each output before pressing Run.

    Program 1 — The Registry: A Dict of Filters

    The simplest duck-typed pipeline. Four classes share only apply. One for loop drives them all. The registry has no type checks — it just holds instances.

    class Grayscale:
        def apply(self, pixel):
            r, g, b = pixel
            y = int(0.299 * r + 0.587 * g + 0.114 * b)
            return (y, y, y)
    
    class Sepia:
        def apply(self, pixel):
            r, g, b = pixel
            return (
                min(255, int(0.393 * r + 0.769 * g + 0.189 * b)),
                min(255, int(0.349 * r + 0.686 * g + 0.168 * b)),
                min(255, int(0.272 * r + 0.534 * g + 0.131 * b)),
            )
    
    class Invert:
        def apply(self, pixel):
            r, g, b = pixel
            return (255 - r, 255 - g, 255 - b)
    
    class Brighten:
        def __init__(self, amount=30):
            self.amount = amount
    
        def apply(self, pixel):
            r, g, b = pixel
            return (min(255, r + self.amount),
                    min(255, g + self.amount),
                    min(255, b + self.amount))
    
    REGISTRY = {
        "grayscale": Grayscale(),
        "sepia": Sepia(),
        "invert": Invert(),
        "brighten": Brighten(30),
    }
    
    def apply_pipeline(pixel, names):
        for name in names:
            pixel = REGISTRY[name].apply(pixel)
        return pixel
    
    px = (120, 80, 200)
    print("Original: ", px)
    print("Grayscale:", apply_pipeline(px, ["grayscale"]))
    print("Sepia:    ", apply_pipeline(px, ["sepia"]))
    print("Invert:   ", apply_pipeline(px, ["invert"]))
    print("Brighten: ", apply_pipeline(px, ["brighten"]))
    print("Chained:  ", apply_pipeline(px, ["grayscale", "brighten"]))
    Expected output
    Original: (120, 80, 200) Grayscale: (110, 110, 110) Sepia: (139, 121, 103) Invert: (135, 175, 55) Brighten: (150, 110, 230) Chained: (140, 140, 140)

    🔍 Deep dive

    apply_pipeline has no type annotations on REGISTRY[name] — it just calls .apply(pixel). Python resolves the method on the object's real class at runtime. Whichever class the registry hands back, the call succeeds as long as the method exists.

    Why this scales: adding "posterize": Posterize() to the registry makes it work immediately. Not one line of apply_pipeline changes.

    Program 2 — A Function as a Filter

    Duck typing doesn't require a class. A function works if you wrap it in a tiny adapter — or you use a callable directly. This program shows both approaches.

    def to_grayscale(pixel):
        r, g, b = pixel
        y = int(0.299 * r + 0.587 * g + 0.114 * b)
        return (y, y, y)
    
    class CallableFilter:
        def __init__(self, func):
            self.func = func
    
        def apply(self, pixel):
            return self.func(pixel)
    
    REGISTRY = {
        "grayscale": CallableFilter(to_grayscale),
        "double": CallableFilter(lambda p: (min(255, p[0]*2), min(255, p[1]*2), min(255, p[2]*2))),
    }
    
    def run(pixel, names):
        for name in names:
            pixel = REGISTRY[name].apply(pixel)
        return pixel
    
    print(run((50, 100, 150), ["grayscale"]))
    print(run((50, 100, 150), ["double"]))
    print(run((50, 100, 150), ["grayscale", "double"]))
    Expected output
    (92, 92, 92) (100, 200, 255) (184, 184, 184)

    🔍 Deep dive

    CallableFilter is the Adapter pattern: it holds any callable and exposes the apply method duck typing expects. Two very different functions — one named, one a lambda — become filters through the same tiny wrapper.

    This is a real Python idiom. Libraries like functools.partial and Flask's view functions all rely on this "callable adapter" approach when the underlying object doesn't match the expected interface.

    Program 3 — Documenting the Contract with Protocol

    A Protocol describes the interface. Nothing inherits from it. But isinstance can now verify a filter actually has apply — and mypy will catch typos.

    from typing import Protocol, runtime_checkable
    
    @runtime_checkable
    class Filter(Protocol):
        def apply(self, pixel):
            ...
    
    class Grayscale:
        def apply(self, pixel):
            r, g, b = pixel
            y = int(0.299 * r + 0.587 * g + 0.114 * b)
            return (y, y, y)
    
    class Broken:
        def aplly(self, pixel):
            return pixel
    
    class NotAFilter:
        pass
    
    good = Grayscale()
    print("Grayscale conforms:", isinstance(good, Filter))
    print("Broken conforms:   ", isinstance(Broken(), Filter))
    print("NotAFilter conforms:", isinstance(NotAFilter(), Filter))
    
    REGISTRY = {"grayscale": good}
    try:
        REGISTRY["broken"] = Broken()
        assert isinstance(REGISTRY["broken"], Filter), "Broken is missing apply"
    except AssertionError as e:
        print("Assertion:", e)
    Expected output
    Grayscale conforms: True Broken conforms: False NotAFilter conforms: False Assertion: Broken is missing apply

    🔍 Deep dive

    isinstance(good, Filter) returns True because @runtime_checkable makes Protocol isinstance checks work structurally — it looks for the attribute, not the class. Broken fails the check because its method is named aplly (typo), and NotAFilter fails because it has no method at all.

    Use it in registration: assert isinstance(f, Filter), "missing apply()" gives you a clear error message right where the mistake is, instead of a cryptic AttributeError later in the pipeline.

    Program 4 — Adding a Filter at Runtime

    Duck typing makes runtime extension trivial. Register a completely new class — even one defined inline with type() — and the pipeline picks it up on the next call.

    class Grayscale:
        def apply(self, pixel):
            r, g, b = pixel
            y = int(0.299 * r + 0.587 * g + 0.114 * b)
            return (y, y, y)
    
    REGISTRY = {"grayscale": Grayscale()}
    
    def run(pixel, names):
        for name in names:
            pixel = REGISTRY[name].apply(pixel)
        return pixel
    
    print("Before:", run((100, 100, 100), ["grayscale"]))
    
    # Register a filter built from scratch at runtime
    Invert = type("Invert", (), {
        "apply": lambda self, p: (255 - p[0], 255 - p[1], 255 - p[2])
    })
    REGISTRY["invert"] = Invert()
    
    print("After: ", run((100, 100, 100), ["invert"]))
    print("Chain: ", run((100, 100, 100), ["grayscale", "invert"]))
    
    # And a second one, using a function directly
    REGISTRY["red_only"] = type("RedOnly", (), {
        "apply": lambda self, p: (p[0], 0, 0)
    })()
    print("Red:   ", run((100, 200, 50), ["red_only"]))
    Expected output
    Before: (100, 100, 100) After: (155, 155, 155) Chain: (155, 155, 155) Red: (100, 0, 0)

    🔍 Deep dive

    type("Invert", (), {...}) builds a class at runtime. Its apply is a lambda attached to the class dict. The pipeline doesn't care that the class was created a millisecond ago — the method exists, so it works.

    This is how plugins are loaded. A plugin loader scans a directory, imports each module, and calls REGISTRY[name] = module.Filter(). Whether the filter was written yesterday or ten years ago doesn't matter — the duck-typed contract is enough.

    Program 5 — Error Handling: EAFP vs LBYL

    Two Pythonic ways to guard a duck-typed call. Both work; both have trade-offs. This program shows the difference in practice.

    class GoodFilter:
        def apply(self, pixel):
            return tuple(min(255, c + 20) for c in pixel)
    
    class NoApply:
        pass
    
    def safe_run_lbyl(filters, pixel):
        for f in filters:
            if hasattr(f, "apply"):
                pixel = f.apply(pixel)
            else:
                print(f"  Skipped {type(f).__name__}: no apply()")
        return pixel
    
    def safe_run_eafp(filters, pixel):
        for f in filters:
            try:
                pixel = f.apply(pixel)
            except AttributeError:
                print(f"  Skipped {type(f).__name__}: no apply()")
        return pixel
    
    filters = [GoodFilter(), NoApply(), GoodFilter()]
    print("LBYL:", safe_run_lbyl(filters, (10, 10, 10)))
    print("EAFP:", safe_run_eafp(filters, (10, 10, 10)))
    
    # The danger of a wide EAFP net
    class Broken:
        def apply(self, pixel):
            return pixel.missing_attribute
    
    try:
        safe_run_eafp([Broken()], (0, 0, 0))
    except AttributeError:
        print("Broken.apply's internal bug looked like 'no apply' — swallowed!")
    Expected output
    Skipped NoApply: no apply() LBYL: (50, 50, 50) Skipped NoApply: no apply() EAFP: (50, 50, 50) Skipped Broken: no apply() Broken.apply's internal bug looked like 'no apply' — swallowed!

    🔍 Deep dive

    LBYL (hasattr) checks before calling. EAFP (try/except) calls and catches. The difference is small here, but the last example shows the danger: a bug inside apply that raises AttributeError gets treated as "this filter doesn't have the method" — which is wrong.

    Rule of thumb: prefer LBYL when the check is cheap (hasattr, isinstance). Prefer EAFP for exceptions you can't pre-check (file open, network call). Never catch a AttributeError across a function call whose body might legitimately raise one.

    Program 6 — Full Image Pipeline

    Bring it all together. Real 2D image data (as a list of tuples), a chained filter pipeline, and a comparison of two different chains on the same image.

    class Grayscale:
        def apply(self, pixel):
            r, g, b = pixel
            y = int(0.299 * r + 0.587 * g + 0.114 * b)
            return (y, y, y)
    
    class Brighten:
        def __init__(self, amount):
            self.amount = amount
    
        def apply(self, pixel):
            return tuple(min(255, c + self.amount) for c in pixel)
    
    class Invert:
        def apply(self, pixel):
            return tuple(255 - c for c in pixel)
    
    class Posterize:
        def __init__(self, levels=3):
            self.levels = levels
    
        def apply(self, pixel):
            step = 255 // (self.levels - 1)
            return tuple(round(c / step) * step for c in pixel)
    
    REGISTRY = {
        "grayscale": Grayscale(),
        "brighten":  Brighten(40),
        "invert":    Invert(),
        "posterize": Posterize(3),
    }
    
    def apply_pipeline(image, names):
        out = []
        for pixel in image:
            for name in names:
                pixel = REGISTRY[name].apply(pixel)
            out.append(pixel)
        return out
    
    image = [
        (10, 20, 30), (100, 50, 200), (200, 200, 50),
        (250, 10, 10), (0, 0, 0), (128, 128, 128),
    ]
    
    print("Original:              ", image[0])
    print("Grayscale:             ", apply_pipeline([image[0]], ["grayscale"])[0])
    print("Invert:                ", apply_pipeline([image[0]], ["invert"])[0])
    print("Posterize:             ", apply_pipeline([image[0]], ["posterize"])[0])
    print("Gray + Brighten:       ", apply_pipeline([image[0]], ["grayscale", "brighten"])[0])
    print("Posterize + Invert:    ", apply_pipeline([image[0]], ["posterize", "invert"])[0])
    
    print("\nTwo chains on the whole image:")
    chain_a = apply_pipeline(image, ["grayscale", "brighten"])
    chain_b = apply_pipeline(image, ["invert", "posterize"])
    for i, (a, b) in enumerate(zip(chain_a, chain_b)):
        print(f"  pixel {i}: A={a}  B={b}")
    Expected output
    Original: (10, 20, 30) Grayscale: (18, 18, 18) Invert: (245, 235, 225) Posterize: (0, 0, 0) Gray + Brighten: (58, 58, 58) Posterize + Invert: (255, 255, 255) Two chains on the whole image: pixel 0: A=(58, 58, 58) B=(255, 255, 255) pixel 1: A=(126, 126, 126) B=(0, 0, 128) pixel 2: A=(230, 230, 230) B=(128, 0, 0) pixel 3: A=(255, 255, 255) B=(0, 255, 255) pixel 4: A=(40, 40, 40) B=(255, 255, 255) pixel 5: A=(168, 168, 168) B=(0, 0, 0)

    🔍 Deep dive

    The outer loop iterates pixels, the inner loop iterates filters — output of one filter becomes input to the next. Two chains run on the same image with zero shared state, because each filter is stateless (or holds only its own config). Posterize quantizes to 3 levels: 0, 128, 255.

    Order matters everywhere. Notice pixel 1: Grayscale-then-Brighten gives 126 (uniform gray); Invert-then-Posterize gives a colored (0, 0, 128) — because inverting preserves the color differences before quantization. Both are correct, but they produce different images.

    Hands-On Exercises

    Try each on your own, then open the solution. Every solution runs in-browser.

    Exercise 1 — Add a Contrast Filter

    Warm-up

    Task: write a Contrast class with apply(pixel) that pushes each channel away from the midpoint 128 by a factor. Formula: new = clamp(128 + (c - 128) * factor). Register it and test with factor=1.5.

    Hint: clamp with max(0, min(255, value)). The class takes factor in __init__.

    Show solution & explanation
    class Contrast:
        def __init__(self, factor=1.5):
            self.factor = factor
    
        def apply(self, pixel):
            return tuple(
                max(0, min(255, round(128 + (c - 128) * self.factor)))
                for c in pixel
            )
    
    REGISTRY["contrast"] = Contrast(1.5)
    print(apply_pipeline((100, 150, 200), ["contrast"]))
    Expected output
    (86, 161, 236)

    100 → 128 + (100-128)*1.5 = 86. 150 → 161. 200 → 236. Values move away from 128; darker pixels get darker, brighter get brighter. Exactly what "increase contrast" should do.

    Exercise 2 — Function-Based Filter

    Easy

    Task: write a filter as a plain function swap_rb(pixel) that swaps the red and blue channels. Wrap it with the CallableFilter adapter from Program 2. Prove it works on the same pipeline.

    Hint: return (b, g, r). Registration line: REGISTRY["swap_rb"] = CallableFilter(swap_rb).

    Show solution & explanation
    def swap_rb(pixel):
        r, g, b = pixel
        return (b, g, r)
    
    class CallableFilter:
        def __init__(self, func):
            self.func = func
    
        def apply(self, pixel):
            return self.func(pixel)
    
    REGISTRY = {"swap_rb": CallableFilter(swap_rb)}
    
    def apply_pipeline(pixel, names):
        for name in names:
            pixel = REGISTRY[name].apply(pixel)
        return pixel
    
    print(apply_pipeline((200, 100, 50), ["swap_rb"]))
    print(apply_pipeline((200, 100, 50), ["swap_rb", "swap_rb"]))
    Expected output
    (50, 100, 200) (200, 100, 50)

    Two swaps restore the original — a handy sanity check for the pipeline itself: any filter composed with its inverse should give the input.

    Exercise 3 — Protocol Validation

    Medium

    Task: write a registration function register(name, filter_obj) that raises a clear TypeError if filter_obj doesn't conform to the Filter Protocol. Test with a valid filter and a broken one.

    Hint: isinstance(filter_obj, Filter) works thanks to @runtime_checkable.

    Show solution & explanation
    from typing import Protocol, runtime_checkable
    
    @runtime_checkable
    class Filter(Protocol):
        def apply(self, pixel): ...
    
    class Good:
        def apply(self, pixel):
            return pixel
    
    class Broken:
        def aplly(self, pixel):
            return pixel
    
    REGISTRY = {}
    
    def register(name, filter_obj):
        if not isinstance(filter_obj, Filter):
            raise TypeError(f"{name!r} is not a Filter — missing apply() method")
        REGISTRY[name] = filter_obj
    
    register("good", Good())
    print("good registered:", REGISTRY["good"])
    try:
        register("broken", Broken())
    except TypeError as e:
        print("TypeError:", e)
    Expected output
    good registered: <__main__.Good object at 0x7f...> TypeError: 'broken' is not a Filter — missing apply() method

    The error fires at registration time, not at pipeline time. Failures appear right where the mistake happened — that's much easier to debug than a mysterious AttributeError deep in a filter chain.

    Exercise 4 — Composition & Order

    Medium

    Task: implement a Chain filter that holds a list of filters in its __init__ and applies them in order in apply. It's a filter itself — so it can be registered and chained like any other.

    Hint: a filter whose apply loops over self.filters. This is the Composite pattern.

    Show solution & explanation
    class Grayscale:
        def apply(self, pixel):
            r, g, b = pixel
            y = int(0.299 * r + 0.587 * g + 0.114 * b)
            return (y, y, y)
    
    class Brighten:
        def __init__(self, amount=20):
            self.amount = amount
    
        def apply(self, pixel):
            return tuple(min(255, c + self.amount) for c in pixel)
    
    class Chain:
        def __init__(self, *filters):
            self.filters = filters
    
        def apply(self, pixel):
            for f in self.filters:
                pixel = f.apply(pixel)
            return pixel
    
    vintage = Chain(Grayscale(), Brighten(20))
    print(vintage.apply((120, 80, 200)))
    
    deep = Chain(Grayscale(), Brighten(20), Chain(Brighten(10), Brighten(5)))
    print(deep.apply((120, 80, 200)))
    Expected output
    (130, 130, 130) (145, 145, 145)

    The deep example nests a Chain inside a Chain — and it just works. Recursive composition is a natural consequence of duck typing: if Chain has apply, it is a filter, full stop.

    Exercise 5 — Debug the Pipeline

    Hard

    Task: this pipeline has several bugs. Run it, read each error, fix them until it works correctly.

    class Grayscale:
        def apply(self, pixel):
            y = int(0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2])
            return (y, y)
    
    class Invert:
        def apply(self, pixel):
            return tuple(255 - pixel[i] for i in range(3))
    
    class Brighten:
        def __init__(self, amount=30):
            self.amount = amount
    
        def apply(self):
            return (min(255, self.r + self.amount),
                    min(255, self.g + self.amount),
                    min(255, self.b + self.amount))
    
    REGISTRY = {
        "grayscale": Grayscale,
        "invert": Invert(),
        "brighten": Brighten(30),
    }
    
    def run(pixel, names):
        for name in names:
            pixel = REGISTRY[name].apply(pixel)
        return pixel
    
    print(run((100, 150, 200), ["grayscale", "invert", "brighten"]))

    Hint: there are four bugs. Check (1) return types, (2) registry values, (3) method signatures, (4) missing attributes.

    Show solution & explanation
    class Grayscale:
        def apply(self, pixel):
            y = int(0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2])
            return (y, y, y)
    
    class Invert:
        def apply(self, pixel):
            return tuple(255 - pixel[i] for i in range(3))
    
    class Brighten:
        def __init__(self, amount=30):
            self.amount = amount
    
        def apply(self, pixel):
            return tuple(min(255, c + self.amount) for c in pixel)
    
    REGISTRY = {
        "grayscale": Grayscale(),
        "invert": Invert(),
        "brighten": Brighten(30),
    }
    
    def run(pixel, names):
        for name in names:
            pixel = REGISTRY[name].apply(pixel)
        return pixel
    
    print(run((100, 150, 200), ["grayscale", "invert", "brighten"]))
    Expected output
    (185, 185, 185)

    Bug 1: Grayscale.apply returns a 2-tuple (y, y); downstream filters unpack 3 values — ValueError. Bug 2: REGISTRY["grayscale"] holds the class, not an instance — AttributeError: 'type' object has no attribute 'apply'. Bug 3: Brighten.apply takes no pixel arg — but more importantly, it tries to use self.r, self.g, self.b that were never set. Bug 4: even after fixing the signature, Brighten reads attributes that don't exist — the fix is to use the pixel argument passed in.

    Watch the Pipeline Run

    Press filter buttons to stack them on the sample image. The trace below shows which class applied which pixel transformation, in order.

    🖼️ Live Pixel Lab Pipeline: none
    Original
    After Pipeline

    What to notice: the render loop never inspects any filter. It receives a pixel, calls apply(pixel), and moves on. If you added a Vignette filter tomorrow, this loop would not change — only the registry would grow.

    Advertisement

    Where Duck Typing Shows Up

    Duck typing is the engine behind most classic design patterns. Here's what the filters in this page demonstrate.

    PatternHow the page uses itProgram
    StrategyEach filter is an interchangeable algorithm. Swap without changing the pipeline.Program 1
    AdapterCallableFilter wraps any function into the apply interface.Program 2
    RegistryA dict maps names to filter instances.Program 1
    CompositeChain is a filter that holds filters.Exercise 4
    PluginAdd a filter by registering a class — the pipeline is untouched.Program 4
    Liskov SubstitutionDuck typing enforces the same rule: anything conforming to the contract is usable anywhere the contract is expected.Everywhere

    Best Practices for Duck-Typed Systems

    Habits That Keep It Healthy

    ✓ DO: Document the contract. A Protocol or docstring makes the informal agreement explicit. New contributors know what apply must return.

    ✓ DO: Return new values, never mutate. Pure filters are predictable, cacheable, and safe to reorder. Shared mutation breaks composition.

    ✓ DO: Fail fast at registration. An isinstance(f, Filter) check at registration catches typos long before they cause silent misbehavior.

    ✓ DO: Type-annotate apply. Even though Python won't enforce it, mypy will — and your editor will autocomplete correctly.

    ✗ DON'T: Catch AttributeError too broadly. A bug inside apply that raises AttributeError looks identical to "no apply method" — you'll hide real bugs.

    ✗ DON'T: Assume signatures. isinstance(f, Filter) checks that apply exists — it doesn't check argument names or return type. If signatures matter, annotate and run mypy.

    ✗ DON'T: Overuse duck typing in public APIs. For a library's user-facing surface, an ABC gives clearer error messages ("You must implement area()"). Duck typing is for internal plugin points.

    ✗ DON'T: Silently skip bad filters in production. Log warnings. A typo in a plugin name that quietly does nothing is one of the hardest bugs to find.

    Cheat Sheet

    🧭 Choose the Approach

    NeedUse
    Plugin that should work with any classDuck typing
    Document the contractProtocol
    Fail at registration@runtime_checkable
    Wrap a function as a classAdapter
    Chain multiple filtersComposite
    Skip broken filter at runtimehasattr / try

    🎨 Pixel Math

    FilterFormula
    Grayscale0.299r + 0.587g + 0.114b
    Invert255 − c per channel
    Brightenmin(255, c + amount)
    Contrast128 + (c − 128) × factor
    Posterizeround(c / step) × step
    Threshold255 if luminance > t else 0

    ⚠️ Common Pitfalls

    MistakeFix
    Registry holds class not instanceCall Cls()
    Wrong return tuple sizeAlways 3 channels
    Broad except AttributeErrorPrefer hasattr
    Mutating pixel in placeReturn new tuple
    Typo in method nameRegister with assert

    🧰 Handy Tools

    ToolPurpose
    hasattr(obj, "apply")LBYL check
    isinstance(x, Filter)Protocol check
    type()Build class at runtime
    lambdaQuick inline filter
    functools.partialPre-bind args

    Quick Knowledge Check

    Five questions on the ideas in this guide.

    0/5

    Great effort — review any missed questions above.

    What You've Learned

    🏁 Duck Typing in Practice

    You've seen how a filter pipeline — one for loop calling apply(pixel) — can drive seven entirely different classes plus functions and nested chains, all without a single base class. That's duck typing's promise: program to the method, not to the class.

    You've also met the tools that make it safe: Protocol for documentation, @runtime_checkable for verification, hasattr or narrow try for graceful degradation, and the adapter pattern for wrapping functions.

    Key Takeaways

    • Duck typing = method presence. If it has apply, it's a filter.
    • Registry + pipeline beats if/elif ladders — new types are new entries.
    • Protocols document the contract without imposing inheritance.
    • @runtime_checkable makes isinstance work structurally.
    • Adapters wrap functions into the expected interface.
    • Pure filters (return new, never mutate) make chaining predictable.
    • Fail fast at registration. Don't let typos silently skip.
    • Order matters. Chains are not commutative — preserve user order.

    ✨ The broader lesson: polymorphism is not just inheritance. Duck typing is a legitimate, Pythonic, and often preferable alternative — especially at plugin boundaries.

    — Glenn Junsay Pansensoy, Poetic Bytes

    Return to Home Polymorphism Guide Back to Top

    Subscribe to Poetic Bytes

    Level up your Python knowledge.

    In-depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.

    Weekly Python tutorials AI & ML deep-dives OOP series updates Free, cancel anytime

    🔒 Your email is never shared or sold.

    Follow Poetic Bytes

    Stay connected for Python tips, OOP insights, and new project walkthroughs.

    Advertisement