Традиционная утиная типизация работает просто: попробовал вызвать метод, получилось - отлично, не получилось - упал с ошибкой в рантайме. Протоколы добавляют сюда проверку на этапе статического анализа. Пишешь код, запускаешь mypy - и сразу видишь, что твой класс не соответствует ожидаемому контракту. До запуска программы. Я столкнулся с этим год назад при рефакторинге высоконагруженного API. Там был хаос: классы наследовались друг от друга без явной необходимости, просто потому что "так надо для типов". Переписал на протоколы - и зависимости рассыпались как карточный домик. Каждый модуль стал независимым. Тесты упростились раза в три, потому что моки создавать стало элементарно.
Протоколы - это формализация того, что Python делал всегда. Раньше мы полагались на документацию и надежду, что разработчик не накосячит. Теперь можно записать ожидания в коде. Написал протокол - и все понятно: вот методы, вот сигнатуры, вот что должен уметь твой класс. Инструменты статического анализа подхватывают это автоматически и ругаются, если что-то не так.
Это не замена абстрактным базовым классам. Это другой инструмент для других задач. Там, где нужна жесткая иерархия и общее поведение - используй ABC. Где нужна гибкость и независимость компонентов - бери протоколы. Я предпочитаю второе, потому что связанность кода - главный враг поддерживаемости.
Отличие протоколов от абстрактных базовых классов и интерфейсов
Абстрактные базовые классы требуют явного наследования. Ты пишешь class MyLogger(BaseLogger): - и теперь MyLogger навсегда привязан к BaseLogger. Хочешь проверить типы - обязан унаследоваться. Протоколы работают иначе: класс соответствует протоколу, если реализует нужные методы. Без наследования, без зависимостей, без импорта протокола в модуле с классом. Вот классический ABC:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from abc import ABC, abstractmethod
class Serializable(ABC):
@abstractmethod
def to_json(self) -> str:
pass
@abstractmethod
def from_json(self, data: str):
pass
class User(Serializable): # Явное наследование обязательно
def __init__(self, name: str):
self.name = name
def to_json(self) -> str:
return f'{{"name": "{self.name}"}}'
def from_json(self, data: str):
# Разбор JSON
pass |
|
Теперь та же идея через протокол:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str: ...
def from_json(self, data: str): ...
class User: # Никакого наследования!
def __init__(self, name: str):
self.name = name
def to_json(self) -> str:
return f'{{"name": "{self.name}"}}'
def from_json(self, data: str):
pass
def save_to_db(obj: Serializable) -> None:
# User подходит, потому что реализует нужные методы
data = obj.to_json()
# Сохраняем... |
|
Разница принципиальная. ABC проверяет: "Ты унаследовался от меня?" Протокол спрашивает: "Ты умеешь то, что мне нужно?" Первое называется номинальной типизацией - важно имя (название класса в иерархии). Второе - структурная типизация - важна структура (наличие методов). Я долго работал с Java, где интерфейсы требуют явной реализации через implements. Это даёт гарантии, но создаёт связанность. Меняешь интерфейс - перекомпилируешь все классы. В Python протоколы решают эту проблему элегантнее. Библиотека определяет протокол, ты пишешь класс, и они работают вместе - не зная друг о друге.
Утиная типизация всегда была в Python. "Если что-то крякает как утка, плавает как утка - это утка". Только проверка происходила в рантайме:
| Python | 1
2
3
| def process(obj):
# Надеемся, что у obj есть метод handle
obj.handle() # Узнаем только здесь, есть он или нет |
|
Протоколы делают утиную типизацию статической. mypy анализирует код и говорит: "У твоего объекта нет метода handle, исправь до запуска". Та же гибкость, но с гарантиями на этапе разработки.
Документирование через протоколы - отдельная тема. Раньше писали в докстрингах: "Ожидается объект с методами x, y, z". Кто это читает? Протокол - живая документация в коде:
| Python | 1
2
3
4
5
6
7
8
9
| class Drawable(Protocol):
"""Объекты, которые можно нарисовать на холсте"""
def draw(self, canvas: Canvas) -> None:
"""Отрисовка объекта на переданном холсте"""
...
def get_bounds(self) -> tuple[int, int, int, int]:
"""Возвращает границы объекта: (x, y, width, height)"""
... |
|
Теперь любой класс, реализующий эти методы, автоматически считается Drawable. IDE подсказывает, какие методы нужны. Статический анализатор проверяет сигнатуры. Новый разработчик смотрит на протокол и сразу понимает контракт. Три месяца назад интегрировал стороннюю библиотеку в проект. Она ожидала объекты с определёнными методами, но нигде это не описывалось. Час потратил на изучение исходников, чтобы понять, что именно нужно реализовать. Если бы автор использовал протоколы - прочитал бы за минуту.
ABC не исчезают. Когда нужно общее поведение, переиспользование кода, защита от дурака - они незаменимы. Но для определения контрактов взаимодействия, особенно между независимыми модулями, протоколы работают чище. Меньше зависимостей, проще тестирование, гибче архитектура.
Как из Python скрипта выполнить другой python скрипт? Как из Python скрипта выполнить другой python скрипт?
Если он находится в той же папке но нужно... Почему синтаксис Python 2.* и Python 3.* так отличается? Привет!
Решил на досуге заняться изучением Python'a. Читаю книгу по второму питону, а пользуюсь... Что лучше учить Python 2 или Python 3? хочу начать учить питон но полазив в нете, частенько попадалась информация что вроде как 2 будет... Python without python Доброго времени суток!
Хотел узнать, что делать с *.py файлом после того как готова программа,...
Анатомия протоколов

