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

The problems of inheritance

This lesson was translated automatically – it may contain errors.

Inheritance looks like the most understandable mechanism in OOP: one line and a class has another one’s behaviour for free. The trouble starts later, when the hierarchy has to change. Below are four typical traps, each with a name the industry knows it by.

Inheritance is a relationship between objects, and there are several such relationships, so the words «composition» and «aggregation» are unavoidable below. If you do not know them yet, do not worry: here it is enough to know they are other ways of connecting classes, and we take them apart in the next lesson.

The square-rectangle problem

The classic example of how «is a» in real life fails to match «is a» in code. In geometry a square is a special case of a rectangle, so the hand reaches to write class Square : Rectangle all by itself.

Then it turns out that a rectangle changes its width and height independently and a square cannot. The subclass is forced to override the setters in a way that makes code written for the base class produce an unexpected result.

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area() => Width * Height;
}

public class Square : Rectangle
{
    // A square cannot change its sides independently,
    // so every setter is forced to change both
    public override int Width
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; }
    }
}

// The function is written for a rectangle and expects an area of 5 × 4 = 20
void Grow(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Console.WriteLine(r.Area());
}

Grow(new Rectangle()); // 20 – as agreed
Grow(new Square());    // 16 – the subclass broke the parent's promise
class Rectangle {
public:
    virtual void SetWidth(int value) { width_ = value; }
    virtual void SetHeight(int value) { height_ = value; }
    int Area() const { return width_ * height_; }

protected:
    int width_ = 0;
    int height_ = 0;
};

class Square : public Rectangle {
public:
    // A square cannot change its sides independently
    void SetWidth(int value) override { width_ = height_ = value; }
    void SetHeight(int value) override { width_ = height_ = value; }
};

// The function is written for a rectangle and expects an area of 5 × 4 = 20
void Grow(Rectangle& r) {
    r.SetWidth(5);
    r.SetHeight(4);
    std::cout << r.Area() << std::endl;
}

Rectangle rect;
Square square;
Grow(rect);   // 20 – as agreed
Grow(square); // 16 – the subclass broke the parent's promise
// Go does not have this trap in this form: embedding does not override methods.
// Square can add its own SetWidth, but Rectangle will still call its own.
type Rectangle struct {
	Width  int
	Height int
}

func (r *Rectangle) SetWidth(v int)  { r.Width = v }
func (r *Rectangle) SetHeight(v int) { r.Height = v }
func (r *Rectangle) Area() int       { return r.Width * r.Height }

type Square struct {
	Rectangle
}

// Substitution goes through an interface – and there the trap comes back:
// declare a Shape with SetWidth/SetHeight and Square can once again
// break the expectations of code written for a rectangle.
class Rectangle:
    def __init__(self):
        self._width = 0
        self._height = 0

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        self._height = value

    def area(self):
        return self._width * self._height

class Square(Rectangle):
    # A square cannot change its sides independently
    @Rectangle.width.setter
    def width(self, value):
        self._width = self._height = value

    @Rectangle.height.setter
    def height(self, value):
        self._width = self._height = value

# The function is written for a rectangle and expects an area of 5 × 4 = 20
def grow(r):
    r.width = 5
    r.height = 4
    print(r.area())

grow(Rectangle())  # 20 – as agreed
grow(Square())     # 16 – the subclass broke the parent's promise
class Rectangle {
    protected w = 0;
    protected h = 0;

    setWidth(value: number): void {
        this.w = value;
    }

    setHeight(value: number): void {
        this.h = value;
    }

    area(): number {
        return this.w * this.h;
    }
}

class Square extends Rectangle {
    // A square cannot change its sides independently
    setWidth(value: number): void {
        this.w = this.h = value;
    }

    setHeight(value: number): void {
        this.w = this.h = value;
    }
}

// The function is written for a rectangle and expects an area of 5 × 4 = 20
function grow(r: Rectangle): void {
    r.setWidth(5);
    r.setHeight(4);
    console.log(r.area());
}

grow(new Rectangle()); // 20 – as agreed
grow(new Square());    // 16 – the subclass broke the parent's promise
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int value) {
        width = value;
    }

    public void setHeight(int value) {
        height = value;
    }

    public int area() {
        return width * height;
    }
}

public class Square extends Rectangle {
    // A square cannot change its sides independently
    @Override
    public void setWidth(int value) {
        width = height = value;
    }

    @Override
    public void setHeight(int value) {
        width = height = value;
    }
}

// The method is written for a rectangle and expects an area of 5 × 4 = 20
static void grow(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    System.out.println(r.area());
}

grow(new Rectangle()); // 20 – as agreed
grow(new Square());    // 16 – the subclass broke the parent's promise
open class Rectangle {
    protected var w = 0
    protected var h = 0

    open fun setWidth(value: Int) {
        w = value
    }

