Композиция предпочтительнее наследования

Перевод с английского статьи известного разработчика на Python Брендана Роудса (Brandon Rhodes), в которой рассмотрен принцип предпочтения композиции классов перед наследованием. Для демонстрации используются различные структуры проектирования для классов создания и фильтрования логов, в том числе с помощью шаблонов "Адаптер", "Мост", "Декоратор" и новой структуры, не предусмотренной шаблонами проектирования "банды четырех".

Источник: The Composition Over Inheritance Principle

Содержание

  • Принцип из книги "банды четырех"
  • Проблема: взрывная волна подклассов
  • Решение № 1: Шаблон "Адаптер" (Adapter Pattern)
  • Решение № 2: Шаблон "Мост" (Bridge Pattern)
  • Решение № 3: Шаблон "Декоратор" (Decorator Pattern)
  • Решение № 4: за пределами шаблонов "банды четырех"
    • Избегать: множественного наследования
    • Избегать: классов-примесей (Mixins)
    • Избегать: динамического построения классов

Принцип из книги "банды четырех"

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

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

Ты должен предпочитать композицию объектов перед наследованием классов

Давайте возьмем какую-нибудь одну проблему проектирования и посмотрим, как работает данный принцип сам по себе, сквозь несколько классических шаблонов проектирования (design patterns) "банды четырех". Каждый шаблон проектирования скомпонует простые классы, не обремененные наследованием (inheritance), в элегантное решение, которое будет исполняться при запуске программы.

Проблема: взрывная волна подклассов

Критической слабостью наследования как стратегии проектирования является то, что классу часто приходится назначать специализацию вдоль нескольких осей проектирования одновременно. Это ведет к тому, что "банда четырех" называет в главе "Мост" (Bridge) "быстрым размножением классов" (proliferation of classes”), а в главе "Декоратор" (Decorator) — "взрывной волной подклассов, необходимой для обслуживания всех комбинаций".

Модуль logging в Python — хороший пример из самой стандартной библиотеки, если мы хотим взять модуль, соблюдающий принцип предпочтительности композиции перед наследованием. Поэтому давайте воспользуемся модулем logging для нашего примера. Представим базовый класс лога, который постепенно нарастил себе подклассы, потому что разработчикам требовалось отправлять сообщения логов новым адресатам.

import sys
import syslog

# Первоначальный класс.

class Logger(object):
    def __init__(self, file):
        self.file = file

    def log(self, message):
        self.file.write(message + '\n')
        self.file.flush()


# Еще два класса, которые направляют сообщения каким-нибудь получателям.


class SocketLogger(Logger):
    def __init__(self, sock):
        self.sock = sock

    def log(self, message):
        self.sock.sendall((message + '\n').encode('ascii'))

class SyslogLogger(Logger):
    def __init__(self, priority):
        self.priority = priority

    def log(self, message):
        syslog.syslog(self.priority, message)

Проблема возникает, когда к этой первой оси проектирования добавляется еще одна. Допустим, теперь нужно фильтровать сообщения логов, потому что какие-нибудь пользователи хотят видеть только те сообщения, которые содержат слово “Error”. Разработчик реагирует новым подклассом для Logger:

# Новая линия проектирования: фильтрование сообщений.


class FilteredLogger(Logger):
    def __init__(self, pattern, file):
        self.pattern = pattern
        super().__init__(file)

    def log(self, message):
        if self.pattern in message:
            super().log(message)


# Работает.


f = FilteredLogger('Error', sys.stdout)
f.log('Ignored: this is not important')
f.log('Error: but you want to see this')

Error: but you want to see this

Вот так расставлен капкан, и он захлопнется в тот момент, когда приложению понадобится отфильтровать сообщения и записать их не в файл, а в ячейку. Данный вариант не закрыт ни одним из созданных классов. Если разработчик продолжит клепать подклассы и создаст FilteredSocketLogger, совмещающий в себе элементы обоих классов, это означает, что взрывная волна подклассов пошла.

Возможно, программисту повезет, и новых комбинаций не потребуется. Однако в общем случае в приложении, в конечном итоге, будет 3×2=6 классов:

Logger            FilteredLogger
SocketLogger      FilteredSocketLogger
SyslogLogger      FilteredSyslogLogger

Общее количество классов будет расти в геометрической прогрессии, если оба m и n продолжат расти. Это и есть то самое "быстрое размножение классов" и "взрывная волна подклассов", которых хочет избежать "банда четырех".

Решение заключается в том, чтобы признать чрезмерную сложность класса, отвечающего и за фильтрование сообщений, и за ведение логов сообщений. В современной объектно-ориентированной практике такой подход будет обвинен в нарушении "Принципа одной ответственности".

