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

Проблемы наследования

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

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

Проблема «квадрат-прямоугольник»

Классический пример того, как отношение «является» в жизни не совпадает с отношением «является» в коде. В геометрии квадрат – частный случай прямоугольника, поэтому рука сама тянется написать class Square : Rectangle.

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

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

public class Square : Rectangle
{
    // Квадрат не может менять стороны по отдельности,
    // поэтому каждый сеттер вынужден менять обе
    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; }
    }
}

// Функция написана под прямоугольник и ждёт площадь 5 × 4 = 20
void Grow(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Console.WriteLine(r.Area());
}

Grow(new Rectangle()); // 20 – как и договаривались
Grow(new Square());    // 16 – наследник нарушил обещание родителя
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:
    // Квадрат не может менять стороны по отдельности
    void SetWidth(int value) override { width_ = height_ = value; }
    void SetHeight(int value) override { width_ = height_ = value; }
};

// Функция написана под прямоугольник и ждёт площадь 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 – как и договаривались
Grow(square); // 16 – наследник нарушил обещание родителя
// В Go этой ловушки в таком виде нет: встраивание не переопределяет методы.
// Square может добавить свой SetWidth, но Rectangle всё равно вызовет свой.
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
}

// Подстановка идёт через интерфейс – и вот здесь ловушка возвращается:
// достаточно объявить Shape с SetWidth/SetHeight, и Square снова
// сможет нарушить ожидания кода, написанного под прямоугольник.
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):
    # Квадрат не может менять стороны по отдельности
    @Rectangle.width.setter
    def width(self, value):
        self._width = self._height = value

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

# Функция написана под прямоугольник и ждёт площадь 5 × 4 = 20
def grow(r):
    r.width = 5
    r.height = 4
    print(r.area())

grow(Rectangle())  # 20 – как и договаривались
grow(Square())     # 16 – наследник нарушил обещание родителя
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 {
    // Квадрат не может менять стороны по отдельности
    setWidth(value: number): void {
        this.w = this.h = value;
    }

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

// Функция написана под прямоугольник и ждёт площадь 5 × 4 = 20
function grow(r: Rectangle): void {
    r.setWidth(5);
    r.setHeight(4);
    console.log(r.area());
}

grow(new Rectangle()); // 20 – как и договаривались
grow(new Square());    // 16 – наследник нарушил обещание родителя
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 {
    // Квадрат не может менять стороны по отдельности
    @Override
    public void setWidth(int value) {
        width = height = value;
    }

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

// Метод написан под прямоугольник и ждёт площадь 5 × 4 = 20
static void grow(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    System.out.println(r.area());
}

grow(new Rectangle()); // 20 – как и договаривались
grow(new Square());    // 16 – наследник нарушил обещание родителя
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() {
    // Квадрат не может менять стороны по отдельности
    override fun setWidth(value: Int) {
        w = value
        h = value
    }

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

// Функция написана под прямоугольник и ждёт площадь 5 × 4 = 20
fun grow(r: Rectangle) {
    r.setWidth(5)
    r.setHeight(4)
    println(r.area())
}

grow(Rectangle()) // 20 – как и договаривались
grow(Square())    // 16 – наследник нарушил обещание родителя

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

Банан, горилла и джунгли

Формулировка принадлежит Джо Армстронгу, создателю Erlang: вы хотели банан, а получили гориллу, которая держит банан, и все джунгли впридачу.

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

Особенно заметно это с фреймворками. Наследуетесь от базового контроллера ради одного удобного хелпера – и вместе с ним получаете сериализацию, внедрение зависимостей, работу с HTTP-контекстом и невозможность создать объект в тесте без половины фреймворка.

Лекарство простое: если нужно поведение, а не роль, возьмите объект в поле и вызывайте его. Композиция даёт ровно банан.

Хрупкий базовый класс

Самая коварная из четырёх, потому что ломается код, который никто не трогал.

Базовый класс имеет право менять свою внутреннюю реализацию – считается, что это его личное дело. Но наследники видят его изнутри и невольно начинают зависеть от того, как он написан, а не только от того, что он обещает.

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

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

    // Базовый класс решил, что AddRange – это цикл из Add.
    // Наследник об этом не знает и знать не должен.
    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); // внутри вызовет Add – счётчик вырастет дважды
    }
}

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

Console.WriteLine(bag.AddedCount); // Ожидаем 2, получаем 4
#include <vector>
#include <string>

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

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

    // Базовый класс решил, что AddRange – это цикл из Add
    virtual void AddRange(const std::vector<std::string>& items) {
        for (const auto& item : items) {
            Add(item); // виртуальный вызов уйдёт в наследника
        }
    }

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); // внутри вызовет Add – счётчик вырастет дважды
    }

    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; // Ожидаем 2, получаем 4
}
// В Go наследования нет, и подменить метод базового типа нельзя:
// встроенный Bag всегда вызывает свой собственный Add.
// Хрупкий базовый класс здесь просто не воспроизводится – это цена и одновременно
// достоинство отказа от наследования.
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) // всегда Bag.Add, даже если снаружи «переопределили»
	}
}

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 не знает про CountingBag.Add
}
class Bag:
    def __init__(self):
        self._items = []

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

    # Базовый класс решил, что add_range – это цикл из 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)  # внутри вызовет add – счётчик вырастет дважды

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

print(bag.added_count)  # Ожидаем 2, получаем 4
class Bag {
    protected items: string[] = [];

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

    // Базовый класс решил, что addRange – это цикл из 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); // внутри вызовет add – счётчик вырастет дважды
    }
}

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

console.log(bag.addedCount); // Ожидаем 2, получаем 4
public class Bag {
    private final List<String> items = new ArrayList<>();

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

