Урок 28: Наследование и переопределение методов (extends)

Сегодня нас ждет один из фундаментальных, краеугольных камней объектно-ориентированного программирования, это наследование. Это тот механизм, который позволяет нашим сущностям не быть изолированными островами, а выстраиваться в сложные и логичные иерархии, перенимая черты и поведение друг у друга. Если вы мечтали создавать элегантные, переиспользуемые и легко поддерживаемые структуры кода, то этот урок для вас.

Мы детально разберем, как создавать подклассы с помощью ключевого слова extends, как управлять родительскими конструкторами с помощью super и как наделять дочерние классы уникальным поведением, переопределяя методы своих предков. Это мощный инструмент и я уверен, что после сегодняшнего занятия, вы почувствуете себя настоящими архитекторами кода.

Что такое наследование?

Давайте начнем с самой сути. Наследование это механизм ООП, который позволяет одному классу (называемому дочерним классом или подклассом) перенимать свойства и методы другого класса (называемого родительским классом, суперклассом или базовым классом). Представьте себе иерархию: есть некое общее, абстрактное понятие, а есть его более конкретные реализации.

Например, представьте класс ТранспортноеСредство. У него есть общие свойства: скоростьвесцвет и методы: двигаться()останавливаться(). Теперь, мы можем создать более специфичные классы: АвтомобильВелосипедЛодка. Все они являются разновидностями транспорта, а значит, логично, что они должны иметь все те же свойства и методы, что и ТранспортноеСредство, плюс какие-то свои уникальные. Автомобиль может иметь свойство количествоДверей и метод сигналить(), а Лодка это осадка и броситьЯкорь(). Наследование как раз и позволяет нам описать эту связь, избегая дублирования кода. Мы не копируем код из ТранспортноеСредство в Автомобиль, а просто говорим: «Эй, Автомобиль, ты частный случай ТранспортногоСредства, поэтому у тебя есть всё, что есть у него и плюс что-то свое».

Это принцип «является» (isa). Автомобиль является ТранспортнымСредствомВелосипед является ТранспортнымСредством. Именно эта связь и моделируется через наследование. В TypeScript для установления этой связи используется ключевое слово extends, о котором мы и поговорим дальше.

Создание подклассов с помощью extends

Синтаксис наследования в TypeScript невероятно прост и интуитивно понячен. Чтобы объявить, что один класс наследует от другого, мы используем ключевое слово extends после имени дочернего класса и перед именем родительского.

Давайте оживим наш пример с транспортом. Для начала создадим базовый класс Vehicle.

typescript
class Vehicle {
    protected speed: number = 0; // protected, чтобы дочерние классы тоже имели доступ
    protected weight: number;
    protected color: string;

    constructor(weight: number, color: string) {
        this.weight = weight;
        this.color = color;
    }

    public move(speed: number): void {
        this.speed = speed;
        console.log(`Транспорт начинает движение со скоростью ${this.speed} км/ч.`);
    }

    public stop(): void {
        this.speed = 0;
        console.log('Транспорт остановился.');
    }

    public getSpeed(): number {
        return this.speed;
    }
}

Теперь создадим класс Car, который будет наследовать от Vehicle. Он получит все его публичные и защищенные (protected) поля и методы.

typescript
class Car extends Vehicle {
    private numberOfDoors: number;

    // Новый конструктор для Car
    constructor(weight: number, color: string, numberOfDoors: number) {
        // Вызов конструктора родительского класса - ОБЯЗАТЕЛЕН!
        super(weight, color);
        this.numberOfDoors = numberOfDoors;
    }

    // Уникальный метод для класса Car
    public honk(): void {
        console.log('Би-бип!');
    }
}

Вот и все! Класс Car теперь автоматически обладает методами movestopgetSpeed и свойствами speedweightcolor, хотя мы явно их внутри Car не объявляли. Мы можем использовать объекты класса Car следующим образом:

typescript
const myCar = new Car(1500, 'синий', 5);
console.log(myCar.getSpeed()); // 0 (значение по умолчанию)
myCar.move(90); // "Транспорт начинает движение со скоростью 90 км/ч."
myCar.honk();   // "Би-бип!"
myCar.stop();   // "Транспорт остановился."

// Ошибка компиляции: Свойство 'numberOfDoors' является приватным и доступно только в классе 'Car'.
// console.log(myCar.numberOfDoors);