Но как мы можем распределить две функции: фильтрование сообщений и вывод сообщений, между разными классами?

Решение № 1: Шаблон "Адаптер" (Adapter Pattern)

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

  • Поэтому мы оставляем первоначальный Logger.
  • Класс FilteredLogger мы тоже оставляем.
  • Но вместо создания специальных классов для всех пунктов назначения мы адаптируем каждый пункт назначения под поведение файла и затем передаем адаптер классу Logger как его выходной файл.

См. далее адаптер для каждого из двух остальных выходов:

import socket

class FileLikeSocket:
    def __init__(self, sock):
        self.sock = sock

    def write(self, message_and_newline):
        self.sock.sendall(message_and_newline.encode('ascii'))

    def flush(self):
        pass

class FileLikeSyslog:
    def __init__(self, priority):
        self.priority = priority

    def write(self, message_and_newline):
        message = message_and_newline.rstrip('\n')
        syslog.syslog(self.priority, message)

    def flush(self):
        pass

Python поощряет утиную типизацию (duck typing), поэтому адаптер несет ответственность только за то, чтобы предлагать правильные методы. Например, наши адаптеры освобождены от необходимости наследовать любой из классов, вокруг которых они заворачиваются, или тип файла, который он имитируют. Также они не обязаны воспроизводить весь список более чем дюжины методов, предлагаемых реальным файлом. Ведь наличие у утки способности ходить не имеет значения, когда вам нужно только крякать, точно так же нашим адаптерам нужно лишь реализовать два файловых метода, которые класс Logger действительно использует.

И вот теперь удалось избежать взрывной волны подклассов! Объекты класса Logger и объекты-адаптеры можно беспрепятственно совмещать и сопоставлять после запуска программы, при этом не нужно создавать никаких новых классов:

sock1, sock2 = socket.socketpair()

fs = FileLikeSocket(sock1)
logger = FilteredLogger('Error', fs)
logger.log('Warning: message number one')
logger.log('Error: message number two')

print('The socket received: %r' % sock2.recv(512))

The socket received: b'Error: message number two\n'

Отмечу, что класс FileLikeSocket написан выше только для примера, потому что в реальности этот адаптер встроен в стандартную библиотеку Python. Нужно просто вызвать метод makefile() в любой ячейке, чтобы получить готовый адаптер, с помощью которого ячейка приобретает внешний вид файла.

Решение № 2: Шаблон "Мост" (Bridge Pattern)

Шаблон Мост разделяет поведение класса на внешний объект "абстракцию" (abstraction), который видит вызывающая программа, и объект "реализацию" (implementation), который упакован внутри. Мы можем применить шаблон Мост в своем примере с модулем logging, если принять (возможно, немного своевольное) решение о том, что фильтрование относится к классу-абстрации, а вывод — к классу-реализации.

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

Поэтому давайте спроектируем внутренний объект-реализацию таким образом, чтобы он принимал сырое сообщение, вместо добавления с конца новой строки, а также сократим интерфейс до одного метода emit() вместо того, чтобы дополнительно брать на себя обязанность обслуживать метод flush(), который обычно был холостым.

# "Абстракции", которые будет видеть вызывающая программа.


class Logger(object):
    def __init__(self, handler):
        self.handler = handler

    def log(self, message):
        self.handler.emit(message)

class FilteredLogger(Logger):
    def __init__(self, pattern, handler):
        self.pattern = pattern
        super().__init__(handler)

    def log(self, message):
        if self.pattern in message:
            super().log(message)

# "Реализации", скрытые за кулисами.

class FileHandler:
    def __init__(self, file):
        self.file = file

    def emit(self, message):
        self.file.write(message + '\n')
        self.file.flush()

class SocketHandler:
    def __init__(self, sock):
        self.sock = sock

    def emit(self, message):
        self.sock.sendall((message + '\n').encode('ascii'))

class SyslogHandler:
    def __init__(self, priority):
        self.priority = priority

    def emit(self, message):
        syslog.syslog(self.priority, message)

Теперь объекты-абстракции и объекты-реализации можно беспрепятственно совмещать после запуска программы:

handler = FileHandler(sys.stdout)
logger = FilteredLogger('Error', handler)

logger.log('Ignored: this will not be logged')
logger.log('Error: this is important')

Error: this is important

Здесь видно больше симметрии, чем с Адаптером. Вместо ситуации, когда файловый вывод является нативным для класса Logger, а для не файлового вывода требуется дополнительный класс, теперь функционирующий логгер всегда конструируется путем совмещения абстракции и реализации.

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

