Абстрактные классы
Теория
Магазин принимает оплату картой, через СБП и в рассрочку, и у каждого способа свой платёжный шлюз. Код оформления заказа рассчитывает, что любой шлюз умеет charge(order_id, amount) и refund(order_id, amount). С утиной типизацией это просто договорённость. Если новый шлюз забудет refund, ошибка всплывёт только при первом возврате денег — через неделю, в продакшене. Абстрактный класс превращает договорённость в правило, которое проверяет Python. Класс наследует от abc.ABC, а обязательные методы помечены @abstractmethod. Создать объект класса, у которого остался хотя бы один нереализованный абстрактный метод, нельзя: TypeError возникает при создании, а не при вызове.
Абстрактный класс — не только список обязательных методов. В нём можно написать обычные методы с общей логикой. Например, pay() проверяет сумму, вызывает абстрактный charge() и записывает результат. Наследники реализуют только свой шаг. Это приём «шаблонный метод»: алгоритм записан один раз в базовом классе, а меняются отдельные шаги. Чем это отличается от Protocol: ABC требует наследования и проверяет методы при создании объекта, а Protocol не требует родства и проверяется только mypy.
Наглядно
Таблица 1. Как задать обязательный метод.
| Способ | Когда видна ошибка «метод не реализован» | Требует наследования |
|---|---|---|
| Договорённость (утиная типизация) | при вызове: AttributeError |
нет |
raise NotImplementedError в базовом методе |
при вызове | да |
ABC + @abstractmethod |
при создании объекта: TypeError |
да |
Protocol + mypy |
до запуска, при проверке типов | нет |
Таблица 2. Из чего состоит абстрактный класс шлюза.
| Часть | Пример | Кто пишет |
|---|---|---|
| Абстрактный метод | charge, refund |
каждый наследник обязан реализовать |
| Обычный метод | pay — проверка суммы, вызов charge, запись в журнал |
базовый класс, один раз для всех |
Атрибуты и __init__ |
self.log = [] |
базовый класс; наследник расширяет через super() |
На практике
Задача: описать контракт платёжного шлюза так, чтобы неполный шлюз нельзя было даже создать, а общая логика оплаты была записана один раз.
Шаг 1. Объявляем абстрактный класс
Тело абстрактного метода не выполняется — вместо него пишут строку документации или ...:
from abc import ABC, abstractmethod from decimal import Decimal class PaymentGateway(ABC): @abstractmethod def charge(self, order_id, amount): """Списать деньги и вернуть id транзакции.""" @abstractmethod def refund(self, order_id, amount): """Вернуть деньги покупателю.""" PaymentGateway()
Результат:
TypeError: Can't instantiate abstract class PaymentGateway without an implementation for abstract methods 'charge', 'refund'
Шаг 2. Видим, что неполный наследник тоже абстрактный
Шлюз СБП реализовал только charge. Python не даёт создать его объект:
class SbpGateway(PaymentGateway): def charge(self, order_id, amount): return f"sbp-{order_id}" SbpGateway()
Результат:
TypeError: Can't instantiate abstract class SbpGateway without an implementation for abstract method 'refund'
Ошибка возникает в момент создания объекта, то есть в первом же тесте или при старте приложения, а не при первом возврате денег.
Шаг 3. Пишем общий алгоритм в базовом классе
pay() — обычный метод: проверяет сумму, вызывает шаг наследника и ведёт журнал. Наследники pay не трогают:
class PaymentGateway(ABC): def __init__(self): self.log = [] def pay(self, order_id, amount): if amount <= 0: raise ValueError(f"заказ {order_id}: сумма должна быть больше нуля") transaction_id = self.charge(order_id, amount) self.log.append((order_id, amount, transaction_id)) return transaction_id @abstractmethod def charge(self, order_id, amount): """Списать деньги и вернуть id транзакции.""" @abstractmethod def refund(self, order_id, amount): """Вернуть деньги покупателю.""" class CardGateway(PaymentGateway): def charge(self, order_id, amount): return f"card-{order_id}" def refund(self, order_id, amount): return f"card-refund-{order_id}" class SbpGateway(PaymentGateway): def charge(self, order_id, amount): return f"sbp-{order_id}" def refund(self, order_id, amount): return f"sbp-refund-{order_id}" for gateway in (CardGateway(), SbpGateway()): print(gateway.pay(1042, Decimal("2998.00")), gateway.log) CardGateway().pay(1043, Decimal("0"))
Результат:
card-1042 [(1042, Decimal('2998.00'), 'card-1042')]
sbp-1042 [(1042, Decimal('2998.00'), 'sbp-1042')]
ValueError: заказ 1043: сумма должна быть больше нуляПроверка суммы и журнал написаны один раз. Новый шлюз получает их автоматически, если реализует два метода.
Шаг 4. Работаем через абстрактный тип
Код оформления зависит от PaymentGateway, а не от конкретного шлюза. Подходит любой наследник:
def checkout(order_id, amount, gateway: PaymentGateway): return gateway.pay(order_id, amount) print(checkout(1044, Decimal("490.00"), SbpGateway())) print(isinstance(SbpGateway(), PaymentGateway))
Результат:
sbp-1044 True
Шаг 5. Сравниваем с NotImplementedError
Старый приём — базовый метод, который выбрасывает NotImplementedError. Объект неполного класса создаётся, и ошибка ждёт первого вызова:
class OldGateway: def refund(self, order_id, amount): raise NotImplementedError("наследник должен реализовать refund") class OldSbp(OldGateway): pass gateway = OldSbp() print("создан") gateway.refund(1042, Decimal("100"))
Результат:
создан NotImplementedError: наследник должен реализовать refund
Частые ошибки
@abstractmethodбезABC. В классе, не унаследованном отABC, декоратор ничего не проверяет: неполные наследники спокойно создаются. Абстрактный класс — всегдаclass X(ABC).- Переопределили шаблонный метод вместо шага. Наследник, который переписал
pay()целиком, теряет общую проверку суммы и журнал. Наследник реализует абстрактные шаги, а алгоритм не трогает. - «Абстрактный класс без абстрактных методов создать нельзя». Можно:
class Base(ABC)без@abstractmethodсоздаётся как обычный класс. Запрещает создание только нереализованный абстрактный метод. - Код в теле абстрактного метода. Он выполнится, только если наследник вызовет
super().charge(...). Логику для всех пишут в обычных методах, а абстрактный оставляют с документацией. - Абстрактный класс на всякий случай. Если реализация одна и вторая не предвидится, контракт ничего не даёт.
ABCнужен, когда реализаций несколько и важно, чтобы ни одна не забыла метод.
Итог
Абстрактный класс
class PaymentGateway(ABC)+@abstractmethod— обязательные методы.- Объект нельзя создать, пока не реализованы все абстрактные методы:
TypeErrorпри создании. - Наследник, реализовавший не всё, сам остаётся абстрактным.
Общая логика
- Обычные методы абстрактного класса — общий алгоритм для всех наследников.
- Шаблонный метод:
pay()в базе вызывает абстрактныйcharge(), наследники реализуют только шаг.
Сравнение
NotImplementedError— ошибка при вызове,ABC— при создании.Protocol— контракт без наследования, проверяет mypy;ABC— с наследованием, проверяет Python.
Вопросы на собеседовании
Что такое абстрактный класс и зачем он нужен?
Класс, объект которого нельзя создать, пока не реализованы все методы с @abstractmethod. Он задаёт контракт для наследников и проверяет его при создании объекта. Кроме того, хранит общую логику, которой пользуются все реализации.
Чем ABC отличается от базового класса с raise NotImplementedError?
С NotImplementedError объект неполного класса создаётся, и ошибка возникает только при вызове метода — возможно, в редком сценарии в продакшене. С ABC неполный класс не даст создать объект, ошибка видна сразу.
Можно ли создать объект класса, унаследованного от ABC, если в нём нет абстрактных методов?
Да. Запрет действует, только пока остаются нереализованные абстрактные методы. class Base(ABC): pass создаётся без ошибки.
Что такое паттерн «шаблонный метод»?
Базовый класс описывает алгоритм в обычном методе и вызывает в нём абстрактные шаги. Наследники реализуют шаги, но не меняют порядок алгоритма. Так общие проверки и журналирование пишутся один раз.
Когда выбрать ABC, а когда Protocol?
ABC — когда реализации — ваши классы, им нужна общая логика из базы и проверка при создании. Protocol — когда нужно принять любые объекты с нужными методами, в том числе из чужих библиотек, без наследования; проверка — статическая, через mypy.
Глубже
ABC устроен через метакласс ABCMeta. Что такое метаклассы и как __init_subclass__ позволяет, например, автоматически регистрировать все платёжные шлюзы, — в курсе «Python под капотом», тема «Метаклассы и __init_subclass__».