Композиция и наследование в Python: как выбрать связь

Наследование выражает отношение «является»: подкласс обещает вести себя как более общий базовый тип. Композиция выражает отношение «содержит» или «использует»: один объект хранит…

Наследование выражает отношение «является»: подкласс обещает вести себя как более общий базовый тип. Композиция выражает отношение «содержит» или «использует»: один объект хранит другой и делегирует ему часть работы. Выбор влияет не на количество строк, а на силу связи и возможность независимо заменять компоненты. Это инженерный критерий проектирования, а не автоматическое правило синтаксиса Python.

Проверка отношений is-a и has-a

Если CsvFormatter можно использовать везде, где ожидается Formatter, наследование может описывать устойчивый контракт. Но Report не является Printer: отчёт использует средство вывода, поэтому поле printer точнее родительского класса. Желание просто переиспользовать пару методов ещё не доказывает отношение «является».

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

Рабочий пример: отправка через композицию

class ConsoleSender:
    def send(self, message):
        print(f'SEND: {message}')


class NotificationService:
    def __init__(self, sender):
        self.sender = sender

    def notify(self, user):
        message = f'Здравствуйте, {user}!'
        self.sender.send(message)


service = NotificationService(ConsoleSender())
service.notify('Аня')

Ожидаемый результат:

SEND: Здравствуйте, Аня!

NotificationService отвечает за текст уведомления, а объект sender — за доставку. Сервису достаточно небольшого поведенческого контракта send(message); для теста можно передать объект, который складывает сообщения в список, не меняя сам сервис.

Нарушенный контракт зависимости

Характерная ошибка композиции проявляется как AttributeError: ... has no attribute 'send', если переданная зависимость не соблюдает ожидаемый контракт. Проверьте объект в точке создания сервиса, имя метода и его сигнатуру. Раннюю понятную проверку можно добавить в конфигурационный слой, однако подмена типов не должна скрывать неверную архитектуру.

При наследовании частый дефект — переопределить __init__ и забыть super().__init__(), из-за чего базовые атрибуты отсутствуют лишь при позднем вызове метода. Проследите цепочку конструкторов и выясните, чья ответственность создавать каждый атрибут. Если подкласс постоянно отключает родительское поведение, подменяет смысл методов или нуждается в большинстве возможностей только «для удобства», рассмотрите отдельный компонент вместо более глубокой иерархии.

Тренировка: заменяемый отправитель

Реализуйте MemorySender с messages = [] на экземпляре и методом send, добавляющим текст. Передайте его в NotificationService, вызовите notify дважды и проверьте список без перехвата консоли. Затем создайте базовый Formatter с методом format(data) и подкласс UpperFormatter, который возвращает строку в верхнем регистре.

Самостоятельно сформулируйте, почему sender выбран как поле, а UpperFormatter — как специализация. Подставьте объект без send, прочитайте traceback, затем исправьте зависимость. Проверьте, что замена ConsoleSender на MemorySender не требует изменений в логике уведомления.

Связь важнее повторного использования

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

Границы композиции и наследования

Композиция всегда лучше наследования?

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

Нужен ли общий базовый класс всем взаимозаменяемым компонентам?

Не обязательно: Python поддерживает структурное соответствие поведению. Однако явный базовый класс или Protocol может лучше документировать контракт и помогать статической проверке.

Можно ли сочетать оба подхода?

Да. Объект может наследовать общий интерфейс и одновременно содержать компоненты для хранения, доставки или форматирования. Важно, чтобы каждая связь имела отдельный понятный смысл.

Источники