backend / python oop / 09_protocols_and_interfaces.md

Python Protocols and Interfaces: A Comprehensive Guide

4 interview angles 16 min read source

Python Protocols and Interfaces: A Comprehensive Guide

Overview

Python’s typing.Protocol provides a way to define structural interfaces without explicit inheritance. Protocols enable duck typing with static type checking, allowing you to define interfaces based on behavior rather than inheritance hierarchies.

Table of Contents

  1. Introduction to Protocols
  2. Basic Protocol Usage
  3. Advanced Protocol Features
  4. Built-in Protocols
  5. Protocol vs Abstract Base Classes
  6. Real-World Examples
  7. Best Practices
  8. Common Interview Questions
  9. Performance Considerations
  10. Type Checking Integration

Introduction to Protocols

What are Protocols?

Protocols define structural interfaces - they specify what methods and attributes an object must have to be considered compatible with the protocol, without requiring explicit inheritance.

Key Concepts

  • Structural Typing: Based on structure (methods/attributes) rather than inheritance
  • Duck Typing: “If it walks like a duck and quacks like a duck, it’s a duck”
  • Static Type Checking: Provides type safety without runtime overhead
  • Composition over Inheritance: Encourages composition-based design

Basic Syntax

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...
    def get_position(self) -> tuple[int, int]: ...

Basic Protocol Usage

Simple Protocol Definition

from typing import Protocol

class Printable(Protocol):
    def print_info(self) -> str: ...

class User:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age
    
    def print_info(self) -> str:
        return f"User: {self.name}, Age: {self.age}"

class Product:
    def __init__(self, name: str, price: float):
        self.name = name
        self.price = price
    
    def print_info(self) -> str:
        return f"Product: {self.name}, Price: ${self.price}"

def display_info(item: Printable) -> None:
    print(item.print_info())

# Both User and Product work with display_info
user = User("Alice", 30)
product = Product("Laptop", 999.99)

display_info(user)    # Output: User: Alice, Age: 30
display_info(product) # Output: Product: Laptop, Price: $999.99

Protocol with Properties

from typing import Protocol

class Measurable(Protocol):
    @property
    def area(self) -> float: ...
    
    @property
    def perimeter(self) -> float: ...

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
    
    @property
    def area(self) -> float:
        return self.width * self.height
    
    @property
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    
    @property
    def area(self) -> float:
        import math
        return math.pi * self.radius ** 2
    
    @property
    def perimeter(self) -> float:
        import math
        return 2 * math.pi * self.radius

def print_measurements(shape: Measurable) -> None:
    print(f"Area: {shape.area:.2f}")
    print(f"Perimeter: {shape.perimeter:.2f}")

# Both shapes work with the function
rectangle = Rectangle(5, 3)
circle = Circle(4)

print_measurements(rectangle)
print_measurements(circle)

Protocol with Generic Types

from typing import Protocol, TypeVar, Generic

T = TypeVar('T')

class Comparable(Protocol[T]):
    def __lt__(self, other: T) -> bool: ...
    def __eq__(self, other: object) -> bool: ...

class Sortable(Protocol[T]):
    def sort(self, items: list[T]) -> list[T]: ...

class NumberSorter:
    def sort(self, items: list[int]) -> list[int]:
        return sorted(items)

class StringSorter:
    def sort(self, items: list[str]) -> list[str]:
        return sorted(items)

def sort_data(sorter: Sortable[T], data: list[T]) -> list[T]:
    return sorter.sort(data)

# Usage
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
strings = ["banana", "apple", "cherry"]

number_sorter = NumberSorter()
string_sorter = StringSorter()

sorted_numbers = sort_data(number_sorter, numbers)
sorted_strings = sort_data(string_sorter, strings)

Advanced Protocol Features

Protocol with Optional Methods

from typing import Protocol, Optional

class Cacheable(Protocol):
    def get(self, key: str) -> Optional[str]: ...
    def set(self, key: str, value: str) -> None: ...
    def delete(self, key: str) -> None: ...