Решение № 3: Шаблон "Декоратор" (Decorator Pattern)

Что, если мы захотим применить два разных фильтра к одному и тому же логу? Ни одно из вышеприведенных решений не обслуживает многократное фильтрование. Допустим, один фильтр будет для приоритета, а другой для сопоставления с ключевым словом.

Вернемся к фильтрам, заданным в предыдущем разделе. Мы не можем штабелировать два фильтра потому, что есть асимметрия между предлагаемыми ими интерфейсами и тем интерфейсом, который они оборачивают: они предлагают метод log(), но вызывают метод emit() своего манипулятора (handler). Если обернуть один фильтр в другой, то в результате появится ошибка AttributeError, когда внешний фильтр попытается вызвать метод emit() внутреннего фильтра.

Если мы вывернем свои фильтры и манипуляторы таким образом, чтобы они предлагали один и тот же интерфейс, чтобы они все одинаково предлагали метод log(), то придем к шаблону Декоратор:

# Все логгеры выполняют реальный вывод.

class FileLogger:
    def __init__(self, file):
        self.file = file

    def log(self, message):
        self.file.write(message + '\n')
        self.file.flush()

class SocketLogger:
    def __init__(self, sock):
        self.sock = sock

    def log(self, message):
        self.sock.sendall((message + '\n').encode('ascii'))

class SyslogLogger:
    def __init__(self, priority):
        self.priority = priority

    def log(self, message):
        syslog.syslog(self.priority, message)

# Фильтр вызывает тот же метод, который он предлагает.

class LogFilter:
    def __init__(self, pattern, logger):
        self.pattern = pattern
        self.logger = logger

    def log(self, message):
        if self.pattern in message:
            self.logger.log(message)

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

Как и в первых двух решениях фильтрование можно совместить с выводом после запуска программы без построения каких-либо специальных совмещенных классов:

log1 = FileLogger(sys.stdout)
log2 = LogFilter('Error', log1)

log1.log('Noisy: this logger always produces output')

log2.log('Ignored: this will be filtered out')
log2.log('Error: this is important and gets printed')

Noisy: this logger always produces output
Error: this is important and gets printed

И, поскольку классы-декораторы симметричны, они предлагают тот интерфейс, вокруг которого они обернуты, теперь мы можем штабелировать несколько разных фильтров на один и тот же лог!

log3 = LogFilter('severe', log2)

log3.log('Error: this is bad, but not that bad')
log3.log('Error: this is pretty severe')

Error: this is pretty severe

Но отмечу одно место, в котором нарушается симметрия этого проектирования: несмотря на то, что фильтры можно штабелировать, процедуры вывода нельзя совмещать или штабелировать. Сообщения логов по-прежнему можно записать только в один вывод.

Решение № 4: за пределами шаблонов "банды четырех"

Модулю logging в Python хотелось еще больше гибкости: не только обслуживать сразу несколько фильтров, но и обслуживать сразу несколько выводов в рамках одного потока сообщений лога. На основе схемы проектирования модулей для записи логов в других языках, см. информацию о главных источниках вдохновения в разделе "Влияния" в PEP 282, модуль logging в Python реализует свой собственный шаблон предпочтения композиции перед наследованием.

  • Класс Logger, с которым взаимодействуют вызывающие программы, сам по себе не реализует ни фильтрование, ни вывод. Вместо этого он держит список фильтров и список манипуляторов.
  • Для каждого сообщения лога логгер вызывает все свои фильтры. Сообщение отбрасывается в случае, если его отклоняет любой из фильтров.
  • Для каждого сообщения лога, принятого всеми фильтрами, логгер запускает итерацию по всем своим манипуляторам вывода и просит каждого из них применить к сообщению метод emit().

Или, как минимум, таков фундамент идеи. На самом деле, модуль logging из стандартной библиотеки сложнее. Например, каждый манипулятор может нести свой собственный список фильтров дополнительно к тем, которые перечислены его логгером. Еще каждый манипулятор также определяет нижний "уровень" сообщения, например INFO или WARN, который почему-то не назначается принудительно ни самим манипулятором, ни его фильтрами. Вместо этого он назначается условным оператором if statement, закопанным глубоко внутри логгера, который совершает итерацию по манипуляторам. Поэтому общее проектирование немного путаное.

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

# Теперь логгер всего один.

class Logger:
    def __init__(self, filters, handlers):
        self.filters = filters
        self.handlers = handlers

    def log(self, message):
        if all(f.match(message) for f in self.filters):
            for h in self.handlers:
                h.emit(message)