// Ошибка компиляции: Свойство 'speed' является защищенным и доступно только в классе 'Vehicle' и его подклассах.
// console.log(myCar.speed);

Обратите внимание, как дочерний класс расширяет функциональность родительского. Он добавляет новое свойство numberOfDoors и новый метод honk(). При этом весь базовый функционал нам достается абсолютно бесплатно, благодаря наследованию. Это и есть сила extends. Но вы наверняка заметили новое ключевое слово super в конструкторе Car. Без него ничего бы не работало. Давайте разберемся, почему.

Ключевое слово super

Ключевое слово super в контексте класса имеет два основных значения и оба они критически важны для работы с наследованием.

  1. super() как вызов конструктора родительского класса.

  2. super.метод() как вызов метода родительского класса из переопределенного метода.

Давайте рассмотрим оба случая по порядку.

Вызов super() в конструкторе. Когда мы создаем экземпляр дочернего класса (например, new Car(...)), цепочка вызовов должна идти сверху вниз. Сначала должен быть корректно проинициализирован родительский класс, а затем дочерний. Язык гарантирует, что до обращения к любым свойствам дочернего класса (через this) должен быть вызван конструктор родительского. Это правило.

Поэтому в конструкторе дочернего класса вызов super() обязателен. Более того, он должен быть первым выражением в конструкторе. super() по сути является вызовом конструктора родительского класса. В наш пример мы передаем ему необходимые аргументы: super(weight, color).

Попробуйте убрать эту строку или поставить ее после this.numberOfDoors = ... и компилятор TypeScript сразу укажет на ошибку.

typescript
class Car extends Vehicle {
    private numberOfDoors: number;

    constructor(weight: number, color: string, numberOfDoors: number) {
        // ОШИБКА: Вызов 'super' должен быть первым оператором в конструкторе, если элемент класса содержит параметры свойств
        this.numberOfDoors = numberOfDoors;
        super(weight, color);
    }
}

Использование super для вызова методов родителя. Вторая роль super проявляется, когда мы переопределяем метод в дочернем классе, но хотим частично использовать реализацию из родительского класса. Это предотвращает дублирование кода и позволяет расширять, а не полностью заменять поведение.

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

typescript
class Car extends Vehicle {
    private numberOfDoors: number;
    private areHeadlightsOn: boolean = false;

    constructor(weight: number, color: string, numberOfDoors: number) {
        super(weight, color);
        this.numberOfDoors = numberOfDoors;
    }

    public honk(): void {
        console.log('Би-бип!');
    }

    // Переопределяем метод move
    public move(speed: number): void {
        // 1. Сначала делаем что-то новое, специфичное для Car
        this.turnOnHeadlights();

        // 2. Затем вызываем оригинальную логику из класса Vehicle с помощью super
        super.move(speed); // Вызов родительского метода move

        // 3. Можем добавить еще что-то после
        console.log('Автомобиль плавно набирает скорость.');
    }

    private turnOnHeadlights(): void {
        this.areHeadlightsOn = true;
        console.log('Фары включены.');
    }
}

Теперь при вызове myCar.move(90) мы получим:

text
Фары включены.
Транспорт начинает движение со скоростью 90 км/ч.
Автомобиль плавно набирает скорость.

Мы успешно дополнили поведение, не копируя код из Vehicle.move(). Ключевая строка здесь super.move(speed). Она говорит: «Выполни ту самую логику движения, которая была определена в родительском классе». Без этого вызова оригинальная логика move была бы полностью заменена на новый код и скорость транспортного средства никогда бы не изменилась.

Переопределение методов (Method Overriding)

Как вы уже догадались из предыдущего примера, переопределение метода это создание в дочернем классе метода с тем же именем, что и у родительского класса. При вызове этого метода у экземпляра дочернего класса будет исполнена новая, переопределенная версия.

Зачем это нужно?

  1. Для расширения функциональности. Как в примере выше мы добавили включение фар к стандартному процессу движения.

  2. Для полного изменения поведения. Иногда реализация родителя слишком общая или вообще не подходит для дочернего класса. Например, метод воспроизвестиЗвук() в классе Животное может быть абстрактным или выводить общий звук. В классе Собака мы переопределим его на console.log('Гав!'), а в классе Кошка на console.log('Мяу!').

  3. Для реализации абстрактных методов. (Мы подробнее поговорим об этом в следующем уроке). Если родительский класс объявил метод как abstract, то любой его неабстрактный подкласс обязан предоставить свою реализацию этого метода. Это частный случай переопределения.

