Наследование vs композиция

Наследование строит иерархию «является» (IS-A), а композиция — «содержит» (HAS-A); в JavaScript рекомендуется предпочитать композицию для гибкой и слабосвязанной архитектуры.

Зачем нужно

Выбор между наследованием и композицией определяет гибкость и поддерживаемость кода. Глубокие иерархии наследования (3+ уровней) становятся хрупкими: изменение базового класса ломает все потомки. Композиция позволяет собирать объекты из независимых частей и менять поведение без риска поломки цепочки.

Где используется

  • Проектирование бизнес-логики: сервисы, репозитории
  • React: HOC vs hooks (hooks — пример победы композиции над наследованием)
  • Entity-Component-System паттерн
  • Фабрики и стратегии

Наследование (IS-A)

class Animal {
  constructor(name) { this.name = name; }
  eat { return `${this.name} ест`; }
}

class Dog extends Animal {
  bark { return 'Гав!'; }
}

class GuideDog extends Dog {
  guide { return `${this.name} ведёт хозяина`; }
}

const buddy = new GuideDog('Бадди');
console.log(buddy.eat);   // Бадди ест
console.log(buddy.bark);  // Гав!
console.log(buddy.guide); // Бадди ведёт хозяина

Проблема: если нужна RobotDog без метода eat, иерархия ломается.

Проблема хрупкого базового класса

class Base {
  method {
    this.helper; // child может переопределить helper
  }
  helper { return 'base'; }
}

class Child extends Base {
  helper { return 'child'; } // меняет поведение Base.method!
}

// Изменение Base.method ломает все дочерние классы

Композиция (HAS-A)

// Независимые поведения
const canEat = {
  eat { return `${this.name} ест`; }
};

const canBark = {
  bark { return 'Гав!'; }
};

const canGuide = {
  guide { return `${this.name} ведёт хозяина`; }
};

// Фабрика с композицией
function createGuideDog(name) {
  return Object.assign(
    { name },
    canEat,
    canBark,
    canGuide
  );
}

function createRobotDog(name) {
  return Object.assign(
    { name },
    canBark,   // нет canEat — роботу не нужно есть
    canGuide
  );
}

const buddy = createGuideDog('Бадди');
const robo  = createRobotDog('Р2Д2');

console.log(buddy.eat);  // Бадди ест
console.log(robo.bark);  // Гав!
// robo.eat — нет такого метода, и это правильно

Функциональная композиция

// Поведения как функции-замыкания
const withLogging = (obj) => ({
  ...obj,
  log(msg) { console.log(`[${obj.name}] ${msg}`); }
});

const withValidation = (obj) => ({
  ...obj,
  validate(data) { return data !== null; }
});

const createService = (name) =>
  withLogging(withValidation({ name }));

const svc = createService('UserService');
svc.log('Запуск');          // [UserService] Запуск
console.log(svc.validate(42)); // true

Когда использовать наследование

Наследование уместно, когда:

  • Отношение действительно IS-A (квадрат IS-A форма)
  • Иерархия неглубокая (1-2 уровня)
  • Есть общая реализация, которую нужно переопределять (template method)
// Уместное наследование: компоненты UI
class Component {
  render { throw new Error('Implement render'); }
  mount(container) { container.innerHTML = this.render; }
}

class Button extends Component {
  constructor(label) { super; this.label = label; }
  render { return `<button>${this.label}</button>`; }
}

Частые ошибки

1. Глубокие иерархии (антипаттерн)

// Плохо: Animal → Mammal → Pet → Dog → GuideDog → ...
// Каждый уровень добавляет хрупкость
// Лучше: плоская структура + миксины/композиция

2. Наследование ради переиспользования кода

// Плохо: наследуем только чтобы получить метод
class UserService extends DatabaseService { // не IS-A
  // использует только db.query
}

// Лучше: инжектируем зависимость
class UserService {
  constructor(db) { this.db = db; }
  getUser(id) { return this.db.query(id); }
}

Три типа композиции

автор (tOIcBrzezK0) выделяет три разных типа отношений, которые часто называют «композиция»:

  • Ассоциация — голая ссылка, инициализируется null, может подменяться на лету
  • Агрегация — готовый объект внедряется через конструктор (DI)
  • Композиция — связанный объект создаётся внутри конструктора владельца

См. подробно Ассоциация, агрегация, композиция.

Тезисы автора о вреде наследования

  • «Длинные цепочки наследования запутывают код и добавляют ненужной жёсткости» (tOIcBrzezK0).
  • «Наследование замедляет исполнение, но не существенно. Замедляет чтение и тем более модификацию кода» (9d5TG1VsLeU).
  • 5-7 уровней наследования — это уже антипаттерн (см. Антипаттерны ООП).

Когда наследование оправдано

  • Полная семантика IS-A: Square действительно прямоугольник
  • Базовый класс предоставляет инфраструктуру (EventEmitter, HTMLElement)
  • Цепочка короткая (1-2 уровня)
  • Поведение только расширяется, не изменяется (LSP)

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

Источники