class SimpleCache:
    def __init__(self):
        self._cache: dict[str, str] = {}
    
    def get(self, key: str) -> Optional[str]:
        return self._cache.get(key)
    
    def set(self, key: str, value: str) -> None:
        self._cache[key] = value
    
    def delete(self, key: str) -> None:
        self._cache.pop(key, None)
    
    def clear(self) -> None:  # Extra method not in protocol
        self._cache.clear()

class RedisCache:
    def __init__(self, connection_string: str):
        self.connection_string = connection_string
    
    def get(self, key: str) -> Optional[str]:
        # Simulate Redis get
        return f"redis_value_for_{key}"
    
    def set(self, key: str, value: str) -> None:
        # Simulate Redis set
        print(f"Setting {key}={value} in Redis")
    
    def delete(self, key: str) -> None:
        # Simulate Redis delete
        print(f"Deleting {key} from Redis")

def cache_operation(cache: Cacheable, key: str, value: str) -> None:
    cache.set(key, value)
    retrieved = cache.get(key)
    print(f"Retrieved: {retrieved}")

# Both cache implementations work
simple_cache = SimpleCache()
redis_cache = RedisCache("redis://localhost:6379")

cache_operation(simple_cache, "test", "value")
cache_operation(redis_cache, "test", "value")

Protocol with Callable Methods

from typing import Protocol, Callable, Any

class EventHandler(Protocol):
    def handle(self, event: str, data: Any) -> None: ...
    def can_handle(self, event: str) -> bool: ...

class LoggingHandler:
    def handle(self, event: str, data: Any) -> None:
        print(f"Logging event: {event} with data: {data}")
    
    def can_handle(self, event: str) -> bool:
        return event.startswith("log.")

class EmailHandler:
    def handle(self, event: str, data: Any) -> None:
        print(f"Sending email for event: {event}")
    
    def can_handle(self, event: str) -> bool:
        return event.startswith("email.")

class EventDispatcher:
    def __init__(self):
        self.handlers: list[EventHandler] = []
    
    def add_handler(self, handler: EventHandler) -> None:
        self.handlers.append(handler)
    
    def dispatch(self, event: str, data: Any) -> None:
        for handler in self.handlers:
            if handler.can_handle(event):
                handler.handle(event, data)

# Usage
dispatcher = EventDispatcher()
dispatcher.add_handler(LoggingHandler())
dispatcher.add_handler(EmailHandler())

dispatcher.dispatch("log.error", "Something went wrong")
dispatcher.dispatch("email.notification", "New message received")

Protocol with Context Managers

from typing import Protocol

class DatabaseConnection(Protocol):
    def connect(self) -> None: ...
    def disconnect(self) -> None: ...
    def execute(self, query: str) -> list[dict]: ...
    def __enter__(self) -> 'DatabaseConnection': ...
    def __exit__(self, exc_type, exc_val, exc_tb) -> None: ...

class SQLiteConnection:
    def __init__(self, database_path: str):
        self.database_path = database_path
        self.connected = False
    
    def connect(self) -> None:
        self.connected = True
        print(f"Connected to SQLite database: {self.database_path}")
    
    def disconnect(self) -> None:
        self.connected = False
        print("Disconnected from SQLite database")
    
    def execute(self, query: str) -> list[dict]:
        if not self.connected:
            raise RuntimeError("Not connected to database")
        print(f"Executing query: {query}")
        return [{"result": "data"}]
    
    def __enter__(self) -> 'SQLiteConnection':
        self.connect()
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.disconnect()

class PostgreSQLConnection:
    def __init__(self, connection_string: str):
        self.connection_string = connection_string
        self.connected = False
    
    def connect(self) -> None:
        self.connected = True
        print(f"Connected to PostgreSQL: {self.connection_string}")
    
    def disconnect(self) -> None:
        self.connected = False
        print("Disconnected from PostgreSQL")
    
    def execute(self, query: str) -> list[dict]:
        if not self.connected:
            raise RuntimeError("Not connected to database")
        print(f"Executing PostgreSQL query: {query}")
        return [{"result": "postgres_data"}]
    
    def __enter__(self) -> 'PostgreSQLConnection':
        self.connect()
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.disconnect()

