ABC и Protocol: абстрактные классы и duck typing с типами
В предыдущих уроках мы создавали обычные классы - от первого __init__ до dataclass и Enum. Но иногда хочется задать контракт - набор методов, которые класс обязан реализовать. Для этого есть два механизма: ABC (Abstract Base Classes) для явного наследования и Protocol для duck typing с проверкой типов. В этом уроке - оба подхода, их различия и когда что использовать.
Зачем нужны абстракции
Представь систему хранения с разными backend (memory, file, redis):
class MemoryStorage:
def get(self, key): ...
def set(self, key, value): ...
class FileStorage:
def get(self, key): ...
def set(self, key, value): ...
Контракт ясен по имени методов, но ничто не гарантирует что новый класс не забудет один из них:
class BrokenStorage:
def get(self, key): ...
# set забыли!
Без проверки этот баг найдётся только когда кто-то вызовет set() - часто далеко от места создания BrokenStorage. ABC позволяет проверить контракт при создании класса.
ABC - Abstract Base Class
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def get(self, key):
...
@abstractmethod
def set(self, key, value):
...
class MemoryStorage(Storage):
def __init__(self):
self.data = {}
def get(self, key):
return self.data.get(key)
def set(self, key, value):
self.data[key] = value
# Storage() # TypeError - cannot instantiate abstract class
m = MemoryStorage() # OK - реализованы все abstract методы
Класс с @abstractmethod нельзя инстанцировать напрямую - попытка Storage() даст TypeError. Только подкласс, реализующий ВСЕ abstract методы, можно создать.
Частичная реализация
Можно реализовать часть методов в ABC - они работают как обычные:
class Repository(ABC):
@abstractmethod
def find_by_id(self, id):
...
def find_by_ids(self, ids):
# default реализация через find_by_id
return [self.find_by_id(i) for i in ids]
Подкласс реализует только find_by_id, find_by_ids наследуется. Это удобно для template method паттерна.
Abstract property
class Shape(ABC):
@property
@abstractmethod
def area(self):
...
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
# Shape() # TypeError
c = Circle(5)
print(c.area) # 78.539...
Декораторы можно стэкать: @property сверху для конечного интерфейса, @abstractmethod снизу для требования.
ABC vs Protocol
ABC требует явного наследования (class Mine(MyABC)). Это работает, но негибко - нельзя сказать «любой класс с этими методами».
Protocol даёт structural subtyping (utинhe typing с проверкой):
from typing import Protocol
class Storage(Protocol):
def get(self, key): ...
def set(self, key, value): ...
class MemoryStorage: # БЕЗ наследования!
def get(self, key): ...
def set(self, key, value): ...
def save(storage: Storage, key: str, value: str):
storage.set(key, value)
save(MemoryStorage(), "k", "v") # mypy одобряет - MemoryStorage соответствует Protocol
Protocol проверяет наличие методов, не цепочку наследования. Это аналог интерфейсов в Go - implicit implementation.
Когда ABC, когда Protocol
| Критерий | ABC | Protocol |
|---|---|---|
| Способ соответствия | Явное наследование | Структурное (по методам) |
| Проверка | Runtime (TypeError при инстансе) | Static (mypy) |
| Поддержка default-методов | Да | Да (в @runtime_checkable) |
| Можно ли реализовать "случайно" | Нет (нужно наследование) | Да |
| Использование isinstance | Да | Только с @runtime_checkable |
ABC выбирается когда:
- Нужно строгая иерархия с принудительной реализацией
- Нужны shared методы в базовом классе
- Runtime проверка через isinstance важна
- Контракт - часть домена (Database, Plugin, Handler)
Protocol выбирается когда:
- Подходит duck typing с type safety
- Не хочешь обязывать наследование
- Работаешь с third-party классами (не можешь добавить наследование)
- Хочешь типизировать «утиный» интерфейс
В современном коде Protocol используется чаще для типизации интерфейсов, ABC - для каркасов фреймворков.
Runtime-проверяемые Protocol
По умолчанию Protocol проверяется только статически (mypy):
isinstance(obj, Storage) # TypeError - Storage не runtime
Чтобы можно было проверять в runtime, нужен @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self): ...
class File:
def close(self):
pass
isinstance(File(), Closable) # True - проверка по методам
Внимание: runtime check смотрит только на наличие методов, не на их сигнатуры. То есть def close(self, force=True) тоже сработает, хотя сигнатура не совпадает.
Default-методы в Protocol
С Python 3.8+:
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
def __eq__(self, other) -> bool:
# default реализация
return not (self < other) and not (other < self)
Default-методы доступны если класс не определяет их.
Generic Protocol
from typing import Protocol, TypeVar
T = TypeVar("T")
class Container(Protocol[T]):
def add(self, item: T) -> None: ...
def get(self, idx: int) -> T: ...
def first(c: Container[T]) -> T:
return c.get(0)
Параметризованные protocol позволяют типизировать generic-интерфейсы (например, контейнеры с типизированными элементами).
ABC из collections.abc
В collections.abc есть готовые ABC для стандартных контейнеров:
from collections.abc import Iterable, Sequence, Mapping, MutableMapping
class MyList(Sequence): # реализуй __getitem__ и __len__
def __init__(self):
self.data = []
def __getitem__(self, idx):
return self.data[idx]
def __len__(self):
return len(self.data)
m = MyList()
# Унаследованные методы: __contains__, __iter__, __reversed__, index, count
print(0 in m)
Sequence ABC даёт много методов бесплатно, нужно только реализовать минимум (__getitem__ и __len__).
Распространённые ABC:
Iterable- имеет__iter__Iterator-__next__(плюс Iterable)Sized-__len__Container-__contains__Hashable-__hash__Sequence- индексация, длина (immutable)MutableSequence- изменяемая последовательность (list-like)Mapping- dict-like (immutable)MutableMapping- изменяемый dictSet- set операцииCallable- имеет__call__
Это удобно для isinstance проверок: isinstance(x, Iterable).
Type-only Protocol
Можно использовать Protocol как «типизированный интерфейс» без реализации:
from typing import Protocol
class Logger(Protocol):
def info(self, msg: str) -> None: ...
def error(self, msg: str) -> None: ...
def process(data, logger: Logger):
logger.info("Processing...")
try:
...
except Exception as e:
logger.error(f"Failed: {e}")
Любой класс с info() и error() подходит - стандартный logging.Logger, кастомный класс, mock в тесте. Не нужно требовать наследование от конкретного класса.
Регистрация ABC - virtual subclassing
from abc import ABC
class Storage(ABC):
@abstractmethod
def get(self, key): ...
class ExternalStorage: # third-party код
def get(self, key):
return ...
# Регистрируем как виртуальный подкласс
Storage.register(ExternalStorage)
isinstance(ExternalStorage(), Storage) # True
issubclass(ExternalStorage, Storage) # True
register делает класс virtual subclass без реального наследования. Полезно для интеграции с third-party библиотеками.
Но: virtual subclass не проверяется на наличие abstract методов - регистрация это «обещание», не контроль.
Best practices
-
Для нового внутреннего кода - Protocol предпочтительнее. Меньше связности, легче тестировать, согласовано с duck typing.
-
Для фреймворков и каркасов - ABC. Жёсткий контракт с runtime-проверкой полезен.
-
Не злоупотребляй абстракциями - простой класс без ABC/Protocol часто проще. Абстракции для случаев когда есть несколько реализаций.
-
from __future__ import annotations- аннотации становятся строками, отложенно вычисляются. Полезно для forward references в Protocol. -
runtime_checkableиспользовать осторожно - не проверяет сигнатуры методов, может ложноположительно срабатывать.
Сравнение с Go и PHP
В Go нет ABC - есть только interfaces, которые работают как Protocol:
type Storage interface {
Get(key string) string
Set(key, value string)
}
type MemoryStorage struct {
data map[string]string
}
func (m *MemoryStorage) Get(key string) string {
return m.data[key]
}
func (m *MemoryStorage) Set(key, value string) {
m.data[key] = value
}
// MemoryStorage автоматически реализует Storage - implicit
Go interface полностью эквивалентен Python Protocol по семантике.
В PHP есть и interface (как Go), и abstract class (как ABC):
interface Storage {
public function get(string $key): string;
public function set(string $key, string $value): void;
}
class MemoryStorage implements Storage { // явная реализация
public function get(string $key): string { ... }
public function set(string $key, string $value): void { ... }
}
Python Protocol появился позже (Python 3.8) и заполнил пробел между ABC (явное) и duck typing (без проверки).
Распространённые ошибки
1. abstractmethod без ABC
from abc import abstractmethod
class Storage: # ОШИБКА - не унаследовано от ABC
@abstractmethod
def get(self): ...
# Storage() # НЕ выбросит TypeError - abstractmethod игнорируется
Класс должен наследовать от ABC (или иметь ABCMeta как metaclass), иначе @abstractmethod не работает.
2. Protocol без typing
class Storage: # ОШИБКА - не Protocol
def get(self): ...
def f(s: Storage): # обычный type hint, не protocol
...
Без Protocol это обычный class - подходят только инстансы Storage или его подклассов.
3. runtime_checkable без понимания
@runtime_checkable
class HasName(Protocol):
def name(self): ...
class A:
def name(self): return "A"
class B:
name = "B" # атрибут, не метод!
isinstance(A(), HasName) # True
isinstance(B(), HasName) # True - тоже True, потому что .name есть!
runtime_checkable проверяет существование атрибута, не его тип. Часто это «слишком слабо» - не различает методы и атрибуты.
Мини-задание
- ABC с template method:
from abc import ABC, abstractmethod
class TextProcessor(ABC):
def process(self, text):
text = self.preprocess(text)
text = self.transform(text)
return self.postprocess(text)
def preprocess(self, text):
return text.strip()
@abstractmethod
def transform(self, text):
...
def postprocess(self, text):
return text
class UpperCase(TextProcessor):
def transform(self, text):
return text.upper()
class ReverseWords(TextProcessor):
def transform(self, text):
return " ".join(text.split()[::-1])
print(UpperCase().process(" hello world ")) # HELLO WORLD
print(ReverseWords().process(" hello world ")) # world hello
- Protocol для duck typing:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle: # без наследования
def draw(self):
return "drawing circle"
class Square:
def draw(self):
return "drawing square"
def render_all(items: list[Drawable]):
for item in items:
print(item.draw())
render_all([Circle(), Square()]) # mypy одобрит
- ABC из collections.abc:
from collections.abc import Sequence
class FibonacciSeq(Sequence):
def __init__(self, count):
self.values = []
a, b = 0, 1
for _ in range(count):
self.values.append(a)
a, b = b, a + b
def __getitem__(self, idx):
return self.values[idx]
def __len__(self):
return len(self.values)
fib = FibonacciSeq(10)
print(list(fib)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
print(fib.index(13)) # 7 - наследовано от Sequence
print(8 in fib) # True - __contains__ из Sequence
Что дальше
- PHP - Наследование, abstract и интерфейсы - abstract class и interface в PHP: сравнение с ABC и Protocol, duck typing vs explicit contract
Модуль 6 «ООП» завершён. Освоили классы, атрибуты и методы, dunder-методы, наследование, dataclass и Enum, ABC и Protocol. В следующем модуле перейдём к итераторам, генераторам и асинхронности: глубоко разберём как Python обрабатывает последовательности и конкурентные задачи.