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

What, if not inheritance

This lesson was translated automatically – it may contain errors.

The conclusion of the previous lesson was this: inheritance is the strongest connection between classes, and it is worth taking only for an honest «is a». But classes have to be connected in all the other cases too. Let us look at what with.

There are four relationships in total, and they differ in one thing: how tightly one object holds another.

Relationship In words Lifetime Example
Association «knows» unrelated an order knows about a payment service
Aggregation «contains» independent a shop contains customers
Composition «owns» shared an order has a cart
Inheritance «is a» an admin is a user

Association – «knows»

Coupling minimal

The weakest connection: one object simply knows about another and can call it. No ownership, no responsibility for its life – an acquaintance and nothing more. Usually it looks like a method parameter.

public class Order
{
    // The payment service arrives as a parameter: the order knows about it
    // only for the duration of the call and never stores it
    public void Pay(IPaymentService payments)
    {
        payments.Charge(Total());
    }
}
class Order {
public:
    // The payment service arrives as a parameter: the order knows about it
    // only for the duration of the call and never stores it.
    // the const reference also says the order does not change it
    void Pay(const PaymentService& payments) const { payments.Charge(Total()); }
};
// The payment service arrives as a parameter: the order knows about it
// only for the duration of the call and never stores it
func (o *Order) Pay(payments PaymentService) error {
	return payments.Charge(o.total())
}
class Order:
    # The payment service arrives as a parameter: the order knows about it
    # only for the duration of the call and never stores it
    def pay(self, payments):
        payments.charge(self._total())
class Order {
    // The payment service arrives as a parameter: the order knows about it
    // only for the duration of the call and never stores it
    pay(payments: PaymentService): void {
        payments.charge(this.total());
    }
}
public class Order {
    // The payment service arrives as a parameter: the order knows about it
    // only for the duration of the call and never stores it
    public void pay(PaymentService payments) {
        payments.charge(total());
    }
}
class Order {
    // The payment service arrives as a parameter: the order knows about it
    // only for the duration of the call and never stores it
    fun pay(payments: PaymentService) = payments.charge(total())
}

The order knows nothing about the payment service before the call and forgets it afterwards. The service existed before the order and will outlive it; the payment implementation can be swapped without touching the order class at all. This is the cheapest connection of them all: breaking it means removing one parameter.

Aggregation – «contains»

Coupling moderate

An object keeps other objects, but is not responsible for their appearance or disappearance. The sign is simple: the object arrives from outside, ready-made. Somebody else created it and you took a reference.

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

    // The customer arrives from outside – the shop did not create them
    public void Register(Customer customer) => _customers.Add(customer);

    public void Leave(Customer customer) => _customers.Remove(customer);
}
class Shop {
public:
    // The customer arrives from outside – the shop did not create them.
    // We keep a pointer: no ownership
    void Register(Customer* customer) { customers_.push_back(customer); }

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

// The customer arrives from outside – the shop did not create them
func (s *Shop) Register(c *Customer) {
	s.customers = append(s.customers, c)
}
class Shop:
    def __init__(self):
        self._customers = []

    # The customer arrives from outside – the shop did not create them
    def register(self, customer):
        self._customers.append(customer)
class Shop {
    readonly #customers: Customer[] = [];

    // The customer arrives from outside – the shop did not create them
    register(customer: Customer): void {
        this.#customers.push(customer);
    }
}
public class Shop {
    private final List<Customer> customers = new ArrayList<>();

    // The customer arrives from outside – the shop did not create them
    public void register(Customer customer) {
        customers.add(customer);
    }
}
class Shop {
    private val customers = mutableListOf<Customer>()

    // The customer arrives from outside – the shop did not create them
    fun register(customer: Customer) {
        customers += customer
    }
}

The shop contains customers: they are on its list, it sends them offers and calculates their average bill. But a customer existed before walking into this shop, and can leave for the one next door at any moment – or sign up at five shops at once. Close the shop and nothing happens to the customers: they simply stop being on its list.

That is exactly why the relationship is called «contains» and not «owns». The shop is in charge of its list, not of the lives of those written on it.

Composition – «owns»

Coupling high

The object creates its parts itself and destroys them along with itself. The sign is the opposite of aggregation: the part is born inside and never handed out – at most a copy goes out.

public class Order
{
    // The lines are created inside the order and live exactly as long as it does
    private readonly List<OrderItem> _items = new();

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

