$ sudo teach IT
МОДУЛЬ 8 · УРОК 4

Наследование и полиморфизм

Расширение классов через extends, переопределение методов и полиморфизм

Цели урока

  • Понять концепцию наследования и зачем оно нужно
  • Освоить ключевое слово extends и вызов super()
  • Научиться переопределять методы в дочерних классах
  • Понять полиморфизм — один интерфейс, разные реализации
  • Разобрать цепочку наследования и её ограничения
  • Изучить типичные ошибки при работе с наследованием

1. Зачем нужно наследование

Представьте, что вы пишете игру. У вас есть класс Enemy — враг. Но враги бывают разные: Goblin, Orc, Dragon. У каждого свои особенности, но у всех есть общее: имя, здоровье, метод атаки.

Без наследования вам пришлось бы копировать код для каждого типа врага. С наследованием вы пишете общую логику один раз в базовом классе, а дочерние классы только добавляют своё.

Аналогия:

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

Ключевое преимущество наследования — DRY (Don't Repeat Yourself): не повторяй себя. Общая логика живёт в родителе, специфичная — в наследнике.

2. extends и super — базовый синтаксис

Ключевое слово extends создаёт наследование: дочерний класс получает все свойства и методы родителя.

// Родительский класс (базовый)
class Animal {
  constructor(
    public name: string,
    protected energy: number = 100
  ) {}

  eat(food: number): void {
    this.energy += food;
    console.log(`${this.name} поел. Энергия: ${this.energy}`);
  }

  sleep(): void {
    this.energy += 50;
    console.log(`${this.name} поспал. Энергия: ${this.energy}`);
  }
}

// Дочерний класс (наследник)
class Dog extends Animal {
  constructor(
    name: string,
    public breed: string
  ) {
    // super() вызывает конструктор родителя
    super(name);
  }

  // Свой метод — специфичный для Dog
  fetch(): void {
    this.energy -= 15;
    console.log(`${this.name} побегал за палкой. Энергия: ${this.energy}`);
  }
}

Использование:

const dog = new Dog("Шарик", "Доберман");

// Dog НАСЛЕДУЕТ методы от Animal
dog.eat(30);     // ✅ Метод родителя
dog.sleep();     // ✅ Метод родителя
dog.fetch();     // ✅ Свой метод
console.log(dog.name); // "Шарик"
console.log(dog.breed); // "Доберман"
Важно про super():

Вызов super() в конструкторе дочернего класса обязателен до любого обращения к this. Если не вызвать super(), TypeScript выдаст ошибку: 'super()' must be called before accessing 'this'.

super() может передавать аргументы конструктору родителя:

class Animal {
  constructor(
    public name: string,
    protected energy: number
  ) {}
}

class Cat extends Animal {
  constructor(
    name: string,
    public indoor: boolean
  ) {
    // Передаём name родителю, а energy задаём по умолчанию
    super(name, 80);
  }

  purr(): void {
    console.log(`${this.name} мурлычет`);
  }
}

const cat = new Cat("Мурка", true);
cat.eat(20); // ✅ Метод родителя
cat.purr();   // "Мурка мурлычет"

3. Переопределение методов (Override)

Дочерний класс может переопределить метод родителя — заменить его реализацию на свою. При этом метод остаётся с тем же именем и сигнатурой:

class Shape {
  constructor(public color: string) {}

  area(): number {
    return 0; // Базовая реализация
  }

  describe(): string {
    return `Фигура площадью ${this.area()}`;
  }
}

class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);
  }

  // Переопределяем area()
  area(): number {
    return Math.PI * this.radius ** 2;
  }

  // Переопределяем describe()
  describe(): string {
    return `${this.color} круг с радиусом ${this.radius}, площадь: ${this.area().toFixed(2)}`;
  }
}

class Rectangle extends Shape {
  constructor(
    color: string,
    public width: number,
    public height: number
  ) {
    super(color);
  }

  area(): number {
    return this.width * this.height;
  }

  describe(): string {
    return `${this.color} прямоугольник ${this.width}×${this.height}, площадь: ${this.area()}`;
  }
}