# Теперь фильтры знают только про строки (strings)!

class TextFilter:
    def __init__(self, pattern):
        self.pattern = pattern

    def match(self, text):
        return self.pattern in text

# Манипуляторы выглядят как "логгеры" из предыдущего решения.

class FileHandler:
    def __init__(self, file):
        self.file = file

    def emit(self, message):
        self.file.write(message + '\n')
        self.file.flush()

class SocketHandler:
    def __init__(self, sock):
        self.sock = sock

    def emit(self, message):
        self.sock.sendall((message + '\n').encode('ascii'))

class SyslogHandler:
    def __init__(self, priority):
        self.priority = priority

    def emit(self, message):
        syslog.syslog(self.priority, message)

Обратите внимание на то, что только с этим последним изворотом нашего проектирования фильтры по-настоящему начинают сверкать той простотой, которой они заслуживают. Впервые они принимают только строку (string) и возвращают только вердикт. Все предыдущие варианты проектирования скрывали фильтрование внутри одного из классов логов или навешивали на фильтры дополнительные обязанности помимо простого обсчета вердикта.

На самом деле, слово "лог" полностью исчезло из наименования класса для фильтрования, и тому есть очень важная причина: в нем больше нет ничего, что было бы напрямую связано с логами! Теперь TextFilter можно беспрепятственно использовать в любом контексте, который может включать в себя строки. Когда мы наконец-то отделили его от узкого контекста логов, его будет проще тестировать и обслуживать.

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

f = TextFilter('Error')
h = FileHandler(sys.stdout)
logger = Logger([f], [h])

logger.log('Ignored: this will not be logged')
logger.log('Error: this is important')

Error: this is important

В этом заключается критически важный урок: принципы проектирования, такие как предпочтение композиции перед наследованием, в конечном счете, важнее индивидуальных шаблонов, например Адаптера или Декоратора. Всегда нужно следовать принципу. Структура, к которой мы пришли, более гибкая, и ее проще обслуживать, чем любую из вышеприведенных структур, даже несмотря на то, что они основаны на официальных шаблонах "банды четырех", а этот последний вариант проектирования - нет. Да, иногда можно найти существующий шаблон проектирования, который идеально подойдет к проблеме. Но, если такого не происходит, структура может стать сильнее, если уйти от них.

Избегать: условного оператора if statement

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

Когда в проектировании появляется новое требование, отправится ли типичный программист на Python писать новый класс? Нет! "Простота лучше сложности". Зачем добавлять класс, если отлично сработает условный оператор? Класс для логгера может быть один, и ему можно постепенно добавлять условные выражения, пока он не начнет обрабатывать все случаи, рассмотренные в наших предыдущих примерах:

# Каждая новая функция в виде выражения с оператором <code>if</code>

class Logger:
    def __init__(self, pattern=None, file=None, sock=None, priority=None):
        self.pattern = pattern
        self.file = file
        self.sock = sock
        self.priority = priority

    def log(self, message):
        if self.pattern is not None:
            if self.pattern not in message:
                return
        if self.file is not None:
            self.file.write(message + '\n')
            self.file.flush()
        if self.sock is not None:
            self.sock.sendall((message + '\n').encode('ascii'))
        if self.priority is not None:
            syslog.syslog(self.priority, message)

# Работает вполне нормально.

logger = Logger(pattern='Error', file=sys.stdout)

logger.log('Warning: not that important')
logger.log('Error: this is important')

Error: this is important

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

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

(Этот класс и правда может обработать только один файл и только одну ячейку, но ведь это несущественное упрощение ради простоты чтения. Мы без проблем можем превратить параметры для файла и ячейки в списки под названием "files" и "sockets").

