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

КритерийABCProtocol
Способ соответствияЯвное наследованиеСтруктурное (по методам)
Проверка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 - изменяемый dict
  • Set - 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

  1. Для нового внутреннего кода - Protocol предпочтительнее. Меньше связности, легче тестировать, согласовано с duck typing.

  2. Для фреймворков и каркасов - ABC. Жёсткий контракт с runtime-проверкой полезен.

  3. Не злоупотребляй абстракциями - простой класс без ABC/Protocol часто проще. Абстракции для случаев когда есть несколько реализаций.

  4. from __future__ import annotations - аннотации становятся строками, отложенно вычисляются. Полезно для forward references в Protocol.

  5. 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 проверяет существование атрибута, не его тип. Часто это «слишком слабо» - не различает методы и атрибуты.

Мини-задание

  1. 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
  1. 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 одобрит
  1. 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

Что дальше

Модуль 6 «ООП» завершён. Освоили классы, атрибуты и методы, dunder-методы, наследование, dataclass и Enum, ABC и Protocol. В следующем модуле перейдём к итераторам, генераторам и асинхронности: глубоко разберём как Python обрабатывает последовательности и конкурентные задачи.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.