    open fun setHeight(value: Int) {
        h = value
    }

    fun area() = w * h
}

class Square : Rectangle() {
    // A square cannot change its sides independently
    override fun setWidth(value: Int) {
        w = value
        h = value
    }

    override fun setHeight(value: Int) {
        w = value
        h = value
    }
}

// The function is written for a rectangle and expects an area of 5 × 4 = 20
fun grow(r: Rectangle) {
    r.setWidth(5)
    r.setHeight(4)
    println(r.area())
}

grow(Rectangle()) // 20 – as agreed
grow(Square())    // 16 – the subclass broke the parent's promise

This is a violation of the Liskov substitution principle, worked through with code in the LSP lesson. What matters here is something else: inheritance inherits not only the methods but the obligations too. If the subclass cannot keep the parent’s promises, the connection between them is built wrong – however plausible it sounds in words.

The banana, the gorilla and the jungle

The wording belongs to Joe Armstrong, the creator of Erlang: you wanted a banana, and what you got was a gorilla holding the banana and the entire jungle with it.

The point is that inheritance is all-or-nothing reuse. You need one method from the base class – you have to take everything else with it: its fields, its dependencies, its lifecycle, its demands on the constructor.

It shows up most clearly with frameworks. You inherit from a base controller for one handy helper – and along with it you get serialisation, dependency injection, work with the HTTP context, and the inability to create the object in a test without half the framework.

The cure is simple: if you need behaviour rather than a role, put the object in a field and call it. Composition gives you exactly the banana.

The fragile base class

The most treacherous of the four, because the code that breaks is code nobody touched.

A base class is entitled to change its internal implementation – that is considered its own business. But subclasses see it from the inside and start depending, without meaning to, on how it is written and not only on what it promises.

public class Bag
{
    private readonly List<string> _items = new();

    public virtual void Add(string item) => _items.Add(item);

    // The base class decided that AddRange is a loop of Add.
    // The subclass does not know that, and should not have to.
    public virtual void AddRange(IEnumerable<string> items)
    {
        foreach (var item in items)
            Add(item);
    }
}

public class CountingBag : Bag
{
    public int AddedCount { get; private set; }

    public override void Add(string item)
    {
        AddedCount++;
        base.Add(item);
    }

    public override void AddRange(IEnumerable<string> items)
    {
        AddedCount += items.Count();
        base.AddRange(items); // calls Add inside – the counter grows twice
    }
}

var bag = new CountingBag();
bag.AddRange(new[] { "a", "b" });

Console.WriteLine(bag.AddedCount); // We expect 2, we get 4
#include <vector>
#include <string>

class Bag {
public:
    virtual ~Bag() = default;

    virtual void Add(const std::string& item) { items_.push_back(item); }

    // The base class decided that AddRange is a loop of Add
    virtual void AddRange(const std::vector<std::string>& items) {
        for (const auto& item : items) {
            Add(item); // a virtual call goes to the subclass
        }
    }

private:
    std::vector<std::string> items_;
};

class CountingBag : public Bag {
public:
    void Add(const std::string& item) override {
        ++added_count_;
        Bag::Add(item);
    }

    void AddRange(const std::vector<std::string>& items) override {
        added_count_ += static_cast<int>(items.size());
        Bag::AddRange(items); // calls Add inside – the counter grows twice
    }

    int added_count() const { return added_count_; }

private:
    int added_count_ = 0;
};

int main() {
    CountingBag bag;
    bag.AddRange({"a", "b"});

    std::cout << bag.added_count() << std::endl; // We expect 2, we get 4
}
// Go has no inheritance, and a method of the base type cannot be replaced:
// the embedded Bag always calls its own Add.
// The fragile base class simply does not reproduce here – that is both the price and
// the virtue of doing without inheritance.
type Bag struct {
	items []string
}

func (b *Bag) Add(item string) { b.items = append(b.items, item) }

func (b *Bag) AddRange(items []string) {
	for _, item := range items {
		b.Add(item) // always Bag.Add, even if it was «overridden» from outside
	}
}

type CountingBag struct {
	Bag
	AddedCount int
}

func (c *CountingBag) Add(item string) {
	c.AddedCount++
	c.Bag.Add(item)
}

func main() {
	bag := &CountingBag{}
	bag.AddRange([]string{"a", "b"})

	fmt.Println(bag.AddedCount) // 0: AddRange knows nothing about CountingBag.Add
}
class Bag:
    def __init__(self):
        self._items = []

    def add(self, item):
        self._items.append(item)

    # The base class decided that add_range is a loop of add
    def add_range(self, items):
        for item in items:
            self.add(item)

class CountingBag(Bag):
    def __init__(self):
        super().__init__()
        self.added_count = 0

    def add(self, item):
        self.added_count += 1
        super().add(item)

    def add_range(self, items):
        self.added_count += len(items)
        super().add_range(items)  # calls add inside – the counter grows twice