Если учесть, что каждый программист на Python быстро учится работать с условным оператором, но разберется в классах гораздо позже, возможно, код явно выиграет от наличия возможности опереться на самый простой из всех существующих механизмов, который доведет функцию до рабочего состояния. Но давайте уравновесим это искушение, прямо говоря о том, что будет потеряно при уходе от принципа предпочтения композиции перед наследованием:

  • Локальность (Locality). Реорганизация кода под использование условного оператора не была абсолютной победой в целях читаемости. Если вам поставили задачу улучшить определенную функцию или провести ее наладку (debugging), то вы обнаружите, что не получится прочитать весь ее код в одном месте. Код, лежащий в основе этой единичной функции разбросан по списку параметров инициализатора, по коду инициализатора и по всему методу log().
  • Удаляемость (Deletability). Есть одно недооцененное свойство хорошей структуры - это простота удаления функций. Возможно, только ветераны больших и развитых приложений на Python в достаточно сильной степени оценят важность удаления кода для здоровья проекта. В случае с нашими решениями на основе классов можно тривиально удалить функций, например запись логов в ячейку, удалив класс SocketHandler и его модульные тесты (unit tests), когда приложение перестанет в нем нуждаться. С другой стороны, удаление функции записи в ячейку из леса условных выражений не только требует внимания, чтобы не удалить соседние строки кода, но и поднимает некрасивый вопрос о том, что делать с параметром socket в инициализаторе. Можно его удалять? Нет, если нам нужно поддерживать стабильность в списке позиционных параметров. Нам придется сохранить параметр, но вызывать исключение при каждом его использовании.
  • Анализ не исполняемого кода (Dead code analysis). С предыдущим пунктом связан тот факт, что при соблюдении принципа предпочтения композиции перед наследованием анализаторы не исполняемого кода тривиально определяют момент, когда из базы кода пропадает последнее место, в котором используется класс SocketHandler. Но часто бывает так, что анализ не исполняемого кода не может принять, например, такое решение: "Теперь вы можете удалить все атрибуты и условные выражения, связанные с выводом в ячейку, потому что все выжившие до настоящего момента вызовы анализатора передают ячейке только None.
  • Тестирование (Testing). Одним из самых сильных показателей здоровья кода, основанных на тестах, является количество не релевантных строк кода, которые приходится запускать перед достижением тестируемой строки. Очень легко тестировать функцию наподобие записи лога в ячейку, если тест может просто раскрутить экземпляр класса SocketHandler, передать его живой ячейке и запросить запуск метода emit() для сообщения. Не запускается никакой код помимо того, который имеет отношение к функции. Однако тестирование записи лога в ячейку на основе леса условных выражений вынудить запустить, как минимум, в три раза больше кода. Необходимость запускать логгера с правильным сочетанием нескольких функций только для того, чтобы протестировать одну из них - важный предупредительный сигнал. Он может показаться тривиальным в этом мелком примере, но становится критически важным по мере разрастания системы.
  • Эффективность (Efficiency). Я сознательно поставил этот пункт последним, потому что читаемость и простота обслуживания обычно более важные соображения. Но проблемы проектирования с лесом условных выражений также сигнализируют о неэффективности подхода. Даже если вы хотите получить простой не отфильтрованный лог в одном файле, каждое сообщение будет вынуждено запустить условное выражение по каждой функции, которую вы потенциально могли бы подключить. Методика композиции, напротив, запускает только код тех функций, которые вы совместили.

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

Избегать: множественного наследования

Некоторые проекты на Python не дотягивают до реализации принципа предпочтения композиции перед наследованием, потому что не могут устоять перед искушением избежать этого принципа с помощью противоречивой функции языка Python: множественное наследование (multiple inheritance).

Давайте вернемся к тому примеру кода, с которого мы начали. В нем были FilteredLogger и SocketLogger, которые являлись двумя разными подклассами базового класса Logger. В каком-нибудь языке, поддерживающем только однократное наследование, для FilteredSocketLogger пришлось бы делать выбор, у кого наследовать: SocketLogger или FilteredLogger. После этого надо было бы продублировать код из другого класса.

Но Python поддерживает множественное наследование, поэтому новый FilteredSocketLogger может создать список, в котором будут оба SocketLogger и FilteredLogger в качестве базовых классов. Он будет наследовать у них обоих:

# Базовый класс и подклассы из нашего первого примера.

class Logger(object):
    def __init__(self, file):
        self.file = file

    def log(self, message):
        self.file.write(message + '\n')
        self.file.flush()

class SocketLogger(Logger):
    def __init__(self, sock):
        self.sock = sock

    def log(self, message):
        self.sock.sendall((message + '\n').encode('ascii'))

class FilteredLogger(Logger):
    def __init__(self, pattern, file):
        self.pattern = pattern
        super().__init__(file)

    def log(self, message):
        if self.pattern in message:
            super().log(message)

# Класс, полученный с помощью множественного наследования.

class FilteredSocketLogger(FilteredLogger, SocketLogger):
    def __init__(self, pattern, sock):
        FilteredLogger.__init__(self, pattern, None)
        SocketLogger.__init__(self, sock)

# Работает вполне нормально.

logger = FilteredSocketLogger('Error', sock1)
logger.log('Warning: not that important')
logger.log('Error: this is important')

print('The socket received: %r' % sock2.recv(512))

The socket received: b'Error: this is important\n'

