Композиция вместо наследования
Композиция создаёт поведение, объединяя независимые компоненты и делегируя им работу. Наследование создаёт новый тип как специализацию базового и неявно переносит его состояние и контракт. В 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 — ситуация, когда изменение базового класса ломает потомков через скрытые допущения.
Типовые вопросы
- Почему композиция обычно предпочтительнее наследования?
- Она делает зависимости и вариативность явными, не наследует случайное состояние и локализует изменение; это не запрет на настоящую подтипизацию.
- Есть ли наследование в Go?
- Нет наследования классов. Интерфейсы дают структурную подтипизацию, а embedding переиспользует и продвигает поля/методы, но не создаёт базовый класс.
- Когда embedding уместен?
- Когда внешний тип действительно должен предоставлять поведение встроенного типа и этот API стабилен; иначе именованное поле яснее.
- Нужно ли объявлять интерфейс для каждой зависимости?
- Нет. Начинайте с конкретного типа; добавляйте маленький интерфейс у потребителя при доказанной необходимости замены или изоляции.
- Как добавить retry без изменения отправителя?
- Реализовать
Sender, который содержит другойSenderи повторяет только безопасные, ограниченные по времени и идемпотентные операции.
- Реализовать
- Чем 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.