В прошлом уроке вывод был такой: наследование – самая сильная связь между классами, и брать её стоит только ради честного «является». Но связывать классы как-то надо и во всех остальных случаях. Разберём, чем.
Отношений всего четыре, и отличаются они одним: насколько крепко один объект держит другой.
| Отношение | Словами | Время жизни | Пример |
|---|---|---|---|
| Ассоциация | «знает» | никак не связано | заказ знает про сервис оплаты |
| Агрегация | «содержит» | независимое | магазин содержит покупателей |
| Композиция | «владеет» | общее | у заказа есть корзина |
| Наследование | «является» | – | админ является пользователем |
Ассоциация – «знает»
Связность минимальная
Самая слабая связь: один объект просто знает про другой и умеет к нему обратиться. Ни владения, ни ответственности за жизнь – знакомство и всё. Обычно выглядит как параметр метода.
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 именно на этом и построил отказ от наследования: разработчику в повседневной работе не так важно, как механизм устроен внутри и как называется в учебнике – важно, что нужный метод вызывается там, где ожидаешь. Зато вместе с иерархией уходят и её проблемы: нет хрупкого базового класса, нет ромба, нет «банана с гориллой» – встроенный объект можно заменить другим, ничего не переписывая.
Что из этого следует
Наследование – сильный механизм, и в своё время он действительно перевернул индустрию: именно возможность описать общее один раз и уточнять его дальше сделала возможными библиотеки и фреймворки в том виде, в каком мы их знаем. Там, где отношение «является» честное, замены ему нет.
Проблема не в механизме, а в том, как часто его берут не по адресу. В большинстве повседневных задач нам не нужна подстановка – нужно всего лишь воспользоваться чужим поведением, а это отношение куда слабее. Заказ не является сервисом оплаты, магазин не является покупателем, а машина не является двигателем – и во всех трёх случаях достаточно связи послабее.
Поэтому наследование – не первый выбор, а последний. Порядок примерно такой:
С чего начинать
- нужен доступ к чужому поведению на один вызов – передайте объект параметром;
- нужен постоянный сотрудник, живущий своей жизнью, – агрегация через конструктор;
- часть без целого не имеет смысла – композиция;
- методов-переходников набралось много – встраивание или делегирование;
- и только если отношение «является» честное – наследование.