Здесь появилось несколько поразительных моментов сходства с нашим решением на основе шаблона Декоратор (Decorator Pattern). В обоих случаях:

  • Есть класс для логгера, работающего с каждым видом вывода (вместо нашей асимметрии Адаптера между прямой записью в файлы и записью в не-файлы через адаптер).
  • Сообщение в точности сохраняет значение, переданное вызывающей программой (вместо присущей нашему Адаптеру привычки заменять его подогнанным под файл значением путем добавления новой строки с конца).
  • Фильтр и логгеры симметричны в той части, в которой они оба реализуют один и тот же метод log(). (В других наших решениях, кроме Декоратора, были классы для фильтрования, которые предлагали один метод, и классы для вывода которые предлагали другой).
  • Фильтр никогда не пытается осуществлять вывод самостоятельно, но, если сообщение выживает после фильтрования, передает задачу осуществить вывод другому коду.

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

Если у нас есть детальные модульные тесты для логгера и фильтра, насколько мы уверены в том, что они будут работать вместе?

  • Успешность примера с Декоратором зависит только от поведения каждого класса на публике: LogFilter предлагает метод log(), который, в свою очередь, вызывает log() с обернутым им объектом (что можно тривиально проверить в тесте с помощью маленького фейкового логгера), а каждый логгер предлагает работающий метод log(). Пока наши модульные тесты подтверждают эти два поведения на публике, мы не можем нарушить композицию без неудачного результата наших модульных тестов.
  • Множественное наследование, напротив, зависит от поведения, которое нельзя подтвердить простым созданием экземпляра для соответствующих классов. Публичное поведение FilteredLogger заключается в том, что он предлагает метод log(), который и фильтрует, и осуществляет запись в файл. Однако множественное наследование зависит не только от этого поведения на публике, но и от того, каким образом такое поведение реализовано внутри. Множественное наследование будет работать в случае, если метод отсылает к своему базовому классу с помощью super(), но не будет в случае, если метод осуществляет свою собственную операцию write() в файл, даже если любая из этих реализаций проходит модульные тесты.
  • Поэтому тестовый комплекс должен выйти за пределы модульного тестирования и осуществить реальное множественное наследование из класса либо применить monkey patch, чтобы проверить, что log() вызывает super().log(). Так он гарантирует, что множественное наследование продолжает работать в то время, когда разработчики будут в будущем работать с кодом.
  • Множественное наследование внедрило новый метод __init__(), потому что ни в одном из базовых классов метод __init__() не принимает достаточное для совмещения фильтра и логгера количество аргументов. Этот новый код нужно тестировать, поэтому для каждого нового подкласса потребуется, как минимум, один тест.
  • Возможно, вас охватит искушение состряпать структуру, которая позволит избежать создания нового метода __init__() для каждого подкласса, например принимать *args и далее передавать их в super().__init__(). (Если вы и правда пойдете этим путем, ознакомьтесь с классическим эссе "Python’s Super Considered Harmful" (Super в Python считается вредным), в котором заявляется о том, что безопасным, на самом деле, является только **kw). Проблема с тако схемой в том, что она вредит читаемости, ведь вы больше не можете разобраться в том, какие аргументы принимает метод __init__(), просто прочитав его список параметров. А инструменты проверки типизации больше не смогут гарантировать корректность.
  • Но независимо от того, даете ли вы каждому производному классу свой собственный __init__() или проектируете их в виде единой цепочки, ваши модульные тесты для первоначального FilteredLogger или SocketLogger не смогут самостоятельно гарантировать, что классы будут инициализировать корректно при совмещении.
  • Шаблон проектирования Декоратор, напротив, поддерживает в своих инициализаторах счастье и прямоугольность. Фильтр принимает его шаблон, логгер принимает его ячейку. Между этими двумя невоможны конфликты.
  • Наконец, есть вероятность того, что два класса нормально работают каждый сам по себе, но будут иметь атрибуты класса или экземпляра с одинаковым названием, что создаст противоречие при совмещении классов с помощью множественного фильтрования.
  • Да, наши маленькие примеры, приведенные выше, создают впечатление, будто шанс столкновения слишком мал, чтобы беспокоиться по поводу него. Но нельзя забывать о том, что эти примеры просто занимают место гораздо более сложных классов, которые вы могли бы написать в реальных приложениях.
  • Даже если программист напишет тесты, чтобы защититься от столкновения, путем запуска dir() с экземплярами каждого класса и проверки их общих атрибутов, или напишет интегрированный тест для любого потенциально возможного подкласса, окажется, что первоначальные модульные тесты двух отдельных классов вновь не смогли гарантировать, что они могут чисто совмещаться через множественное наследование.

