📘 TypeScript

OOP: Encapsulation and Polymorphism in TypeScript: A Detailed Guide

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты собираешь сложный конструктор LEGO. У тебя есть множество деталей разного цвета и формы. Если просто высыпать их в кучу, собрать что-то большое и красивое будет очень трудно — детали перепутаются, потеряются, а механизмы сломаются. Объектно-Ориентированное Программирование (ООП) — это как раз те самые правила и коробочки, которые помогают нам не превратить программу в беспорядочную кучу деталей.

Сегодня мы разберём два главных «волшебных» принципа, которые делают код мощным и удобным: Инкапсуляцию и Полиморфизм.

Аналогия с автомобилем

Давай представим обычный автомобиль. Это идеальный пример ООП.

  • Инкапсуляция — это капот автомобиля. Ты, как водитель, не видишь, как именно работают поршни, клапаны и ремни. Ты видишь только руль, педали и рычаг коробки передач. Внутреннее устройство двигателя скрыто (инкапсулировано) под капотом. Ты не можешь случайно нажать на поршень рукой или порвать ремень ГРМ, потому что доступа к ним нет. Это защищает и тебя, и двигатель. В программировании инкапсуляция прячет внутренние данные объекта и заставляет работать с ними только через специальные методы (как педали и руль).
  • Полиморфизм — это педаль газа. Независимо от того, какой у тебя автомобиль — бензиновый, дизельный, электрический или гибридный — ты всегда нажимаешь на педаль газа, чтобы поехать. Результат один (машина едет), но внутри всё работает по-разному: в электромобиле ток поступает в мотор, в бензиновом — открывается дроссельная заслонка. Это и есть полиморфизм: один интерфейс (педаль) — много реализаций (двигатели).
Важно: Инкапсуляция отвечает на вопрос «КАК защитить данные?», а полиморфизм — на вопрос «КАК сделать код гибким и расширяемым?».

Теория: как это работает изнутри

Инкапсуляция: Защита и контроль

В программировании у каждого объекта есть свойства (данные, характеристики) и методы (действия). Инкапсуляция — это механизм, который объединяет данные и методы в одном объекте (классе) и ограничивает доступ к ним извне.

Представь, что у нас есть класс БанковскийСчет. У него есть свойство баланс. Если дать прямой доступ к этому свойству, любой программист (или злоумышленник) сможет написать: счет.баланс = 1000000 или счет.баланс = -500. Это катастрофа! Инкапсуляция говорит: «Нет, ты не можешь просто взять и изменить баланс. Ты можешь вызвать метод положитьДеньги() или снятьДеньги(), а внутри этих методов мы проверим, достаточно ли денег, не отрицательное ли число, и только потом изменим баланс».

В TypeScript (и в большинстве языков) есть три уровня доступа (модификатора):

  • public (публичный) — доступно всем. Это как витрина магазина, любой прохожий может посмотреть.
  • private (приватный) — доступно только внутри самого класса. Это сейф в подсобке, доступ есть только у директора (методов класса).
  • protected (защищённый) — доступно внутри класса и его наследников (дочерних классов). Это склад, куда могут заходить только сотрудники компании и её филиалов.

Полиморфизм: Один интерфейс — много форм

Слово «полиморфизм» происходит от греческих слов «πολύς» (много) и «μορφή» (форма). Это способность объектов разных классов отвечать на одни и те же вызовы методов по-своему.

В основе полиморфизма лежит наследование (когда один класс наследует свойства и методы другого) и переопределение методов (когда дочерний класс изменяет поведение родительского метода).

Представь, что у нас есть базовый класс Животное с методом податьГолос(). Для кошки этот метод выведет «Мяу», для собаки — «Гав», для коровы — «Му». Мы, как программисты, не знаем, какое именно животное перед нами, но мы точно знаем, что у любого животного есть метод податьГолос(). Мы можем написать функцию, которая принимает массив животных и заставляет каждого подать голос, не задумываясь, кто из них кто.

Ключевая идея: Полиморфизм позволяет писать код, который работает с объектами разных типов, не зная их конкретного типа. Это делает программу гибкой и лёгкой для расширения.

Синтаксис в TypeScript: все формы и варианты

TypeScript — это «надстройка» над JavaScript, которая добавляет строгую типизацию. Это значит, что мы можем явно указывать, какого типа должны быть переменные, параметры и возвращаемые значения. Это делает инкапсуляцию и полиморфизм ещё более мощными и безопасными.

