Абстрактные классы

Теория

Магазин принимает оплату картой, через СБП и в рассрочку, и у каждого способа свой платёжный шлюз. Код оформления заказа рассчитывает, что любой шлюз умеет 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. Объявляем абстрактный класс

Тело абстрактного метода не выполняется — вместо него пишут строку документации или ...:

python
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 не даёт создать его объект:

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 не трогают:

python
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, а не от конкретного шлюза. Подходит любой наследник:

python
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. Объект неполного класса создаётся, и ошибка ждёт первого вызова:

python
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__».

Ошибка в тексте

Сообщить об ошибке могут зарегистрированные пользователи — так в сообщениях меньше спама.

Войти, чтобы сообщить