Сегодня нас ждет один из фундаментальных, краеугольных камней объектно-ориентированного программирования, это наследование. Это тот механизм, который позволяет нашим сущностям не быть изолированными островами, а выстраиваться в сложные и логичные иерархии, перенимая черты и поведение друг у друга. Если вы мечтали создавать элегантные, переиспользуемые и легко поддерживаемые структуры кода, то этот урок для вас.
Мы детально разберем, как создавать подклассы с помощью ключевого слова extends, как управлять родительскими конструкторами с помощью super и как наделять дочерние классы уникальным поведением, переопределяя методы своих предков. Это мощный инструмент и я уверен, что после сегодняшнего занятия, вы почувствуете себя настоящими архитекторами кода.
Что такое наследование?
Давайте начнем с самой сути. Наследование это механизм ООП, который позволяет одному классу (называемому дочерним классом или подклассом) перенимать свойства и методы другого класса (называемого родительским классом, суперклассом или базовым классом). Представьте себе иерархию: есть некое общее, абстрактное понятие, а есть его более конкретные реализации.
Например, представьте класс ТранспортноеСредство. У него есть общие свойства: скорость, вес, цвет и методы: двигаться(), останавливаться(). Теперь, мы можем создать более специфичные классы: Автомобиль, Велосипед, Лодка. Все они являются разновидностями транспорта, а значит, логично, что они должны иметь все те же свойства и методы, что и ТранспортноеСредство, плюс какие-то свои уникальные. Автомобиль может иметь свойство количествоДверей и метод сигналить(), а Лодка это осадка и броситьЯкорь(). Наследование как раз и позволяет нам описать эту связь, избегая дублирования кода. Мы не копируем код из ТранспортноеСредство в Автомобиль, а просто говорим: «Эй, Автомобиль, ты частный случай ТранспортногоСредства, поэтому у тебя есть всё, что есть у него и плюс что-то свое».
Это принцип «является» (isa). Автомобиль является ТранспортнымСредством. Велосипед является ТранспортнымСредством. Именно эта связь и моделируется через наследование. В TypeScript для установления этой связи используется ключевое слово extends, о котором мы и поговорим дальше.
Создание подклассов с помощью extends
Синтаксис наследования в TypeScript невероятно прост и интуитивно понячен. Чтобы объявить, что один класс наследует от другого, мы используем ключевое слово extends после имени дочернего класса и перед именем родительского.
Давайте оживим наш пример с транспортом. Для начала создадим базовый класс Vehicle.
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) поля и методы.
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 теперь автоматически обладает методами move, stop, getSpeed и свойствами speed, weight, color, хотя мы явно их внутри Car не объявляли. Мы можем использовать объекты класса Car следующим образом:
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 в контексте класса имеет два основных значения и оба они критически важны для работы с наследованием.
-
super()как вызов конструктора родительского класса. -
super.метод()как вызов метода родительского класса из переопределенного метода.
Давайте рассмотрим оба случая по порядку.
Вызов super() в конструкторе. Когда мы создаем экземпляр дочернего класса (например, new Car(...)), цепочка вызовов должна идти сверху вниз. Сначала должен быть корректно проинициализирован родительский класс, а затем дочерний. Язык гарантирует, что до обращения к любым свойствам дочернего класса (через this) должен быть вызван конструктор родительского. Это правило.
Поэтому в конструкторе дочернего класса вызов super() обязателен. Более того, он должен быть первым выражением в конструкторе. super() по сути является вызовом конструктора родительского класса. В наш пример мы передаем ему необходимые аргументы: super(weight, color).
Попробуйте убрать эту строку или поставить ее после this.numberOfDoors = ... и компилятор 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.
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) мы получим:
Фары включены. Транспорт начинает движение со скоростью 90 км/ч. Автомобиль плавно набирает скорость.
Мы успешно дополнили поведение, не копируя код из Vehicle.move(). Ключевая строка здесь super.move(speed). Она говорит: «Выполни ту самую логику движения, которая была определена в родительском классе». Без этого вызова оригинальная логика move была бы полностью заменена на новый код и скорость транспортного средства никогда бы не изменилась.
Переопределение методов (Method Overriding)
Как вы уже догадались из предыдущего примера, переопределение метода это создание в дочернем классе метода с тем же именем, что и у родительского класса. При вызове этого метода у экземпляра дочернего класса будет исполнена новая, переопределенная версия.
Зачем это нужно?
-
Для расширения функциональности. Как в примере выше мы добавили включение фар к стандартному процессу движения.
-
Для полного изменения поведения. Иногда реализация родителя слишком общая или вообще не подходит для дочернего класса. Например, метод
воспроизвестиЗвук()в классеЖивотноеможет быть абстрактным или выводить общий звук. В классеСобакамы переопределим его наconsole.log('Гав!'), а в классеКошканаconsole.log('Мяу!'). -
Для реализации абстрактных методов. (Мы подробнее поговорим об этом в следующем уроке). Если родительский класс объявил метод как
abstract, то любой его неабстрактный подкласс обязан предоставить свою реализацию этого метода. Это частный случай переопределения.
TypeScript помогает нам избежать ошибок при переопределении. Он следит, чтобы сигнатура метода (имя, тип возвращаемого значения, типы параметров) в дочернем классе была совместима с сигнатурой в родительском. Вы не можете, например, переопределить метод move(speed: number): void методом move(): void или move(speed: string): void. Типы должны совпадать.
Рассмотрим пример с полным изменением поведения:
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.
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 правильных аргументов.
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). Про демонстрируйте полиморфизм, создав массив сотрудников разных типов и вычислив общую сумму зарплат.
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} руб.`);
Вывод в консоли:
Иван: 30000 руб. Мария: 130000 руб. // 30000 + (5 * 20000) Петр: 1000000 руб. // 500000 + (10 * 50000) Общие расходы на зарплаты: 1160000 руб.
Эта задача отлично показывает всю мощь иерархий и полиморфизма. Код, который вычисляет общую зарплату (for...of цикл), абсолютно не заботится о том, какой конкретно тип сотрудника перед ним. Он просто вызывает calculateSalary() и правильная реализация метода выбирается автоматически, в зависимости от реального типа объекта. Это делает код невероятно гибким: чтобы добавить новый тип сотрудника (например, Intern), нам нужно лишь создать новый класс, унаследованный от Employee или его потомков и переопределить один метод. Цикл расчета общей зарплаты изменять не потребуется!
Важные замечания и частые ошибки
-
Одиночное наследование. В TypeScript, как и в JavaScript, класс может наследовать только от одного другого класса. Множественное наследование не поддерживается. Однако, эту проблему решают с помощью миксинов (mixins) или композиции (предпочтительный способ во многих случаях), но это тема для отдельного advanced-урока.
-
Порядок вызова конструкторов. При создании экземпляра дочернего класса цепочка вызовов всегда идет от самого старшего предка к самому младшему потомку. Сначала выполняется конструктор
Vehicle, затемCar, затем (если бы был)SportsCar. -
superтолько в дочерних классах. Ключевое словоsuperимеет смысл только внутри класса, который от кого-то наследуется. Попытка использовать его в обычном классе вызовет ошибку. -
Совместимость сигнатур. Всегда следите, чтобы переопределяемый метод был совместим с родительским. Это включает в себя и количество и типы параметров. Используйте строгую проверку типов TypeScript себе в помощь.
-
Композиция вместо наследования. Это важнейший принцип проектирования. Прежде чем тянуться к
extends, спросите себя: «А правда ли моя сущность является частным случаем другой? Или ей просто нужно использовать функционал другой сущности?». Если верно второе, возможно, лучше использовать не наследование, а включение объекта нужного класса в качестве свойства (композиция).Caris-aVehicleнаследование оправдано.Carhas-anEngine, тут лучше композиция: создать классEngineи добавить свойствоengine: Engineвнутрь классаCar.
Наследование и переопределение методов это инструменты, которые помогут вам строить сложные, но хорошо структурированные и гибкие приложения.
Хотите пройти курс полностью и стать экспертом в TypeScript? Переходите на страницу с полным курсом по TypeScript для начинающих и найдите там все 40 уроков, дополнительные материалы, задания и поддержку от автора.
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