1. Инкапсуляция: Модификаторы доступа

В TypeScript модификаторы доступа ставятся перед свойством или методом:

class BankAccount {
    // Приватное свойство. Доступно только внутри этого класса.
    private balance: number;

    // Публичное свойство. Доступно всем.
    public ownerName: string;

    // Конструктор вызывается при создании нового объекта
    constructor(ownerName: string, initialBalance: number) {
        this.ownerName = ownerName;
        // Проверяем, что начальный баланс не отрицательный
        if (initialBalance < 0) {
            this.balance = 0;
        } else {
            this.balance = initialBalance;
        }
    }

    // Публичный метод для получения баланса (геттер)
    public getBalance(): number {
        return this.balance;
    }

    // Публичный метод для пополнения счета
    public deposit(amount: number): void {
        if (amount > 0) {
            this.balance += amount;
            console.log(`Пополнено на ${amount}. Баланс: ${this.balance}`);
        } else {
            console.log('Сумма пополнения должна быть положительной!');
        }
    }

    // Приватный метод для внутренней проверки (снаружи не вызвать)
    private logTransaction(type: string, amount: number): void {
        console.log(`[ЛОГ] ${type}: ${amount} рублей. Остаток: ${this.balance}`);
    }
}

// Создаем объект
const myAccount = new BankAccount('Иван', 1000);
console.log(myAccount.ownerName); // Иван - работает
console.log(myAccount.getBalance()); // 1000 - работает
myAccount.deposit(500); // Пополнено на 500. Баланс: 1500

// Ошибка! Свойство balance приватное
// console.log(myAccount.balance);

// Ошибка! Метод logTransaction приватный
// myAccount.logTransaction('test', 100);

2. Полиморфизм: Наследование и переопределение

Создадим базовый класс и два дочерних, которые переопределяют метод.

// Базовый класс (родитель)
class Animal {
    protected name: string;

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

    // Метод, который будут переопределять наследники
    public makeSound(): void {
        console.log('Животное издает звук');
    }

    public getName(): string {
        return this.name;
    }
}

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

    // Переопределяем метод makeSound
    public makeSound(): void {
        console.log(`${this.name} говорит: Мяу!`);
    }
}

// Дочерний класс Dog
class Dog extends Animal {
    constructor(name: string) {
        super(name);
    }

    public makeSound(): void {
        console.log(`${this.name} говорит: Гав!`);
    }
}

// Дочерний класс RobotDog (наследуется от Dog)
class RobotDog extends Dog {
    constructor(name: string) {
        super(name);
    }

    // Переопределяем метод еще раз
    public makeSound(): void {
        console.log(`${this.name} говорит: Бип-бип! Гав! (робот)`);
    }
}

// Используем полиморфизм
const animals: Animal[] = [
    new Cat('Мурка'),
    new Dog('Шарик'),
    new RobotDog('R2-D2')
];

// Проходим по массиву и вызываем один и тот же метод
// Каждый объект ведет себя по-своему!
for (const animal of animals) {
    animal.makeSound();
}
// Вывод:
// Мурка говорит: Мяу!
// Шарик говорит: Гав!
// R2-D2 говорит: Бип-бип! Гав! (робот)

3. Абстрактные классы и интерфейсы

Это продвинутые инструменты для полиморфизма. Абстрактный класс — это класс, от которого нельзя создать объект. Он нужен только для того, чтобы быть родителем. Интерфейс — это «контракт», который описывает, какие методы и свойства должен иметь класс.

// Абстрактный класс
abstract class Shape {
    abstract getArea(): number; // Абстрактный метод - нет реализации

    public describe(): void {
        console.log(`Это фигура с площадью ${this.getArea()}`);
    }
}

// Класс, наследующий абстрактный класс
class Circle extends Shape {
    private radius: number;

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

    // Обязаны реализовать абстрактный метод
    public getArea(): number {
        return Math.PI * this.radius * this.radius;
    }
}

// Интерфейс - контракт
interface Flyable {
    fly(): void;
    land(): void;
}

// Класс может реализовать несколько интерфейсов
class Bird implements Flyable {
    public fly(): void {
        console.log('Птица летит, махая крыльями');
    }

    public land(): void {
        console.log('Птица приземляется на ветку');
    }
}

