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