Абстрактные базовые классы в Python
Когда-то давно мы с коллегой дискутировали на тему того, какие паттерны проектирования лучше всего использовать, чтобы построить удобную в использовании и поддержке иерархию классов на Python. В частности тогда речь шла в контексте разработки бэкенда для какого-то веб-сервиса.
У нас был базовый класс BaseService
, который определял общий интерфейс, а также несколько классов-потомков, с именами вроде MockService
, RealService
и т. п., которые имплементировали определяемы родительским классом интерфейс.
Чтобы сделать наш код удобным в поддержке и дружественным для разработчиков, незнакомых с существующей кодовой базой, нам требовалось две вещи:
- базовый класс невозможно бы было инстанцировать;
- если разработчик забыл имплементировать интерфейсный метод в дочернем классе, то выбрасывалось бы исключение.
"Традиционный" подход в 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
Наше решение проблемы выглядит как-будто неплохо, однако есть пара неприятных моментов:
- мы без проблем можем инстанцировать базовый класс, не получив никаких исключений вплоть до того момента, пока не будет вызван какой-либо метод;
- с дочерними классами похожая ситуация: если мы забудем определить интерфейсный метод, мы опять-таки об этом не узнаем до тех пор, пока метод не будет вызван.
Решить описанные проблемы призван модуль 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
и предлагаемый им паттерн программирования не является заменой проверке типов времени компиляции. Однако он существенно упрощает разработку и поддержку иерархий классов, в основе которых лежат абстрактные базовые классы. Используя абстрактные базовые классы вы на порядок повышаете модульность и читаемость вашего когда, позволяя другим разработчикам быстрее понять суть в нём происходящего.