def run_query(db: DatabaseConnection, query: str) -> list[dict]:
    with db:
        return db.execute(query)

# Both database types work
sqlite_db = SQLiteConnection("test.db")
postgres_db = PostgreSQLConnection("postgresql://localhost/test")

results1 = run_query(sqlite_db, "SELECT * FROM users")
results2 = run_query(postgres_db, "SELECT * FROM users")

Built-in Protocols

Iterator Protocol

from typing import Protocol, Iterator, TypeVar

T = TypeVar('T')

class Iterable(Protocol[T]):
    def __iter__(self) -> Iterator[T]: ...

class Iterator(Protocol[T]):
    def __next__(self) -> T: ...
    def __iter__(self) -> Iterator[T]: ...

class CustomRange:
    def __init__(self, start: int, end: int):
        self.start = start
        self.end = end
        self.current = start
    
    def __iter__(self) -> Iterator[int]:
        return self
    
    def __next__(self) -> int:
        if self.current >= self.end:
            raise StopIteration
        result = self.current
        self.current += 1
        return result

def process_items(items: Iterable[int]) -> None:
    for item in items:
        print(f"Processing: {item}")

# CustomRange works with any function expecting an Iterable
custom_range = CustomRange(1, 5)
process_items(custom_range)
process_items([1, 2, 3, 4, 5])

Context Manager Protocol

from typing import Protocol

class ContextManager(Protocol):
    def __enter__(self) -> object: ...
    def __exit__(self, exc_type, exc_val, exc_tb) -> bool: ...

class FileManager:
    def __init__(self, filename: str):
        self.filename = filename
        self.file = None
    
    def __enter__(self) -> 'FileManager':
        self.file = open(self.filename, 'r')
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        if self.file:
            self.file.close()
        return False  # Don't suppress exceptions
    
    def read(self) -> str:
        return self.file.read() if self.file else ""

def process_file(file_manager: ContextManager) -> None:
    with file_manager as fm:
        if hasattr(fm, 'read'):
            content = fm.read()
            print(f"File content: {content}")

# FileManager works with any function expecting a ContextManager
file_mgr = FileManager("example.txt")
process_file(file_mgr)

Callable Protocol

from typing import Protocol, Callable, TypeVar, Any

T = TypeVar('T')
R = TypeVar('R')

class Mapper(Protocol[T, R]):
    def __call__(self, item: T) -> R: ...

class StringProcessor:
    def __call__(self, text: str) -> str:
        return text.upper().strip()

class NumberProcessor:
    def __call__(self, number: int) -> float:
        return number * 1.5

def apply_mapper(mapper: Mapper[T, R], items: list[T]) -> list[R]:
    return [mapper(item) for item in items]

# Both processors work as mappers
string_processor = StringProcessor()
number_processor = NumberProcessor()

texts = ["  hello  ", "  world  "]
numbers = [1, 2, 3, 4, 5]

processed_texts = apply_mapper(string_processor, texts)
processed_numbers = apply_mapper(number_processor, numbers)

print(processed_texts)   # ['HELLO', 'WORLD']
print(processed_numbers) # [1.5, 3.0, 4.5, 6.0, 7.5]

Protocol vs Abstract Base Classes

Comparison Table

Feature Protocol Abstract Base Class
Inheritance Not required Required
Type Checking Static only Runtime + Static
Performance No runtime overhead Runtime overhead
Flexibility High (structural) Lower (nominal)
Explicit Declaration Not needed Required
Method Implementation Optional Required

Example Comparison

from typing import Protocol
from abc import ABC, abstractmethod

# Protocol approach
class Drawable(Protocol):
    def draw(self) -> None: ...
    def get_area(self) -> float: ...

class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    
    def draw(self) -> None:
        print(f"Drawing circle with radius {self.radius}")
    
    def get_area(self) -> float:
        import math
        return math.pi * self.radius ** 2

# ABC approach
class DrawableABC(ABC):
    @abstractmethod
    def draw(self) -> None:
        pass
    
    @abstractmethod
    def get_area(self) -> float:
        pass

