ООП: Наследование в TypeScript: подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты работаешь на кондитерской фабрике. Сначала ты создал рецепт обычного Пирога: у него есть тесто, начинка и способ выпекания. Этот рецепт — твой базовый класс. Теперь тебе нужно сделать Яблочный пирог. Вместо того чтобы писать рецепт с нуля, ты берёшь рецепт обычного пирога и просто добавляешь в него пункт: «положить яблоки». То же самое с Мясным пирогом — ты берёшь базу и меняешь начинку.
Это и есть наследование в программировании. Ты создаёшь один общий класс (родительский), а затем на его основе строишь новые классы (дочерние), которые автоматически получают все свойства и методы родителя. Дочерний класс может:
- использовать всё, что есть у родителя, без переписывания кода;
- добавлять свои собственные свойства и методы;
- изменять (переопределять) поведение родительских методов.
Зачем это нужно? Представь, что у тебя в программе есть 10 разных типов пользователей: Админ, Модератор, Редактор, Подписчик. Если не использовать наследование, тебе придётся 10 раз писать код для имени, email и пароля. А если ты захочешь добавить поле «аватарка», тебе придётся лезть во все 10 классов. Наследование решает эту проблему: ты пишешь общий код один раз в классе User, а все остальные просто наследуют его.
💡 Наследование — это отношение «ЯВЛЯЕТСЯ» (is-a). Яблочный пирог ЯВЛЯЕТСЯ пирогом. Админ ЯВЛЯЕТСЯ пользователем. Собака ЯВЛЯЕТСЯ животным. Если этой связи нет — наследование использовать нельзя.
Теория: как это работает изнутри
Когда ты создаёшь дочерний класс, TypeScript (и JavaScript под капотом) делает следующее:
- Создаётся цепочка прототипов. Дочерний класс получает ссылку на родительский класс через специальное свойство
__proto__(или[[Prototype]]). Когда ты вызываешь метод у объекта дочернего класса, JavaScript сначала ищет этот метод в самом объекте. Если не находит — поднимается по цепочке прототипов к родителю. - Конструктор родителя вызывается через
super(). Если у родителя есть логика в конструкторе (например, установка имени), дочерний класс должен её запустить. Для этого используется ключевое словоsuper. Это как сказать: «Сначала сделай всё, что делает родитель при рождении, а потом добавь мои особенности». - Методы не копируются, а ссылаются. Когда ты наследуешь метод, он не копируется в дочерний класс. Дочерний класс просто знает, что может им воспользоваться. Это экономит память.
- Переопределение методов. Если ты объявляешь в дочернем классе метод с тем же именем, что и у родителя, он «перекрывает» родительский. При этом ты всё ещё можешь вызвать родительскую версию через
super.имяМетода().
В TypeScript добавляется ещё один важный слой — модификаторы доступа. Они контролируют, что именно дочерний класс может видеть и использовать:
| Модификатор | Видимость для дочернего класса | Видимость снаружи |
|---|---|---|
public |
Доступно (можно использовать и переопределять) | Доступно |
protected |
Доступно (можно использовать и переопределять) | НЕ доступно |
private |
НЕ доступно | НЕ доступно |
⚠️ Важно: private в TypeScript — это строгое ограничение на уровне компиляции. В скомпилированном JavaScript его нет, но TypeScript не даст тебе обратиться к приватному полю из дочернего класса.
Синтаксис в TypeScript: все формы и варианты
Давай разберём все возможные способы использования наследования в TypeScript. Начнём с самой простой формы и будем усложнять.
1. Базовое наследование (extends)
Ключевое слово extends — это основа. Оно говорит: «Этот класс является расширением другого класса».
// Родительский класс
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
makeSound(): void {
console.log("Издаёт звук");
}
}
// Дочерний класс — наследует всё от Animal
class Dog extends Animal {
// Добавляем новый метод, которого нет у родителя
wagTail(): void {
console.log(`${this.name} виляет хвостом`);
}
}
const myDog = new Dog("Бобик");
myDog.makeSound(); // "Издаёт звук" — унаследовано
myDog.wagTail(); // "Бобик виляет хвостом" — собственный метод
2. Вызов родительского конструктора через super()
Если у родителя есть конструктор с параметрами, дочерний класс обязан его вызвать через super(). Это первая строка в конструкторе дочернего класса.
class Vehicle {
brand: string;
year: number;
constructor(brand: string, year: number) {
this.brand = brand;
this.year = year;
}
getInfo(): string {
return `${this.brand}, ${this.year} года выпуска`;
}
}
class Car extends Vehicle {
doorsCount: number;
constructor(brand: string, year: number, doorsCount: number) {
// Сначала вызываем конструктор родителя
super(brand, year);
// Потом добавляем свои свойства
this.doorsCount = doorsCount;
}
getCarInfo(): string {
// Можно использовать методы родителя
return `${this.getInfo()}, количество дверей: ${this.doorsCount}`;
}
}
const myCar = new Car("Toyota", 2020, 4);
console.log(myCar.getCarInfo()); // "Toyota, 2020 года выпуска, количество дверей: 4"
3. Переопределение методов (override)
Дочерний класс может полностью заменить метод родителя. В TypeScript для этого используется ключевое слово override (начиная с версии 4.3). Оно не обязательно, но помогает избежать ошибок.
class Bird {
fly(): void {
console.log("Птица летит");
}
}
class Penguin extends Bird {
// Переопределяем метод fly
override fly(): void {
console.log("Пингвины не летают, они плавают");
}
}
const penguin = new Penguin();
penguin.fly(); // "Пингвины не летают, они плавают"
4. Вызов родительского метода через super.метод()
Иногда нужно не заменить метод полностью, а дополнить его. Для этого вызываем родительскую версию через super.
class Worker {
name: string;
constructor(name: string) {
this.name = name;
}
work(): void {
console.log(`${this.name} работает`);
}
}
class Developer extends Worker {
language: string;
constructor(name: string, language: string) {
super(name);
this.language = language;
}
override work(): void {
// Сначала делаем то, что делает обычный работник
super.work();
// Потом добавляем специфику разработчика
console.log(`${this.name} пишет код на ${this.language}`);
}
}
const dev = new Developer("Анна", "TypeScript");
dev.work();
// "Анна работает"
// "Анна пишет код на TypeScript"
5. Защищённые (protected) свойства и методы
Иногда нужно, чтобы свойство было доступно дочерним классам, но скрыто от внешнего мира. Для этого используем protected.
class Account {
protected balance: number = 0; // Доступно только внутри класса и наследников
deposit(amount: number): void {
this.balance += amount;
}
public getBalance(): number {
return this.balance;
}
}
class SavingsAccount extends Account {
private interestRate: number = 0.05;
addInterest(): void {
// Мы можем обратиться к balance, потому что он protected
const interest = this.balance * this.interestRate;
this.balance += interest;
console.log(`Начислены проценты: ${interest}`);
}
}
const savings = new SavingsAccount();
savings.deposit(1000);
savings.addInterest(); // "Начислены проценты: 50"
console.log(savings.getBalance()); // 1050
// console.log(savings.balance); // Ошибка! balance — protected
6. Абстрактные классы
Абстрактный класс — это класс, который нельзя создать напрямую. Он служит только для наследования. В нём могут быть абстрактные методы — методы без реализации, которые обязан реализовать дочерний класс.
abstract class Shape {
abstract getArea(): number; // Абстрактный метод — нет тела
// Обычный метод — есть реализация
describe(): void {
console.log(`Это фигура с площадью ${this.getArea()}`);
}
}
class Circle extends Shape {
radius: number;
constructor(radius: number) {
super();
this.radius = radius;
}
// Обязаны реализовать абстрактный метод
override getArea(): number {
return Math.PI * this.radius ** 2;
}
}
// const shape = new Shape(); // Ошибка! Нельзя создать экземпляр абстрактного класса
const circle = new Circle(5);
circle.describe(); // "Это фигура с площадью 78.53981633974483"
7. Множественное наследование через интерфейсы
TypeScript (как и JavaScript) не поддерживает множественное наследование классов (когда один класс наследует от двух родителей). Но эту проблему решают интерфейсы. Класс может реализовать несколько интерфейсов.
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
// Класс может реализовать несколько интерфейсов
class Duck implements Flyable, Swimmable {
fly(): void {
console.log("Утка летит");
}
swim(): void {
console.log("Утка плывёт");
}
}
const duck = new Duck();
duck.fly(); // "Утка летит"
duck.swim(); // "Утка плывёт"
Примеры кода — от простого к сложному
Пример 1: Простейшее наследование
// Базовый класс "Транспорт"
class Transport {
speed: number;
constructor(speed: number) {
this.speed = speed;
}
move(): void {
console.log(`Движется со скоростью ${this.speed} км/ч`);
}
}
// Дочерний класс "Велосипед"
class Bicycle extends Transport {
// Ничего не добавляем, просто наследуем
}
const bike = new Bicycle(25);
bike.move(); // "Движется со скоростью 25 км/ч"
Пример 2: Добавление свойств и super()
class Smartphone {
brand: string;
protected batteryLevel: number = 100;
constructor(brand: string) {
this.brand = brand;
}
charge(): void {
this.batteryLevel = 100;
console.log("Батарея заряжена");
}
}
class IPhone extends Smartphone {
iosVersion: string;
constructor(brand: string, iosVersion: string) {
super(brand); // Обязательно вызываем родительский конструктор
this.iosVersion = iosVersion;
}
// Новый метод
updateIOS(): void {
console.log(`Обновляем ${this.brand} до версии ${this.iosVersion}`);
}
}
const myPhone = new IPhone("Apple", "17.0");
myPhone.charge(); // "Батарея заряжена" — унаследовано
myPhone.updateIOS(); // "Обновляем Apple до версии 17.0"
Пример 3: Переопределение метода с super
class Chef {
cook(): void {
console.log("Готовит обычное блюдо");
}
}
class PizzaChef extends Chef {
override cook(): void {
super.cook(); // Сначала делаем базовое действие
console.log("Добавляет сыр и томатный соус");
console.log("Запекает в печи");
}
}
const pizzaChef = new PizzaChef();
pizzaChef.cook();
// "Готовит обычное блюдо"
// "Добавляет сыр и томатный соус"
// "Запекает в печи"
Пример 4: Защищённые свойства
class Employee {
public name: string;
protected salary: number; // Доступно наследникам, но не снаружи
private passportData: string; // Доступно только внутри класса
constructor(name: string, salary: number, passport: string) {
this.name = name;
this.salary = salary;
this.passportData = passport;
}
getSalaryInfo(): string {
return `Зарплата: ${this.salary}`;
}
}
class Manager extends Employee {
private teamSize: number;
constructor(name: string, salary: number, passport: string, teamSize: number) {
super(name, salary, passport);
this.teamSize = teamSize;
}
getFullInfo(): string {
// Можно обратиться к salary, так как он protected
return `${this.name}, зарплата: ${this.salary}, команда: ${this.teamSize} человек`;
// this.passportData — ошибка! private не доступен наследникам
}
}
const manager = new Manager("Иван", 150000, "1234 567890", 5);
console.log(manager.getFullInfo()); // "Иван, зарплата: 150000, команда: 5 человек"
// console.log(manager.salary); // Ошибка! protected
// console.log(manager.passportData); // Ошибка! private
Пример 5: Абстрактный класс с несколькими наследниками
abstract class Database {
abstract connect(): void;
abstract disconnect(): void;
// Общий метод для всех баз данных
executeQuery(query: string): void {
this.connect();
console.log(`Выполняем запрос: ${query}`);
this.disconnect();
}
}
class MySQLDatabase extends Database {
override connect(): void {
console.log("Подключаемся к MySQL...");
}
override disconnect(): void {
console.log("Отключаемся от MySQL...");
}
}
class PostgreSQLDatabase extends Database {
override connect(): void {
console.log("Подключаемся к PostgreSQL...");
}
override disconnect(): void {
console.log("Отключаемся от PostgreSQL...");
}
}
const mysql = new MySQLDatabase();
mysql.executeQuery("SELECT * FROM users");
// "Подключаемся к MySQL..."
// "Выполняем запрос: SELECT * FROM users"
// "Отключаемся от MySQL..."
const postgres = new PostgreSQLDatabase();
postgres.executeQuery("SELECT * FROM orders");
// "Подключаемся к PostgreSQL..."
// "Выполняем запрос: SELECT * FROM orders"
// "Отключаемся от PostgreSQL..."
Пример 6: Цепочка наследования (многоуровневое)
class LivingBeing {
isAlive: boolean = true;
breathe(): void {
console.log("Дышит");
}
}
class Animal2 extends LivingBeing {
name: string;
constructor(name: string) {
super();
this.name = name;
}
eat(): void {
console.log(`${this.name} ест`);
}
}
class Mammal extends Animal2 {
hasFur: boolean = true;
feedMilk(): void {
console.log(`${this.name} кормит молоком`);
}
}
class Dog2 extends Mammal {
breed: string;
constructor(name: string, breed: string) {
super(name);
this.breed = breed;
}
bark(): void {
console.log(`${this.name} лает`);
}
}
const myDog2 = new Dog2("Рекс", "Овчарка");
myDog2.breathe(); // "Дышит" — от LivingBeing
myDog2.eat(); // "Рекс ест" — от Animal2
myDog2.feedMilk(); // "Рекс кормит молоком" — от Mammal
myDog2.bark(); // "Рекс лает" — собственный
Пример 7: Статические методы и наследование
class Config {
static defaultTimeout: number = 5000;
static getDefaultConfig(): object {
return { timeout: this.defaultTimeout };
}
}
class ExtendedConfig extends Config {
static maxRetries: number = 3;
// Переопределяем статический метод
static getDefaultConfig(): object {
// Вызываем родительский статический метод
const baseConfig = super.getDefaultConfig();
return { ...baseConfig, maxRetries: this.maxRetries };
}
}
console.log(Config.getDefaultConfig()); // { timeout: 5000 }
console.log(ExtendedConfig.getDefaultConfig()); // { timeout: 5000, maxRetries: 3 }
Сравнение с Python: чем похоже и чем отличается
Если ты уже знаком с Python или планируешь изучать его, вот ключевые различия в реализации наследования:
| Характеристика | TypeScript | Python |
|---|---|---|
| Ключевое слово для наследования | extends |
Класс указывается в скобках: class Dog(Animal): |
| Вызов родительского конструктора | super() |
super().__init__() или Animal.__init__(self) |
| Множественное наследование классов | НЕ поддерживается (только через интерфейсы) | Поддерживается: class C(A, B): |
| Модификаторы доступа | public, protected, private |
Нет ключевых слов. Используются соглашения: _protected (одно подчёркивание) и __private (два подчёркивания) |
| Абстрактные классы | Ключевое слово abstract |
Модуль abc: from abc import ABC, abstractmethod |
| Переопределение методов | Опционально override |
Просто объявляешь метод с тем же именем |
| Типизация | Статическая (проверяется на этапе компиляции) | Динамическая (проверяется во время выполнения) |
| Проверка типа | instanceof |
isinstance() |
Пример на Python для сравнения:
// TypeScript
class Animal {
constructor(public name: string) {}
speak(): void {
console.log(`${this.name} издаёт звук`);
}
}
class Cat extends Animal {
constructor(name: string) {
super(name);
}
override speak(): void {
console.log(`${this.name} мяукает`);
}
}
# Python
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self):
print(f"{self.name} издаёт звук")
class Cat(Animal):
def __init__(self, name: str):
super().__init__(name)
def speak(self):
print(f"{self.name} мяукает")
💡 Главное отличие: Python позволяет наследовать от нескольких классов сразу, но это часто приводит к проблемам (ромбовидное наследование). TypeScript запрещает это на уровне языка, заставляя использовать интерфейсы — это считается более безопасным подходом.
Типичные ошибки и подводные камни
Ошибка 1: Забыть вызвать super()
Если у родительского класса есть конструктор, дочерний класс обязан вызвать super(). Иначе TypeScript выдаст ошибку компиляции.
class Parent {
constructor(value: number) {
// ...
}
}
class Child extends Parent {
constructor() {
// Ошибка! Нужно super(value)
}
}
Ошибка 2: Вызов super() не в первой строке
super() должен быть вызван до того, как ты обратишься к this в дочернем конструкторе.
class Child extends Parent {
constructor() {
this.someProperty = 5; // Ошибка! Нельзя использовать this до super()
super(10);
}
}
Ошибка 3: Путаница между public, protected и private
Новички часто делают все свойства public, а потом удивляются, что внешний код может их менять. Или наоборот, ставят private там, где нужно protected, и наследники не могут получить доступ.
class Base {
private secret: string = "секрет";
}
class Derived extends Base {
revealSecret(): void {
// Ошибка! secret — private, недоступен наследникам
console.log(this.secret);
}
}
Ошибка 4: Нарушение принципа подстановки Лисков (LSP)
Это правило гласит: объекты дочернего класса должны заменять объекты родительского класса без изменения корректности программы. Если ты переопределяешь метод так, что он выбрасывает исключение или требует других параметров — это ошибка проектирования.
class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
// Нарушение LSP: квадрат не должен наследовать прямоугольник
class Square extends Rectangle {
constructor(size: number) {
super(size, size);
}
// Ломает логику родителя
override setWidth(width: number): void {
this.width = width;
this.height = width; // Меняет и высоту тоже!
}
}
Ошибка 5: Слишком глубокая иерархия наследования
Если у тебя класс наследует от класса, который наследует от класса, который наследует... — это «спагетти-код». Сложно понять, откуда берутся методы, и любое изменение в корне может сломать всё.
⚠️ Золотое правило: не более 3 уровней наследования. Если нужно больше — используй композицию (включение объектов) вместо наследования.
Ошибка 6: Использование наследования для переиспользования кода, когда нет отношения «является»
Класс Car не должен наследовать класс Engine, потому что машина не является двигателем. У машины есть двигатель (композиция).
// ПЛОХО: наследование
class Engine {
start(): void { console.log("Двигатель запущен"); }
}
class Car extends Engine { } // Машина — это двигатель? Нет!
// ХОРОШО: композиция
class Engine2 {
start(): void { console.log("Двигатель запущен"); }
}
class Car2 {
constructor(private engine: Engine2) {}
start(): void { this.engine.start(); }
}
Лучшие практики и советы профессионалов
- Используй наследование только для отношения «является». Если ты сомневаешься, спроси себя: «Могу ли я сказать, что дочерний класс — это разновидность родительского?» Если нет — используй композицию.
- Делай классы максимально плоскими. Глубокая иерархия — зло. Старайся, чтобы было не больше 2-3 уровней.
- Используй
protectedвместоpublicдля внутренних свойств. Это защитит данные от случайного изменения извне, но оставит доступ наследникам. - Всегда используй
overrideпри переопределении методов. Это поможет TypeScript найти ошибку, если ты случайно неправильно написал имя метода. - Предпочитай интерфейсы абстрактным классам, если нет общей реализации. Интерфейсы более гибкие и позволяют реализовать несколько контрактов.
- Не бойся композиции. Часто лучше передать объект в конструктор, чем наследовать от него. Композиция даёт больше гибкости.
- Документируй, какие методы можно переопределять. Если ты пишешь библиотеку, помечай методы, которые предназначены для переопределения, комментарием или используй абстрактные классы.
- Тестируй наследование. Напиши тесты, которые проверяют, что дочерний класс корректно работает везде, где ожидается родительский.
💡 Совет от профессионалов: «Предпочитай композицию наследованию» (GoF, "Банда четырёх"). Это не значит, что наследование плохое — оно просто должно использоваться осознанно и там, где это действительно нужно.
Вопросы для самопроверки
-
Вопрос: Что такое наследование в ООП? Приведи пример из реальной жизни.
Ответ: Наследование — это механизм, позволяющий создать новый класс на основе существующего. Дочерний класс получает все свойства и методы родителя. Пример: класс «Пирог» — родитель, класс «Яблочный пирог» — дочерний. Яблочный пирог — это пирог, но с дополнительными свойствами.
-
Вопрос: Какое ключевое слово используется для наследования в TypeScript?
Ответ: Ключевое слово
extends. Пример:class Dog extends Animal {}. -
Вопрос: Зачем нужен вызов
super()в конструкторе дочернего класса?Ответ:
super()вызывает конструктор родительского класса. Это необходимо, чтобы инициализировать свойства, определённые в родителе. Без этого вызова TypeScript выдаст ошибку, если у родителя есть конструктор. -
Вопрос: В чём разница между
public,protectedиprivate?Ответ:
public— доступно всем.protected— доступно внутри класса и его наследников.private— доступно только внутри класса, где объявлено. Наследники не видятprivateчлены. -
Вопрос: Что такое переопределение метода? Как вызвать родительскую версию метода?
Ответ: Переопределение — это создание в дочернем классе метода с тем же именем, что и у родителя. Чтобы вызвать родительскую версию, используется
super.имяМетода(). -
Вопрос: Что такое абстрактный класс? Можно ли создать его экземпляр?
Ответ: Абстрактный класс — это класс, который служит только для наследования. Он помечается ключевым словом
abstract. Создать его экземпляр напрямую нельзя. Он может содержать абстрактные методы (без реализации), которые обязаны реализовать наследники. -
Вопрос: Поддерживает ли TypeScript множественное наследование классов?
Ответ: Нет, TypeScript не поддерживает множественное наследование классов (один класс не может наследовать от двух классов). Вместо этого используются интерфейсы: класс может реализовать несколько интерфейсов через ключевое слово
implements. -
Вопрос: В чём разница между наследованием и композицией?