Ассоциация, агрегация, композиция
Три типа отношений между классами/объектами, которые в большинстве случаев должны использоваться вместо наследования. Различаются по тому, кто создаёт связанный объект и кто управляет его жизненным циклом.
«Логер от стрима не наследует. Мы избавляемся от цепочек наследования. Длинные цепочки запутывают код и добавляют ненужной жёсткости», 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.
Перенос между типами отношений
Часто проектирование идёт ассоциация → агрегация → композиция по мере роста уверенности в дизайне. Композиция — самое жёсткое решение, и обратно её сложнее всего разобрать.
Источники
- Timur · Object Association, Aggregation, and Composition in JavaScript (2019-10-31, 40 мин)
- Timur · Наследование vs композиция в комплексных системах (2020-03-03)