class Airplane implements Flyable {
    public fly(): void {
        console.log('Самолет летит на реактивной тяге');
    }

    public land(): void {
        console.log('Самолет приземляется на взлетную полосу');
    }
}

// Полиморфизм через интерфейс
const flyingObjects: Flyable[] = [new Bird(), new Airplane()];
flyingObjects.forEach(obj => obj.fly());
// Птица летит, махая крыльями
// Самолет летит на реактивной тяге

Примеры кода — от простого к сложному

Пример 1: Простейшая инкапсуляция (счетчик)

class Counter {
    private count: number = 0;

    public increment(): void {
        this.count++;
    }

    public getCount(): number {
        return this.count;
    }
}

const counter = new Counter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2
// counter.count = 100; // Ошибка! count приватный

Пример 2: Инкапсуляция с валидацией (возраст)

class Person {
    private _age: number;

    constructor(age: number) {
        this._age = this.validateAge(age);
    }

    private validateAge(age: number): number {
        if (age < 0 || age > 150) {
            console.log('Некорректный возраст, установлено 0');
            return 0;
        }
        return age;
    }

    public get age(): number {
        return this._age;
    }

    public set age(newAge: number) {
        this._age = this.validateAge(newAge);
    }
}

const person = new Person(25);
console.log(person.age); // 25
person.age = -5; // Некорректный возраст, установлено 0
console.log(person.age); // 0

Пример 3: Базовый полиморфизм (фигуры)

class Shape {
    public getArea(): number {
        return 0;
    }
}

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

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

class Triangle extends Shape {
    constructor(private base: number, private height: number) {
        super();
    }

    public getArea(): number {
        return (this.base * this.height) / 2;
    }
}

function printArea(shape: Shape): void {
    console.log(`Площадь: ${shape.getArea()}`);
}

printArea(new Rectangle(5, 10)); // 50
printArea(new Triangle(6, 4));   // 12

Пример 4: Полиморфизм с массивами (платежная система)

abstract class Payment {
    abstract processPayment(amount: number): void;
}

class CreditCardPayment extends Payment {
    public processPayment(amount: number): void {
        console.log(`Оплата картой: списано ${amount} руб.`);
    }
}

class PayPalPayment extends Payment {
    public processPayment(amount: number): void {
        console.log(`Оплата через PayPal: списано ${amount} руб.`);
    }
}

class CashPayment extends Payment {
    public processPayment(amount: number): void {
        console.log(`Оплата наличными: принято ${amount} руб.`);
    }
}

const payments: Payment[] = [
    new CreditCardPayment(),
    new PayPalPayment(),
    new CashPayment()
];

payments.forEach(p => p.processPayment(1000));

Пример 5: Инкапсуляция + Полиморфизм (игровой персонаж)

abstract class GameCharacter {
    protected health: number;
    protected name: string;

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

    public abstract attack(): void;

    public takeDamage(damage: number): void {
        this.health -= damage;
        console.log(`${this.name} получил ${damage} урона. Осталось здоровья: ${this.health}`);
        if (this.health <= 0) {
            console.log(`${this.name} погиб!`);
        }
    }

    public getHealth(): number {
        return this.health;
    }
}

class Warrior extends GameCharacter {
    constructor(name: string) {
        super(name, 150);
    }

    public attack(): void {
        console.log(`${this.name} атакует мечом! (урон 30)`);
    }
}

class Mage extends GameCharacter {
    constructor(name: string) {
        super(name, 80);
    }

    public attack(): void {
        console.log(`${this.name} кастует огненный шар! (урон 50)`);
    }
}

const hero: GameCharacter = new Warrior('Артур');
hero.attack(); // Артур атакует мечом!
hero.takeDamage(40); // Артур получил 40 урона...

Пример 6: Интерфейсы для гибкости (логирование)

interface Logger {
    log(message: string): void;
}

class ConsoleLogger implements Logger {
    public log(message: string): void {
        console.log(`[Console] ${message}`);
    }
}

class FileLogger implements Logger {
    public log(message: string): void {
        // Имитация записи в файл
        console.log(`[File] ${message} (записано в файл)`);
    }
}

class Application {
    constructor(private logger: Logger) {}

    public run(): void {
        this.logger.log('Приложение запущено');
        // ... какая-то логика
        this.logger.log('Приложение завершило работу');
    }
}

const app1 = new Application(new ConsoleLogger());
app1.run();