bag = CountingBag()
bag.add_range(["a", "b"])

print(bag.added_count)  # We expect 2, we get 4
class Bag {
    protected items: string[] = [];

    add(item: string): void {
        this.items.push(item);
    }

    // The base class decided that addRange is a loop of add
    addRange(items: string[]): void {
        for (const item of items) {
            this.add(item);
        }
    }
}

class CountingBag extends Bag {
    addedCount = 0;

    add(item: string): void {
        this.addedCount++;
        super.add(item);
    }

    addRange(items: string[]): void {
        this.addedCount += items.length;
        super.addRange(items); // calls add inside – the counter grows twice
    }
}

const bag = new CountingBag();
bag.addRange(["a", "b"]);

console.log(bag.addedCount); // We expect 2, we get 4
public class Bag {
    private final List<String> items = new ArrayList<>();

    public void add(String item) {
        items.add(item);
    }

    // The base class decided that addAll is a loop of add
    public void addAll(Collection<String> newItems) {
        for (String item : newItems) {
            add(item);
        }
    }
}

public class CountingBag extends Bag {
    private int addedCount;

    @Override
    public void add(String item) {
        addedCount++;
        super.add(item);
    }

    @Override
    public void addAll(Collection<String> newItems) {
        addedCount += newItems.size();
        super.addAll(newItems); // calls add inside – the counter grows twice
    }

    public int getAddedCount() {
        return addedCount;
    }
}

CountingBag bag = new CountingBag();
bag.addAll(List.of("a", "b"));

System.out.println(bag.getAddedCount()); // We expect 2, we get 4
open class Bag {
    private val items = mutableListOf<String>()

    open fun add(item: String) {
        items += item
    }

    // The base class decided that addAll is a loop of add
    open fun addAll(newItems: List<String>) {
        newItems.forEach { add(it) }
    }
}

class CountingBag : Bag() {
    var addedCount = 0
        private set

    override fun add(item: String) {
        addedCount++
        super.add(item)
    }

    override fun addAll(newItems: List<String>) {
        addedCount += newItems.size
        super.addAll(newItems) // calls add inside – the counter grows twice
    }
}

val bag = CountingBag()
bag.addAll(listOf("a", "b"))

println(bag.addedCount) // We expect 2, we get 4

What happened here

  • Bag can add items one by one (Add) and in bulk (AddRange), and internally the bulk version is a loop of Add – an implementation detail that was never promised to the outside.
  • CountingBag wants to count the added items and overrides both methods: Add adds one, AddRange adds the length of the list at once.
  • We call AddRange with two items. The subclass adds 2 and hands the work to the parent, the parent calls Add for each item – and the overridden Add adds one more each time. Four instead of two.

Let the parent rewrite AddRange without going through Add and the counter suddenly becomes correct, even though nobody touched the subclass. Hence the name: the behaviour depends not on what the base class promises but on how it is written inside.

Note that neither of the two classes contains a mistake on its own. The mistake comes out of their combination – and it will show up on the day the author of the base class decides to rewrite AddRange without calling Add. Or, the other way round, adds such a call.

Hence the rule: a class meant to be inherited from must either document its internal calls as part of the contract or be closed for inheritance. In Kotlin the latter is the default – classes are final until open is written explicitly.

The diamond problem

It appears with multiple inheritance. Class D inherits from B and C, and both of those inherit from a common ancestor A. If B and C overrode the same method differently, it is unclear which version D gets. Drawn as a diagram these links look like a diamond – hence the name.

Languages answer the question differently, which is a good illustration of how differently inheritance can be treated.

// C# forbids multiple inheritance of classes.
// The diamond is possible only on interfaces with default implementations,
// and the compiler will require the conflict to be resolved by hand.
public interface IDevice
{
    void Start() => Console.WriteLine("Device");
}

public interface IPrinter : IDevice
{
    void IDevice.Start() => Console.WriteLine("Printer");
}

public interface IScanner : IDevice
{
    void IDevice.Start() => Console.WriteLine("Scanner");
}

public class Mfp : IPrinter, IScanner
{
    // Without an explicit implementation – a compile error: ambiguity
    public void Start() => Console.WriteLine("Mfp");
}
// C++ is the only mainstream language where the diamond is literally possible.
// Without virtual the base class is copied twice and the access is ambiguous.
class Device {
public:
    virtual void Start() { std::cout << "Device" << std::endl; }
    virtual ~Device() = default;
};

// virtual inheritance makes the Device subobject shared by both branches
class Printer : virtual public Device {
public:
    void Start() override { std::cout << "Printer" << std::endl; }
};

class Scanner : virtual public Device {
public:
    void Start() override { std::cout << "Scanner" << std::endl; }
};

