Программа курса← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF

Что, если не наследование

В прошлом уроке вывод был такой: наследование – самая сильная связь между классами, и брать её стоит только ради честного «является». Но связывать классы как-то надо и во всех остальных случаях. Разберём, чем.

Отношений всего четыре, и отличаются они одним: насколько крепко один объект держит другой.

Отношение Словами Время жизни Пример
Ассоциация «знает» никак не связано заказ знает про сервис оплаты
Агрегация «содержит» независимое магазин содержит покупателей
Композиция «владеет» общее у заказа есть корзина
Наследование «является» админ является пользователем

Ассоциация – «знает»

Связность минимальная

Самая слабая связь: один объект просто знает про другой и умеет к нему обратиться. Ни владения, ни ответственности за жизнь – знакомство и всё. Обычно выглядит как параметр метода.

public class Order
{
    // Сервис оплаты приходит параметром: заказ знает про него
    // ровно на время вызова и нигде не сохраняет
    public void Pay(IPaymentService payments)
    {
        payments.Charge(Total());
    }
}
class Order {
public:
    // Сервис оплаты приходит параметром: заказ знает про него
    // ровно на время вызова и нигде не сохраняет.
    // const-ссылка заодно говорит, что заказ его и не меняет
    void Pay(const PaymentService& payments) const { payments.Charge(Total()); }
};
// Сервис оплаты приходит параметром: заказ знает про него
// ровно на время вызова и нигде не сохраняет
func (o *Order) Pay(payments PaymentService) error {
	return payments.Charge(o.total())
}
class Order:
    # Сервис оплаты приходит параметром: заказ знает про него
    # ровно на время вызова и нигде не сохраняет
    def pay(self, payments):
        payments.charge(self._total())
class Order {
    // Сервис оплаты приходит параметром: заказ знает про него
    // ровно на время вызова и нигде не сохраняет
    pay(payments: PaymentService): void {
        payments.charge(this.total());
    }
}
public class Order {
    // Сервис оплаты приходит параметром: заказ знает про него
    // ровно на время вызова и нигде не сохраняет
    public void pay(PaymentService payments) {
        payments.charge(total());
    }
}
class Order {
    // Сервис оплаты приходит параметром: заказ знает про него
    // ровно на время вызова и нигде не сохраняет
    fun pay(payments: PaymentService) = payments.charge(total())
}

Заказ ничего не знает о сервисе оплаты до вызова и забывает про него после. Сервис существовал раньше заказа и переживёт его; поменять реализацию оплаты можно, не трогая класс заказа вообще. Это самая дешёвая связь из всех: разорвать её – значит убрать один параметр.

Агрегация – «содержит»

Связность умеренная

Объект держит другие объекты у себя, но не отвечает за их появление и исчезновение. Признак простой: объект приходит снаружи готовым. Его создал кто-то другой, а вы взяли ссылку.

public class Shop
{
    private readonly List<Customer> _customers = new();

    // Покупатель приходит снаружи – магазин его не создавал
    public void Register(Customer customer) => _customers.Add(customer);

    public void Leave(Customer customer) => _customers.Remove(customer);
}
class Shop {
public:
    // Покупатель приходит снаружи – магазин его не создавал.
    // Храним указатель: владения нет
    void Register(Customer* customer) { customers_.push_back(customer); }

private:
    std::vector<Customer*> customers_;
};
type Shop struct {
	customers []*Customer
}

// Покупатель приходит снаружи – магазин его не создавал
func (s *Shop) Register(c *Customer) {
	s.customers = append(s.customers, c)
}
class Shop:
    def __init__(self):
        self._customers = []

    # Покупатель приходит снаружи – магазин его не создавал
    def register(self, customer):
        self._customers.append(customer)
class Shop {
    readonly #customers: Customer[] = [];

    // Покупатель приходит снаружи – магазин его не создавал
    register(customer: Customer): void {
        this.#customers.push(customer);
    }
}
public class Shop {
    private final List<Customer> customers = new ArrayList<>();

    // Покупатель приходит снаружи – магазин его не создавал
    public void register(Customer customer) {
        customers.add(customer);
    }
}
class Shop {
    private val customers = mutableListOf<Customer>()

    // Покупатель приходит снаружи – магазин его не создавал
    fun register(customer: Customer) {
        customers += customer
    }
}

Магазин содержит покупателей: они в его списке, он рассылает им акции и считает их средний чек. Но покупатель существовал до того, как зашёл в этот магазин, и в любой момент может уйти в соседний – или зарегистрироваться сразу в пяти. Закройте магазин, и с покупателями ничего не случится: они просто перестанут числиться в его списке.

Именно поэтому связь называется «содержит», а не «владеет». Магазин распоряжается своим списком, но не жизнью тех, кто в нём записан.

Композиция – «владеет»

Связность высокая

Объект создаёт части сам и уничтожает вместе с собой. Признак обратный агрегации: часть рождается внутри и наружу не выдаётся – в крайнем случае наружу отдают копию.

public class Order
{
    // Позиции создаются внутри заказа и живут ровно столько, сколько он
    private readonly List<OrderItem> _items = new();

    public void Add(string product, int count) => _items.Add(new OrderItem(product, count));

    // Наружу отдаём копию: списком владеет заказ
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
}
class Order {
public:
    void Add(const std::string& product, int count) {
        items_.emplace_back(product, count);
    }