const app2 = new Application(new FileLogger());
app2.run();

Пример 7: Композиция и инкапсуляция (двигатель и машина)

class Engine {
    private isRunning: boolean = false;

    public start(): void {
        this.isRunning = true;
        console.log('Двигатель запущен');
    }

    public stop(): void {
        this.isRunning = false;
        console.log('Двигатель остановлен');
    }

    public getStatus(): string {
        return this.isRunning ? 'работает' : 'заглушен';
    }
}

class Car {
    private engine: Engine; // Инкапсулируем двигатель внутри машины

    constructor() {
        this.engine = new Engine();
    }

    public startCar(): void {
        console.log('Поворачиваю ключ зажигания...');
        this.engine.start();
    }

    public stopCar(): void {
        console.log('Глушу двигатель...');
        this.engine.stop();
    }

    public getEngineStatus(): string {
        return this.engine.getStatus();
    }
}

const myCar = new Car();
myCar.startCar(); // Поворачиваю ключ... Двигатель запущен
console.log(myCar.getEngineStatus()); // работает
// myCar.engine.start(); // Ошибка! engine приватный

Сравнение с Python: чем похоже и чем отличается

Характеристика TypeScript Python
Типизация Строгая, статическая. Типы указываются явно. Динамическая, утиная. Типы определяются во время выполнения.
Инкапсуляция (private) Есть ключевое слово private. Настоящая защита на уровне компиляции. Нет настоящего private. Используется соглашение: _private (одно подчеркивание) и __private (два подчеркивания — name mangling).
Интерфейсы Есть. Явно описывают структуру объекта. Нет встроенных интерфейсов. Используются абстрактные базовые классы (ABC) или протоколы.
Абстрактные классы Есть. Ключевое слово abstract. Есть. Модуль abc и декоратор @abstractmethod.
Переопределение методов Работает как в классических языках. Нужно вызывать super(). Работает аналогично. super() тоже используется.
Геттеры/Сеттеры Есть синтаксис get и set. Есть декораторы @property, @setter.
Множественное наследование Не поддерживается (класс может наследовать только от одного класса). Поддерживается (класс может наследовать от нескольких классов).
Совет: Если ты знаешь Python, то в TypeScript тебе будет проще с дисциплиной. TypeScript заставляет тебя думать о типах заранее, что предотвращает многие ошибки на этапе написания кода, а не во время выполнения.

Типичные ошибки и подводные камни

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

class Parent {
    constructor(public name: string) {}
}

class Child extends Parent {
    constructor() {
        // Ошибка! Нужно super('какое-то имя');
        // this.name = 'test'; // Тоже ошибка, до super() нельзя обращаться к this
    }
}

Ошибка 2: Думать, что private в TypeScript защищает на 100%

На самом деле, private — это защита на уровне компиляции. В скомпилированном JavaScript коде (который выполняется в браузере) этих свойств не будет, и к ним можно будет обратиться. Для настоящей защиты используйте # (приватные поля JavaScript) или замыкания.

Ошибка 3: Путаница между protected и private

protected — доступно наследникам. private — только внутри класса. Если ты хочешь, чтобы дочерний класс мог использовать свойство, используй protected.

Ошибка 4: Нарушение принципа подстановки Лисков (LSP)

Это важный принцип полиморфизма: если у тебя есть функция, которая принимает Animal, ты должен иметь возможность передать туда Dog или Cat без изменения поведения программы. Если дочерний класс выбрасывает исключение там, где родительский работал нормально, или возвращает другой тип данных — это ошибка.

Ошибка 5: Слишком глубокая иерархия наследования

Не делай «башню» из 10 уровней наследования. Это делает код нечитаемым. Помни: композиция часто лучше наследования. Вместо того чтобы создавать класс ЛетающаяСобака extends Собака extends Животное, лучше создать интерфейс Летающий и добавить его собаке.

Лучшие практики и советы профессионалов

  1. Делай свойства приватными по умолчанию. Открывай доступ только через методы (геттеры/сеттеры). Это даст тебе контроль над изменениями.
  2. Используй интерфейсы для описания контрактов. Это делает код гибким. Ты можешь заменить одну реализацию на другую, не меняя код, который её использует.
  3. Предпочитай композицию наследованию. Вместо «класс Машина наследует класс Двигатель» (что неправильно, машина не является двигателем), используй «класс Машина содержит объект Двигатель». Это гибче.
  4. Не делай методы слишком длинными. Каждый метод должен делать одну вещь. Если метод занимает больше 20 строк — разбей его на несколько приватных методов.
  5. Используй readonly для свойств, которые не должны меняться после создания. Это упрощает понимание кода.
  6. Пиши тесты. Инкапсуляция и полиморфизм облегчают тестирование. Ты можешь подменить настоящий объект на «заглушку» (mock) и проверить, как работает твой код.
  7. Изучи SOLID. Это пять принципов ООП, которые помогут тебе писать идеальный код. Первые два (S — Single Responsibility, O — Open/Closed) напрямую связаны с инкапсуляцией и полиморфизмом.

Вопросы для самопроверки

  1. Вопрос: Что такое инкапсуляция? Приведи пример из жизни.

    Ответ: Это сокрытие внутреннего устройства объекта и предоставление доступа к данным только через специальные методы. Пример: пульт от телевизора — мы нажимаем кнопки, не зная, как устроены микросхемы внутри.

  2. Вопрос: Какие модификаторы доступа есть в TypeScript?

    Ответ: public (доступно всем), private (только внутри класса), protected (внутри класса и его наследников).

  3. Вопрос: В чем разница между private и protected?

    Ответ: private запрещает доступ даже наследникам. protected разрешает доступ наследникам, но запрещает всем остальным.

  4. Вопрос: Что такое полиморфизм?

    Ответ: Способность объектов разных классов отвечать на один и тот же вызов метода по-разному. Например, метод play() для гитары звучит как «струны», а для пианино — как «клавиши».

  5. Вопрос: Как реализовать полиморфизм в TypeScript?

    Ответ: Через наследование (переопределение методов) или через интерфейсы (разные классы реализуют один интерфейс).

  6. Вопрос: Что такое абстрактный класс? Чем он отличается от интерфейса?

    Ответ: Абстрактный класс может содержать реализацию методов и свойства. Интерфейс — это чистый контракт, он описывает только сигнатуры методов. Класс может наследовать только один абстрактный класс, но может реализовать много интерфейсов.

  7. Вопрос: Почему не стоит делать все свойства публичными?

    Ответ: Это нарушает инкапсуляцию. Любой код может изменить свойство, что приведет к неожиданным ошибкам. Ты теряешь контроль над валидацией данных.

  8. Вопрос: Что такое «утиная типизация»? Есть ли она в TypeScript?

    Ответ: «Если это выглядит как утка, плавает как утка и крякает как утка, то это утка». В TypeScript есть структурная типизация (duck typing на уровне типов), но она проверяется на этапе компиляции, а не во время выполнения, как в Python.

  9. Вопрос: Какой принцип ООП нарушается, если дочерний класс удаляет метод родителя?

    Ответ: Принцип подстановки Лисков (LSP). Наследник не должен сужать функциональность родителя.

  10. Вопрос: Можно ли в TypeScript создать объект от абстрактного класса?

    Ответ: Нет. Абстрактный класс предназначен только для наследования. Попытка создать экземпляр вызовет ошибку компиляции.

Итог и что изучить дальше

Мы прошли долгий путь. Ты узнал, что:

  • Инкапсуляция — это защита данных и контроль доступа к ним. Она делает код безопасным и понятным.
  • Полиморфизм — это гибкость и расширяемость. Он позволяет писать универсальный код, который работает с разными типами объектов.

Эти два принципа — фундамент, на котором строится любое серьёзное приложение. Без них твой код будет похож на спагетти — запутанный и хрупкий.

Что изучать дальше?

  1. Наследование — третий столп ООП. Мы его уже немного затронули, но стоит углубиться.
  2. Принципы SOLID — пять правил, которые превращают хороший код в идеальный.
  3. Паттерны проектирования — готовые решения типовых задач. Например, «Фабрика» (Factory) или «Стратегия» (Strategy) активно используют полиморфизм.
  4. Generics (Обобщения) в TypeScript — мощный инструмент для создания переиспользуемых компонентов.
  5. Практика. Напиши небольшую игру (например, «Бой героев») или симуляцию банковской системы, используя все полученные знания.
Запомни: ООП — это не магия, а инструмент. Он помогает управлять сложностью. Чем сложнее твоя программа, тем важнее следовать этим принципам. Удачи в кодинге!