Композиция и наследование в 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 может лучше документировать контракт и помогать статической проверке.
Можно ли сочетать оба подхода?
Да. Объект может наследовать общий интерфейс и одновременно содержать компоненты для хранения, доставки или форматирования. Важно, чтобы каждая связь имела отдельный понятный смысл.