    // A copy goes out: the order owns the list
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
}
class Order {
public:
    void Add(const std::string& product, int count) {
        items_.emplace_back(product, count);
    }

    // A const reference goes out: the order owns the list
    const std::vector<OrderItem>& items() const { return items_; }

private:
    // The vector sits inside the object itself and dies with it
    std::vector<OrderItem> items_;
};
type Order struct {
	// The slice is created together with the order and lives with it
	items []OrderItem
}

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

// A copy goes out: the order owns the slice
func (o *Order) Items() []OrderItem {
	return append([]OrderItem(nil), o.items...)
}
class Order:
    def __init__(self):
        # The list is created here and lives exactly as long as the order
        self._items = []

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

    @property
    def items(self):
        # A tuple goes out: the order owns the list
        return tuple(self._items)
class Order {
    // The array is created together with the order
    readonly #items: OrderItem[] = [];

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

    // A copy goes out: the order owns the array
    get items(): readonly OrderItem[] {
        return [...this.#items];
    }
}
public class Order {
    // The list is created together with the order
    private final List<OrderItem> items = new ArrayList<>();

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

    // An immutable list goes out: the order owns it
    public List<OrderItem> getItems() {
        return List.copyOf(items);
    }
}
class Order {
    // The list is created together with the order
    private val _items = mutableListOf<OrderItem>()

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

    // A copy goes out: the order owns the list
    val items: List<OrderItem> get() = _items.toList()
}

An order line does not exist apart from the order: it cannot be «moved» to another order and there is no point in storing it on its own. The order creates it, never hands it out, and takes it away when it disappears.

Compare that with the shop: the customer was handed to it, while the order line was born of the order. In code both cases look like a field with a list, and the only thing that tells them apart is this – where the object came from and what becomes of it when the owner is gone.

How to tell them apart in somebody else's code

  • the object arrives as a method parameter – association;
  • the object arrives through a constructor or a setter and is kept in a field – aggregation;
  • the object is created inside and never handed out – composition.

Embedding – composition that looks like inheritance

Coupling high, but the object is replaceable

One mechanism deserves a separate mention – the one different languages call embedding or delegation. The idea: an object keeps another object in a field, but that object’s methods are visible from outside as if they were declared on it.

type Vehicle struct{ Weight int }

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

// Vehicle is embedded without a field name – its methods are available right on Truck
type Truck struct {
	Vehicle
	IsLoaded bool
}

truck := &Truck{}
truck.TurnOnHeadlights() // works, even though the method is declared on Vehicle
interface Engine {
    fun start()
}

// by means: delegate every Engine method to the engine object
class Car(engine: Engine) : Engine by engine

// The compiler writes the start() method itself, calling engine.start()

The upside is that no pass-through methods have to be written by hand, while the connection stays composition: the inner object can be swapped and no type hierarchy appears. The downside is exactly the one inheritance has: while reading the code it is not obvious where the method actually lives.

A mechanism that looks like OOP but is not

Formally embedding is composition: inside there is an ordinary field, no type hierarchy appears, substitution does not work. From outside it is exactly inheritance: you write truck.TurnOnHeadlights() even though the method is declared on another type.

Go built its refusal of inheritance on precisely that: in day-to-day work a developer does not much care how the mechanism is arranged inside or what the textbook calls it – what matters is that the method you need is called where you expect it. And along with the hierarchy its problems go away too: no fragile base class, no diamond, no «banana with a gorilla» – the embedded object can be replaced by another one without rewriting anything.

What follows from this

Inheritance is a powerful mechanism, and in its day it genuinely turned the industry around: it was the ability to describe the common part once and refine it further that made libraries and frameworks possible in the form we know them. Where the «is a» is honest, nothing replaces it.

The problem is not the mechanism but how often it is taken to the wrong address. Most everyday tasks do not need substitution – they only need to make use of somebody else’s behaviour, and that is a far weaker relationship. An order is not a payment service, a shop is not a customer, and a car is not an engine – and in all three cases a weaker connection is enough.

So inheritance is not the first choice but the last. The order is roughly this:

Where to start

  • you need somebody else's behaviour for one call – pass the object as a parameter;
  • you need a permanent collaborator that lives its own life – aggregation through the constructor;
  • the part makes no sense without the whole – composition;
  • the pass-through methods have piled up – embedding or delegation;
  • and only if the «is a» is honest – inheritance.