class Rectangle(DrawableABC):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
    
    def draw(self) -> None:
        print(f"Drawing rectangle {self.width}x{self.height}")
    
    def get_area(self) -> float:
        return self.width * self.height

# Both work with the same function
def render_shape(shape: Drawable) -> None:
    shape.draw()
    print(f"Area: {shape.get_area()}")

# Protocol: No inheritance needed
circle = Circle(5)
render_shape(circle)

# ABC: Inheritance required
rectangle = Rectangle(4, 6)
render_shape(rectangle)

Real-World Examples

Plugin System

from typing import Protocol, Dict, Any
import json

class Plugin(Protocol):
    def initialize(self, config: Dict[str, Any]) -> None: ...
    def process(self, data: Any) -> Any: ...
    def cleanup(self) -> None: ...

class DataProcessorPlugin:
    def __init__(self):
        self.config = {}
    
    def initialize(self, config: Dict[str, Any]) -> None:
        self.config = config
        print(f"DataProcessor initialized with config: {config}")
    
    def process(self, data: Any) -> Any:
        if isinstance(data, str):
            return data.upper()
        return data
    
    def cleanup(self) -> None:
        print("DataProcessor cleanup completed")

class JsonFormatterPlugin:
    def __init__(self):
        self.config = {}
    
    def initialize(self, config: Dict[str, Any]) -> None:
        self.config = config
        print(f"JsonFormatter initialized with config: {config}")
    
    def process(self, data: Any) -> Any:
        return json.dumps(data, indent=2)
    
    def cleanup(self) -> None:
        print("JsonFormatter cleanup completed")

class PluginManager:
    def __init__(self):
        self.plugins: list[Plugin] = []
    
    def register_plugin(self, plugin: Plugin) -> None:
        self.plugins.append(plugin)
    
    def run_pipeline(self, data: Any) -> Any:
        result = data
        for plugin in self.plugins:
            plugin.initialize({"mode": "production"})
            result = plugin.process(result)
            plugin.cleanup()
        return result

# Usage
manager = PluginManager()
manager.register_plugin(DataProcessorPlugin())
manager.register_plugin(JsonFormatterPlugin())

input_data = {"name": "john", "age": 30}
output = manager.run_pipeline(input_data)
print(output)

Database ORM Interface

from typing import Protocol, TypeVar, Generic, List, Optional, Dict, Any
from datetime import datetime

T = TypeVar('T')

class Model(Protocol):
    id: int
    created_at: datetime
    updated_at: datetime

class Repository(Protocol[T]):
    def create(self, data: Dict[str, Any]) -> T: ...
    def get_by_id(self, id: int) -> Optional[T]: ...
    def update(self, id: int, data: Dict[str, Any]) -> Optional[T]: ...
    def delete(self, id: int) -> bool: ...
    def list_all(self) -> List[T]: ...

class User:
    def __init__(self, id: int, name: str, email: str):
        self.id = id
        self.name = name
        self.email = email
        self.created_at = datetime.now()
        self.updated_at = datetime.now()

class UserRepository:
    def __init__(self):
        self.users: Dict[int, User] = {}
        self.next_id = 1
    
    def create(self, data: Dict[str, Any]) -> User:
        user = User(
            id=self.next_id,
            name=data['name'],
            email=data['email']
        )
        self.users[user.id] = user
        self.next_id += 1
        return user
    
    def get_by_id(self, id: int) -> Optional[User]:
        return self.users.get(id)
    
    def update(self, id: int, data: Dict[str, Any]) -> Optional[User]:
        if id not in self.users:
            return None
        user = self.users[id]
        user.name = data.get('name', user.name)
        user.email = data.get('email', user.email)
        user.updated_at = datetime.now()
        return user
    
    def delete(self, id: int) -> bool:
        if id in self.users:
            del self.users[id]
            return True
        return False
    
    def list_all(self) -> List[User]:
        return list(self.users.values())