Ключевое слово override (TypeScript 4.3+) — явное указание, что метод переопределяет родительский:

class Circle extends Shape {
  // override защищает от опечаток в имени метода
  override area(): number {
    return Math.PI * this.radius ** 2;
  }

  // Если написать areа() (с опечаткой) — override выдаст ошибку
  // override areа(): number { ... } // ❌ Method 'areа' is not declared in class
}

4. Полиморфизм — один интерфейс, много реализаций

Полиморфизм (от греч. «много форм») — это возможность treats объектов разных классов через общий интерфейс. Вы вызываете один и тот же метод, а он ведёт себя по-разному в зависимости от типа объекта.

Аналогия:

Представьте кнопку «Включить» на пульте. Вы нажимаете одну кнопку, но TV включает телевизор, DVD — проигрыватель, AC — кондиционер. Одно действие — разные результаты в зависимости от объекта.

// Функция принимает родительский тип
// Но работает с любым наследником!
function printArea(shape: Shape): void {
  console.log(`Площадь: ${shape.area()}`);
}

// Разные объекты — одно и то же действие
const circle = new Circle("красный", 5);
const rect = new Rectangle("синий", 10, 5);

printArea(circle); // "Площадь: 78.54" — вызывается Circle.area()
printArea(rect);   // "Площадь: 50" — вызывается Rectangle.area()

Без полиморфизма вам пришлось бы проверять тип каждого объекта:

// ❌ Без полиморфизма — проверки типов везде
function printAreaBad(shape: Circle | Rectangle) {
  if (shape instanceof Circle) {
    console.log(`Площадь круга: ${shape.area()}`);
  } else if (shape instanceof Rectangle) {
    console.log(`Площадь прямоугольника: ${shape.area()}`);
  }
}

// ✅ С полиморфизмом — просто вызываем метод
function printAreaGood(shape: Shape) {
  console.log(`Площадь: ${shape.area()}`);
}

Полиморфизм позволяет писать код, который работает с любым количеством новых наследников без изменений:

// Добавляем новый класс — printArea() НЕ МЕНЯЕТСЯ!
class Triangle extends Shape {
  constructor(color: string, public base: number, public h: number) {
    super(color);
  }
  area(): number { return 0.5 * this.base * this.h; }
}

// printArea() работает с Triangle без изменений!
printArea(new Triangle("зелёный", 10, 6)); // "Площадь: 30"

5. Проверка типа через instanceof

Иногда нужно проверить, к какому конкретному классу относится объект. Для этого используется instanceof:

function describeShape(shape: Shape): string {
  if (shape instanceof Circle) {
    // TypeScript ЗНАЕТ, что shape — это Circle
    // Можно обращаться к shape.radius
    return `Круг радиусом ${shape.radius}`;
  } else if (shape instanceof Rectangle) {
    // TypeScript ЗНАЕТ, что shape — это Rectangle
    return `Прямоугольник ${shape.width}×${shape.height}`;
  } else {
    return "Неизвестная фигура";
  }
}
Совет:

Постарайтесь избегать instanceof — это признак нарушения полиморфизма. Если вам нужно проверять тип, возможно, стоит добавить метод в родительский класс или использовать другие паттерны.

6. Цепочка наследования

Наследование может быть многоуровневым: класс A наследует от B, B от C, и так далее.

// Базовый класс
class LivingThing {
  constructor(public name: string) {}
  breathe(): void { console.log(`${this.name} дышит`); }
}

// Наследник LivingThing
class Animal extends LivingThing {
  constructor(name: string, public legs: number) {
    super(name);
  }
  walk(): void { console.log(`${this.name} идёт`); }
}

// Наследник Animal
class Dog extends Animal {
  constructor(name: string, public breed: string) {
    super(name, 4);
  }
  bark(): void { console.log("Гав-гав!"); }
}

// Dog имеет ВСЕ методы из всех уровней!
const dog = new Dog("Рекс", "Овчарка");
dog.breathe(); // ✅ из LivingThing
dog.walk();   // ✅ из Animal
dog.bark();   // ✅ из Dog
Предупреждение:

