HardТеория7 min

Protocol и продвинутые паттерны

Структурная типизация с Protocol, TypedDict, Literal, Annotated, Final и другие продвинутые возможности системы типов

Python поддерживает два подхода к типизации: номинальную (наследование) и структурную (duck typing). Protocol формализует duck typing, позволяя проверять совместимость типов по наличию методов и атрибутов, а не по иерархии наследования.

Protocol -- структурная типизация

Проблема без Protocol

# Without Protocol — must use inheritance
from abc import ABC, abstractmethod

class Printable(ABC):
    @abstractmethod
    def to_string(self) -> str: ...

class User(Printable):  # Must explicitly inherit!
    def __init__(self, name: str) -> None:
        self.name = name

    def to_string(self) -> str:
        return f"User({self.name})"

# What about classes from third-party libraries?
# Can't make them inherit from Printable!

Решение: Protocol

from typing import Protocol

class Printable(Protocol):
    """Any object with a to_string() method."""
    def to_string(self) -> str: ...

class User:
    """User class — does NOT inherit from Printable."""
    def __init__(self, name: str) -> None:
        self.name = name

    def to_string(self) -> str:
        return f"User({self.name})"

class Product:
    """Product class — also does NOT inherit from Printable."""
    def __init__(self, title: str) -> None:
        self.title = title

    def to_string(self) -> str:
        return f"Product({self.title})"

# Both work — structural compatibility, no inheritance needed
def display(item: Printable) -> None:
    print(item.to_string())

display(User("Alice"))       # OK — User has to_string()
display(Product("Laptop"))   # OK — Product has to_string()

Protocol с атрибутами

from typing import Protocol

class HasName(Protocol):
    """Any object with a 'name' attribute."""
    name: str

class HasSize(Protocol):
    """Any object with a 'size' property."""
    @property
    def size(self) -> int: ...

class Document:
    def __init__(self, name: str, content: str) -> None:
        self.name = name
        self.content = content

    @property
    def size(self) -> int:
        return len(self.content)

def greet(entity: HasName) -> str:
    return f"Hello, {entity.name}!"

def is_large(entity: HasSize) -> bool:
    return entity.size > 1000

doc = Document("readme", "Hello world")
print(greet(doc))       # OK — Document has .name
print(is_large(doc))    # OK — Document has .size property

Комбинирование Protocol

from typing import Protocol, runtime_checkable

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

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

class ReadWritable(Readable, Writable, Protocol):
    """Combines both protocols."""
    pass

# runtime_checkable allows isinstance checks
@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

class FileWrapper:
    def read(self) -> str:
        return "data"

    def write(self, data: str) -> None:
        pass

    def close(self) -> None:
        pass

# isinstance works with runtime_checkable Protocol
wrapper = FileWrapper()
print(isinstance(wrapper, Closeable))  # True

TypedDict -- типизированные словари

TypedDict описывает структуру словаря с конкретными ключами и типами:

from typing import TypedDict, NotRequired

class UserDict(TypedDict):
    name: str
    email: str
    age: int
    bio: NotRequired[str]  # Optional key (Python 3.11+)

# Type checker validates keys and value types
user: UserDict = {
    "name": "Alice",
    "email": "[email protected]",
    "age": 30,
}

# Errors caught by type checker:
# user["name"] = 42          # Error: expected str
# user["unknown"] = "value"  # Error: unknown key
# bad: UserDict = {"name": "Bob"}  # Error: missing 'email' and 'age'

# Access is type-safe
name: str = user["name"]     # Type checker knows it's str
age: int = user["age"]       # Type checker knows it's int

Вложенные TypedDict

from typing import TypedDict

class Address(TypedDict):
    street: str
    city: str
    country: str

class Company(TypedDict):
    name: str
    address: Address
    employees: int

company: Company = {
    "name": "Acme Corp",
    "address": {
        "street": "123 Main St",
        "city": "Moscow",
        "country": "Russia",
    },
    "employees": 50,
}

# Type-safe nested access
city: str = company["address"]["city"]

Наследование TypedDict

from typing import TypedDict

class BaseUser(TypedDict):
    name: str
    email: str

class AdminUser(BaseUser):
    role: str
    permissions: list[str]

admin: AdminUser = {
    "name": "Alice",
    "email": "[email protected]",
    "role": "superadmin",
    "permissions": ["read", "write", "delete"],
}

Literal -- конкретные значения как типы

from typing import Literal

# Only specific values are allowed
def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]) -> None:
    print(f"Log level set to: {level}")

set_log_level("INFO")     # OK
set_log_level("DEBUG")    # OK
# set_log_level("TRACE")  # Error: not a valid literal

# Literal with other types
Mode = Literal["read", "write", "append"]

def open_file(path: str, mode: Mode = "read") -> str:
    return f"Opening {path} in {mode} mode"

# Literal in return type — narrows the type
def get_status(code: int) -> Literal["ok", "error", "pending"]:
    if code == 200:
        return "ok"
    elif code >= 400:
        return "error"
    return "pending"