class UserService:
    def __init__(self, repository: Repository[User]):
        self.repository = repository
    
    def create_user(self, name: str, email: str) -> User:
        return self.repository.create({'name': name, 'email': email})
    
    def get_user(self, id: int) -> Optional[User]:
        return self.repository.get_by_id(id)
    
    def update_user(self, id: int, name: str = None, email: str = None) -> Optional[User]:
        data = {}
        if name:
            data['name'] = name
        if email:
            data['email'] = email
        return self.repository.update(id, data)
    
    def delete_user(self, id: int) -> bool:
        return self.repository.delete(id)
    
    def list_users(self) -> List[User]:
        return self.repository.list_all()

# Usage
user_repo = UserRepository()
user_service = UserService(user_repo)

# Create users
user1 = user_service.create_user("Alice", "alice@example.com")
user2 = user_service.create_user("Bob", "bob@example.com")

# List all users
users = user_service.list_users()
for user in users:
    print(f"User: {user.name} ({user.email})")

# Update user
updated_user = user_service.update_user(user1.id, name="Alice Smith")
print(f"Updated: {updated_user.name}")

# Delete user
success = user_service.delete_user(user2.id)
print(f"Delete successful: {success}")

Best Practices

1. Keep Protocols Simple

# Good: Simple, focused protocol
class Readable(Protocol):
    def read(self) -> str: ...

# Bad: Too many methods in one protocol
class FileHandler(Protocol):
    def read(self) -> str: ...
    def write(self, data: str) -> None: ...
    def delete(self) -> None: ...
    def copy(self, destination: str) -> None: ...
    def move(self, destination: str) -> None: ...
    def compress(self) -> None: ...
    def encrypt(self) -> None: ...

2. Use Composition of Protocols

from typing import Protocol

class Readable(Protocol):
    def read(self) -> str: ...

class Writable(Protocol):
    def write(self, data: str) -> None: ...

class ReadWritable(Protocol):
    def read(self) -> str: ...
    def write(self, data: str) -> None: ...

# Or use composition
class FileHandler(Protocol):
    def read(self) -> str: ...
    def write(self, data: str) -> None: ...

class DatabaseHandler(Protocol):
    def read(self) -> str: ...
    def write(self, data: str) -> None: ...

# Both implement the same interface
def process_data(handler: ReadWritable) -> None:
    data = handler.read()
    processed_data = data.upper()
    handler.write(processed_data)

3. Use Generic Protocols for Type Safety

from typing import Protocol, TypeVar, Generic

T = TypeVar('T')

class Container(Protocol[T]):
    def add(self, item: T) -> None: ...
    def remove(self, item: T) -> None: ...
    def contains(self, item: T) -> bool: ...

class ListContainer:
    def __init__(self):
        self.items: list[T] = []
    
    def add(self, item: T) -> None:
        self.items.append(item)
    
    def remove(self, item: T) -> None:
        if item in self.items:
            self.items.remove(item)
    
    def contains(self, item: T) -> bool:
        return item in self.items

class SetContainer:
    def __init__(self):
        self.items: set[T] = set()
    
    def add(self, item: T) -> None:
        self.items.add(item)
    
    def remove(self, item: T) -> None:
        self.items.discard(item)
    
    def contains(self, item: T) -> bool:
        return item in self.items

4. Document Protocol Intent

from typing import Protocol

class Cache(Protocol):
    """Protocol for cache implementations.
    
    This protocol defines the interface for cache objects that can
    store and retrieve key-value pairs.
    """
    
    def get(self, key: str) -> str | None: ...
    """Retrieve a value by key. Returns None if key doesn't exist."""
    
    def set(self, key: str, value: str) -> None: ...
    """Store a key-value pair in the cache."""
    
    def delete(self, key: str) -> None: ...
    """Remove a key-value pair from the cache."""

Common Interview Questions

1. Basic Questions

Q: What is the difference between a Protocol and an Abstract Base Class?

# Protocol: Structural typing, no inheritance required
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:  # No inheritance needed
    def draw(self) -> None:
        print("Drawing circle")

# ABC: Nominal typing, inheritance required
from abc import ABC, abstractmethod

class DrawableABC(ABC):
    @abstractmethod
    def draw(self) -> None:
        pass

class Rectangle(DrawableABC):  # Must inherit
    def draw(self) -> None:
        print("Drawing rectangle")

Q: When would you use a Protocol instead of inheritance?

