Абстрактные базовые классы в Python

Oleksandr Shepetko30 мая, 17:54 137 0

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

Когда-то давно мы с коллегой дискутировали на тему того, какие паттерны проектирования лучше всего использовать, чтобы построить удобную в использовании и поддержке иерархию классов на Python. В частности тогда речь шла в контексте разработки бэкенда для какого-то веб-сервиса.

У нас был базовый класс BaseService, который определял общий интерфейс, а также несколько классов-потомков, с именами вроде MockServiceRealService и т. п., которые имплементировали определяемы родительским классом интерфейс.

Чтобы сделать наш код удобным в поддержке и дружественным для разработчиков, незнакомых с существующей кодовой базой, нам требовалось две вещи:

  1. базовый класс невозможно бы было инстанцировать;
  2. если разработчик забыл имплементировать интерфейсный метод в дочернем классе, то выбрасывалось бы исключение.

"Традиционный" подход в Python для решения описанных задач выглядит примерно так:

class Base:
    def foo(self):
        raise NotImplementedError()

    def bar(self):
        raise NotImplementedError()

class Concrete(Base):
    def foo(self):
        return 'foo() called'

    # Упс, мы забыли переопределить bar()...
    # def bar(self):
    #     return "bar() called"

Таким образом, если мы попытаемся вызвать какой-либо метод экземпляра родительского класса, мы справедливо получим исключение NotImplementedError:

>>> b = Base()
>>> b.foo()
NotImplementedError

Также это будет прекрасно работать и с экземплярами дочерних классов в случае, если мы забудем переопределить какой-либо интерфейсный метод:

>>> c = Concrete()
>>> c.foo()
'foo() called'
>>> c.bar()
NotImplementedError

Наше решение проблемы выглядит как-будто неплохо, однако есть пара неприятных моментов:

  1. мы без проблем можем инстанцировать базовый класс, не получив никаких исключений вплоть до того момента, пока не будет вызван какой-либо метод;
  2. с дочерними классами похожая ситуация: если мы забудем определить интерфейсный метод, мы опять-таки об этом не узнаем до тех пор, пока метод не будет вызван.

Решить описанные проблемы призван модуль abc, входящий в стандартную поставку Python начиная с версии 2.6. Давайте взглянем на решение с использованием этого модуля.

from abc import ABCMeta, abstractmethod

class Base(metaclass=ABCMeta):
    @abstractmethod
    def foo(self):
        pass

    @abstractmethod
    def bar(self):
        pass

class Concrete(Base):
    def foo(self):
        pass

    # И снова забудем переопределить bar()...

В плане иерархии классов всё остаётся как и прежде:

assert issubclass(Concrete, Base)

Однако теперь у нас обнаруживается новое полезное свойство: мы получим исключение в момент инстанцирования подклассов всякий раз, когда забудем имплементировать хотя бы один абстрактный метод. При этом из текста исключения мы будем точно знать, что и где мы забыли:

>>> c = Concrete()
TypeError:
"Can't instantiate abstract class Concrete with abstract methods bar"

Без использования abc мы бы получали NotImplementedError только момент фактического вызова метода. Получать же исключения в момент инстанцирования объекта класса -- это намного лучше в силу того, что у разработчика остаётся меньше шансов написать нерабочий код, который всплывёт позднее, чем в момент запуска программы.

Конечно же, модуль abc и предлагаемый им паттерн программирования не является заменой проверке типов времени компиляции. Однако он существенно упрощает разработку и поддержку иерархий классов, в основе которых лежат абстрактные базовые классы. Используя абстрактные базовые классы вы на порядок повышаете модульность и читаемость вашего когда, позволяя другим разработчикам быстрее понять суть в нём происходящего.