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

↑ 1.4 ООП в C♯ · ← Предыдущая

Обязательно~6 мин чтенияУровень: junior

Зачем это на собесе

«Почему композиция лучше наследования?» — вопрос на понимание дизайна. На senior-уровне ждут умения показать, как переделать хрупкую иерархию в набор взаимозаменяемых компонентов.

Объяснение

  • Наследование — отношение «является» (is-a). Сильная связь: наследник зависит от деталей реализации базового класса, иерархия фиксируется при компиляции.
  • Композиция — отношение «имеет» или «использует» (has-a). Объект собирается из других объектов через интерфейсы, поведение можно менять и комбинировать, в том числе в рантайме.

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

// Нужны отчёты: PDF или Excel × с подписью или без × с водяным знаком или без
class Report {}
class PdfReport : Report {}
class SignedPdfReport : PdfReport {}
class WatermarkedSignedPdfReport : SignedPdfReport {}
class SignedExcelReport : ExcelReport {}   // … 8 классов, и каждый новый признак удваивает число

Решение композицией

public interface IReportExporter { byte[] Export(Document doc); }        // PDF, Excel
public interface IDocumentDecorator { void Apply(Document doc); }       // подпись, водяной знак, QR
 
public sealed class ReportGenerator(
    IDataCollector collector,
    IReportExporter exporter,
    IEnumerable<IDocumentDecorator> decorators)
{
    public async Task<byte[]> GenerateAsync(ReportRequest req, CancellationToken ct)
    {
        var data = await collector.CollectAsync(req, ct);
        var doc = Layout(data);
        foreach (var d in decorators) d.Apply(doc);    // любое сочетание без новых классов
        return exporter.Export(doc);
    }
}

Новый формат экспорта — новый IReportExporter. Новое оформление — новый IDocumentDecorator. Существующий код не меняется.

Паттерны, построенные на композиции

  • Strategy — подменяемый алгоритм через интерфейс (экспорт в разные форматы).
  • Decorator — обёртка, добавляющая поведение (кэширующий репозиторий поверх обычного, логирующий HttpMessageHandler).
  • Adapter — приведение чужого API к своему интерфейсу (клиент 1С → IAccountingGateway).
// Decorator: кэш поверх сервиса терминологии, без изменения исходного класса
public sealed class CachedTerminologyService(ITerminologyService inner, IMemoryCache cache) : ITerminologyService
{
    public Task<Concept?> LookupAsync(string system, string code, CancellationToken ct) =>
        cache.GetOrCreateAsync($"{system}|{code}", _ => inner.LookupAsync(system, code, ct));
    public Task<IReadOnlyList<Concept>> ExpandAsync(string url, CancellationToken ct) => inner.ExpandAsync(url, ct);
}
// регистрация с Scrutor: services.Decorate<ITerminologyService, CachedTerminologyService>();

Когда наследование уместно

  • Настоящая иерархия «является» со стабильным базовым типом (исключения, базовые классы фреймворка: ControllerBase, BackgroundService, DbContext).
  • Template Method с общим алгоритмом и небольшими вариациями.
  • Неглубоко: 1–2 уровня.

Нюансы и подводные камни

  • Композиция добавляет интерфейсов и классов. Для простого случая без вариативности это оверинжиниринг.
  • Слишком много мелких абстракций усложняет навигацию по коду: «куда делась логика?».
  • Декораторы в DI удобно регистрировать через Scrutor или вручную фабрикой. Порядок декораторов важен (кэш снаружи логирования или наоборот).
  • Во фронтенде тот же принцип: composables вместо mixins, слоты вместо наследования компонентов.

Тестирование

Композиция упрощает тесты: каждую часть тестируют отдельно, остальные подменяют.

[Fact]
public async Task Cache_calls_inner_once()
{
    var inner = Substitute.For<ITerminologyService>();
    inner.LookupAsync("s", "c", Arg.Any<CancellationToken>()).Returns(new Concept("c"));
    var sut = new CachedTerminologyService(inner, new MemoryCache(new MemoryCacheOptions()));
    await sut.LookupAsync("s", "c", default);
    await sut.LookupAsync("s", "c", default);
    await inner.Received(1).LookupAsync("s", "c", Arg.Any<CancellationToken>());
}

Вопросы с ответами

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