# Use Protocol when:
# 1. You want to work with existing classes you can't modify
# 2. You want to avoid tight coupling
# 3. You prefer composition over inheritance

class Logger(Protocol):
    def log(self, message: str) -> None: ...

# Works with any class that has a log method
class FileLogger:
    def log(self, message: str) -> None:
        with open("app.log", "a") as f:
            f.write(f"{message}\n")

class ConsoleLogger:
    def log(self, message: str) -> None:
        print(f"LOG: {message}")

def log_message(logger: Logger, message: str) -> None:
    logger.log(message)

# Both work without inheritance
log_message(FileLogger(), "File log message")
log_message(ConsoleLogger(), "Console log message")

2. Advanced Questions

Q: How do you create a Protocol with optional methods?

from typing import Protocol, Optional

class Cache(Protocol):
    def get(self, key: str) -> Optional[str]: ...
    def set(self, key: str, value: str) -> None: ...
    # Optional method - not required for compatibility
    def clear(self) -> None: ...

class SimpleCache:
    def __init__(self):
        self._data: dict[str, str] = {}
    
    def get(self, key: str) -> Optional[str]:
        return self._data.get(key)
    
    def set(self, key: str, value: str) -> None:
        self._data[key] = value
    
    # Optional method - can be implemented or not
    def clear(self) -> None:
        self._data.clear()

class BasicCache:
    def __init__(self):
        self._data: dict[str, str] = {}
    
    def get(self, key: str) -> Optional[str]:
        return self._data.get(key)
    
    def set(self, key: str, value: str) -> None:
        self._data[key] = value
    # No clear method - still compatible with Cache protocol

Q: How do you use Protocols with generic types?

from typing import Protocol, TypeVar, Generic, List

T = TypeVar('T')

class Collection(Protocol[T]):
    def add(self, item: T) -> None: ...
    def remove(self, item: T) -> None: ...
    def get_all(self) -> List[T]: ...

class NumberCollection:
    def __init__(self):
        self.items: List[int] = []
    
    def add(self, item: int) -> None:
        self.items.append(item)
    
    def remove(self, item: int) -> None:
        if item in self.items:
            self.items.remove(item)
    
    def get_all(self) -> List[int]:
        return self.items.copy()

class StringCollection:
    def __init__(self):
        self.items: List[str] = []
    
    def add(self, item: str) -> None:
        self.items.append(item)
    
    def remove(self, item: str) -> None:
        if item in self.items:
            self.items.remove(item)
    
    def get_all(self) -> List[str]:
        return self.items.copy()

def process_collection(collection: Collection[T]) -> None:
    print(f"Items: {collection.get_all()}")

# Type safety maintained
number_collection = NumberCollection()
string_collection = StringCollection()

process_collection(number_collection)  # Works with int collection
process_collection(string_collection)  # Works with str collection

3. Practical Questions

Q: How would you implement a plugin system using Protocols?

from typing import Protocol, Dict, Any, List

class Plugin(Protocol):
    def initialize(self, config: Dict[str, Any]) -> None: ...
    def process(self, data: Any) -> Any: ...
    def cleanup(self) -> None: ...

class PluginManager:
    def __init__(self):
        self.plugins: List[Plugin] = []
    
    def register(self, plugin: Plugin) -> None:
        self.plugins.append(plugin)
    
    def run_pipeline(self, data: Any) -> Any:
        result = data
        for plugin in self.plugins:
            plugin.initialize({"mode": "production"})
            result = plugin.process(result)
            plugin.cleanup()
        return result

class DataFilterPlugin:
    def initialize(self, config: Dict[str, Any]) -> None:
        self.config = config
    
    def process(self, data: Any) -> Any:
        if isinstance(data, list):
            return [item for item in data if item is not None]
        return data
    
    def cleanup(self) -> None:
        pass

class DataTransformPlugin:
    def initialize(self, config: Dict[str, Any]) -> None:
        self.config = config
    
    def process(self, data: Any) -> Any:
        if isinstance(data, list):
            return [str(item).upper() for item in data]
        return str(data).upper()
    
    def cleanup(self) -> None:
        pass

