handbookНаписать

languages/python/01-protocols-vs-abc/README.md · тема · 1 язык · 7 тестов

Protocol и ABC в Python (Protocols vs abstract base classes) ​

Protocol описывает, что нужно вызывающему коду, и принимает любой объект подходящей формы; ABC описывает, что обязаны уметь наследники, и делится с ними общим кодом. Хранилищам файлов нужны оба, но в разных местах.

Проблема ​

Счета в PDF надо хранить то на диске, то в S3. Первая версия обычно наследует всё от одного базового класса и проверяет тип:

python
class InvoiceArchive:
    def __init__(self, storage: BaseStorage) -> None:
        if not isinstance(storage, BaseStorage):  # only our own subclasses
            raise TypeError("storage must inherit BaseStorage")
        self.storage = storage

Работает, пока все хранилища свои. Потом появляется клиент чужого облака с теми же put и get, но без нашего базового класса, и его приходится оборачивать в адаптер ради одного isinstance. Тестовый двойник тоже обязан наследовать BaseStorage. А если базового класса нет совсем и всё держится на duck typing, забытый метод в новом хранилище всплывёт только в момент вызова, на проде.

Решение ​

Разделить два вопроса, на которые отвечают разные инструменты:

  • Protocol (структурная типизация) - FileStorage с put и get говорит, что нужно InvoiceArchive. Наследовать его не надо: подходит любой объект с такими методами, и mypy или pyright проверят это статически. @runtime_checkable добавляет isinstance(obj, FileStorage), но проверяет только наличие имён, не сигнатуры.
  • ABC (номинальная типизация) - StorageBackend с @abstractmethod говорит, что обязаны реализовать наши бэкенды. Класс без get не создастся: TypeError при конструировании, а не при первом вызове. Плюс общий код: copy() пишется один раз в базовом классе.

LocalStorage и S3StorageStub наследуют StorageBackend и поэтому заодно подходят под FileStorage. Чужой VendorBlobClient ничего не наследует, но тоже подходит.

Правило выбора: тип параметра, который принимает чужие объекты, - Protocol; базовый класс для своих реализаций, если у них есть общий код или нужна гарантия при создании, - ABC. Одно не мешает другому.

Структура ​

по форме

по форме

«Protocol»

FileStorage

+put(key, data)

+get(key) : bytes

«abstract»

StorageBackend

+put(key, data)

+get(key) : bytes

+copy(src, dst)

LocalStorage

-root: Path

S3StorageStub

-bucket: str

-objects: dict

VendorBlobClient

InvoiceArchive

-storage: FileStorage

+store(invoice_id, pdf) : str

+load(invoice_id) : bytes

Сплошная стрелка - наследование, его требует ABC. Пунктир "по форме" - соответствие протоколу, его никто не объявляет, его проверяет анализатор. InvoiceArchive зависит только от протокола, поэтому про StorageBackend не знает вовсе.

Как это собирается ​

При старте приложение читает настройки (STORAGE_DRIVER=s3, STORAGE_BUCKET=billing) и создаёт нужный бэкенд, обычно в DI-контейнере или фабрике рядом с main. InvoiceArchive получает его через конструктор и работает с ним как с FileStorage. Общий метод copy() доступен потому, что бэкенд наш и наследует StorageBackend. Когда приходится подключить клиент чужой библиотеки, он передаётся в InvoiceArchive как есть, без адаптера: форма совпала, и этого достаточно.

Сценарий целиком с выводом: пример на Python.

Как это в других языках ​

PHP. Типизация только номинальная: объект подходит под interface FileStorage, лишь если класс написал implements FileStorage. Чужой клиент без этого слова не пройдёт, и адаптер нужен всегда. Абстрактный класс играет роль ABC: и контракт, и общий код, и ошибка при new неполного класса.

Go. Интерфейсы структурные, как Protocol, и это основной способ: type FileStorage interface { Put(...); Get(...) } объявляют рядом с потребителем, а реализации о нём не знают. Наследования нет, поэтому общий код вроде copy - это функция Copy(s FileStorage, src, dst string) или встраивание структуры.

TypeScript. Структурная типизация по умолчанию: любой объект с put и get уже FileStorage, и проверяются даже сигнатуры. Но только при компиляции: в рантайме интерфейса нет, и аналог isinstance пишут руками как type guard. abstract class есть и работает как ABC.

Запуск: make test T=languages/python/01-protocols-vs-abc

Тесты прогоняют одни и те же проверки на обоих бэкендах (фикстура с параметрами), показывают, что InvoiceArchive принимает класс без наследования, что isinstance по протоколу пропускает объект с неверными сигнатурами, а ABC не даёт создать неполный бэкенд.

Плюсы и минусы ​

  • ✅ Protocol не связывает реализации с вызывающим кодом: чужие классы и тестовые двойники подходят без адаптеров.
  • ✅ Протокол объявляется там, где он нужен, и может быть узким: два метода, а не весь API хранилища.
  • ✅ ABC ловит забытый метод при создании объекта и даёт место для общего кода.
  • ❌ Соответствие протоколу проверяет только анализатор; без mypy или pyright в CI ошибка формы снова всплывёт в момент вызова.
  • ❌ runtime_checkable смотрит на имена, а не на сигнатуры: isinstance вернёт True для put(self) без аргументов.
  • ❌ ABC навязывает наследование: класс из чужой библиотеки под него не подогнать.

Когда не применять ​

  • Protocol с runtime_checkable как замена валидации: проверка формы через isinstance слабая, для входных данных нужен явный разбор.
  • ABC ради одного контракта без общего кода и без своих наследников: это протокол, только с лишним наследованием.
  • Ни то, ни другое, если реализация одна и вторая не предвидится: хватит конкретного класса, протокол можно выделить потом.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS