Python Protocol Practice Problems & Exercises
Practice: Protocol and Structural Subtyping
← Back to lessonEasy
Define a Drawable protocol with a draw() method. Write three unrelated classes that structurally satisfy it and a render() function that accepts any Drawable.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
class Circle:
def __init__(self, radius: int) -> None:
self.radius = radius
def draw(self) -> str:
return f"Drawing Circle(radius={self.radius})"
class Square:
def __init__(self, side: int) -> None:
self.side = side
def draw(self) -> str:
return f"Drawing Square(side={self.side})"
class Triangle:
def __init__(self, base: int, height: int) -> None:
self.base = base
self.height = height
def draw(self) -> str:
return f"Drawing Triangle(base={self.base}, height={self.height})"
def render(shape: Drawable) -> None:
print(shape.draw())
shapes = [Circle(5), Square(4), Triangle(3, 6)]
for s in shapes:
render(s)Expected Output
Drawing Circle(radius=5)
Drawing Square(side=4)
Drawing Triangle(base=3, height=6)Hints
Hint 1: Define a Protocol with a single draw() method. Any class that has draw() satisfies it, regardless of inheritance.
Hint 2: The render function accepts Drawable — any object with draw() works, no registration needed.
Create a @runtime_checkable Quackable protocol. Show that isinstance() checks work on arbitrary objects.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Quackable(Protocol):
def quack(self) -> str:
...
class Duck:
def quack(self) -> str:
return "Quack!"
class Person:
def quack(self) -> str:
return "I'm quacking like a duck!"
class Cat:
def meow(self) -> str:
return "Meow!"
duck = Duck()
person = Person()
cat = Cat()
print(f"duck is Quackable: {isinstance(duck, Quackable)}")
print(f"person is Quackable: {isinstance(person, Quackable)}")
print(f"cat is Quackable: {isinstance(cat, Quackable)}")Expected Output
duck is Quackable: True
person is Quackable: True
cat is Quackable: FalseHints
Hint 1: Decorate the Protocol class with @runtime_checkable. This enables isinstance() checks at runtime.
Hint 2: isinstance only checks for the presence of methods — it does not verify their signatures.
Define a Comparable protocol with __lt__. Write a Product class that satisfies it, then use sorted() and min() with the protocol-typed function.
from typing import Protocol, Any, List, TypeVar
C = TypeVar("C", bound="Comparable")
class Comparable(Protocol):
def __lt__(self, other: Any) -> bool:
...
def sort_items(items: List[C]) -> List[C]:
return sorted(items)
def minimum(items: List[C]) -> C:
return min(items)
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
def __lt__(self, other: "Product") -> bool:
return self.price < other.price
def __repr__(self) -> str:
return f"Product(price={self.price})"
products = [
Product("Laptop", 29.99),
Product("Cable", 5.0),
Product("Keyboard", 12.99),
]
sorted_products = sort_items(products)
print(f"Sorted products: {sorted_products}")
print(f"Min: {minimum(products)}")Expected Output
Sorted products: [Product(price=5.0), Product(price=12.99), Product(price=29.99)]
Min: Product(price=5.0)Hints
Hint 1: Define a Comparable protocol with __lt__(self, other: Any) -> bool.
Hint 2: Any class implementing __lt__ satisfies the protocol — it works with sorted() and min().
Medium
Define a Serializable protocol with to_dict() and a classmethod from_dict(). Implement it on two unrelated classes and write a generic serialize/deserialize pipeline.
from typing import Protocol, Dict, Any, Type, TypeVar
import json
T = TypeVar("T", bound="Serializable")
class Serializable(Protocol):
def to_dict(self) -> Dict[str, Any]:
...
@classmethod
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
...
class User:
def __init__(self, id: int, name: str) -> None:
self.id = id
self.name = name
def to_dict(self) -> Dict[str, Any]:
return {"id": self.id, "name": self.name}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "User":
return cls(data["id"], data["name"])
def __repr__(self) -> str:
return f"User(id={self.id}, name={self.name!r})"
class Point:
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
def to_dict(self) -> Dict[str, Any]:
return {"x": self.x, "y": self.y}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Point":
return cls(data["x"], data["y"])
def to_json(obj: Serializable) -> str:
return json.dumps(obj.to_dict())
u = User(1, "Alice")
p = Point(10, 20)
print(f"JSON: {to_json(u)}")
print(f"JSON: {to_json(p)}")
# Round-trip
restored = User.from_dict(u.to_dict())
print(f"Roundtrip OK: {restored.to_dict() == u.to_dict()}")Expected Output
JSON: {"id": 1, "name": "Alice"}
JSON: {"x": 10, "y": 20}
Roundtrip OK: TrueHints
Hint 1: Define Serializable with to_dict() -> dict and from_dict(cls, data: dict) -> Self as a classmethod.
Hint 2: Both User and Point implement the protocol without inheriting from a common base.
Create Readable and Writable protocols, then compose them into ReadableWritable. Implement FileStream to satisfy the combined protocol.
from typing import Protocol, runtime_checkable
class Readable(Protocol):
def read(self) -> str:
...
class Writable(Protocol):
def write(self, data: str) -> bool:
...
@runtime_checkable
class ReadableWritable(Readable, Writable, Protocol):
"""Composed protocol: must implement both read and write."""
pass
class FileStream:
def __init__(self, initial: str = "") -> None:
self._buffer = initial
def read(self) -> str:
return self._buffer
def write(self, data: str) -> bool:
self._buffer = data
return True
def process_stream(stream: ReadableWritable) -> None:
stream.write("hello world")
content = stream.read()
print(f"read: {content}")
print(f"write complete: True")
fs = FileStream()
print(f"ReadableWritable check: {isinstance(fs, ReadableWritable)}")
print(f"FileStream satisfies ReadableWritable: {isinstance(fs, ReadableWritable)}")
process_stream(fs)Expected Output
ReadableWritable check: True
FileStream satisfies ReadableWritable: True
read: hello world
write complete: TrueHints
Hint 1: Protocol classes can inherit from other Protocols to compose requirements: class ReadableWritable(Readable, Writable, Protocol): ...
Hint 2: Any class implementing all methods from both parent protocols satisfies the composed protocol.
Define a Shape protocol with area and perimeter properties. Write a Circle implementation and a validator function.
from typing import Protocol, runtime_checkable
import math
@runtime_checkable
class Shape(Protocol):
@property
def area(self) -> float:
...
@property
def perimeter(self) -> float:
...
class Circle:
def __init__(self, radius: float) -> None:
self._radius = radius
@property
def area(self) -> float:
return math.pi * self._radius ** 2
@property
def perimeter(self) -> float:
return 2 * math.pi * self._radius
class Rectangle:
def __init__(self, w: float, h: float) -> None:
self._w = w
self._h = h
@property
def area(self) -> float:
return self._w * self._h
@property
def perimeter(self) -> float:
return 2 * (self._w + self._h)
def describe(shape: Shape) -> None:
print(f"area = {shape.area}")
print(f"perimeter = {shape.perimeter}")
print(f"Validates: {isinstance(shape, Shape)}")
describe(Circle(5))Expected Output
area = 78.53981633974483
perimeter = 31.41592653589793
Validates: TrueHints
Hint 1: Declare @property methods inside the Protocol class body. Implementations must provide matching properties.
Hint 2: The protocol checks for the method name — both @property and regular methods with the same name satisfy it at runtime.
Define a ContextManagerProtocol and implement a Transaction class that satisfies it. Use it with a with statement.
from typing import Protocol, Optional, Type
from types import TracebackType
class ContextManagerProtocol(Protocol):
def __enter__(self) -> "ContextManagerProtocol":
...
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> Optional[bool]:
...
class Transaction:
def __init__(self, name: str) -> None:
self.name = name
self._committed = False
def __enter__(self) -> "Transaction":
print(f"Entering {self.name}")
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> Optional[bool]:
success = exc_type is None
print(f"Exiting {self.name} (success={success})")
if success:
self._committed = True
print(f"Transaction committed")
else:
print(f"Transaction rolled back")
return False # don't suppress exceptions
def run_with(cm: ContextManagerProtocol) -> str:
with cm:
print("Operation 1 completed")
print("Operation 2 completed")
return "success"
t = Transaction("transaction")
result = run_with(t)
print(f"Result: {result}")Expected Output
Entering transaction
Operation 1 completed
Operation 2 completed
Exiting transaction (success=True)
Transaction committed
Result: successHints
Hint 1: Define a ContextManagerProtocol with __enter__(self) -> T and __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool].
Hint 2: A class satisfying the protocol can be used with the "with" statement directly.
Hard
Define Iterable[T] and Iterator[T] protocols manually, then implement a FibonacciIterator that satisfies both. Write a generic take(n, iterable) function.
from typing import Protocol, TypeVar, Generic, List
T = TypeVar("T")
class IteratorProtocol(Protocol[T]):
def __next__(self) -> T:
...
def __iter__(self) -> "IteratorProtocol[T]":
...
class IterableProtocol(Protocol[T]):
def __iter__(self) -> IteratorProtocol[T]:
...
class FibonacciIterator:
def __init__(self, limit: int) -> None:
self._limit = limit
self._count = 0
self._a = 0
self._b = 1
def __iter__(self) -> "FibonacciIterator":
return self
def __next__(self) -> int:
if self._count >= self._limit:
raise StopIteration
result = self._a
self._a, self._b = self._b, self._a + self._b
self._count += 1
return result
def take(n: int, iterable: IterableProtocol) -> List:
result = []
for item in iterable:
result.append(item)
if len(result) >= n:
break
return result
fib = FibonacciIterator(10)
values = take(10, fib)
print(f"Fibonacci: {values}")
print(f"Sum via protocol: {sum(values)}")
# Verify it works with for loop
total = 0
for v in FibonacciIterator(5):
total += v
print(f"Works with for loop: {total == 4}")Expected Output
Fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Sum via protocol: 78
Works with for loop: TrueHints
Hint 1: Define Iterable[T] and Iterator[T] protocols. Iterator must have __next__() -> T and __iter__() -> Iterator[T].
Hint 2: FibonacciIterator implements both by returning self from __iter__ and computing the next value in __next__.
Use a SortStrategy protocol to implement three sorting strategies. Inject them into a Sorter class and verify they all produce the same result.
from typing import Protocol, TypeVar, List
T = TypeVar("T")
class SortStrategy(Protocol):
def sort(self, items: List[int]) -> List[int]:
...
class BubbleSort:
def sort(self, items: List[int]) -> List[int]:
arr = list(items)
n = len(arr)
for i in range(n):
for j in range(0, n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
class MergeSort:
def sort(self, items: List[int]) -> List[int]:
if len(items) <= 1:
return list(items)
mid = len(items) // 2
left = self.sort(items[:mid])
right = self.sort(items[mid:])
return self._merge(left, right)
def _merge(self, left: List[int], right: List[int]) -> List[int]:
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i]); i += 1
else:
result.append(right[j]); j += 1
return result + left[i:] + right[j:]
class TimSort:
def sort(self, items: List[int]) -> List[int]:
return sorted(items)
class Sorter:
def __init__(self, strategy: SortStrategy) -> None:
self._strategy = strategy
def sort(self, items: List[int]) -> List[int]:
return self._strategy.sort(items)
data = [5, 3, 8, 1, 2]
strategies = [
("BubbleSort", BubbleSort()),
("MergeSort", MergeSort()),
("TimSort", TimSort()),
]
results = []
for name, strategy in strategies:
sorter = Sorter(strategy)
result = sorter.sort(data)
results.append(result)
print(f"{name}: {result}")
print(f"All strategies agree: {all(r == results[0] for r in results)}")Expected Output
BubbleSort: [1, 2, 3, 5, 8]
MergeSort: [1, 2, 3, 5, 8]
TimSort: [1, 2, 3, 5, 8]
All strategies agree: TrueHints
Hint 1: Define a SortStrategy protocol with sort(items: List[T]) -> List[T]. Each strategy implements the method independently.
Hint 2: A Sorter class accepts a SortStrategy at construction time and delegates to it.
Define a generic Repository[T] protocol with CRUD operations. Implement an InMemoryRepository[T] and use it with a User entity.
from typing import Protocol, TypeVar, Generic, Optional, List, Dict
T = TypeVar("T")
IdType = TypeVar("IdType")
class HasId(Protocol):
@property
def id(self) -> int:
...
class Repository(Protocol[T]):
def save(self, entity: T) -> T:
...
def find_by_id(self, entity_id: int) -> Optional[T]:
...
def find_all(self) -> List[T]:
...
def delete(self, entity_id: int) -> bool:
...
class InMemoryRepository(Generic[T]):
def __init__(self) -> None:
self._store: Dict[int, T] = {}
def save(self, entity: T) -> T:
entity_id = getattr(entity, "id")
self._store[entity_id] = entity
return entity
def find_by_id(self, entity_id: int) -> Optional[T]:
return self._store.get(entity_id)
def find_all(self) -> List[T]:
return list(self._store.values())
def delete(self, entity_id: int) -> bool:
if entity_id in self._store:
del self._store[entity_id]
return True
return False
class User:
def __init__(self, id: int, name: str) -> None:
self.id = id
self.name = name
def __repr__(self) -> str:
return f"User(id={self.id}, name={self.name!r})"
repo: InMemoryRepository[User] = InMemoryRepository()
u1 = repo.save(User(1, "Alice"))
u2 = repo.save(User(2, "Bob"))
print(f"Saved {u1}")
print(f"Found: {repo.find_by_id(1)}")
print(f"All: {repo.find_all()}")
print(f"Deleted 1: {repo.delete(1)}")
print(f"All after delete: {repo.find_all()}")Expected Output
Saved User(id=1, name='Alice')
Found: User(id=1, name='Alice')
All: [User(id=1, name='Alice'), User(id=2, name='Bob')]
Deleted 1: True
All after delete: [User(id=2, name='Bob')]Hints
Hint 1: Combine Protocol and Generic[T]: class Repository(Protocol[T]) with save, find_by_id, find_all, delete methods.
Hint 2: InMemoryRepository[T] uses a dict as storage. The TypeVar ensures the methods consistently work on the same entity type.
Build a plugin registry that validates plugins against a @runtime_checkable Protocol before registration. Route data to the correct plugin by name.
from typing import Protocol, runtime_checkable, Dict, List
@runtime_checkable
class PluginProtocol(Protocol):
@property
def name(self) -> str:
...
def process(self, data: str) -> str:
...
def validate(self) -> bool:
...
class PluginRegistry:
def __init__(self) -> None:
self._plugins: Dict[str, PluginProtocol] = {}
def register(self, plugin: object) -> None:
if not isinstance(plugin, PluginProtocol):
raise TypeError(
f"{type(plugin).__name__} does not implement PluginProtocol"
)
p = plugin # type: ignore[assignment]
if not p.validate():
raise ValueError(f"Plugin '{p.name}' failed validation")
self._plugins[p.name] = p
print(f"Registered: {p.name}")
def run(self, plugin_name: str, data: str) -> str:
if plugin_name not in self._plugins:
raise KeyError(f"Plugin '{plugin_name}' not found")
return self._plugins[plugin_name].process(data)
class TextPlugin:
@property
def name(self) -> str:
return "TextPlugin"
def process(self, data: str) -> str:
return data.upper()
def validate(self) -> bool:
return True
class NumberPlugin:
@property
def name(self) -> str:
return "NumberPlugin"
def process(self, data: str) -> str:
return str(len(data))
def validate(self) -> bool:
return True
class InvalidPlugin:
# Missing process() and validate()
def name(self) -> str: # not a property
return "invalid"
registry = PluginRegistry()
registry.register(TextPlugin())
registry.register(NumberPlugin())
print(f"TextPlugin output: {registry.run('TextPlugin', 'hello world')}")
print(f"NumberPlugin output: {registry.run('NumberPlugin', 'hello world')}")
try:
registry.register(InvalidPlugin())
print("Invalid plugin accepted (wrong)")
except TypeError:
print("Invalid plugin rejected: True")Expected Output
Registered: TextPlugin
Registered: NumberPlugin
TextPlugin output: HELLO WORLD
NumberPlugin output: 42
Invalid plugin rejected: TrueHints
Hint 1: A PluginProtocol defines name (property), process(data: str) -> str, and validate() -> bool.
Hint 2: A PluginRegistry registers plugins, validates them via isinstance with the runtime_checkable Protocol, and routes data to the right plugin.
