Protocol против ABC в Python: цена проверки типов
isinstance с Protocol работает в разы медленнее наследования от ABC, а без runtime_checkable падает с ошибкой. Разбираем, где протоколы бесплатны, а где бьют по горячему пути.

Замена ABC на typing.Protocol в проекте на Python 3.12 сделала код чище: классы перестали тянуть базовый класс ради одной проверки. Но профилировщик показал, что цикл с isinstance(obj, MyProtocol) на 200 000 объектов стал занимать в среднем в 3–4 раза больше времени, чем тот же цикл с наследованием от abc.ABC. Разберём, откуда берётся эта разница, когда протоколы бесплатны, а когда их проверка попадает в горячий путь и начинает стоить денег.
Что именно проверяется в рантайме
Утиная типизация в Python исторически не требовала объявлений: если у объекта есть метод read, его можно использовать как файл. Protocol из модуля typing формализует это для статических анализаторов — mypy и pyright видят структурное соответствие без явного наследования. Пока дело касается только проверки типов на этапе разработки, стоимость нулевая: в рантайме протокол не участвует.
Цена появляется, когда вы вешаете декоратор @runtime_checkable и вызываете isinstance или issubclass. Без этого декоратора isinstance(obj, MyProtocol) выбрасывает TypeError. А с ним проверка выполняется не по MRO, как у обычного класса, а перебором: для каждого метода протокола вызывается hasattr на проверяемом объекте.
Протокол с @runtime_checkable проверяет только наличие атрибутов по имени. Он не проверяет их сигнатуры и не отличает метод от совпавшего по имени поля. Это не строгая проверка типа, а быстрая проверка «есть ли у объекта нужные ручки».
Почему это медленнее наследования
Когда класс наследуется от abc.ABC (или от обычного базового класса), isinstance сводится к обходу цепочки наследования — операции, которая в CPython реализована на C и работает быстро. Протокол же на каждый вызов isinstance итерирует по набору членов протокола и делает hasattr на объекте. Чем больше методов в протоколе, тем дороже одна проверка. На горячем цикле это складывается.
Замеры: что и как мерить
Точные числа зависят от версии CPython, железа и числа методов в протоколе, поэтому не воспринимайте абсолютные значения как константу — важен порядок разницы. Ниже минимальный стенд, который вы можете запустить у себя, чтобы получить собственные цифры на своей версии интерпретатора.
# Python 3.12, стандартный timeit
import timeit
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
class ReaderABC(ABC):
@abstractmethod
def read(self) -> bytes: ...
@abstractmethod
def close(self) -> None: ...
@runtime_checkable
class ReaderProto(Protocol):
def read(self) -> bytes: ...
def close(self) -> None: ...
class ConcreteABC(ReaderABC):
def read(self) -> bytes:
return b""
def close(self) -> None:
pass
class ConcretePlain:
def read(self) -> bytes:
return b""
def close(self) -> None:
pass
obj_abc = ConcreteABC()
obj_plain = ConcretePlain()
# Каждый вызов timeit прогоняет проверку number раз
t_abc = timeit.timeit(lambda: isinstance(obj_abc, ReaderABC), number=1_000_000)
t_proto = timeit.timeit(lambda: isinstance(obj_plain, ReaderProto), number=1_000_000)
print(f"ABC: {t_abc:.4f} c")
print(f"Protocol: {t_proto:.4f} c")
print(f"отношение: {t_proto / t_abc:.1f}x")
На таком стенде проверка через протокол стабильно выходит в несколько раз дороже проверки через ABC. Если увеличить число методов в протоколе, разрыв растёт. Запустите с двумя, пятью и десятью методами — увидите зависимость своими глазами.
Важная деталь про кеш: результат isinstance для протокола частично кешируется по классу проверяемого объекта в некоторых версиях CPython, но не полагайтесь на это как на гарантию — поведение менялось между релизами. Мерьте на тех типах объектов, которые реально приходят в ваш горячий путь, а не на одном экземпляре в цикле.
Условия замера, которые нельзя опускать
- Версия интерпретатора. Между 3.8, где протоколы стабилизировались, и 3.12 менялась и реализация
runtime_checkable, и внутренний кеш проверок. - Число членов протокола. Один метод и десять методов дают разную стоимость проверки.
- Разнообразие типов объектов. Если через проверку проходят объекты сотни разных классов, кеш работает хуже, чем когда тип один.
- Совпадает ли объект с протоколом. Отрицательный результат (метода нет) и положительный могут стоить по-разному.
Где протокол бесплатен, а где бьёт по карману
Ключевой вопрос — попадает ли isinstance в горячий путь.
- Статическая проверка типов. Протокол в аннотациях параметров и возвращаемых значений в рантайме не стоит ничего. Это его основное и самое дешёвое применение.
- Разовая диспетчеризация на старте. Проверить тип плагина один раз при загрузке — цена не имеет значения даже при тысяче плагинов.
- Горячий цикл обработки данных. Вот здесь разница между протоколом и
ABCпревращается в реальные миллисекунды на каждой итерации. Если вы гоняетеisinstanceпо миллионам объектов в цикле сериализации или роутинга, протокол — плохой выбор.
Таблица: чем платите за каждый подход
| Подход | Стоимость isinstance | Требует наследования | Что проверяет |
|---|---|---|---|
| Наследование от ABC | Низкая, обход MRO на C | Да, класс тянет базу | Явную регистрацию в иерархии |
| Protocol + runtime_checkable | Высокая, hasattr по членам | Нет, структурно | Наличие атрибутов по имени |
| Protocol без runtime_checkable | isinstance падает с TypeError | Нет, структурно | Только статически, в рантайме недоступно |
| Прямой hasattr / getattr | Минимальная, одна операция | Нет | Наличие конкретного атрибута |
Как убрать проверку из горячего пути
Если протокол нравится за статическую типизацию, но isinstance по нему съедает время в цикле, разнесите две задачи.
- Оставьте протокол в аннотациях. Пусть mypy и pyright видят структуру. Это бесплатно.
- Проверяйте один раз, не в цикле. Отфильтруйте или диспетчеризуйте объекты на входе, до горячего участка, а внутри цикла работайте с уже проверенными данными.
- Замените проверку на прямой доступ. Вместо
isinstance(obj, ReaderProto)в цикле используйтеtry/except AttributeErrorвокруг вызоваobj.read()— это дешевле и ближе к духу утиной типизации.
# Дорого: проверка типа на каждой итерации
result = []
for obj in huge_stream:
if isinstance(obj, ReaderProto): # hasattr по всем членам каждый раз
result.append(obj.read())
# Дешевле: пробуем вызвать, ловим отсутствие метода
result = []
for obj in huge_stream:
try:
result.append(obj.read())
except AttributeError:
continue # объект не читаемый, пропускаем
Второй вариант выигрывает, когда почти все объекты подходят: исключение — редкий случай, а не норма. Если же непригодных объектов много, стоимость перехвата исключений сама станет проблемой, и тогда честная проверка на входе потока лучше.
Граничные случаи, о которых легко забыть
- Протокол с полями данных.
runtime_checkableпроверяет наличие атрибутов, но не различает метод и поле. Объект с полемreadвместо метода пройдёт проверку и упадёт при вызове. - Протоколы с дженериками. Параметры типа в рантайме стираются —
isinstanceпроверит структуру, но не параметр. - Наследование от протокола. Можно унаследоваться от протокола явно и получить его как базовый класс. Тогда проверка снова идёт по MRO и стоит дёшево, но вы теряете структурную свободу.
- Абстрактные методы в протоколе. Протокол — не замена
ABCдля запрета инстанцирования неполных классов. Если нужен именно контроль реализации на этапе создания объекта,ABCсabstractmethodостаётся уместным.
Что мерить и настроить дальше
Прежде чем менять ABC на протоколы в существующем коде, найдите профилировщиком, где именно вызывается isinstance. Если это старт приложения или редкая ветка — меняйте смело, выигрыш в чистоте кода бесплатен. Если это тело цикла на большом потоке — сначала замерьте на своей версии CPython и своих типах, и только потом решайте. Смотрите на долю времени, которую isinstance занимает в горячей функции: если она заметна в профиле, переносите проверку из цикла наружу. Сравнение производительности разных структур данных внутри самих обработчиков — отдельная тема, здесь мы говорили только о цене проверки типа.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: typing — Protocol и runtime_checkable, документация Python, PEP 544 — Protocols: Structural subtyping
Частые вопросы
А если у меня Python 3.8 или 3.9, а не 3.12?
Почему isinstance с протоколом вообще падает без декоратора?
Стоит ли вообще отказываться от ABC в пользу протоколов?
А если проверка не сработала как я ожидал — объект прошёл, но упал при вызове?
Насколько именно медленнее протокол — можно назвать точное число?
try/except вокруг вызова всегда быстрее isinstance?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.