class Mfp : public Printer, public Scanner {
public:
    // Without this override – a compile error: ambiguity
    void Start() override { Printer::Start(); }
};
// In Go both types can be embedded, but calling the shared method
// becomes a compile error: ambiguous selector.
type Printer struct{}

func (Printer) Start() { fmt.Println("Printer") }

type Scanner struct{}

func (Scanner) Start() { fmt.Println("Scanner") }

type Mfp struct {
	Printer
	Scanner
}

func main() {
	mfp := Mfp{}

	// mfp.Start() will not compile: it is unclear whose Start it is
	mfp.Printer.Start() // choose explicitly
}
# Python allows multiple inheritance,
# and the order is decided by the MRO (the C3 linearisation algorithm).
class Device:
    def start(self):
        print("Device")

class Printer(Device):
    def start(self):
        print("Printer")

class Scanner(Device):
    def start(self):
        print("Scanner")

class Mfp(Printer, Scanner):
    pass

Mfp().start()  # Printer – the first in the list of bases wins
print([c.__name__ for c in Mfp.__mro__])
# ['Mfp', 'Printer', 'Scanner', 'Device', 'object']
// TypeScript has no multiple inheritance of classes.
// The diamond shows up in mixins, and the order of application decides everything.
type Constructor = new (...args: any[]) => {};

const Printer = <T extends Constructor>(Base: T) =>
    class extends Base {
        start(): void {
            console.log("Printer");
        }
    };

const Scanner = <T extends Constructor>(Base: T) =>
    class extends Base {
        start(): void {
            console.log("Scanner");
        }
    };

class Device {}

// The mixin applied last wins
class Mfp extends Scanner(Printer(Device)) {}

new Mfp().start(); // Scanner
// Java forbids multiple inheritance of classes.
// The diamond is possible on interfaces with default methods,
// and then the compiler requires the implementation to be chosen explicitly.
public interface Device {
    default void start() {
        System.out.println("Device");
    }
}

public interface Printer extends Device {
    default void start() {
        System.out.println("Printer");
    }
}

public interface Scanner extends Device {
    default void start() {
        System.out.println("Scanner");
    }
}

public class Mfp implements Printer, Scanner {
    // Without this method – a compile error
    @Override
    public void start() {
        Printer.super.start(); // choose explicitly
    }
}
// In Kotlin a diamond on interfaces is resolved explicitly, through super<Type>
interface Device {
    fun start() {
        println("Device")
    }
}

interface Printer : Device {
    override fun start() {
        println("Printer")
    }
}

interface Scanner : Device {
    override fun start() {
        println("Scanner")
    }
}

class Mfp : Printer, Scanner {
    // The compiler forces you to override and pick an implementation
    override fun start() {
        super<Printer>.start()
    }
}

Notice the pattern: every language that appeared after C++ banned multiple inheritance of classes. They allow it only on interfaces and only with the conflict resolved explicitly. That is a direct admission that the problem is real and that preventing it is cheaper than untangling it.

Strictly speaking an interface is not inherited but implemented: inheritance hands over ready behaviour, while an interface only sets obligations the class will fulfil itself. But «inherited an interface» has stuck in conversation – it looks the same from outside, and in C#, C++ and Kotlin it is even written the same way, with a colon after the class name. Java is the honest one here: extends for inheritance and implements for implementation are different words. Knowing the difference is worth it; arguing about the wording is not.

What to do about all this

None of the four traps means that inheritance is bad. What they mean is something else: inheritance is the strongest connection between two classes, and it is worth paying for only when substitution is genuinely needed.

It is also worth remembering that there are three kinds of connection between classes, and inheritance describes only one of them:

  • composition – «owns»: an order has a cart, and without the order the cart does not live;
  • aggregation – «contains»: employees work in a department, but disband the department and they stay;
  • inheritance – «is a»: a truck is a vehicle.

Almost every trap above grows from one root: the relationship really was «owns» or «contains», and it was written down as «is a». Before you put a colon after a class name, test yourself with the question «is X a special case of Y» – and if the answer is even slightly strained, the connection is the wrong one.

An honest «is a» looks like this: an administrator is a user, with the same login and password, and can be substituted anywhere the code expects a user. On top of that they only add their own – ban somebody, look at other people's orders – without taking anything away from the parent.

The practical rule

  • you need behaviour – put the object in a field and call its methods (composition);
  • you need a role the object can be substituted in – use an interface;
  • you need both, and the «is a» is honest – then inheritance.

What to connect classes with, if not inheritance, is the subject of the next lesson. And how to build on composition what the hand reaches to build with inheritance is shown by the patterns in the last module: Strategy instead of a hierarchy of algorithms, Bridge instead of multiplying subclasses, and Composite instead of a hierarchy of containers.