TypeScript помогает нам избежать ошибок при переопределении. Он следит, чтобы сигнатура метода (имя, тип возвращаемого значения, типы параметров) в дочернем классе была совместима с сигнатурой в родительском. Вы не можете, например, переопределить метод move(speed: number): void методом move(): void или move(speed: string): void. Типы должны совпадать.

Рассмотрим пример с полным изменением поведения:

typescript
class Animal {
    public makeSound(): void {
        console.log('Какой-то общий звук животного...');
    }
}

class Dog extends Animal {
    // Полностью переопределяем поведение
    public makeSound(): void {
        console.log('Гав-гав!');
    }
}

class Cat extends Animal {
    // Полностью переопределяем поведение
    public makeSound(): void {
        console.log('Мяу!');
    }
}

const myAnimal: Animal = new Animal();
const myDog: Animal = new Dog(); // Обратите внимание: полиморфизм! Тип Animal, но объект Dog.
const myCat: Cat = new Cat();

myAnimal.makeSound(); // "Какой-то общий звук животного..."
myDog.makeSound();    // "Гав-гав!" - работает переопределенная версия
myCat.makeSound();    // "Мяу!"

Этот пример также демонстрирует полиморфизм: переменная myDog имеет тип Animal, но хранит ссылку на объект Dog. При вызове makeSound() выполняется метод не по типу переменной (Animal), а по типу реального объекта (Dog). Это одно из самых мощных свойств ООП.

Практические задачи и примеры для закрепления

Давайте закрепим знания через несколько задач и примеров.

Задача 1: Базовый класс и простое наследование
Создайте базовый класс Shape (Фигура) с защищенным свойством color: string и конструктором для его инициализации. Добавьте метод getArea(): number, который возвращает 0. Создайте класс Circle (Круг), который наследует от Shape. Добавьте ему приватное свойство radius: number. Переопределите метод getArea() так, чтобы он вычислял площадь круга по формуле π * r². Используйте Math.PI.

typescript
class Shape {
    protected color: string;

    constructor(color: string) {
        this.color = color;
    }

    public getArea(): number {
        return 0;
    }

    public getColor(): string {
        return this.color;
    }
}

class Circle extends Shape {
    private radius: number;

    constructor(color: string, radius: number) {
        super(color);
        this.radius = radius;
    }

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

// Проверяем
const redCircle = new Circle('red', 5);
console.log(`Цвет круга: ${redCircle.getColor()}`); // "Цвет круга: red"
console.log(`Площадь круга: ${redCircle.getArea().toFixed(2)}`); // "Площадь круга: 78.54"

Задача 2: Расширение функциональности через super
Создайте класс Rectangle (Прямоугольник), наследуемый от Shape. Добавьте свойства width и height. Переопределите getArea(). Теперь создайте класс Square (Квадрат), который наследует от Rectangle. Квадрат это частный случай прямоугольника. В его конструкторе должен приниматься только color и side (сторона). Используйте вызов super() для передачи в конструктор Rectangle правильных аргументов.

typescript
class Rectangle extends Shape {
    private width: number;
    private height: number;

    constructor(color: string, width: number, height: number) {
        super(color);
        this.width = width;
        this.height = height;
    }

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

class Square extends Rectangle {
    // Квадрат принимает только сторону, а width и height для родителя будут одинаковыми
    constructor(color: string, side: number) {
        super(color, side, side); // Ключевой момент!
    }
}

// Проверяем
const blueSquare = new Square('blue', 4);
console.log(`Площадь квадрата: ${blueSquare.getArea()}`); // "Площадь квадрата: 16"

Обратите внимание, как элегантно мы используем наследование: Square наследует всю логику от Rectangle и нам не пришлось писать метод getArea() заново. Конструктор Square лишь подготавливает аргументы для родительского конструктора.

Задача 3: Многоуровневое наследование и полное переопределение
Создайте класс Employee (Сотрудник) со свойствами name (публичное) и yearOfExperience (защищенное, стаж). Добавьте метод calculateSalary(): number, который возвращает базовую зарплату 30000. Создайте класс Manager (Менеджер), наследующий от Employee. Переопределите calculateSalary(), чтобы она вычислялась как базовая зарплата + (стаж * 20000). Затем создайте класс CEO, наследующий от Manager. Переопределите его метод calculateSalary(), чтобы он возвращал фиксированную сумму 500000 + (стаж * 50000). Про демонстрируйте полиморфизм, создав массив сотрудников разных типов и вычислив общую сумму зарплат.

typescript
class Employee {
    public name: string;
    protected yearOfExperience: number;