Literal с числами и bool

from typing import Literal

# HTTP status codes
HTTPSuccess = Literal[200, 201, 204]
HTTPError = Literal[400, 401, 403, 404, 500]

def handle_response(status: HTTPSuccess | HTTPError) -> str:
    match status:
        case 200:
            return "OK"
        case 201:
            return "Created"
        case 404:
            return "Not Found"
        case _:
            return f"Status: {status}"

Annotated -- метаданные типов

Annotated добавляет метаданные к типам. Используется для валидации (Pydantic), документации и constraints:

from typing import Annotated

# Add metadata to types
type PositiveInt = Annotated[int, "Must be positive"]
type NonEmptyStr = Annotated[str, "Must not be empty"]
type Email = Annotated[str, "Must be a valid email address"]
type Percentage = Annotated[float, "Value between 0 and 100"]

def create_user(
    name: NonEmptyStr,
    email: Email,
    age: PositiveInt,
) -> dict:
    return {"name": name, "email": email, "age": age}

# With Pydantic — metadata becomes validation rules
from pydantic import Field
# This is how Pydantic uses Annotated:
# type Age = Annotated[int, Field(ge=0, le=150)]
# type Email = Annotated[str, Field(pattern=r"^[\w.-]+@[\w.-]+\.\w+$")]

Final -- константы

from typing import Final

# Value cannot be reassigned
MAX_RETRIES: Final = 3
API_BASE_URL: Final[str] = "https://api.example.com"
SUPPORTED_FORMATS: Final[list[str]] = ["json", "csv", "toml"]

# MAX_RETRIES = 5  # Error: cannot assign to final variable

class Config:
    MAX_CONNECTIONS: Final = 100
    DEFAULT_TIMEOUT: Final[float] = 30.0

ClassVar -- переменные класса

from typing import ClassVar

class Counter:
    """Class with tracked instance count."""
    total_instances: ClassVar[int] = 0  # Shared across all instances
    name: str                            # Instance variable

    def __init__(self, name: str) -> None:
        self.name = name
        Counter.total_instances += 1

# ClassVar tells type checkers this is NOT an instance variable
c1 = Counter("first")
c2 = Counter("second")
print(Counter.total_instances)  # 2
# c1.total_instances = 5  # Type checker warning

TypeGuard и TypeIs

TypeGuard (Python 3.10+)

from typing import TypeGuard

def is_string_list(items: list[object]) -> TypeGuard[list[str]]:
    """Check if all items are strings."""
    return all(isinstance(item, str) for item in items)

def process(items: list[object]) -> None:
    if is_string_list(items):
        # Type checker now knows items is list[str]
        for item in items:
            print(item.upper())  # OK — item is str

TypeIs (Python 3.13+)

from typing import TypeIs

def is_int(value: object) -> TypeIs[int]:
    """Narrow type to int."""
    return isinstance(value, int)

def process(value: str | int) -> None:
    if is_int(value):
        print(value + 1)     # OK — value is int
    else:
        print(value.upper())  # OK — value is str

Overload -- перегрузка функций

from typing import overload

@overload
def get_item(index: int) -> str: ...
@overload
def get_item(index: slice) -> list[str]: ...

def get_item(index: int | slice) -> str | list[str]:
    """Get item by index or slice."""
    items = ["a", "b", "c", "d"]
    return items[index]

# Type checker knows exact return type
single: str = get_item(0)           # Returns str
multiple: list[str] = get_item(slice(0, 2))  # Returns list[str]

Практический пример: типобезопасный Event System

from typing import Protocol, TypedDict, Literal
from collections.abc import Callable
from dataclasses import dataclass, field

type EventType = Literal["user.created", "user.deleted", "order.placed"]

class EventData(TypedDict):
    event_type: EventType
    timestamp: str
    payload: dict[str, str | int]

class EventHandler(Protocol):
    """Protocol for event handlers."""
    def __call__(self, event: EventData) -> None: ...

@dataclass
class EventBus:
    """Type-safe event bus."""
    _handlers: dict[EventType, list[EventHandler]] = field(
        default_factory=dict
    )

    def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
        """Subscribe a handler to an event type."""
        if event_type not in self._handlers:
            self._handlers[event_type] = []
        self._handlers[event_type].append(handler)

    def publish(self, event: EventData) -> None:
        """Publish an event to all subscribers."""
        handlers = self._handlers.get(event["event_type"], [])
        for handler in handlers:
            handler(event)

# Type-safe usage
bus = EventBus()

def on_user_created(event: EventData) -> None:
    print(f"User created: {event['payload']}")

bus.subscribe("user.created", on_user_created)
bus.publish({
    "event_type": "user.created",
    "timestamp": "2024-01-15T10:30:00",
    "payload": {"name": "Alice", "age": 30},
})

Проверь себя

Что дает декоратор @runtime_checkable для Protocol?

Что делает TypedDict?

Какой тип использовать для значения, которое нельзя переприсвоить?

Для чего используется Literal?

В чем отличие Protocol от ABC (абстрактного класса)?