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

Композиция создаёт поведение, объединяя независимые компоненты и делегируя им работу. Наследование создаёт новый тип как специализацию базового и неявно переносит его состояние и контракт. В Go нет классического наследования классов: есть embedding, интерфейсы и обычные поля; это подталкивает к явной композиции.

Зачем это на интервью

На интервью принцип раскрывает понимание расширяемости. Нужно объяснить, почему «базовый сервис» с десятками protected-методов и наследниками становится хрупким, а зависимости, переданные через конструктор, проще заменить, наблюдать и тестировать. В Go отдельно проверяют, что embedding — не наследование и не причина бездумно продвигать методы наружу.

Минимум для E4

  • Различать has-a композицию, embedding и is-a подтипизацию.
  • Собирать use case из маленьких зависимостей с явным конструктором.
  • Объявлять узкий интерфейс на стороне потребителя и принимать конкретный тип, когда вариативность не нужна.
  • Не использовать embedding только ради сокращения вызова метода.

Например, отправка уведомления зависит от доставки, а не наследует её. Use case владеет оркестрацией, а адаптер — конкретным протоколом:

type Message struct {
    To   string
    Body string
}
 
type Sender interface {
    Send(context.Context, Message) error
}
 
type Notifier struct {
    sender Sender
}
 
func (n Notifier) Notify(ctx context.Context, to, body string) error {
    return n.sender.Send(ctx, Message{To: to, Body: body})
}

Notifier не обязан знать SMTP, HTTP-провайдера или очередь. Его можно проверить fake-реализацией Sender, а production wiring выбирает адаптер. Но если приложение всегда пишет файл и тест этого не усложняет, добавлять интерфейс «на будущее» не требуется.

Embedding в Go продвигает методы встроенного поля в method set внешнего типа. Это удобно для небольших value-типов или намеренного переиспользования реализации, но создаёт неявную часть публичного API. Встраивание sync.Mutex или внешнего клиента может раскрыть методы, нарушить инкапсуляцию и затруднить последующую замену; часто лучше именованное поле.

Углубление для E5/Senior

Наследование проблемно, когда базовый класс фиксирует представление, жизненный цикл и поведение, а наследник меняет только часть. Возникает fragile base class: безобидное изменение родителя ломает переопределение, порядок вызовов или инвариант потомка. Liskov substitution principle требует, чтобы подтип сохранял ожидаемые свойства базового контракта; в реальных иерархиях это часто нарушают усилением предусловий, ослаблением постусловий, неожиданными исключениями или no-op-методами.

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

Для cross-cutting concerns используйте декораторы: обёртка Sender может добавить метрики, tracing или retry, не меняя основной алгоритм. Порядок декораторов — часть поведения: retry вокруг metrics считает попытки иначе, чем metrics вокруг retry. Зафиксируйте это в тесте и документации, особенно для timeout, идемпотентности и логирования чувствительных данных.

Ключевые понятия

  • Композиция — объект содержит компоненты и делегирует им часть работы.
  • Делегирование — передача операции зависимому объекту вместо переопределения базового метода.
  • Embedding — синтаксис Go для встраивания поля с продвижением его методов; не классическое наследование.
  • Интерфейс потребителя — минимальный набор операций, нужный конкретному use case.
  • Декоратор — реализация того же контракта, оборачивающая другую для добавления поведения.
  • Fragile base class — ситуация, когда изменение базового класса ломает потомков через скрытые допущения.

Типовые вопросы

  1. Почему композиция обычно предпочтительнее наследования?
    • Она делает зависимости и вариативность явными, не наследует случайное состояние и локализует изменение; это не запрет на настоящую подтипизацию.
  2. Есть ли наследование в Go?
    • Нет наследования классов. Интерфейсы дают структурную подтипизацию, а embedding переиспользует и продвигает поля/методы, но не создаёт базовый класс.
  3. Когда embedding уместен?
    • Когда внешний тип действительно должен предоставлять поведение встроенного типа и этот API стабилен; иначе именованное поле яснее.
  4. Нужно ли объявлять интерфейс для каждой зависимости?
    • Нет. Начинайте с конкретного типа; добавляйте маленький интерфейс у потребителя при доказанной необходимости замены или изоляции.
  5. Как добавить retry без изменения отправителя?
    • Реализовать Sender, который содержит другой Sender и повторяет только безопасные, ограниченные по времени и идемпотентные операции.
  6. Чем fake лучше mock в этом примере?
    • Простой fake хранит отправленные сообщения и проверяет результат сценария; он меньше привязан к внутренней последовательности вызовов, чем строгий mock.

Практика

  • Реализуйте Sender для теста и production-адаптер. Критерий: Notifier тестируется без сети, а одна таблица тестов покрывает оба ожидаемых исхода use case.
  • Добавьте декоратор metrics и retry. Критерий: retry ограничен числом попыток и deadline, не повторяет неидемпотентную операцию без ключа идемпотентности, а метрика явно считает попытки или логические сообщения.
  • Замените embedding внешнего клиента на именованное поле. Критерий: публичный API типа больше не раскрывает методы клиента, а вызовы проходят через осмысленный метод-обёртку.

Частые ошибки и ловушки

  • Путать embedding с наследованием и рассчитывать на полиморфное переопределение методов.
  • Встраивать sync.Mutex или внешний клиент и случайно делать их методы частью публичного API.
  • Создавать фабрики, стратегии и интерфейсы без подтверждённой вариативности.
  • Добавлять retry ко всем ошибкам и повторять операции, которые создают дубликаты.
  • Подменять бизнесовый тест проверкой точного порядка внутренних вызовов, когда важнее результат и инвариант.

Связанные темы

Принципы проектирования · Связность и зацепление · Go · Проектирование Go-приложений · Тестирование · Delivery

Источники

  • Go documentation: Effective Go, разделы Embedding и Interfaces.
  • Go specification: Struct types, Method sets и Interface types.
  • Erich Gamma et al., Design Patterns, паттерны Strategy и Decorator.
  • Robert C. Martin, Agile Software Development, раздел о Liskov Substitution Principle.