Не увлекайтесь глубокими цепочками наследования. В реальных проектах рекомендуется не более 2-3 уровней. Глубокое наследование делает код сложным и хрупким. Часто лучше использовать композицию (встраивание объектов) вместо наследования.

7. Практический пример — Иерархия сотрудников

class Employee {
  protected bonus: number = 0;

  constructor(
    public name: string,
    protected baseSalary: number
  ) {}

  calculateSalary(): number {
    return this.baseSalary + this.bonus;
  }

  getInfo(): string {
    return `${this.name}: ${this.calculateSalary()} руб.`;
  }
}

// Менеджер — получает бонус за управление
class Manager extends Employee {
  override calculateSalary(): number {
    this.bonus = this.baseSalary * 0.3;
    return super.calculateSalary();
  }

  manage(): void {
    console.log(`${this.name} управляет командой`);
  }
}

// Разработчик — получает бонус за код
class Developer extends Employee {
  constructor(
    name: string,
    baseSalary: number,
    public language: string
  ) {
    super(name, baseSalary);
  }

  override calculateSalary(): number {
    this.bonus = 5000;
    return super.calculateSalary();
  }

  code(): void {
    console.log(`${this.name} пишет код на ${this.language}`);
  }
}

Использование полиморфизма:

// Полиморфизм — массив родительского типа
const team: Employee[] = [
  new Manager("Анна", 150000),
  new Developer("Борис", 120000, "TypeScript"),
  new Developer("Вика", 130000, "Python"),
];

// Один цикл — разные результаты!
team.forEach(emp => {
  console.log(emp.getInfo());
});
// "Анна: 195000 руб."
// "Борис: 125000 руб."
// "Вика: 135000 руб."

8. Частые ошибки

Ошибка 1: Забыли super() в конструкторе

// ❌ Ошибка: Must call super constructor
class Dog extends Animal {
  constructor(name: string) {
    // ❌ super() забыли!
    this.name = name; // ❌ 'this' not allowed before super()
  }
}

// ✅ Правильно:
class Dog2 extends Animal {
  constructor(name: string) {
    super(name); // ✅ Сначала super()
  }
}

Ошибка 2: Обращение к private свойству родителя

class Parent {
  private secret = "hidden";
}

class Child extends Parent {
  show() {
    // console.log(this.secret); // ❌ Property 'secret' is private
  }
}

// ✅ Решение: используйте protected вместо private

Ошибка 3: Переопределили метод, изменив сигнатуру

// ❌ Сигнатура метода должна совпадать
class Base {
  greet(name: string): string {
    return `Привет, ${name}`;
  }
}

class Child2 extends Base {
  // ❌ Изменили количество параметров!
  greet(name: string, title: string): string {
    return `${title} ${name}`;
  }
}

Практическое задание

Создайте иерархию классов для транспорта:

  • Vehicle — базовый: brand, year, start(), stop()
  • Car extends Vehicle — additional: doors, isElectric, openTrunk()
  • Bicycle extends Vehicle — additional: hasBell, ringBell()
  • Переопределите start() в каждом классе с разным сообщением
  • Создайте массив Vehicle[] и пройдитесь по нему циклом, вызывая start()

Чек-лист урока

  • extends — создаёт наследование: class Child extends Parent
  • super() — вызывает конструктор родителя, обязателен в конструкторе наследника
  • super.method() — вызывает метод родителя (полезно при переопределении)
  • Override — явное указание, что метод переопределяет родительский
  • Полиморфизм — объекты разных классов обрабатываются через общий интерфейс
  • instanceof — проверка, является ли объект экземпляром класса
  • Цепочка наследования — A extends B extends C (рекомендуется не более 2-3 уровней)
  • Protected — для свойств, доступных наследникам
  • Private не наследуется — наследники не могут обращаться к private свойствам
  • DRY — общая логика в родителе, специфичная — в наследнике
  • Частые ошибки — забыли super(), обращение к private, несовпадение сигнатур

Тест по наследованию и полиморфизму

8 вопросов

Наследование Employee от Person

Premium

Цепочка наследования с override

Premium