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.
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.
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
| Term | Plain-English meaning | Where 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 typing | The formal name: an object's type is determined by its structure (methods), not its declared ancestry. | The concept duck typing implements |
| Nominal typing | The opposite: an object's type is determined by its declared inheritance. | Java, C#, most static languages |
| Protocol | A typing tool (typing.Protocol) that describes a duck-typed interface. | Program 3 |
runtime_checkable | Decorator 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 pattern | A dict mapping names to objects. One lookup, no if/elif ladders. | Program 1 |
| Pipeline | A chain of transformations applied in order: output of step 1 is input to step 2. | The whole page |
| Pixel | A 3-tuple (r, g, b) with each channel in [0, 255]. | Every filter |
| Pure function | A 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-based | Duck typing | |
|---|---|---|
| Question asked | "Are you a Filter?" | "Can you .apply()?" |
| Needs a shared parent | Yes | No |
| Third-party types | Won't work unless they subclass yours | Work if they have the method |
| Checked when | At class definition (ABC) or at call | Only when called |
| Great for | Families with shared code | Plugin systems, mocks, adapters |
| Risk | Deep hierarchies get rigid | Failures 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
| Layer | Responsibility | Knows about… |
|---|---|---|
| Registry | Holds a dict {name: filter_instance}. Filters are registered here. | Every filter class — but only once. |
| Pipeline | Receives (pixel, filter_list) and calls apply(pixel) for each filter, chained. | Only the method name apply. |
| Filters | Each 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:
- Start: pixel
(120, 80, 200). - Apply Grayscale: luminance = 0.299·120 + 0.587·80 + 0.114·200 ≈ 106 →
(106, 106, 106). - Apply Brighten(30): each channel + 30 →
(136, 136, 136). - Apply Invert: each channel 255 − c →
(119, 119, 119). - 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"]))🔍 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"]))🔍 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)🔍 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"]))🔍 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!")🔍 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}")🔍 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-upTask: 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"]))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
EasyTask: 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"]))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
MediumTask: 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)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
MediumTask: 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)))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
HardTask: 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"]))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.
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.
Where Duck Typing Shows Up
Duck typing is the engine behind most classic design patterns. Here's what the filters in this page demonstrate.
| Pattern | How the page uses it | Program |
|---|---|---|
| Strategy | Each filter is an interchangeable algorithm. Swap without changing the pipeline. | Program 1 |
| Adapter | CallableFilter wraps any function into the apply interface. | Program 2 |
| Registry | A dict maps names to filter instances. | Program 1 |
| Composite | Chain is a filter that holds filters. | Exercise 4 |
| Plugin | Add a filter by registering a class — the pipeline is untouched. | Program 4 |
| Liskov Substitution | Duck 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
| Need | Use |
|---|---|
| Plugin that should work with any class | Duck typing |
| Document the contract | Protocol |
| Fail at registration | @runtime_checkable |
| Wrap a function as a class | Adapter |
| Chain multiple filters | Composite |
| Skip broken filter at runtime | hasattr / try |
🎨 Pixel Math
| Filter | Formula |
|---|---|
| Grayscale | 0.299r + 0.587g + 0.114b |
| Invert | 255 − c per channel |
| Brighten | min(255, c + amount) |
| Contrast | 128 + (c − 128) × factor |
| Posterize | round(c / step) × step |
| Threshold | 255 if luminance > t else 0 |
⚠️ Common Pitfalls
| Mistake | Fix |
|---|---|
| Registry holds class not instance | Call Cls() |
| Wrong return tuple size | Always 3 channels |
Broad except AttributeError | Prefer hasattr |
| Mutating pixel in place | Return new tuple |
| Typo in method name | Register with assert |
🧰 Handy Tools
| Tool | Purpose |
|---|---|
hasattr(obj, "apply") | LBYL check |
isinstance(x, Filter) | Protocol check |
type() | Build class at runtime |
lambda | Quick inline filter |
functools.partial | Pre-bind args |
Quick Knowledge Check
Five questions on the ideas in this guide.
Great effort — review any missed questions above.
Explore the Other Live Apps
Each of these demonstrates a different flavor of polymorphism in production-style code.
Circle, Square, Triangle override draw(), area(), perimeter().
🎨 OverridingA live Money class with __add__, __mul__, __eq__, __lt__.
➕ OperatorsOne format_quantity() dispatches by type with @singledispatch.
🎯 Single DispatchSongs, podcasts, audiobooks all override play(), pause(), duration().
🎵 OverridingReal file, fake file, flaky file share read() / write().
🦆 Duck TypingDebit / Credit support __neg__, __add__, __eq__, __hash__.
➕ Operatorsemit() dispatches str, dict, Exception, bytes by type.
🎯 Single DispatchEvery piece overrides legal_moves() and symbol().
♟️ OverridingPaste a class; see which typing.Protocol interfaces it satisfies.
🦆 Duck TypingThe full tutorial — all four flavors with exercises and quiz.
📚 GuideWhat 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/elifladders — new types are new entries. - Protocols document the contract without imposing inheritance.
@runtime_checkablemakesisinstancework 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
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.
🔒 Your email is never shared or sold.