Протоколы живут в модуле typing, который стал стандартной частью Python начиная с версии 3.5. Базовый класс Protocol появился в 3.8, хотя до этого его можно было использовать через пакет typing_extensions. Импортируется просто:
| Python | 1
| from typing import Protocol |
|
Объявление протокола выглядит как обычный класс с единственным отличием - наследованием от Protocol. Внутри описываешь методы и атрибуты, которые должен реализовать класс для соответствия протоколу:
| Python | 1
2
3
4
5
6
7
8
9
10
| from typing import Protocol
class Comparable(Protocol):
def __lt__(self, other) -> bool:
"""Меньше чем"""
...
def __le__(self, other) -> bool:
"""Меньше или равно"""
... |
|
Многоточие в теле метода - не заглушка. Это синтаксически корректный способ показать, что реализация не требуется. Можно написать pass, но многоточие стало негласным стандартом для протоколов. Короче, понятнее.
Атрибуты описываются через аннотации типов без присваивания значений:
| Python | 1
2
3
4
5
6
7
| class Identifiable(Protocol):
id: int # Атрибут, который должен существовать
name: str
def get_id(self) -> int:
"""Получить идентификатор"""
... |
|
Теперь любой класс с атрибутами id, name и методом get_id автоматически соответствует протоколу Identifiable. Без наследования, без явных деклараций.
Проверка соответствия по умолчанию работает только статически - через mypy, pyright или другие анализаторы. В рантайме isinstance и issubclass с протоколами не работают:
| Python | 1
2
3
4
5
6
7
8
9
10
| class User:
def __init__(self, user_id: int, username: str):
self.id = user_id
self.name = username
def get_id(self) -> int:
return self.id
user = User(42, "admin")
# isinstance(user, Identifiable) # Ошибка: Protocols cannot be used with isinstance |
|
Год назад поймал баг именно на этом. Пытался использовать isinstance с протоколом для валидации входных данных в API. Падало с непонятной ошибкой. Полчаса искал причину, пока не вспомнил про декоратор runtime_checkable.
Чтобы включить проверки в рантайме, протокол нужно пометить декоратором:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
| from typing import Protocol, runtime_checkable
@runtime_checkable
class Identifiable(Protocol):
id: int
name: str
def get_id(self) -> int: ...
# Теперь isinstance работает
user = User(42, "admin")
print(isinstance(user, Identifiable)) # True |
|
Но тут важный нюанс. Проверка в рантайме смотрит только на наличие атрибутов и методов, но не проверяет их сигнатуры. Метод может принимать совершенно другие аргументы, возвращать не тот тип - isinstance всё равно вернёт True. Это поверхностная проверка, а не полная валидация контракта.
| Python | 1
2
3
4
5
6
7
8
9
10
| @runtime_checkable
class Processor(Protocol):
def process(self, data: str) -> dict: ...
class BrokenProcessor:
def process(self, x: int, y: int) -> list: # Другая сигнатура!
return [x, y]
broken = BrokenProcessor()
print(isinstance(broken, Processor)) # True - только проверка наличия метода! |
|
Этот момент часто упускают. runtime_checkable полезен для быстрых проверок на входе функции, но полагаться на него как на полноценную валидацию - ошибка. Статический анализ ловит такие несоответствия, рантайм-проверки - нет.
issubclass тоже работает с runtime_checkable протоколами:
| Python | 1
2
| print(issubclass(User, Identifiable)) # True
print(issubclass(BrokenProcessor, Processor)) # True |
|
Опять же, проверка поверхностная. Класс формально соответствует протоколу по наличию членов, но их типы и сигнатуры могут не совпадать.
Протоколы можно комбинировать через наследование. Один протокол расширяет другой:
| Python | 1
2
3
4
5
6
7
8
9
| class Readable(Protocol):
def read(self) -> bytes: ...
class Writable(Protocol):
def write(self, data: bytes) -> int: ...
class ReadWritable(Readable, Writable, Protocol):
"""Объект, который можно читать и записывать"""
pass |
|
Класс, соответствующий ReadWritable, должен реализовать методы обоих родительских протоколов. Это композиция контрактов, и она работает интуитивно.
Протоколы могут содержать свойства через декоратор @property:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| class Sized(Protocol):
@property
def size(self) -> int:
"""Размер объекта"""
...
class Buffer:
def __init__(self, capacity: int):
self._capacity = capacity
@property
def size(self) -> int:
return self._capacity
# Buffer соответствует Sized |
|
Недавно писал систему кеширования, где протоколы с property оказались очень кстати. Разные реализации кеша (в памяти, Redis, файловый) имели одинаковый интерфейс через свойства, без лишних методов-геттеров.
Интересная деталь: протоколы могут содержать реализацию методов. Это не обязывает классы переопределять их, но даёт возможность предоставить дефолтное поведение:
| Python | 1
2
3
4
5
6
7
| class Timestamped(Protocol):
created_at: float
def age(self) -> float:
"""Возвращает возраст объекта в секундах"""
import time
return time.time() - self.created_at |
|
Класс с атрибутом created_at автоматически получает метод age. Если хочет переопределить - может, но не обязан. Правда, это скорее исключение. Обычно протоколы содержат только сигнатуры.
Generic протоколы с TypeVar позволяют параметризовать типы. Это мощный инструмент для универсальных контейнеров:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| from typing import Protocol, TypeVar
T = TypeVar('T')
class Container(Protocol[T]):
def add(self, item: T) -> None: ...
def get(self) -> T: ...
class IntContainer:
def __init__(self):
self._items: list[int] = []
def add(self, item: int) -> None:
self._items.append(item)
def get(self) -> int:
return self._items[0] if self._items else 0
# IntContainer соответствует Container[int] |
|
Обобщённые протоколы незаменимы при работе с коллекциями, фабриками, паттернами стратегии - везде, где тип данных может меняться, но интерфейс остаётся единым.
Протоколы не создают экземпляров. Попытка инстанцировать протокол вызовет TypeError:
| Python | 1
2
3
4
5
| class Loggable(Protocol):
def log(self, message: str) -> None: ...
# Так нельзя
# logger = Loggable() # TypeError: Protocols cannot be instantiated |
|
Это логично - протокол описывает контракт, а не реализацию. Он говорит "что должно быть", но не "как это работает". Для создания объектов нужны обычные классы.
Модуль typing предоставляет несколько готовых протоколов для стандартных операций. SupportsInt, SupportsFloat, SupportsComplex - описывают объекты, конвертируемые в соответствующие типы:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
| from typing import SupportsInt
def process_numeric(value: SupportsInt) -> int:
# Принимаем всё, что можно превратить в int
return int(value)
class Custom:
def __int__(self) -> int:
return 42
# Работает
result = process_numeric(Custom()) # 42 |
|
SupportsAbs, SupportsRound, SupportsBytes - аналогичные протоколы для других операций. Использую их постоянно в функциях, работающих с числами или строками, когда не хочу ограничиваться конкретными типами.
Протоколы могут содержать магические методы. Это открывает интересные возможности:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| class Addable(Protocol):
def __add__(self, other) -> 'Addable':
"""Поддержка оператора +"""
...
class Vector2D:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __add__(self, other: 'Vector2D') -> 'Vector2D':
return Vector2D(self.x + other.x, self.y + other.y)
def combine(a: Addable, b: Addable) -> Addable:
return a + b
# Vector2D соответствует Addable
v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)
v3 = combine(v1, v2) # Статический анализатор доволен |
|
Месяц назад рефакторил математическую библиотеку для обработки геометрических примитивов. Протоколы с магическими методами позволили написать универсальные функции, работающие с точками, векторами, матрицами - без общего базового класса и без дублирования кода.
Вложенные протоколы тоже работают, хотя используются редко:
| Python | 1
2
3
4
5
6
| class Serializer(Protocol):
class Config(Protocol):
indent: int
sort_keys: bool
def serialize(self, obj: object, config: Config) -> str: ... |
|
Честно говоря, за пять лет ни разу не видел такое в production коде. Слишком сложно, проще разделить на независимые протоколы.
Ковариантность и контравариантность в протоколах работают через параметры TypeVar:
| Python | 1
2
3
4
5
6
7
8
9
10
| from typing import Protocol, TypeVar
T_co = TypeVar('T_co', covariant=True) # Ковариантный параметр
T_contra = TypeVar('T_contra', contravariant=True) # Контравариантный
class Producer(Protocol[T_co]):
def produce(self) -> T_co: ...
class Consumer(Protocol[T_contra]):
def consume(self, item: T_contra) -> None: ... |
|
Ковариантность означает, что Producer[Dog] является подтипом Producer[Animal]. Контравариантность - обратное: Consumer[Animal] является подтипом Consumer[Dog]. Это важно для корректной проверки типов в иерархиях, но в повседневной разработке использую нечасто. В основном при работе с коллекциями и callback-функциями.
Протоколы не поддерживают множественное наследование от обычных классов. Либо Protocol, либо обычный класс:
| Python | 1
2
3
4
5
6
| class BaseClass:
def method(self) -> None: ...
# Так нельзя
# class Mixed(BaseClass, Protocol): # TypeError
# def protocol_method(self) -> None: ... |
|
Пытался обойти это ограничение пару раз - не получилось. Приходится выбирать: либо протокол, либо базовый класс с наследованием. Гибридные решения не работают, и это правильно - смешивание номинальной и структурной типизации привело бы к путанице.
Встроенные протоколы стандартной библиотеки
Python имел протоколы задолго до появления typing.Protocol. Магические методы всегда определяли поведение объектов через структурную типизацию. Разница лишь в том, что раньше это работало только в рантайме, а теперь можно проверять статически. Возьмём итерируемые объекты. Любой класс с методом __iter__ считается итерируемым, и его можно использовать в циклах:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class Countdown:
def __init__(self, start: int):
self.current = start
def __iter__(self):
# Возвращаем итератор
return CountdownIterator(self.current)
class CountdownIterator:
def __init__(self, start: int):
self.value = start
def __iter__(self):
return self
def __next__(self) -> int:
if self.value <= 0:
raise StopIteration
self.value -= 1
return self.value + 1
# Работает в цикле
for num in Countdown(5):
print(num) # 5, 4, 3, 2, 1 |
|
Модуль collections.abc формализует это через ABC, но typing предоставляет протоколы. Iterable[T] описывает объект, возвращающий итератор:
| Python | 1
2
3
4
5
6
7
8
9
10
11
| from typing import Iterator, Iterable
def process_items(items: Iterable[int]) -> None:
for item in items:
# Обработка каждого элемента
print(item * 2)
# Подходит любой итерируемый объект
process_items([1, 2, 3]) # Список
process_items(range(5)) # Range
process_items(Countdown(3)) # Наш класс |
|
Iterator[T] требует оба метода - и __iter__, и __next__. Важное различие: каждый итератор является итерируемым, но не наоборот. Список итерируем, но не итератор - он создаёт новый итератор при каждом вызове iter(). Полгода назад отлаживал генератор данных для обучения нейросети. Там были бесконечные потоки изображений, и я запутался в различиях между Iterable и Iterator. Функция ожидала Iterator, а я передавал класс с __iter__, который каждый раз создавал новый итератор. Результат - данные обрабатывались по кругу, обучение зависло. Явная аннотация типов через протоколы помогла найти проблему за минуты.
Контекстные менеджеры - ещё один фундаментальный протокол. Два магических метода: __enter__ и __exit__:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| from typing import Protocol, TypeVar
T = TypeVar('T')
class ContextManager(Protocol[T]):
def __enter__(self) -> T: ...
def __exit__(self, exc_type, exc_val, exc_tb) -> bool | None: ...
class DatabaseConnection:
def __init__(self, host: str):
self.host = host
self.connected = False
def __enter__(self):
# Устанавливаем соединение
self.connected = True
print(f"Подключились к {self.host}")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Закрываем соединение
self.connected = False
print(f"Отключились от {self.host}")
return False # Не подавляем исключения
def execute_query(manager: ContextManager[DatabaseConnection]) -> None:
with manager as conn:
# Работаем с подключением
assert conn.connected |
|
Использую контекстные менеджеры постоянно для управления ресурсами. Файлы, сетевые соединения, блокировки - всё что требует гарантированной очистки. Протокол ContextManager позволяет написать универсальные функции, работающие с любыми менеджерами без привязки к конкретным классам.
Callable - протокол для вызываемых объектов. Работает с функциями, методами, классами с __call__:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| from typing import Callable
class Multiplier:
def __init__(self, factor: int):
self.factor = factor
def __call__(self, x: int) -> int:
# Объект можно вызывать как функцию
return x * self.factor
def apply_operation(value: int, operation: Callable[[int], int]) -> int:
return operation(value)
doubler = Multiplier(2)
result = apply_operation(10, doubler) # 20
result = apply_operation(10, lambda x: x + 5) # 15 |
|
Три года назад разрабатывал систему обработки событий. Обработчики могли быть функциями, методами, классами - любыми вызываемыми объектами. Callable[...] в аннотациях типов сделал код самодокументируемым и помог поймать несколько ошибок, где передавались невызываемые объекты.
Sized протокол требует метод __len__. Container проверяет __contains__ для оператора in:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| from typing import Sized, Container
class CustomSet:
def __init__(self, items: list[int]):
self._items = set(items)
def __len__(self) -> int:
return len(self._items)
def __contains__(self, item: int) -> bool:
return item in self._items
def check_size(obj: Sized) -> bool:
# Работает с любым объектом, у которого есть len()
return len(obj) > 0
def check_membership(container: Container[int], value: int) -> bool:
return value in container
custom = CustomSet([1, 2, 3])
print(check_size(custom)) # True
print(check_membership(custom, 2)) # True |
|
Collection объединяет несколько протоколов: Sized, Iterable и Container. Это композиция, и она показывает силу протоколов - можно комбинировать контракты без создания сложных иерархий наследования.
Дескрипторы тоже имеют протокол, хотя используются реже:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| class Descriptor(Protocol):
def __get__(self, instance, owner): ...
def __set__(self, instance, value): ...
def __delete__(self, instance): ...
class ValidatedAttribute:
def __init__(self, min_value: int):
self.min_value = min_value
self.value = 0
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value: int):
if value < self.min_value:
raise ValueError(f"Значение должно быть >= {self.min_value}")
self.value = value
class Product:
price = ValidatedAttribute(0) # Цена не может быть отрицательной |
|
Когда писал ORM для внутреннего проекта, дескрипторы с протоколами оказались незаменимы для реализации полей модели. Явный контракт через протокол упростил расширение системы новыми типами полей.
Протоколы отлично сочетаются с dataclass и attrs. Эти инструменты автоматически генерируют методы, которые могут соответствовать протоколам:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| from dataclasses import dataclass
from typing import Protocol
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
def __le__(self, other) -> bool: ...
@dataclass(order=True) # Генерирует методы сравнения
class Score:
value: int
name: str
def get_max(a: Comparable, b: Comparable) -> Comparable:
return a if a > b else b
# Score автоматически соответствует Comparable
score1 = Score(100, "Alice")
score2 = Score(85, "Bob")
winner = get_max(score1, score2) # score1 |
|
Параметр order=True в dataclass генерирует все методы сравнения, делая класс совместимым с протоколами вроде Comparable. Не нужно вручную реализовывать магические методы - декоратор делает всё за тебя, и протокол просто проверяет, что они есть. attrs работает похожим образом, но с большей гибкостью:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| import attr
from typing import Protocol
class Hashable(Protocol):
def __hash__(self) -> int: ...
def __eq__(self, other) -> bool: ...
@attr.s(frozen=True, auto_attribs=True) # frozen генерирует __hash__
class UserId:
value: int
def validate(self):
if self.value <= 0:
raise ValueError("ID должен быть положительным")
# UserId соответствует Hashable
ids = {UserId(1), UserId(2), UserId(1)} # Работает как множество |
|
Параметр frozen=True делает объект неизменяемым и автоматически добавляет __hash__, что превращает класс в полноценный хешируемый тип. Это критично для использования в словарях и множествах.
Reversible требует метод __reversed__ для обратной итерации:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| from typing import Reversible, Iterator
class Playlist:
def __init__(self, tracks: list[str]):
self._tracks = tracks
def __iter__(self) -> Iterator[str]:
return iter(self._tracks)
def __reversed__(self) -> Iterator[str]:
# Итерация в обратном порядке
return reversed(self._tracks)
def shuffle_backwards(items: Reversible[str]) -> None:
for item in reversed(items):
print(f"Проигрываем: {item}")
playlist = Playlist(["Intro", "Main", "Outro"])
shuffle_backwards(playlist) # Outro, Main, Intro |
|
Писал аудиоплеер два года назад - там Reversible пригодился для реализации истории воспроизведения. Не пришлось городить отдельные методы для прямого и обратного обхода, всё работало через стандартный протокол.
Mapping и MutableMapping - протоколы для словарей и словареподобных структур:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| from typing import Mapping, MutableMapping, Iterator
class ConfigStore:
def __init__(self):
self._data: dict[str, str] = {}
def __getitem__(self, key: str) -> str:
return self._data[key]
def __setitem__(self, key: str, value: str) -> None:
# Валидация при записи
if not key.isupper():
raise KeyError("Ключи должны быть в верхнем регистре")
self._data[key] = value
def __delitem__(self, key: str) -> None:
del self._data[key]
def __iter__(self) -> Iterator[str]:
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
def read_config(config: Mapping[str, str]) -> str:
# Только чтение, изменения невозможны
return config.get("DATABASE_URL", "localhost")
def update_config(config: MutableMapping[str, str], key: str, value: str) -> None:
# Можно изменять
config[key] = value
store = ConfigStore()
update_config(store, "API_KEY", "secret123") # Ошибка: ключ не в верхнем регистре |
|
Различие между Mapping и MutableMapping принципиально. Первый гарантирует неизменность - функция не сможет модифицировать данные даже случайно. Второй даёт полный доступ. Использую эту дихотомию везде, где работаю с конфигурациями или shared state между модулями.
Sequence и MutableSequence описывают списки и подобные структуры с индексацией:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| from typing import Sequence, MutableSequence
class RingBuffer:
def __init__(self, capacity: int):
self._capacity = capacity
self._data: list[int] = []
def __getitem__(self, index: int) -> int:
# Циклическая индексация
return self._data[index % len(self._data)]
def __setitem__(self, index: int, value: int) -> None:
self._data[index % len(self._data)] = value
def __len__(self) -> int:
return len(self._data)
def append(self, value: int) -> None:
if len(self._data) < self._capacity:
self._data.append(value)
else:
# Перезаписываем старейший элемент
self._data[len(self._data) % self._capacity] = value
def sum_sequence(seq: Sequence[int]) -> int:
# Работает с любой последовательностью
total = 0
for val in seq:
total += val
return total
buffer = RingBuffer(5)
buffer.append(10)
buffer.append(20)
print(sum_sequence(buffer)) # 30 |
|
AsyncIterable и AsyncIterator - асинхронные аналоги обычных протоколов итерации. Требуют __aiter__ и __anext__:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| from typing import AsyncIterator
import asyncio
class AsyncCounter:
def __init__(self, limit: int):
self.limit = limit
self.current = 0
def __aiter__(self):
return self
async def __anext__(self) -> int:
if self.current >= self.limit:
raise StopAsyncIteration
# Имитация асинхронной операции
await asyncio.sleep(0.1)
self.current += 1
return self.current
async def process_async(source: AsyncIterator[int]) -> None:
async for value in source:
print(f"Обработали: {value}")
# asyncio.run(process_async(AsyncCounter(3))) |
|
Полгода назад переписывал парсер логов на асинхронный вариант. AsyncIterator позволил создать единый интерфейс для чтения из файлов, сетевых потоков и баз данных. Код получился чище, чем с callback-функциями или ручным управлением корутинами.
SupportsAbs, SupportsRound и подобные протоколы проверяют поддержку встроенных функций:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| from typing import SupportsAbs, SupportsRound
class ComplexNumber:
def __init__(self, real: float, imag: float):
self.real = real
self.imag = imag
def __abs__(self) -> float:
# Модуль комплексного числа
return (self.real [B] 2 + self.imag [/B] 2) ** 0.5
def __round__(self, ndigits: int = 0) -> 'ComplexNumber':
return ComplexNumber(
round(self.real, ndigits),
round(self.imag, ndigits)
)
def calculate_distance(point: SupportsAbs[float]) -> float:
# abs() работает с любым объектом, поддерживающим __abs__
return abs(point)
num = ComplexNumber(3.0, 4.0)
print(calculate_distance(num)) # 5.0 |
|
Эти протоколы кажутся мелочью, но избавляют от необходимости писать isinstance-проверки или try-except блоки. Статический анализатор видит, что объект поддерживает нужную операцию, и этого достаточно.
Создание собственных протоколов
Определить протокол просто - опиши методы и атрибуты, которые тебе нужны. Сложнее сделать это так, чтобы контракт был понятным, полезным и не превращался в монстра с двадцатью методами, из которых реально используется три.
Начну с реального примера. Год назад разрабатывал систему уведомлений для микросервисной архитектуры. Email, SMS, push-уведомления, Slack, Telegram - куча каналов. Первая мысль - создать базовый класс NotificationChannel. Но зачем? Каналы ничего общего между собой не имеют, кроме способности отправлять сообщения. Протокол решил проблему элегантно:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| from typing import Protocol
class NotificationChannel(Protocol):
def send(self, recipient: str, message: str) -> bool:
"""Отправка сообщения получателю. Возвращает True при успехе"""
...
def is_available(self) -> bool:
"""Проверка доступности канала"""
...
class EmailNotifier:
def __init__(self, smtp_host: str, smtp_port: int):
self.host = smtp_host
self.port = smtp_port
self.connected = False
def send(self, recipient: str, message: str) -> bool:
# Отправка через SMTP
if not self.connected:
return False
print(f"Email на {recipient}: {message}")
return True
def is_available(self) -> bool:
return self.connected
class TelegramNotifier:
def __init__(self, bot_token: str):
self.token = bot_token
def send(self, recipient: str, message: str) -> bool:
# Отправка через Telegram API
print(f"Telegram для {recipient}: {message}")
return True
def is_available(self) -> bool:
return bool(self.token)
def broadcast(channels: list[NotificationChannel], users: list[str], text: str) -> None:
"""Рассылка сообщения через все доступные каналы"""
for channel in channels:
if not channel.is_available():
continue
for user in users:
channel.send(user, text) |
|
Никакого наследования, никаких зависимостей. EmailNotifier и TelegramNotifier даже не знают о существовании протокола. Они просто реализуют нужные методы, и этого достаточно. Добавить новый канал? Пиши класс с методами send и is_available - готово. Контракт должен быть минималистичным. Два-три метода максимум, редко больше. Если протокол требует десять методов - что-то не так с архитектурой. Либо ты пытаешься запихнуть слишком много ответственности в один контракт, либо нужно разбить на несколько специализированных протоколов.
Композиция протоколов - мощный инструмент. Вместо одного большого контракта создаёшь несколько маленьких и комбинируешь их:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| from typing import Protocol
class Serializable(Protocol):
def to_dict(self) -> dict:
"""Преобразование объекта в словарь"""
...
class Validatable(Protocol):
def validate(self) -> bool:
"""Проверка корректности данных"""
...
class Persistable(Serializable, Validatable, Protocol):
"""Объект, который можно сериализовать, валидировать и сохранять"""
def save_to_db(self) -> None:
"""Сохранение в базу данных"""
...
class UserProfile:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def to_dict(self) -> dict:
return {"name": self.name, "age": self.age}
def validate(self) -> bool:
# Простая валидация
return len(self.name) > 0 and self.age >= 0
def save_to_db(self) -> None:
if not self.validate():
raise ValueError("Некорректные данные профиля")
# Сохранение в базу
data = self.to_dict()
print(f"Сохраняем: {data}")
def persist_entity(entity: Persistable) -> None:
"""Универсальная функция сохранения"""
if entity.validate():
entity.save_to_db()
print(f"Сохранено: {entity.to_dict()}")
else:
print("Валидация провалилась")
profile = UserProfile("Alice", 30)
persist_entity(profile) |
|
Три простых протокола объединились в один сложный. Можно использовать их по отдельности или вместе. Функция может требовать только Serializable, если ей нужна сериализация. Или Persistable, если нужен полный цикл. Гибкость без раздувания иерархий наследования.
Обобщённые протоколы с TypeVar позволяют параметризовать типы. Незаменимо для универсальных контейнеров, фабрик, стратегий:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| from typing import Protocol, TypeVar, Generic
T = TypeVar('T')
R = TypeVar('R')
class Mapper(Protocol[T, R]):
"""Преобразование объектов одного типа в другой"""
def map(self, item: T) -> R:
...
class IntToStringMapper:
def map(self, item: int) -> str:
# Преобразуем число в строку с форматированием
return f"Число: {item:05d}"
class UserToJsonMapper:
def map(self, item: dict) -> str:
# Преобразуем словарь в JSON-строку
import json
return json.dumps(item)
def transform_batch(items: list[T], mapper: Mapper[T, R]) -> list[R]:
"""Применение преобразования к списку элементов"""
return [mapper.map(item) for item in items]
# Работает с разными типами
int_mapper = IntToStringMapper()
strings = transform_batch([1, 2, 3], int_mapper) # ["Число: 00001", ...]
user_mapper = UserToJsonMapper()
users = [{"name": "Alice"}, {"name": "Bob"}]
jsons = transform_batch(users, user_mapper) # ['{"name": "Alice"}', ...] |
|
TypeVar делает протокол типобезопасным. Статический анализатор понимает, что если передаёшь список int и Mapper[int, str], то получишь list[str]. Без Generic пришлось бы использовать Any или писать отдельные протоколы для каждой комбинации типов.
Ковариантность и контравариантность добавляют гибкости, но усложняют понимание:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| from typing import Protocol, TypeVar
T_co = TypeVar('T_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)
class Source(Protocol[T_co]):
"""Источник данных - только чтение"""
def get(self) -> T_co:
...
class Sink(Protocol[T_contra]):
"""Приёмник данных - только запись"""
def put(self, item: T_contra) -> None:
...
class IntSource:
def get(self) -> int:
return 42
class NumberSink:
def put(self, item: int | float) -> None:
print(f"Получили число: {item}")
# Source ковариантен: Source[int] является подтипом Source[int | float]
# Sink контравариантен: Sink[int | float] является подтипом Sink[int] |
|
Честно говоря, за три года активной разработки с протоколами использовал ковариантность от силы раз пять. В большинстве случаев инвариантные TypeVar работают нормально. Но когда пишешь библиотеку с публичным API - ковариантность критична для корректной проверки типов.
Протоколы могут содержать свойства через @property, и это открывает интересные возможности:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| from typing import Protocol
class Measurable(Protocol):
@property
def width(self) -> float:
"""Ширина объекта"""
...
@property
def height(self) -> float:
"""Высота объекта"""
...
class Rectangle:
def __init__(self, w: float, h: float):
self._width = w
self._height = h
@property
def width(self) -> float:
return self._width
@property
def height(self) -> float:
return self._height
class Circle:
def __init__(self, radius: float):
self._radius = radius
@property
def width(self) -> float:
# Диаметр круга
return self._radius * 2
@property
def height(self) -> float:
return self._radius * 2
def calculate_area(obj: Measurable) -> float:
"""Приблизительная площадь"""
return obj.width * obj.height
rect = Rectangle(10, 5)
circ = Circle(3)
print(calculate_area(rect)) # 50.0
print(calculate_area(circ)) # 36.0 |
|
Свойства в протоколах полезны, когда интерфейс должен скрывать внутреннюю реализацию. Объект может хранить данные как угодно - в атрибутах, вычислять на лету, получать из базы. Протокол требует только наличие readable property с определённым типом.
Протоколы могут требовать магические методы, что даёт контроль над операторами:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| from typing import Protocol
class Comparable(Protocol):
def __lt__(self, other) -> bool:
"""Оператор <"""
...
def __le__(self, other) -> bool:
"""Оператор <="""
...
class Priority:
def __init__(self, level: int, name: str):
self.level = level
self.name = name
def __lt__(self, other: 'Priority') -> bool:
# Сравнение по уровню приоритета
return self.level < other.level
def __le__(self, other: 'Priority') -> bool:
return self.level <= other.level
def find_highest(items: list[Comparable]) -> Comparable:
"""Поиск элемента с максимальным приоритетом"""
if not items:
raise ValueError("Список пуст")
highest = items[0]
for item in items[1:]:
if item > highest: # Использует __lt__ и __le__
highest = item
return highest
tasks = [Priority(3, "Low"), Priority(1, "Critical"), Priority(2, "Medium")]
urgent = find_highest(tasks)
print(urgent.name) # "Critical" |
|
Протоколы с магическими методами позволяют писать алгоритмы, работающие с любыми сравнимыми объектами. Не нужно знать, что именно сравнивается - числа, строки, кастомные классы. Главное, что они поддерживают нужные операторы.
Вложенность и сложные структуры в протоколах возможны, но редко оправданы:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from typing import Protocol
class Repository(Protocol):
def find(self, id: int) -> dict | None:
"""Поиск записи по ID"""
...
def save(self, data: dict) -> int:
"""Сохранение и возврат ID"""
...
class UnitOfWork(Protocol):
repository: Repository # Протокол как атрибут!
def commit(self) -> None:
"""Фиксация транзакции"""
...
def rollback(self) -> None:
"""Откат изменений"""
... |
|
Такие конструкции усложняют код без явной пользы. Если Repository - протокол, то UnitOfWork не может проверить его в рантайме без runtime_checkable. А статический анализ путается в сложных зависимостях. Лучше держать протоколы простыми и независимыми.
Протоколы и статический анализ кода

Статический анализ без протоколов проверяет типы поверхностно. Если функция ожидает BaseLogger, передать можно только его наследников. Протоколы снимают это ограничение - проверяется структура, а не родословная. mypy видит: есть нужные методы? Типы совпадают? Пропускает. Нет - ругается ещё до запуска программы.
Запускаю mypy на проекте обычно так:
| Python | 1
| mypy --strict --show-error-codes src/ |
|
Режим --strict включает все проверки сразу. Жестоко, но эффективно. Находит проблемы, о которых даже не подозревал. Месяц назад добавил протокол для обработчиков событий в микросервисе. mypy сразу показал три класса, где методы возвращали не тот тип. В продакшене это вылезло бы через неделю в виде странных багов.
Возьмём простой пример - функция принимает логгер:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| from typing import Protocol
class Logger(Protocol):
def log(self, message: str, level: str) -> None:
...
def process_data(logger: Logger, data: list[int]) -> None:
logger.log(f"Обработка {len(data)} элементов", "INFO")
# Обработка данных
logger.log("Готово", "INFO")
class FileLogger:
def __init__(self, path: str):
self.path = path
def log(self, message: str) -> None: # Неправильная сигнатура!
# Записываем в файл
with open(self.path, 'a') as f:
f.write(message + '\n')
# mypy увидит ошибку
file_logger = FileLogger("app.log")
process_data(file_logger, [1, 2, 3]) # error: Missing positional argument "level" |
|
mypy моментально ловит несоответствие. FileLogger.log принимает один аргумент, а протокол требует два. До запуска, без тестов, без дебаггинга. Исправляешь сигнатуру - и всё работает.
pyright работает похоже, но быстрее и интегрируется в VS Code нативно. Я переключился на него полгода назад - скорость анализа выше раза в три. На большом проекте разница ощутима. Конфигурация через pyrightconfig.json:
| JSON | 1
2
3
4
5
| {
"typeCheckingMode": "strict",
"reportMissingTypeStubs": true,
"pythonVersion": "3.11"
} |
|
Обнаружение несоответствий происходит на уровне сигнатур методов, но не только. Протоколы проверяют наличие атрибутов, их типы, ковариантность возвращаемых значений:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| from typing import Protocol
class Storage(Protocol):
capacity: int # Атрибут обязателен
def store(self, item: str) -> bool:
...
def retrieve(self) -> str | None:
...
class MemoryStorage:
def __init__(self):
self.data: list[str] = []
# Забыли capacity!
def store(self, item: str) -> bool:
self.data.append(item)
return True
def retrieve(self) -> str | None:
return self.data.pop() if self.data else None
def use_storage(storage: Storage) -> None:
print(f"Ёмкость: {storage.capacity}") # mypy: error: "MemoryStorage" has no attribute "capacity" |
|
Статический анализатор видит, что MemoryStorage не соответствует Storage - нет атрибута capacity. Добавляешь self.capacity в __init__ - проблема решена. Без протокола эта ошибка всплыла бы в рантайме при обращении к атрибуту.
Постепенная типизация legacy кода - отдельная песня. Год назад взялся за проект на 50 тысяч строк без единой аннотации типов. Переписывать с нуля нереально, заказчик не даст времени. Протоколы спасли ситуацию. Стратегия простая: начинаешь с критичных модулей. Определяешь протоколы для публичных интерфейсов, добавляешь аннотации в сигнатуры функций. Внутренности классов пока игнорируешь - работаешь только с границами модулей:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
| # Было
def send_notification(channel, user, message):
channel.send(user, message)
# Стало
from typing import Protocol
class NotificationChannel(Protocol):
def send(self, recipient: str, message: str) -> bool:
...
def send_notification(channel: NotificationChannel, user: str, message: str) -> None:
channel.send(user, message) |
|
Запускаешь mypy только на этом модуле, не трогая остальной код:
| Python | 1
| mypy --no-strict-optional src/notifications.py |
|
Флаг --no-strict-optional отключает часть строгих проверок - для legacy кода это критично. Иначе mypy взорвётся от количества ошибок. Постепенно ужесточаешь правила, переходя к соседним модулям.
За три месяца покрыл протоколами весь публичный API проекта. Внутренности остались нетипизированными, но интерфейсы стали надёжными. Баги на стыках модулей сократились на 60% - mypy ловил их до коммита.
Интеграция в CI/CD делает проверки автоматическими. В GitLab CI конфиг выглядит так:
| YAML | 1
2
3
4
5
6
7
8
| type-check:
stage: test
image: python:3.11
script:
- pip install mypy
- mypy --config-file mypy.ini src/
only:
- merge_requests |
|
Каждый merge request проходит проверку типов. Ошибки - pipeline красный, код не мержится. Жестко, но дисциплинирует команду. Два месяца назад добавил это в проект - количество багов, связанных с несоответствием интерфейсов, упало до нуля.
mypy.ini настраиваешь под конкретный проект:
| Python | 1
2
3
4
5
6
7
8
9
10
11
| [mypy]
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = False # Для legacy кода
[mypy-tests.*]
ignore_errors = True # Игнорируем тесты пока
[mypy-third_party.*]
ignore_missing_imports = True # Сторонние библиотеки без типов |
|
Постепенно параметры ужесточаются. Сначала disallow_untyped_defs выключен, через полгода включаешь. Код обрастает типами естественным путём, без революций.
pre-commit хуки запускают mypy локально перед коммитом:
| YAML | 1
2
3
4
5
6
7
| repos:
- repo: [url]https://github.com/pre-commit/mirrors-mypy[/url]
rev: v1.5.1
hooks:
- id: mypy
args: [--strict]
additional_dependencies: [types-requests] |
|
Разработчик видит ошибки типов сразу, не дожидаясь CI. Удобно, хотя иногда бесит - хочешь быстро закоммитить мелкий фикс, а mypy требует добавить аннотации. Но оно того стоит.
Протоколы делают проверки типов гибкими. Можешь постепенно типизировать код, не ломая существующую функциональность. Начинаешь с интерфейсов, углубляешься по мере необходимости. Legacy проекты становятся типобезопасными без полной переписки, а новые получают надёжность из коробки. Разные анализаторы по-разному обрабатывают протоколы. mypy строже к Generic-параметрам, pyright лучше работает с переопределением методов. Pyre вообще экзотика - видел его только в проектах Facebook, и то давно. Большинство команд выбирают между mypy и pyright, остальное - нишевые инструменты.
Однажды столкнулся с забавной ситуацией: mypy пропускал код, pyright ругался. Протокол с Generic-параметром и сложная цепочка наследования. mypy упрощал проверку и считал всё нормальным, pyright копал глубже и находил несоответствие в ковариантности. Кто прав? Технически pyright, но в реальном коде это никогда не вызвало бы проблем. Пришлось добавить # type: ignore с комментарием для будущих поколений.
Generic протоколы требуют явного указания параметров типов при использовании. Забыл квадратные скобки - получишь ошибку:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
| from typing import Protocol, TypeVar
T = TypeVar('T')
class Container(Protocol[T]):
def get(self) -> T: ...
def process(c: Container) -> None: # Ошибка! Нужно Container[SomeType]
pass
def process_correct(c: Container[int]) -> None: # Правильно
item = c.get() # mypy знает, что item: int |
|
Частая ошибка - использовать Any вместо TypeVar, думая что это одно и то же. Any отключает проверки типов полностью. TypeVar сохраняет информацию о типе и позволяет анализатору проверять согласованность.
Optional и Union в протоколах работают предсказуемо, но есть нюанс с возвращаемыми значениями. Если метод протокола возвращает str | None, реализация может вернуть только str - это валидно по принципу подстановки Барбары Лисков. Обратное неверно:
| Python | 1
2
3
4
5
6
7
8
9
10
| class Reader(Protocol):
def read(self) -> str | None: ...
class StrictReader:
def read(self) -> str: # Всегда возвращает строку - OK
return "data"
class OptionalReader:
def read(self) -> str | None: # Может вернуть None - тоже OK
return None if random.random() < 0.5 else "data" |
|
Производительность статического анализа на больших проектах становится проблемой. mypy на 100 тысячах строк может работать минуту и больше. Решение - кеширование через --cache-dir и инкрементальный режим. После первого запуска последующие занимают секунды:
| Python | 1
| mypy --cache-dir=.mypy_cache --incremental src/ |
|
pyright из коробки быстрее за счёт оптимизаций и параллельной обработки модулей. На том же проекте работает в три раза шустрее mypy. Но жрёт больше памяти - видел случаи, когда на CI с 2GB RAM падал с out of memory.
IDE интеграция делает протоколы удобнее. VS Code с Pylance (основан на pyright) подсвечивает ошибки типов в реальном времени, предлагает автодополнение по протоколам. PyCharm тоже научился работать с Protocol, хотя поддержка появилась позже и работает медленнее. Когда пишу класс, соответствующий протоколу, IDE сразу показывает, каких методов не хватает. Не нужно гадать - видишь список требований прямо в редакторе. Это экономит часы при работе со сложными интерфейсами.
Циклические импорты убивают проверку типов. Модуль A импортирует протокол из B, B импортирует класс из A - mypy путается и выдаёт странные ошибки. Решение через TYPE_CHECKING:
| Python | 1
2
3
4
5
6
7
| from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from module_b import SomeClass # Импорт только для проверки типов
class MyProtocol(Protocol):
def method(self, obj: 'SomeClass') -> None: ... # Строковая аннотация |
|
Импорт внутри TYPE_CHECKING не выполняется в рантайме, разрывая цикл. Строковые аннотации откладывают разрешение типа до момента проверки. Грязный хак, но работает.
Реальные сценарии применения
Адаптеры к внешним API демонстрируют силу протоколов максимально наглядно. Полгода назад писал интеграцию с платёжными системами для e-commerce проекта. Stripe, PayPal, ЮMoney, Тинькофф - каждый со своим SDK, своими методами, своими моделями данных. Попытка создать единый базовый класс провалилась - слишком разные интерфейсы. Протокол решил проблему элегантно:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
| from typing import Protocol
from decimal import Decimal
class PaymentGateway(Protocol):
def charge(self, amount: Decimal, currency: str, card_token: str) -> str:
"""Списание средств. Возвращает ID транзакции"""
...
def refund(self, transaction_id: str, amount: Decimal) -> bool:
"""Возврат средств"""
...
def get_status(self, transaction_id: str) -> str:
"""Проверка статуса платежа"""
...
class StripeAdapter:
def __init__(self, api_key: str):
import stripe
stripe.api_key = api_key
self.stripe = stripe
def charge(self, amount: Decimal, currency: str, card_token: str) -> str:
# Stripe работает с копейками
amount_cents = int(amount * 100)
charge = self.stripe.Charge.create(
amount=amount_cents,
currency=currency.lower(),
source=card_token
)
return charge.id
def refund(self, transaction_id: str, amount: Decimal) -> bool:
try:
refund = self.stripe.Refund.create(
charge=transaction_id,
amount=int(amount * 100)
)
return refund.status == 'succeeded'
except Exception:
return False
def get_status(self, transaction_id: str) -> str:
charge = self.stripe.Charge.retrieve(transaction_id)
return charge.status
class TinkoffAdapter:
def __init__(self, terminal_key: str, secret_key: str):
self.terminal = terminal_key
self.secret = secret_key
self.base_url = "https://securepay.tinkoff.ru/v2/"
def charge(self, amount: Decimal, currency: str, card_token: str) -> str:
# Тинькофф работает с рублями в копейках
import requests
response = requests.post(
f"{self.base_url}Init",
json={
"TerminalKey": self.terminal,
"Amount": int(amount * 100),
"OrderId": self._generate_order_id(),
"DATA": {"Token": card_token}
}
)
return response.json()["PaymentId"]
def refund(self, transaction_id: str, amount: Decimal) -> bool:
# Реализация возврата для Тинькофф
return True
def get_status(self, transaction_id: str) -> str:
# Проверка статуса
return "succeeded"
def _generate_order_id(self) -> str:
import uuid
return str(uuid.uuid4()) |
|
Бизнес-логика работает с любым адаптером без изменений. Добавить новый шлюз? Пишешь класс с тремя методами - готово. Никаких изменений в существующем коде, никаких зависимостей между адаптерами.
Тестирование через моки становится тривиальным. Раньше мокал классы через unittest.mock.MagicMock или создавал фейковые реализации вручную. С протоколами моки пишутся за минуту:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| class MockPaymentGateway:
def __init__(self):
self.charges: dict[str, Decimal] = {}
self.refunds: dict[str, Decimal] = {}
self._counter = 0
def charge(self, amount: Decimal, currency: str, card_token: str) -> str:
self._counter += 1
tx_id = f"mock_tx_{self._counter}"
self.charges[tx_id] = amount
return tx_id
def refund(self, transaction_id: str, amount: Decimal) -> bool:
if transaction_id in self.charges:
self.refunds[transaction_id] = amount
return True
return False
def get_status(self, transaction_id: str) -> str:
return "succeeded" if transaction_id in self.charges else "failed"
def test_payment_flow():
gateway: PaymentGateway = MockPaymentGateway()
# Тестируем без реальных API-вызовов
tx_id = gateway.charge(Decimal("100.50"), "RUB", "test_token")
assert gateway.get_status(tx_id) == "succeeded"
assert gateway.refund(tx_id, Decimal("50.00")) |
|
Статический анализатор видит, что MockPaymentGateway соответствует протоколу. Тесты запускаются мгновенно, не бьют по внешним сервисам, не требуют настройки окружения. Написал полсотни таких тестов за вечер - раньше на это уходила неделя.
Система плагинов через протоколы работает без центрального реестра. Прошлым летом делал ETL-конвейер для обработки данных из разных источников. CSV, JSON, XML, базы данных, внешние API - форматов дюжина. Протокол DataSource описывал минимальный контракт:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| from typing import Protocol, Iterator
class DataSource(Protocol):
def fetch(self) -> Iterator[dict]:
"""Получение данных построчно"""
...
def close(self) -> None:
"""Очистка ресурсов"""
...
class CSVSource:
def __init__(self, filepath: str):
self.filepath = filepath
self.file = None
def fetch(self) -> Iterator[dict]:
import csv
self.file = open(self.filepath, 'r', encoding='utf-8')
reader = csv.DictReader(self.file)
for row in reader:
yield row
def close(self) -> None:
if self.file:
self.file.close()
class PostgresSource:
def __init__(self, connection_string: str):
self.conn_string = connection_string
self.connection = None
def fetch(self) -> Iterator[dict]:
import psycopg2
import psycopg2.extras
self.connection = psycopg2.connect(self.conn_string)
cursor = self.connection.cursor(cursor_factory=psycopg2.extras.DictCursor)
cursor.execute("SELECT * FROM raw_data")
for row in cursor:
yield dict(row)
def close(self) -> None:
if self.connection:
self.connection.close() |
|
Конвейер обработки не знает об источниках данных ничего. Принимает DataSource, читает, трансформирует, загружает. Добавить новый источник? Класс с двумя методами, никаких регистраций, никаких изменений в пайплайне. За три месяца команда добавила восемь источников - каждый в отдельном модуле, без конфликтов.
Dependency Injection контейнеры обходятся без магии через протоколы. Год назад внедрял DI в монолитное приложение, где сервисы зависели друг от друга хаотично. Традиционный подход - фреймворк вроде dependency-injector с декораторами и регистрацией зависимостей. Я пошёл проще:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| from typing import Protocol
class UserRepository(Protocol):
def get_by_id(self, user_id: int) -> dict | None: ...
def save(self, user: dict) -> None: ...
class EmailService(Protocol):
def send(self, recipient: str, subject: str, body: str) -> None: ...
class UserService:
def __init__(self, repo: UserRepository, email: EmailService):
self.repo = repo
self.email = email
def register_user(self, username: str, email_addr: str) -> None:
user = {"username": username, "email": email_addr}
self.repo.save(user)
self.email.send(
email_addr,
"Добро пожаловать!",
f"Привет, {username}!"
)
# Реализации можно менять без изменения UserService
class SQLRepository:
def get_by_id(self, user_id: int) -> dict | None:
# SQL запрос
return {"id": user_id, "username": "test"}
def save(self, user: dict) -> None:
# INSERT запрос
pass
class SMTPEmail:
def send(self, recipient: str, subject: str, body: str) -> None:
# Отправка через SMTP
print(f"Email to {recipient}: {subject}") |
|
Зависимости передаются явно через конструктор. Никаких глобальных синглтонов, никакой магии с импортами. Тесты пишутся элементарно - создал моки для протоколов, передал в конструктор, проверил логику. Рефакторинг сервисов перестал быть кошмаром.
Архитектурное разделение на слои через протоколы держит код в узде. Application layer определяет протоколы для инфраструктуры, но не зависит от реализаций. Domain layer вообще ничего не знает о базах, сети, файлах. Infrastructure layer реализует протоколы, зная о домене. Классическая чистая архитектура, только без абстрактных классов:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| # domain/models.py - чистая бизнес-логика
class Order:
def __init__(self, order_id: int, items: list[dict]):
self.id = order_id
self.items = items
self.total = sum(item['price'] for item in items)
def apply_discount(self, percent: int) -> None:
self.total *= (1 - percent / 100)
# application/protocols.py - контракты для инфраструктуры
from typing import Protocol
class OrderRepository(Protocol):
def find(self, order_id: int) -> Order | None: ...
def save(self, order: Order) -> None: ...
# application/services.py - бизнес-логика
class OrderService:
def __init__(self, repo: OrderRepository):
self.repo = repo
def apply_promo_code(self, order_id: int, code: str) -> bool:
order = self.repo.find(order_id)
if not order:
return False
if code == "SALE20":
order.apply_discount(20)
self.repo.save(order)
return True
return False
# infrastructure/repositories.py - детали реализации
class PostgresOrderRepository:
def __init__(self, db_url: str):
self.db_url = db_url
def find(self, order_id: int) -> Order | None:
# SQL запрос и маппинг в Order
return Order(order_id, [])
def save(self, order: Order) -> None:
# UPDATE в базе
pass |
|
Domain не импортирует ничего из infrastructure. Application слой знает только протоколы. Infrastructure реализует всё, что нужно. Меняешь базу с Postgres на MongoDB? Пишешь новый репозиторий, реализуешь протокол - готово. Остальной код даже не заметит.
Кеширование декораторами демонстрирует композируемость протоколов. Три недели назад оптимизировал API, где одни методы возвращали данные из памяти, другие из Redis, третьи вычисляли на лету. Протокол Cacheable дал единый интерфейс:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| from typing import Protocol, TypeVar, Callable, Any
from functools import wraps
T = TypeVar('T')
class CacheBackend(Protocol):
def get(self, key: str) -> Any | None:
"""Получение из кеша"""
...
def set(self, key: str, value: Any, ttl: int) -> None:
"""Сохранение в кеш с TTL в секундах"""
...
class MemoryCache:
def __init__(self):
self._storage: dict[str, tuple[Any, float]] = {}
def get(self, key: str) -> Any | None:
import time
if key in self._storage:
value, expires = self._storage[key]
if time.time() < expires:
return value
del self._storage[key]
return None
def set(self, key: str, value: Any, ttl: int) -> None:
import time
self._storage[key] = (value, time.time() + ttl)
def cached(backend: CacheBackend, ttl: int = 300):
"""Декоратор кеширования с произвольным бэкендом"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
# Генерируем ключ из аргументов
cache_key = f"{func.__name__}:{args}:{kwargs}"
# Проверяем кеш
cached_value = backend.get(cache_key)
if cached_value is not None:
return cached_value
# Вычисляем и кешируем
result = func(*args, **kwargs)
backend.set(cache_key, result, ttl)
return result
return wrapper
return decorator
# Использование с любым бэкендом
memory = MemoryCache()
@cached(memory, ttl=60)
def expensive_calculation(n: int) -> int:
# Тяжёлые вычисления
return sum(i * i for i in range(n))
# Первый вызов вычисляет
result1 = expensive_calculation(10000) # Медленно
# Второй берёт из кеша
result2 = expensive_calculation(10000) # Мгновенно |
|
Меняешь бэкенд на Redis - декоратор работает без изменений. Пишешь класс RedisCache с двумя методами - готово. Протокол изолирует логику кеширования от хранилища.
Валидация входных данных через протоколы убирает дублирование. В REST API обычно проверяешь типы, форматы, ограничения. Вместо копипасты валидаторов по контроллерам, пишешь протокол:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| from typing import Protocol
class Validator(Protocol):
def validate(self, value: Any) -> bool:
"""Проверка корректности значения"""
...
def error_message(self) -> str:
"""Сообщение об ошибке"""
...
class EmailValidator:
def validate(self, value: Any) -> bool:
import re
if not isinstance(value, str):
return False
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return bool(re.match(pattern, value))
def error_message(self) -> str:
return "Некорректный формат email"
class RangeValidator:
def __init__(self, min_val: int, max_val: int):
self.min = min_val
self.max = max_val
def validate(self, value: Any) -> bool:
if not isinstance(value, (int, float)):
return False
return self.min <= value <= self.max
def error_message(self) -> str:
return f"Значение должно быть от {self.min} до {self.max}"
def validate_field(value: Any, validators: list[Validator]) -> tuple[bool, list[str]]:
"""Проверка значения набором валидаторов"""
errors = []
for validator in validators:
if not validator.validate(value):
errors.append(validator.error_message())
return len(errors) == 0, errors
# Композиция валидаторов
age_validators = [RangeValidator(0, 150)]
email_validators = [EmailValidator()]
is_valid, errors = validate_field(25, age_validators) # (True, [])
is_valid, errors = validate_field("bad-email", email_validators) # (False, [...]) |
|
Каждый валидатор независим, можно комбинировать произвольно. Добавить проверку уникальности в базе? Класс UniqueValidator реализует протокол - вписывается в существующую систему без переделки.
Стратегии обработки ошибок выигрывают от протоколов. В микросервисе было пять способов реагировать на сбои: retry, circuit breaker, fallback к дефолтным данным, логирование и игнорирование, немедленный fail. Протокол ErrorHandler унифицировал подход:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| from typing import Protocol, Callable, TypeVar
T = TypeVar('T')
class ErrorHandler(Protocol[T]):
def handle(self, error: Exception, operation: Callable[[], T]) -> T:
"""Обработка ошибки и возврат результата"""
...
class RetryHandler:
def __init__(self, attempts: int = 3):
self.attempts = attempts
def handle(self, error: Exception, operation: Callable[[], T]) -> T:
for attempt in range(self.attempts):
try:
return operation()
except Exception as e:
if attempt == self.attempts - 1:
raise e
# Повторяем попытку
raise error
class FallbackHandler:
def __init__(self, default_value: T):
self.default = default_value
def handle(self, error: Exception, operation: Callable[[], T]) -> T:
try:
return operation()
except Exception:
return self.default
def execute_with_handler(operation: Callable[[], T], handler: ErrorHandler[T]) -> T:
"""Выполнение операции с заданным обработчиком ошибок"""
try:
return operation()
except Exception as e:
return handler.handle(e, operation)
# Разные стратегии для разных операций
retry = RetryHandler(attempts=5)
fallback = FallbackHandler(default_value=[])
# Критичная операция - ретраи
result = execute_with_handler(lambda: fetch_user_data(), retry)
# Некритичная - фолбек к пустому списку
items = execute_with_handler(lambda: load_recommendations(), fallback) |
|
Выбор стратегии обработки ошибок стал конфигурационным решением, а не хардкодом в каждой функции. Меняется требование - меняешь handler, логика остаётся нетронутой.
Ловушки и подводные камни
Производительность runtime_checkable - первая засада, в которую попадают почти все. Декоратор включает проверки через isinstance, но работает он примитивно. Python просто смотрит: есть атрибут с таким именем? Есть метод? Отлично, объект соответствует протоколу. Типы не проверяются, сигнатуры не сравниваются, ничего глубокого. Полгода назад ловил баг в продакшене три дня. API принимал объекты, валидируя их через isinstance с протоколом. Всё работало в тестах, на стейдже - красота. На проде начались странные падения: метод вызывался с неправильными аргументами, возвращал не тот тип. Оказалось, один из модулей реализовал метод с другой сигнатурой - принимал два аргумента вместо одного, возвращал список вместо словаря. isinstance вернул True, потому что метод с таким именем существовал. Статический анализ в этом модуле был отключен - legacy код, некогда было типизировать.
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from typing import Protocol, runtime_checkable
@runtime_checkable
class DataProcessor(Protocol):
def process(self, data: dict) -> list[str]:
"""Обработка данных"""
...
class BrokenProcessor:
def process(self, data: dict, options: dict) -> dict: # Другая сигнатура!
"""Реализация с дополнительным параметром"""
return {"result": "broken"}
# isinstance говорит True - метод process есть
broken = BrokenProcessor()
print(isinstance(broken, DataProcessor)) # True, но это ложь!
# А вот тут упадёт
def use_processor(proc: DataProcessor):
result = proc.process({"key": "value"}) # TypeError: не хватает аргумента options!
return result |
|
Решение простое - не полагайся на runtime_checkable для критичных проверок. Используй его для быстрых санити-чеков на входе, не более. Настоящая валидация - через статический анализ и тесты. mypy поймает несоответствие сигнатур до запуска, isinstance - нет.
Еще одна засада - производительность самих проверок. isinstance с протоколом медленнее обычного isinstance примерно в десять раз. Python обходит все атрибуты класса, проверяет их типы через getattr, строит граф зависимостей. На одной проверке разница незаметна, но когда делаешь тысячи проверок в цикле - профайлер показывает isinstance в топе по времени.
Конфликты с наследованием возникают, когда пытаешься совместить протоколы и классическую иерархию. Вроде всё логично: класс наследуется от базового класса и одновременно соответствует протоколу. Но Python путается в приоритетах:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| from typing import Protocol
class BaseService:
def __init__(self):
self.initialized = False
def setup(self) -> None:
"""Инициализация сервиса"""
self.initialized = True
class Loggable(Protocol):
def log(self, message: str) -> None:
"""Логирование"""
...
class UserService(BaseService):
def log(self, message: str) -> None:
"""Добавляем логирование"""
print(f"[USER] {message}")
def create_user(self, name: str) -> None:
if not self.initialized:
self.setup()
self.log(f"Создаём пользователя {name}") |
|
Всё нормально до тех пор, пока не начинаешь миксовать это с Generic протоколами или множественным наследованием от нескольких протоколов. Месяц назад столкнулся с ситуацией: класс наследовался от BaseRepository и соответствовал трём протоколам - Cacheable, Loggable, Measurable. mypy начал ругаться на несовместимость TypeVar между протоколами. Пришлось переделывать архитектуру, убирать наследование от BaseRepository, оставить только протоколы.
Структурная типизация проверяет форму, но не логику. Класс может иметь все нужные методы с правильными сигнатурами, но при этом работать совершенно неправильно. Протокол гарантирует только наличие интерфейса, не корректность поведения:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| from typing import Protocol
class Stack(Protocol):
def push(self, item: int) -> None:
"""Добавление элемента"""
...
def pop(self) -> int:
"""Извлечение элемента"""
...
class BrokenStack:
def __init__(self):
self.items: list[int] = []
def push(self, item: int) -> None:
# Реализация неправильная - добавляем в начало вместо конца!
self.items.insert(0, item)
def pop(self) -> int:
# Извлекаем с конца
return self.items.pop() if self.items else 0
# Статический анализ доволен - методы есть, типы совпадают
def use_stack(s: Stack) -> None:
s.push(1)
s.push(2)
s.push(3)
result = s.pop() # Ожидаем 3, получим 1!
print(result) |
|
Протокол не гарантирует семантику операций. Он говорит "есть методы push и pop", но не проверяет, что push добавляет в конец, а pop извлекает оттуда же. Это ограничение структурной типизации. Тесты обязательны, без них протоколы дают ложное чувство безопасности.
Асинхронные протоколы добавляют путаницы. AsyncIterator выглядит как Iterator, но методы другие - __aiter__ вместо __iter__, __anext__ вместо __next__. Легко ошибиться и реализовать синхронную версию, думая что делаешь асинхронную:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| from typing import AsyncIterator
import asyncio
class AsyncDataStream:
def __init__(self, data: list[int]):
self.data = data
self.index = 0
# Забыли сделать методы асинхронными!
def __aiter__(self):
return self
def __anext__(self) -> int: # Должно быть async def!
if self.index >= len(self.data):
raise StopAsyncIteration
value = self.data[self.index]
self.index += 1
return value
async def process_stream(stream: AsyncIterator[int]) -> None:
# Упадёт с TypeError - __anext__ должен возвращать awaitable
async for item in stream:
print(item) |
|
Ошибка не очевидна при беглом взгляде на код. mypy может не заметить проблему, если аннотации типов стоят неправильно. Падение происходит в рантайме с загадочной ошибкой про awaitable. Два месяца назад потратил час на отладку похожего бага - забыл async перед def в одном из методов генератора событий.
Протоколы с Generic параметрами и вложенными структурами превращаются в ад для понимания. Когда TypeVar используется ковариантно, контравариантно и инвариантно в разных местах одного протокола - даже опытный разработчик путается:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| from typing import Protocol, TypeVar, Generic
T_in = TypeVar('T_in', contravariant=True)
T_out = TypeVar('T_out', covariant=True)
class Transformer(Protocol[T_in, T_out]):
def transform(self, input_data: T_in) -> T_out:
"""Преобразование входных данных в выходные"""
...
# Попробуй быстро понять, какие типы куда подставляются
class StringToIntTransformer:
def transform(self, input_data: str) -> int:
return len(input_data)
# Это Transformer[str, int] или наоборот? А может Transformer[int, str]?
# Контравариантность и ковариантность меняют правила игры |
|
Читаемость кода страдает. Коллеги при ревью задают вопросы, почему TypeVar именно ковариантный. Приходится объяснять теорию типов вместо обсуждения бизнес-логики. Большинство проектов обходится инвариантными TypeVar - проще понимать, меньше ошибок.
Неявное несоответствие протоколу ловится только статическим анализом. Если его нет - узнаешь о проблеме в продакшене. Три недели назад видел код, где класс почти соответствовал протоколу. Метод был, но принимал на один аргумент больше с дефолтным значением. Технически работало, но mypy ругался. Команда игнорировала предупреждения, добавляя # type: ignore. Когда протокол изменился, добавился еще один обязательный аргумент - всё сломалось в продакшене. Пришлось экстренно фиксить, откатывать деплой.
Протоколы дают ложное ощущение гибкости. Кажется: вот оно, решение всех проблем с зависимостями. На практике излишнее увлечение протоколами раздувает кодовую базу. Начинаешь создавать протоколы для всего подряд - каждому классу свой протокол, для каждой функции отдельный контракт. Читать такой код невозможно, постоянно прыгаешь между файлами, разбираясь где реализация, где протокол, кто кому соответствует. Я видел проект, где на 10 тысяч строк кода было 200 протоколов. Треть вообще не использовалась, просто висела мертвым грузом. Половина содержала один метод - переделка их в обычные функции упростила бы код вдвое. Протоколы - инструмент, а не религия. Используй там, где действительно нужна гибкость и независимость компонентов, не везде подряд.
Версионирование протоколов превращается в головную боль при развитии API. Добавил новый метод в протокол - все старые реализации перестали соответствовать. mypy начинает ругаться на весь проект. Удалил метод - код компилируется, но падает в рантайме у тех, кто его вызывал. Изменил сигнатуру - статический анализатор кричит, но не всегда понятно где именно проблема.
Прошлой осенью поддерживал библиотеку с публичным протоколом Plugin. Добавили обязательный метод get_version() в новой версии - пользователи взвыли. Их плагины перестали работать, нужно было срочно обновлять код. Решили делать метод опциональным через hasattr проверку, но это убивает всю идею статической типизации. В итоге создали Plugin2 протокол с новым методом, оставив старый для совместимости. Теперь в коде два протокола, функции принимают Union[Plugin, Plugin2], читаемость упала.
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| from typing import Protocol, Union
class Plugin(Protocol):
def execute(self) -> None: ...
class Plugin2(Protocol):
def execute(self) -> None: ...
def get_version(self) -> str: ...
def run_plugin(plugin: Union[Plugin, Plugin2]) -> None:
plugin.execute()
# Проверяем наличие метода в рантайме - некрасиво
if hasattr(plugin, 'get_version'):
print(f"Версия: {plugin.get_version()}") |
|
Правильного решения нет. Либо ломаешь обратную совместимость, либо городишь костыли с проверками в рантайме. Семантическое версионирование помогает слабо - протокол это интерфейс, а не реализация. Любое изменение потенциально breaking change. IDE поддержка протоколов отстаёт от возможностей языка. VS Code с Pylance работает нормально, PyCharm тормозит и иногда не видит соответствия между классом и протоколом. Автодополнение глючит - предлагает методы, которых нет, или наоборот не показывает существующие. Рефакторинг через IDE опасен - переименовываешь метод в протоколе, а IDE не обновляет все реализации.
Monkey patching убивает гарантии протоколов. Python динамический язык, можно добавить методы объекту в рантайме. Статический анализ об этом не знает, проверяет исходный код класса. Результат - isinstance возвращает True, mypy думает что всё плохо:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
class Shape:
pass
# Добавляем метод в рантайме
shape = Shape()
shape.draw = lambda: print("Рисуем фигуру") # type: ignore
print(isinstance(shape, Drawable)) # True - метод есть
# Но mypy считает Shape несоответствующим Drawable |
|
В тестах иногда делаешь такие трюки для быстрых моков. Работает, но анализатор ругается. Приходится расставлять type: ignore или писать полноценные mock-классы. Статическая типизация и динамические фичи Python конфликтуют.
Третьесторонние библиотеки без type hints превращают протоколы в декорацию. Пытаешься использовать объект из библиотеки, соответствующий твоему протоколу - mypy не видит методов, потому что у библиотеки нет аннотаций. Либо пишешь stub-файлы с типами вручную, либо оборачиваешь объекты в адаптеры. Оба варианта - дополнительная работа и код. Протоколы хороши, когда используются разумно. Один-два ключевых контракта на проект, четкие границы между модулями, осознанный выбор между протоколами и ABC. Но увлекаться не стоит - иначе получишь больше проблем, чем пользы.
Демонстрационный фреймворк для плагинов с протоколами
Полгода назад делал систему для обработки данных из разных источников. Требовалось добавлять новые форматы без перекомпиляции основного приложения. Классический случай для плагинов. Попробовал несколько подходов - через точки входа setuptools, через декораторы регистрации, через конфигурационные файлы. Везде был компромисс между гибкостью и типобезопасностью. Протоколы решили задачу элегантно.
Начну с определения контрактов. Плагин должен уметь три вещи: инициализироваться с конфигурацией, обрабатывать данные и сообщать о своих возможностях. Протокол фиксирует эти требования:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| from typing import Protocol, Any
class DataPlugin(Protocol):
"""Контракт для плагинов обработки данных"""
@property
def name(self) -> str:
"""Уникальное имя плагина"""
...
@property
def version(self) -> str:
"""Версия плагина"""
...
def initialize(self, config: dict[str, Any]) -> None:
"""Инициализация плагина с конфигурацией"""
...
def process(self, data: bytes) -> dict[str, Any]:
"""Обработка входных данных"""
...
def can_handle(self, data_type: str) -> bool:
"""Проверка поддержки типа данных"""
... |
|
Архитектура держится на менеджере плагинов, который загружает модули, проверяет соответствие протоколу и маршрутизирует данные:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
| import importlib
import importlib.util
from pathlib import Path
from typing import runtime_checkable
@runtime_checkable
class DataPlugin(Protocol):
# ... определение из примера выше
class PluginManager:
"""Менеджер загрузки и управления плагинами"""
def __init__(self, plugins_dir: Path):
self.plugins_dir = plugins_dir
self.plugins: dict[str, DataPlugin] = {}
self._load_errors: list[tuple[str, Exception]] = []
def discover_plugins(self) -> None:
"""Поиск и загрузка всех плагинов из директории"""
if not self.plugins_dir.exists():
raise ValueError(f"Директория плагинов не существует: {self.plugins_dir}")
# Ищем Python-модули
for plugin_file in self.plugins_dir.glob("*.py"):
if plugin_file.name.startswith("_"):
continue # Пропускаем служебные файлы
try:
self._load_plugin(plugin_file)
except Exception as e:
# Собираем ошибки, но не падаем
self._load_errors.append((plugin_file.name, e))
def _load_plugin(self, plugin_path: Path) -> None:
"""Загрузка одного плагина из файла"""
module_name = plugin_path.stem
# Динамический импорт модуля
spec = importlib.util.spec_from_file_location(module_name, plugin_path)
if spec is None or spec.loader is None:
raise ImportError(f"Не удалось загрузить спецификацию для {plugin_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Ищем классы, соответствующие протоколу
for attr_name in dir(module):
if attr_name.startswith("_"):
continue
attr = getattr(module, attr_name)
# Проверяем, что это класс и соответствует протоколу
if not isinstance(attr, type):
continue
# Создаем экземпляр и проверяем через isinstance
try:
instance = attr()
if isinstance(instance, DataPlugin):
plugin_name = instance.name
if plugin_name in self.plugins:
print(f"Предупреждение: плагин {plugin_name} уже загружен, пропускаем")
continue
self.plugins[plugin_name] = instance
print(f"Загружен плагин: {plugin_name} v{instance.version}")
except Exception as e:
# Не смогли создать экземпляр - пропускаем
print(f"Ошибка при инстанцировании {attr_name}: {e}")
def get_plugin_for_type(self, data_type: str) -> DataPlugin | None:
"""Поиск плагина, поддерживающего указанный тип данных"""
for plugin in self.plugins.values():
if plugin.can_handle(data_type):
return plugin
return None
def process_data(self, data: bytes, data_type: str, config: dict[str, Any] | None = None) -> dict[str, Any]:
"""Обработка данных через подходящий плагин"""
plugin = self.get_plugin_for_type(data_type)
if plugin is None:
raise ValueError(f"Нет плагина для обработки типа: {data_type}")
# Инициализируем с конфигурацией если нужно
if config:
plugin.initialize(config)
return plugin.process(data)
@property
def load_errors(self) -> list[tuple[str, Exception]]:
"""Список ошибок при загрузке плагинов"""
return self._load_errors |
|
Теперь несколько реальных плагинов для демонстрации. JSON-процессор:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| import json
from typing import Any
class JSONPlugin:
"""Плагин для обработки JSON-данных"""
def __init__(self):
self._config: dict[str, Any] = {}
@property
def name(self) -> str:
return "json_processor"
@property
def version(self) -> str:
return "1.0.0"
def initialize(self, config: dict[str, Any]) -> None:
"""Сохраняем конфигурацию"""
self._config = config
def process(self, data: bytes) -> dict[str, Any]:
"""Парсинг JSON и добавление метаданных"""
parsed = json.loads(data.decode('utf-8'))
# Добавляем метаданные из конфигурации
result = {
"data": parsed,
"plugin": self.name,
"version": self.version
}
if "add_timestamp" in self._config and self._config["add_timestamp"]:
import time
result["processed_at"] = time.time()
return result
def can_handle(self, data_type: str) -> bool:
"""Поддерживаем JSON"""
return data_type.lower() in ("json", "application/json") |
|
XML-процессор демонстрирует обработку ошибок:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| import xml.etree.ElementTree as ET
from typing import Any
class XMLPlugin:
"""Плагин для обработки XML-данных"""
@property
def name(self) -> str:
return "xml_processor"
@property
def version(self) -> str:
return "1.0.0"
def initialize(self, config: dict[str, Any]) -> None:
"""XML-плагину конфигурация не нужна"""
pass
def process(self, data: bytes) -> dict[str, Any]:
"""Парсинг XML в словарь"""
try:
root = ET.fromstring(data.decode('utf-8'))
# Преобразуем XML в словарь
result = {
"root_tag": root.tag,
"attributes": root.attrib,
"children": []
}
for child in root:
result["children"].append({
"tag": child.tag,
"text": child.text,
"attributes": child.attrib
})
return result
except ET.ParseError as e:
# Возвращаем информацию об ошибке
return {
"error": "XML parsing failed",
"message": str(e),
"plugin": self.name
}
def can_handle(self, data_type: str) -> bool:
"""Поддерживаем XML"""
return data_type.lower() in ("xml", "application/xml", "text/xml") |
|
Валидация работает на двух уровнях. Статический анализ через mypy проверяет соответствие протоколу до запуска. Создаём тестовый скрипт для проверки типов:
| Python | 1
2
3
4
5
6
7
8
9
10
| from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Импорты только для проверки типов
from json_plugin import JSONPlugin
from xml_plugin import XMLPlugin
# mypy проверит, что классы соответствуют протоколу
json_plugin: DataPlugin = JSONPlugin()
xml_plugin: DataPlugin = XMLPlugin() |
|
Запускаем mypy - если плагин не соответствует протоколу, увидим ошибку сразу. Это ловит проблемы на этапе разработки, до развёртывания. Runtime-валидация через isinstance отсекает плагины с несоответствующей структурой. Менеджер проверяет каждый загруженный класс и пропускает только те, что прошли проверку. Неправильные плагины попадают в список ошибок, но не роняют всю систему.
Использование фреймворка простое:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| from pathlib import Path
# Инициализация менеджера
manager = PluginManager(Path("./plugins"))
manager.discover_plugins()
# Проверка ошибок загрузки
if manager.load_errors:
print("Ошибки при загрузке плагинов:")
for filename, error in manager.load_errors:
print(f" {filename}: {error}")
# Обработка JSON-данных
json_data = b'{"name": "test", "value": 42}'
result = manager.process_data(
json_data,
"json",
config={"add_timestamp": True}
)
print(f"JSON результат: {result}")
# Обработка XML-данных
xml_data = b'<root><item>test</item></root>'
result = manager.process_data(xml_data, "xml")
print(f"XML результат: {result}") |
|
Расширение системы требует только создания нового файла в директории plugins. Класс реализует протокол - фреймворк подхватит автоматически. Никаких регистраций, никаких изменений в основном коде. Статический анализ гарантирует корректность интерфейса, runtime-проверки отсекают некорректные реализации.
Я использую похожую архитектуру в трёх продакшен-проектах. Один обрабатывает логи разных форматов, второй интегрируется с API внешних сервисов, третий реализует стратегии кеширования. Везде одна идея - протоколы определяют контракты, динамическая загрузка даёт гибкость, статический анализ обеспечивает надёжность.
Обработка приоритетов плагинов добавляет контроля, когда несколько обработчиков поддерживают один тип данных. Расширяю протокол дополнительным свойством:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| from typing import Protocol
class PrioritizedPlugin(Protocol):
"""Протокол с поддержкой приоритетов"""
name: str
version: str
@property
def priority(self) -> int:
"""Приоритет плагина (больше = выше)"""
...
def initialize(self, config: dict[str, Any]) -> None: ...
def process(self, data: bytes) -> dict[str, Any]: ...
def can_handle(self, data_type: str) -> bool: ... |
|
Менеджер сортирует плагины по приоритету при выборе обработчика:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| def get_plugin_for_type(self, data_type: str) -> DataPlugin | None:
"""Поиск плагина с наивысшим приоритетом"""
candidates = [
plugin for plugin in self.plugins.values()
if plugin.can_handle(data_type)
]
if not candidates:
return None
# Сортируем по приоритету, если он есть
def get_priority(plugin: DataPlugin) -> int:
return getattr(plugin, 'priority', 0)
return max(candidates, key=get_priority) |
|
Прошлым летом такая система спасла проект. Базовый CSV-плагин обрабатывал стандартные файлы с приоритетом 10. Добавили специализированный плагин для банковских выписок - приоритет 50. Он перехватывал обработку только для своего формата, остальное шло через базовый. Никакого if-else ада в роутинге.
Dependency injection через протоколы решает проблему общих ресурсов. Логгер, подключение к базе, кеш - всё это плагины могут получать через инициализацию:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| class Logger(Protocol):
def log(self, level: str, message: str) -> None: ...
class PluginContext:
"""Контекст выполнения для плагинов"""
def __init__(self, logger: Logger):
self.logger = logger
self.shared_cache: dict[str, Any] = {}
class ContextAwarePlugin(Protocol):
"""Плагин с доступом к контексту"""
name: str
version: str
def set_context(self, context: PluginContext) -> None:
"""Установка контекста перед использованием"""
...
def process(self, data: bytes) -> dict[str, Any]: ...
def can_handle(self, data_type: str) -> bool: ... |
|
Менеджер передаёт контекст при загрузке:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class AdvancedPluginManager:
def __init__(self, plugins_dir: Path, logger: Logger):
self.plugins_dir = plugins_dir
self.context = PluginContext(logger)
self.plugins: dict[str, ContextAwarePlugin] = {}
def _load_plugin(self, plugin_path: Path) -> None:
# ... загрузка модуля
instance = attr()
# Передаём контекст если плагин его поддерживает
if hasattr(instance, 'set_context'):
instance.set_context(self.context)
self.plugins[instance.name] = instance |
|
Graceful degradation защищает от падений отдельных плагинов. Обёртка-декоратор перехватывает исключения:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| from functools import wraps
from typing import Callable, TypeVar
T = TypeVar('T')
def safe_plugin_call(fallback_value: T) -> Callable:
"""Декоратор для безопасного вызова методов плагина"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
try:
return func(*args, **kwargs)
except Exception as e:
# Логируем ошибку
print(f"Ошибка в плагине: {e}")
return fallback_value
return wrapper
return decorator
class RobustPluginWrapper:
"""Обёртка плагина с защитой от ошибок"""
def __init__(self, plugin: DataPlugin):
self._plugin = plugin
@property
def name(self) -> str:
return self._plugin.name
@safe_plugin_call(fallback_value={})
def process(self, data: bytes) -> dict[str, Any]:
return self._plugin.process(data) |
|
Тестирование плагинов упрощается благодаря протоколам. Создаёшь мок-менеджер, проверяешь изолированно:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| import pytest
class MockPluginManager:
"""Упрощённый менеджер для тестов"""
def __init__(self):
self.plugins: dict[str, DataPlugin] = {}
def register(self, plugin: DataPlugin) -> None:
self.plugins[plugin.name] = plugin
def test_json_plugin():
manager = MockPluginManager()
plugin = JSONPlugin()
manager.register(plugin)
# Проверяем обработку
data = b'{"test": "value"}'
result = plugin.process(data)
assert "data" in result
assert result["data"]["test"] == "value"
assert result["plugin"] == "json_processor"
def test_plugin_protocol_compliance():
"""Проверка соответствия протоколу"""
plugin = JSONPlugin()
# Статический анализатор проверит эти присваивания
p: DataPlugin = plugin
assert callable(p.process)
assert callable(p.can_handle)
assert isinstance(p.name, str) |
|
Архитектура фреймворка позволяет менять реализацию менеджера без правки плагинов. Три недели назад переписал загрузку с динамического импорта на точки входа setuptools. Ни один плагин не потребовал изменений - контракт остался прежним, изменилась только механика обнаружения. Вот что даёт протокольный подход к проектированию.
Python 35 Выполнить файл из python shell Есть файл do.py :
print('start')
import os
import sys
import re
import inspect
def... Python - момент истины. Python - как оружие возмездие против системы Какие модули в python мне нужны для взлома баз данных? Перехвата информации? Внедрения в систему?
... Сложности с переходом с python 2.x на python 3.x def _load_config(self):
for fn in CONFIG_FILES:
fn = os.path.expanduser(fn)
... Изменение кода запроса с Python 2 на Python 3 Доброго времени суток.
Я пишу программу и для её реализации мне необходимо, чтобы она делала... Порт pyqt5 (python 3.5) программы на android - Python Подскажите пожалуйста возможно ли программу написанную на python методами pyqt5 переделать под... Перевод кода из Pascal в Python - Python Имеется код программы на языке Pascal, требуется перевести его в Python.
Я не могу перевести его в... Не могу получить ответ от python скрипта и на его основе создать список (зависимые списки js ajax python) Привет!
Есть необходимость сделать динамические списки при помощи js, ajax jQuery, Python.
Данные... Cx_freeze python error in main script как исправить- Python Пытался создать из .py .exe , но при запуске .exe получаю ошибку вот код setup.py
from cx_Freeze... Перевод из Python в C/C++ - Python перевести все программы с файла в C\C++ Перевод кода с C++ на Python - Python #include <iostream>
#include <fstream>
#include <string>
#include <cstdlib>
using namespace... Maching pursuit. Согласованный поиск на Python. Требуется сам алгоритм, написанный на Python Требуется сам алгоритм, написанный на Python. Заранее спасибо. Fatal error in launcher: Unable to create process using '“d:\autocompiler\temp\portable python-3.7.3 x64\app\python\pyth Я использую портативный Python3.7.3. в моем USB-диске. Когда я пытаюсь установить pandos, я получаю...
|