Ассоциация, агрегация, композиция

Три типа отношений между классами/объектами, которые в большинстве случаев должны использоваться вместо наследования. Различаются по тому, кто создаёт связанный объект и кто управляет его жизненным циклом.

«Логер от стрима не наследует. Мы избавляемся от цепочек наследования. Длинные цепочки запутывают код и добавляют ненужной жёсткости», tOIcBrzezK0.

Три отношения на примере Logger + Stream

1. Ассоциация — голая ссылка

class Logger {
  constructor { this.stream = null; }
  log(msg) { if (this.stream) this.stream.write(msg); }
}
const l = new Logger();
l.stream = process.stdout; // можно подменять на лету
  • Logger ничего не знает о Stream при создании
  • Поле инициализируется null (совпадение по типу — указатель)
  • Один stream можно подсунуть в несколько Logger
  • В методах нужны if (this.stream) проверки

2. Агрегация — готовый объект в конструктор

class Logger {
  constructor(stream) { this.stream = stream; }
  log(msg) { this.stream.write(msg); }
}
const l = new Logger(process.stdout); // dependency injection
  • Logger принимает уже созданный Stream
  • Зависимость внедряется снаружи (DI)
  • Logger не отвечает за создание/уничтожение Stream
  • Любой writable (с методом write) подойдёт — duck typing

3. Композиция — создание внутри

class Logger {
  constructor(file) {
    this.stream = fs.createWriteStream(file);
  }
  log(msg) { this.stream.write(msg); }
}
  • Stream создаётся внутри конструктора Logger
  • Logger управляет жизненным циклом: создание и (в языках с деструктором) уничтожение
  • Связанность сильнее — Logger знает про fs
  • if исчезает — поле гарантированно есть
  • Stream можно сделать приватным → никто извне не дотянется

Когда что выбирать

Ассоциация Агрегация Композиция
Кто создаёт внешний код внешний код сам класс
Кто уничтожает внешний код внешний код сам класс
Подмена на лету да при пересоздании нет
Тестируемость хорошая отличная (моки) плохая (надо подменять внутренности)
Связанность низкая средняя высокая
if на null нужен не нужен не нужен

Правило большого пальца: агрегация по умолчанию (классическая DI), композиция когда зависимость концептуально неотделима (например, рендерер у Canvas).

Альтернатива наследованию

// Плохо: Logger extends Stream — наследуем чужой жирный интерфейс
class Logger extends Stream { /* ... */ }

// Хорошо: ассоциация/агрегация — отношение has-a вместо is-a
class Logger {
  constructor(stream) { this.stream = stream; }
  log(m) { this.stream.write(m); }
}

Через замыкания (FP-аналог)

Контекст хранится не в this, а в замыкании:

// Агрегация на замыкании
const loggerFactory = stream => ({
  log(msg) { stream.write(msg); }
});

// Композиция на замыкании
const loggerFactory = file => {
  const stream = fs.createWriteStream(file);
  return { log(msg) { stream.write(msg); } };
};

Преимущество: stream приватен по построению — недоступен извне без this.

Перенос между типами отношений

Часто проектирование идёт ассоциация → агрегация → композиция по мере роста уверенности в дизайне. Композиция — самое жёсткое решение, и обратно её сложнее всего разобрать.

Источники

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