    // Базовый класс решил, что addAll – это цикл из 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); // внутри вызовет add – счётчик вырастет дважды
    }

    public int getAddedCount() {
        return addedCount;
    }
}

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

System.out.println(bag.getAddedCount()); // Ожидаем 2, получаем 4
open class Bag {
    private val items = mutableListOf<String>()

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

    // Базовый класс решил, что addAll – это цикл из 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) // внутри вызовет add – счётчик вырастет дважды
    }
}

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

println(bag.addedCount) // Ожидаем 2, получаем 4

Что здесь произошло

  • Bag умеет добавлять по одному (Add) и пачкой (AddRange). Пачка внутри реализована как цикл из Add – это деталь реализации, наружу она не обещана.
  • CountingBag хочет считать добавленные элементы и переопределяет оба метода: Add прибавляет единицу, AddRange – сразу длину списка.
  • Вызываем AddRange с двумя элементами. Наследник прибавляет 2 и передаёт работу родителю, родитель вызывает Add на каждый элемент – и переопределённый Add прибавляет ещё по единице. Итого 4 вместо 2.

Стоит родителю переписать AddRange без обращения к Add – и счётчик внезапно станет верным, хотя наследника никто не менял. Отсюда и название: поведение зависит не от обещаний базового класса, а от того, как он написан внутри.

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

Отсюда правило: класс, от которого будут наследоваться, должен либо документировать свои внутренние вызовы как часть контракта, либо быть закрыт для наследования. В Kotlin это поведение по умолчанию – классы final, пока явно не написано open.

Бриллиантовая проблема

Возникает при множественном наследовании. Класс D наследуется от B и C, а те оба – от общего предка A. Если B и C по-разному переопределили один метод, непонятно, какую версию получит D. Схема этих связей на диаграмме напоминает ромб – отсюда название.

Языки решают вопрос по-разному, и это хорошая иллюстрация того, насколько по-разному можно относиться к наследованию.

// В C# множественное наследование классов запрещено.
// Ромб возможен только на интерфейсах с реализацией по умолчанию,
// и компилятор потребует разрешить конфликт вручную.
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
{
    // Без явной реализации – ошибка компиляции: неоднозначность
    public void Start() => Console.WriteLine("Mfp");
}
// C++ – единственный из массовых языков, где ромб возможен буквально.
// Без virtual базовый класс копируется дважды, и обращение неоднозначно.
class Device {
public:
    virtual void Start() { std::cout << "Device" << std::endl; }
    virtual ~Device() = default;
};

// virtual-наследование делает подобъект Device общим для обеих ветвей
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:
    // Без этого переопределения – ошибка компиляции: неоднозначность
    void Start() override { Printer::Start(); }
};
// В Go можно встроить оба типа, но обращение к общему методу
// станет ошибкой компиляции: 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() – не скомпилируется: непонятно, чей Start
	mfp.Printer.Start() // выбираем явно
}
# В Python множественное наследование разрешено,
# а порядок разрешения задаёт MRO (алгоритм C3-линеаризации).
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 – побеждает первый в списке баз
print([c.__name__ for c in Mfp.__mro__])
# ['Mfp', 'Printer', 'Scanner', 'Device', 'object']
// Множественного наследования классов в TypeScript нет.
// Ромб проявляется в примесях, и порядок применения решает всё.
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 {}

// Побеждает примесь, применённая последней
class Mfp extends Scanner(Printer(Device)) {}

new Mfp().start(); // Scanner
// Множественное наследование классов в Java запрещено.
// Ромб возможен на интерфейсах с default-методами,
// и тогда компилятор требует явно выбрать реализацию.
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 {
    // Без этого метода – ошибка компиляции
    @Override
    public void start() {
        Printer.super.start(); // выбираем явно
    }
}
// В Kotlin ромб на интерфейсах разрешается явно, через super<Тип>
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 {
    // Компилятор обязывает переопределить и выбрать реализацию
    override fun start() {
        super<Printer>.start()
    }
}

Обратите внимание на закономерность: все языки, появившиеся после C++, множественное наследование классов запретили. Разрешают его только на интерфейсах и только с явным разрешением конфликта. Это прямое признание того, что проблема реальна и дешевле её предотвратить, чем разбирать.

Строго говоря, интерфейс не наследуют, а реализуют: наследование передаёт готовое поведение, а интерфейс задаёт только обязательства, выполнять которые класс будет сам. Но в разговоре прижилось «унаследовал интерфейс» – внешне это выглядит одинаково, а в C#, C++ и Kotlin и пишется одинаково – через двоеточие после имени класса. Java здесь честнее остальных: extends для наследования и implements для реализации – разные слова. Понимать разницу стоит, спорить о словах – не обязательно.

Что со всем этим делать

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

Стоит помнить и о том, что связей между классами вообще-то три, и наследование описывает только одну из них:

  • композиция – «владеет»: у заказа есть корзина, и без заказа она не живёт;
  • агрегация – «содержит»: в отделе работают сотрудники, но отдел расформируют, а они останутся;
  • наследование – «является»: грузовик является автомобилем.

Почти все разобранные выше ловушки растут из одного корня: отношение на самом деле было «владеет» или «содержит», а записали его как «является». Прежде чем писать двоеточие после имени класса, проверьте себя вопросом «является ли X частным случаем Y» – и если ответ хоть немного натянут, связь выбрана неверно.

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

Практическое правило

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

Чем связывать классы, если не наследованием, – тема следующего урока. А как на композиции строят то, что рука тянется построить наследованием, показывают паттерны из последнего модуля: Strategy вместо иерархии алгоритмов, Bridge вместо перемножения подклассов и Composite вместо иерархии контейнеров.