Благодаря любой из этих причин модульные тесты двух базовых классов, возможно, будут и дальше показывать зеленый цвет, потому что их способность к совмещению через множественное наследование больше не работает. Это означает, что "взрывная волна подклассов для поддержки всех комбинаций" "банда четырех" тоже заразит ваши тесты. Только путем тестирования всех комбинаций базовых классов m×n в своем приложении вы сможете обеспечить приложению безопасное использование таких классов после запуска программы.

Помимо поломки гарантий модульного тестирования множественное наследование влечет за собой, как минимум, еще три нагрузки.

  • В примере с Декоратором легко осуществить самопроверку. Можно просто выполнить print(my_filter.logger) или отобразить данный атрибут в отладчике, чтобы увидеть подключенный логгер вывода. Однако при использовании множественного наследования вам сможете узнать, какие фильтры и логгеры были совмещены, только изучив метаданные самого класса путем прочтения его __mro__ или тестирования объекта с помощью серии тестов isinstance().
  • В примере с Декоратором тривиально можно взять живую комбинацию фильтра и логгера и после запуска программы заменить логгер другим путем присвоения на атрибут .logger, например, если пользователь только что поменял настройку в интерфейсе приложения. Но чтобы сделать то же самое в примере с множественным наследованием, потребуется гораздо более неоднозначный маневр с перезаписью класса объекта. Нельзя сказать, что изменить класс объекта при запущенной программе невозможно в динамическом языке, каким является Python, но обычно это считается симптомом того, что проектирование ПО пошло не тем путем.
  • Наконец, множественное наследование не предлагает встроенного механизма, помогающего программисту выстроить базовые классы в правильном порядке. Класс FilteredSocketLogger не сможет успешно произвести запись в ячейку, если изменить его базовые классы, к тому же, как подтверждают десятки вопросов на Stack Overflow, программисты на Python вечно испытывают проблемы с правильным порядком сторонних базовых классов. С шаблоном Декоратор, напротив, способ сочетания классов очевиден: метод __init__() фильтра хочет получить объект logger, но метод __init__() логгера не просит дать ему фильтр.

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

Избегать: классов-примесей (Mixins)

В предыдущем разделе классу FilteredSocketLogger нужен был свой индивидуальный метод __init__(), потому что ему нужно было принимать аргументы для обоих своих базовых классов. Но, оказывается, данной нагрузки можно избежать. Разумеется, в тех случаях, когда подкласс не требует никаких дополнительных данных, данная проблема не возникнет. Но даже те классы, которым действительно нужны дополнительные данные, могут получить их другим способом.

Мы можем лучше подстроить FilteredLogger к множественному наследованию, если мы зададим значение по умолчанию для шаблона в самом классе, а затем предложим вызывающим программам модифицировать атрибут под себя напрямую, за пределами маршрута инициализации:

# Не принимать "шаблон" во время инициализации.

class FilteredLogger(Logger):
    pattern = ''

    def log(self, message):
        if self.pattern in message:
            super().log(message)

# Множественное наследование стало проще.

class FilteredSocketLogger(FilteredLogger, SocketLogger):
    pass  # В этом подклассе больше никакого кода не нужно!

# Вызывающая программа может просто назначать "шаблон" напрямую.

logger = FilteredSocketLogger(sock1)
logger.pattern = 'Error'

# Работает вполне нормально.

logger.log('Warning: not that important')
logger.log('Error: this is important')

print('The socket received: %r' % sock2.recv(512))

The socket received: b'Error: this is important\n'

Вывернув класс FilteredLogger с помощью маневра с инициализацией, направленного перпендикулярно маршруту его базового класса, почему бы не распространить идею перпендикулярности на его логическое заключение? Мы можем преобразовать FilteredLogger в класс-примесь (mixin), существующий только за пределами иерархии классов, с которой будет совмещаться множественное наследование.

# Упрощаем фильтр, превращая его в примесь.

class FilterMixin:  # No base class!
    pattern = ''

    def log(self, message):
        if self.pattern in message:
            super().log(message)

# Множественное наследование выглядит так же, как раньше.

class FilteredLogger(FilterMixin, FileLogger):
    pass  # Вновь, в подклассе больше не нужно никакого кода.

# Работает вполне нормально.

logger = FilteredLogger(sys.stdout)
logger.pattern = 'Error'
logger.log('Warning: not that important')
logger.log('Error: this is important')

Error: this is important