    constructor(name: string, yearOfExperience: number) {
        this.name = name;
        this.yearOfExperience = yearOfExperience;
    }

    public calculateSalary(): number {
        return 30000;
    }
}

class Manager extends Employee {
    public calculateSalary(): number {
        // Используем логику родителя (базовую зарплату) и добавляем свою
        const baseSalary = super.calculateSalary(); // Получаем 30000
        return baseSalary + (this.yearOfExperience * 20000);
    }
}

class CEO extends Manager {
    public calculateSalary(): number {
        // Полностью своя логика, не используем super
        return 500000 + (this.yearOfExperience * 50000);
    }
}

// Демонстрация полиморфизма
const employees: Employee[] = []; // Массив типа родительского класса
employees.push(new Employee('Иван', 1));
employees.push(new Manager('Мария', 5));
employees.push(new CEO('Петр', 10));

let totalSalary = 0;
for (const employee of employees) {
    console.log(`${employee.name}: ${employee.calculateSalary()} руб.`);
    totalSalary += employee.calculateSalary();
}
console.log(`Общие расходы на зарплаты: ${totalSalary} руб.`);

Вывод в консоли:

text
Иван: 30000 руб.
Мария: 130000 руб. // 30000 + (5 * 20000)
Петр: 1000000 руб. // 500000 + (10 * 50000)
Общие расходы на зарплаты: 1160000 руб.

Эта задача отлично показывает всю мощь иерархий и полиморфизма. Код, который вычисляет общую зарплату (for...of цикл), абсолютно не заботится о том, какой конкретно тип сотрудника перед ним. Он просто вызывает calculateSalary() и правильная реализация метода выбирается автоматически, в зависимости от реального типа объекта. Это делает код невероятно гибким: чтобы добавить новый тип сотрудника (например, Intern), нам нужно лишь создать новый класс, унаследованный от Employee или его потомков и переопределить один метод. Цикл расчета общей зарплаты изменять не потребуется!

Важные замечания и частые ошибки

  1. Одиночное наследование. В TypeScript, как и в JavaScript, класс может наследовать только от одного другого класса. Множественное наследование не поддерживается. Однако, эту проблему решают с помощью миксинов (mixins) или композиции (предпочтительный способ во многих случаях), но это тема для отдельного advanced-урока.

  2. Порядок вызова конструкторов. При создании экземпляра дочернего класса цепочка вызовов всегда идет от самого старшего предка к самому младшему потомку. Сначала выполняется конструктор Vehicle, затем Car, затем (если бы был) SportsCar.

  3. super только в дочерних классах. Ключевое слово super имеет смысл только внутри класса, который от кого-то наследуется. Попытка использовать его в обычном классе вызовет ошибку.

  4. Совместимость сигнатур. Всегда следите, чтобы переопределяемый метод был совместим с родительским. Это включает в себя и количество и типы параметров. Используйте строгую проверку типов TypeScript себе в помощь.

  5. Композиция вместо наследования. Это важнейший принцип проектирования. Прежде чем тянуться к extends, спросите себя: «А правда ли моя сущность является частным случаем другой? Или ей просто нужно использовать функционал другой сущности?». Если верно второе, возможно, лучше использовать не наследование, а включение объекта нужного класса в качестве свойства (композиция). Car is-a Vehicle наследование оправдано. Car has-an Engine,  тут лучше композиция: создать класс Engine и добавить свойство engine: Engine внутрь класса Car.

Наследование и переопределение методов это инструменты, которые помогут вам строить сложные, но хорошо структурированные и гибкие приложения.

Хотите пройти курс полностью и стать экспертом в TypeScript? Переходите на страницу с полным курсом по TypeScript для начинающих и найдите там все 40 уроков, дополнительные материалы, задания и поддержку от автора.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий