Protocol и ABC в Python (Protocols vs abstract base classes)
Protocolописывает, что нужно вызывающему коду, и принимает любой объект подходящей формы;ABCописывает, что обязаны уметь наследники, и делится с ними общим кодом. Хранилищам файлов нужны оба, но в разных местах.
Проблема
Счета в PDF надо хранить то на диске, то в S3. Первая версия обычно наследует всё от одного базового класса и проверяет тип:
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. Одно не мешает другому.
Структура
Сплошная стрелка - наследование, его требует 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ради одного контракта без общего кода и без своих наследников: это протокол, только с лишним наследованием.- Ни то, ни другое, если реализация одна и вторая не предвидится: хватит конкретного класса, протокол можно выделить потом.
Связанные темы
- Адаптер (Adapter) - что делать, когда форма чужого класса не совпала
- Шаблонный метод (Template Method) - общий код в базовом классе, как
copy()вStorageBackend - Принцип разделения интерфейсов - узкий протокол под нужды одного клиента
- Принцип инверсии зависимостей -
InvoiceArchiveзависит от абстракции, которую объявил сам