По своей концепции класс-примесь проще, чем отфильтрованный подкласс, который мы видели в предыдущем разделе: у него нет базового класса, который может усложнить порядок разрешения методов, поэтому super() всегда будет вызывать следующий базовый класс по перечню в операторе class statement.

Также для класса-примеси характерно более простое тестирование по сравнению с аналогичным подклассом. Классу FilteredLogger потребуются тесты, которые смогут как запустить его индивидуально, так и совместить его с другими классами, а классу FilterMixin потребуются только тесты, совмещающие его с логгером. Поскольку класс-примесь сам по себе неполный, не получится даже написать тест, который запустит его отдельно.

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

Избегать: динамического построения классов

В двух предыдущих разделах мы уже видели, что традиционное множественное наследование и классы-примеси не решают сформулированную "бандой четырех" проблемы "взрывной волны подклассов для обслуживания всех комбинаций". Они просто избегают дублирования кода в случае, когда необходимо совместить два класса.

По общему правилу множественному наследованию по-прежнему необходимо "активное размножение классов" с количеством операторов class statement по формуле m×n, каждый из которых похож вот на это:

class FilteredSocketLogger(FilteredLogger, SocketLogger):
    ...

Но Python, как оказывается, предлагает обходной путь.

Представим, что наше приложение читает файл конфигурации, чтобы найти фильтр лога и пункт назначения для лога, который оно должно использовать. То есть, содержание файла не известно до запуска программы. Вместо того, чтобы заранее строить все возможные классы в количестве по формуле m×n, а затем выбирать нужный, мы можем подождать и воспользоваться тем фактом, что Python не только поддерживает оператор class statement, но и встроенную функцию type(), которая создает новые классы динамически после запуска программы:

# Представим, что в наличии два отфильтрованных логгера и три логгера вывода.
 
filters = {
    'pattern': PatternFilteredLog,
    'severity': SeverityFilteredLog,
}
outputs = {
    'file': FileLog,
    'socket': SocketLog,
    'syslog': SyslogLog,
}

# Выбираем два класса, которые мы хотим совместить.

with open('config') as f:
    filter_name, output_name = f.read().split()

filter_cls = filters[filter_name]
output_cls = outputs[output_name]

# Строим новый производный класс (!)

name = filter_name.replace('Log', '') + output_name
cls = type(name, (filter_cls, output_cls), {})

# Вызываем его как обычно, чтобы создать экземпляр.

logger = cls(...)

Кортеж (tuple) из классов, переданный type() имеет такое же значение, что и серия базовых классов в операторе class statement. Такой вызов type() создает новый класс из фильтрованного логгера и логгера вывода одновременно с помощью множественного наследования.

Предвижу ваш вопрос: да, также получится сделать оператора class statement простым текстом и затем передать его функции eval().

Однако, построение классов на лету создает очень большие нагрузки.

  • Страдает читаемость. Каждому, кто будет читать вышеприведенный фрагмент кода (code snippet), придется проделать дополнительную работу, чтобы какого рода объектом является экземпляр cls. Кроме того, многие программисты на Python не знакомы с type(), и им придется прекратить работу и поразгадывать документацию этой функции. Если у них возникнут проблемы с новой концепцией о том, что классы можно определять динамически, они все равно будут растеряны.
  • Если заранее созданный класс, например PatternFilteredFileLog будет упомянут в сообщении об исключении или ошибке, разработчик, возможно, не обрадуется, ничего не увидев в результате поиска этого имени класса по базе кода. Процесс отладки осложняется, когда вы даже не можете найти класс. Возможно, значительное время будет потрачено на поиск вызовов функции type() в базе кода и на попытки определить, какой из них генерирует класс. Иногда разработчикам приходится прибегать к вызову каждого метода с плохими аргументами и следить за номерами строк в полученных сообщениях обратной трассировки (traceback), чтобы проследить путь до базовых классов.
  • По общему правилу, самопроверка типа не подходит для классов, сконструированных динамически после запуска программы. Если вы используете ярлыки, чтобы "перепрыгнуть к классу", в своем редакторе, то в данном случае им некуда вас отвести после того, как вы подсветите экземпляр класса PatternFilteredFileLog в отладчике. Кроме того, движки проверки типизации, например mypy и pyre-check, вряд ли предложат сильную защиту сгенерированным вами классам, которую они могут предоставить нормальным классам в Python.
  • В Jupyter Notebook есть прекрасная функция %autoreload, обладающая почти сверхъестественной способностью находить и перезагружать модифицированный исходный код в живом интерпретаторе Python. Но ее может одурачить, например, классы с множественным наследованием, которые matplotlib создает после запуска программы через вызовы функции type() в subplot_class_factory().

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