# Usage
manager = PluginManager()
manager.register(DataFilterPlugin())
manager.register(DataTransformPlugin())

input_data = [1, None, "hello", 3, None]
output = manager.run_pipeline(input_data)
print(output)  # ['1', 'HELLO', '3']

Performance Considerations

1. Runtime Overhead

# Protocols have zero runtime overhead
from typing import Protocol

class Fast(Protocol):
    def run(self) -> None: ...

class FastImplementation:
    def run(self) -> None:
        print("Running fast")

# No runtime cost - just type checking
def execute(fast: Fast) -> None:
    fast.run()

# This is just a regular function call
execute(FastImplementation())

2. Type Checking Performance

# Protocols can be complex, but they're only checked at type-checking time
from typing import Protocol, TypeVar, Generic

T = TypeVar('T')

class ComplexProtocol(Protocol[T]):
    def method1(self, arg: T) -> T: ...
    def method2(self, arg: T) -> list[T]: ...
    def method3(self, arg: list[T]) -> dict[str, T]: ...

# This complexity only affects mypy/pyright, not runtime
class SimpleImplementation:
    def method1(self, arg: str) -> str:
        return arg.upper()
    
    def method2(self, arg: str) -> list[str]:
        return [arg]
    
    def method3(self, arg: list[str]) -> dict[str, str]:
        return {"key": arg[0] if arg else ""}

3. Memory Usage

# Protocols don't add any memory overhead to instances
class Lightweight(Protocol):
    def process(self) -> None: ...

class LightweightImpl:
    def __init__(self):
        self.data = "some data"
    
    def process(self) -> None:
        print(self.data)

# The instance has the same memory footprint
instance = LightweightImpl()
# No additional memory for protocol compatibility

Type Checking Integration

1. Mypy Configuration

# mypy.ini
[mypy]
python_version = 3.8
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

[mypy.plugins.protocols.*]
# Protocol-specific settings

2. Pyright Configuration

{
  "include": ["src"],
  "exclude": ["**/node_modules", "**/__pycache__"],
  "typeCheckingMode": "strict",
  "useLibraryCodeForTypes": true
}

3. IDE Support

# Most IDEs (PyCharm, VS Code) support Protocol syntax
from typing import Protocol

class MyProtocol(Protocol):
    def my_method(self) -> str: ...

class MyClass:
    def my_method(self) -> str:
        return "Hello"

# IDE will provide autocomplete and type checking
def use_protocol(instance: MyProtocol) -> None:
    result = instance.my_method()  # IDE knows this returns str
    print(result.upper())  # IDE provides string methods

Summary

Protocols in Python provide a powerful way to define structural interfaces:

  • Structural Typing: Based on behavior, not inheritance
  • Zero Runtime Overhead: Only affects static type checking
  • Flexibility: Work with existing code without modification
  • Type Safety: Provide compile-time type checking
  • Composition: Encourage composition over inheritance

Protocols are particularly useful for:

  • Plugin systems
  • Dependency injection
  • Testing with mocks
  • Working with third-party libraries
  • Defining clear interfaces without tight coupling

Choose Protocols when you want the benefits of interfaces without the constraints of inheritance hierarchies.

Interview angle

  • “What is a Protocol?” — structural typing: a class satisfies it by having the right methods, with no inheritance or registration. If it has read() and close() with matching signatures, it’s a Readable, whether or not it ever heard of your Protocol.
  • “Protocol or ABC?” — Protocol when you don’t own the implementations, when you want duck typing that the type checker can verify, or when defining a port at an architectural boundary. ABC when you own the hierarchy, want shared implementation, and want instantiation to fail loudly on a missing method.
  • “Is a Protocol checked at runtime?” — not by default; it’s a static-typing construct. @runtime_checkable enables isinstance, but that only verifies method names, not signatures or types, so it’s a weaker check than it appears.
  • “Why do Protocols suit dependency inversion?” — the interface can live with the consumer rather than the implementer, so the high-level module defines what it needs and low-level modules simply satisfy it. No import from the abstraction to the implementation, which is the dependency-inversion principle expressed cleanly.