    // Наружу отдаём const-ссылку: списком владеет заказ
    const std::vector<OrderItem>& items() const { return items_; }

private:
    // Вектор лежит в самом объекте и умрёт вместе с ним
    std::vector<OrderItem> items_;
};
type Order struct {
	// Срез создаётся вместе с заказом и живёт с ним
	items []OrderItem
}

func (o *Order) Add(product string, count int) {
	o.items = append(o.items, OrderItem{Product: product, Count: count})
}

// Наружу отдаём копию: срезом владеет заказ
func (o *Order) Items() []OrderItem {
	return append([]OrderItem(nil), o.items...)
}
class Order:
    def __init__(self):
        # Список создаётся здесь и живёт ровно столько, сколько заказ
        self._items = []

    def add(self, product, count):
        self._items.append(OrderItem(product, count))

    @property
    def items(self):
        # Наружу отдаём кортеж: списком владеет заказ
        return tuple(self._items)
class Order {
    // Массив создаётся вместе с заказом
    readonly #items: OrderItem[] = [];

    add(product: string, count: number): void {
        this.#items.push(new OrderItem(product, count));
    }

    // Наружу отдаём копию: массивом владеет заказ
    get items(): readonly OrderItem[] {
        return [...this.#items];
    }
}
public class Order {
    // Список создаётся вместе с заказом
    private final List<OrderItem> items = new ArrayList<>();

    public void add(String product, int count) {
        items.add(new OrderItem(product, count));
    }

    // Наружу отдаём неизменяемый список: им владеет заказ
    public List<OrderItem> getItems() {
        return List.copyOf(items);
    }
}
class Order {
    // Список создаётся вместе с заказом
    private val _items = mutableListOf<OrderItem>()

    fun add(product: String, count: Int) {
        _items += OrderItem(product, count)
    }

    // Наружу отдаём копию: списком владеет заказ
    val items: List<OrderItem> get() = _items.toList()
}

Позиция заказа отдельно от заказа не существует: её нельзя «перевести» в другой заказ и незачем хранить саму по себе. Заказ создаёт её сам, никому не отдаёт наружу и уносит с собой, когда исчезает.

Сравните с магазином: покупателя туда передали, а позицию заказ породил. В коде оба случая выглядят как поле со списком, и различает их только это – откуда взялся объект и что с ним станет, когда владелец исчезнет.

Как отличить одно от другого в чужом коде

  • объект приходит параметром метода – ассоциация;
  • объект приходит в конструктор или сеттер и сохраняется в поле – агрегация;
  • объект создаётся внутри и наружу не выдаётся – композиция.

Встраивание – композиция, которая выглядит как наследование

Связность высокая, но объект заменяем

Отдельно стоит упомянуть механизм, который в разных языках называют встраиванием или делегированием. Идея: объект хранит другой объект в поле, но его методы видны снаружи так, будто объявлены у него самого.

type Vehicle struct{ Weight int }

func (v *Vehicle) TurnOnHeadlights() { /* ... */ }

// Vehicle встроен без имени поля – методы Vehicle доступны прямо на Truck
type Truck struct {
	Vehicle
	IsLoaded bool
}

truck := &Truck{}
truck.TurnOnHeadlights() // работает, хотя метод описан у Vehicle
interface Engine {
    fun start()
}

// by означает: все методы Engine перепоручить объекту engine
class Car(engine: Engine) : Engine by engine

// Компилятор сам напишет метод start(), который вызовет engine.start()

Плюс в том, что писать методы-переходники руками не нужно, а связь остаётся композицией: внутренний объект можно подменить, и никакой иерархии типов не возникает. Минус – ровно тот же, что и у наследования: при чтении кода неочевидно, где на самом деле живёт метод.

Механизм, который выглядит как ООП, но им не является

Формально встраивание – это композиция: внутри лежит обычное поле, никакой иерархии типов не возникает, подстановка не работает. А внешне – ровно наследование: пишете truck.TurnOnHeadlights(), хотя метод объявлен у другого типа.

Go именно на этом и построил отказ от наследования: разработчику в повседневной работе не так важно, как механизм устроен внутри и как называется в учебнике – важно, что нужный метод вызывается там, где ожидаешь. Зато вместе с иерархией уходят и её проблемы: нет хрупкого базового класса, нет ромба, нет «банана с гориллой» – встроенный объект можно заменить другим, ничего не переписывая.

Что из этого следует

Наследование – сильный механизм, и в своё время он действительно перевернул индустрию: именно возможность описать общее один раз и уточнять его дальше сделала возможными библиотеки и фреймворки в том виде, в каком мы их знаем. Там, где отношение «является» честное, замены ему нет.

Проблема не в механизме, а в том, как часто его берут не по адресу. В большинстве повседневных задач нам не нужна подстановка – нужно всего лишь воспользоваться чужим поведением, а это отношение куда слабее. Заказ не является сервисом оплаты, магазин не является покупателем, а машина не является двигателем – и во всех трёх случаях достаточно связи послабее.

Поэтому наследование – не первый выбор, а последний. Порядок примерно такой:

С чего начинать

  • нужен доступ к чужому поведению на один вызов – передайте объект параметром;
  • нужен постоянный сотрудник, живущий своей жизнью, – агрегация через конструктор;
  • часть без целого не имеет смысла – композиция;
  • методов-переходников набралось много – встраивание или делегирование;
  • и только если отношение «является» честное – наследование.