⚡ JavaScript

ООП: Инкапсуляция и полиморфизм в JavaScript (Node.js): подробное руководство

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

Представьте, что вы — шеф-повар в ресторане. У вас есть холодильник, плита, ножи и продукты. Чтобы приготовить блюдо, вы открываете холодильник, достаёте продукты, режете их, жарите на плите. Всё это — внутренняя кухня, которую посетители ресторана не видят. Они видят только готовое блюдо на тарелке.

Теперь представьте, что вы — посетитель ресторана. Вы не заходите на кухню, не трогаете ножи и не включаете плиту. Вы просто делаете заказ официанту: «Принесите, пожалуйста, пасту карбонара». Через некоторое время вам приносят готовое блюдо. Вы не знаете, как именно его готовили — это скрыто от вас. Вы только взаимодействуете с результатом.

В программировании происходит то же самое. У нас есть объекты (как шеф-повар), у которых есть внутренние данные (продукты, рецепты) и методы (нарезка, жарка). Но мы, как программисты, не хотим, чтобы кто-то посторонний мог залезть внутрь объекта и испортить его данные. Мы хотим, чтобы с объектом можно было общаться только через специальные публичные методы (как через официанта).

Это и есть инкапсуляция — сокрытие внутреннего устройства объекта и предоставление только безопасного интерфейса для работы с ним.

А что такое полиморфизм? Представьте, что у вас есть универсальный пульт управления. На нём есть кнопка «Воспроизвести». Если вы нажмёте её на телевизоре — включится фильм. Если нажмёте на музыкальном центре — заиграет песня. Если нажмёте на игровой приставке — запустится игра. Одна и та же команда («Воспроизвести») работает по-разному в зависимости от того, к какому устройству она применяется.

В программировании полиморфизм — это способность разных объектов по-разному реагировать на одну и ту же команду. Например, у нас есть класс «Кошка» и класс «Собака». У обоих есть метод «податьГолос()». Но кошка скажет «Мяу», а собака — «Гав». Одна команда — разное поведение.

💡 Запомните главное: Инкапсуляция — это про «что можно трогать, а что нельзя». Полиморфизм — это про «один интерфейс — много реализаций».

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

Инкапсуляция: защита данных

В JavaScript инкапсуляция реализуется через замыкания и приватные поля (начиная с ES2022). Давайте разберём, что это значит.

Когда вы создаёте объект, у него есть свойства (данные) и методы (функции). Без инкапсуляции любое свойство можно изменить напрямую:

// ПЛОХОЙ ПРИМЕР — без инкапсуляции
let пользователь = {
    имя: "Иван",
    пароль: "12345"  // Любой может это прочитать и изменить!
};

// Любой может сделать так:
пользователь.пароль = "взломан";  // Опасность!
console.log(пользователь.пароль);  // "взломан"

С инкапсуляцией мы прячем пароль и даём доступ только через специальные методы:

// ХОРОШИЙ ПРИМЕР — с инкапсуляцией
function СоздатьПользователя(имя, пароль) {
    // Приватная переменная — её нельзя получить снаружи
    let _пароль = пароль;
    
    return {
        имя: имя,
        
        // Публичный метод для проверки пароля
        проверитьПароль(введенныйПароль) {
            return _пароль === введенныйПароль;
        },
        
        // Публичный метод для смены пароля
        сменитьПароль(старыйПароль, новыйПароль) {
            if (_пароль === старыйПароль) {
                _пароль = новыйПароль;
                return true;
            }
            return false;
        }
    };
}

let пользователь = СоздатьПользователя("Иван", "12345");
console.log(пользователь._пароль);  // undefined — не видно!
console.log(пользователь.проверитьПароль("12345"));  // true
пользователь.сменитьПароль("12345", "новый_пароль");  // успешно

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

Полиморфизм в JavaScript достигается через наследование и переопределение методов. Когда дочерний класс наследует метод от родительского, он может переопределить его — то есть написать свою версию.

Представьте, что у нас есть базовый класс «Транспорт» с методом «двигаться()». Автомобиль будет ехать по дороге, лодка — плыть по воде, самолёт — лететь по воздуху. Все они «двигаются», но по-разному.

// Базовый класс
class Транспорт {
    двигаться() {
        console.log("Транспорт движется");
    }
}

// Дочерние классы переопределяют метод
class Автомобиль extends Транспорт {
    двигаться() {
        console.log("🚗 Едет по дороге");
    }
}

class Лодка extends Транспорт {
    двигаться() {
        console.log("🚤 Плывёт по воде");
    }
}

class Самолет extends Транспорт {
    двигаться() {
        console.log("✈️ Летит по воздуху");
    }
}

// Полиморфизм в действии:
function запуститьДвижение(транспорт) {
    транспорт.двигаться();  // Одна команда — разное поведение
}

запуститьДвижение(new Автомобиль());  // 🚗 Едет по дороге
запуститьДвижение(new Лодка());       // 🚤 Плывёт по воде
запуститьДвижение(new Самолет());     // ✈️ Летит по воздуху
🔑 Ключевая идея: Функция запуститьДвижение не знает, с каким именно транспортом она работает. Она просто вызывает метод двигаться(), и каждый объект сам решает, как его выполнить.

Синтаксис в JavaScript (Node.js): все формы и варианты

1. Инкапсуляция через замыкания (старый способ, до ES6)

// Фабричная функция с замыканием
function Счет(начальнаяСумма) {
    // Приватная переменная — доступна только внутри функции
    let _баланс = начальнаяСумма;
    
    // Возвращаем объект с методами
    return {
        // Геттер — получает значение
        получитьБаланс() {
            return _баланс;
        },
        
        // Сеттер — изменяет значение с проверкой
        пополнить(сумма) {
            if (сумма > 0) {
                _баланс += сумма;
                return true;
            }
            return false;
        },
        
        снять(сумма) {
            if (сумма > 0 && сумма <= _баланс) {
                _баланс -= сумма;
                return true;
            }
            return false;
        }
    };
}

let мойСчет = Счет(1000);
console.log(мойСчет.получитьБаланс());  // 1000
мойСчет.пополнить(500);
console.log(мойСчет.получитьБаланс());  // 1500
// мойСчет._баланс — undefined, не видно!

2. Инкапсуляция через приватные поля (современный способ, ES2022+)

// Используем символ # для обозначения приватных полей
class БанковскийСчет {
    // Приватное поле — начинается с #
    #баланс;
    #владелец;
    
    constructor(владелец, начальныйБаланс = 0) {
        this.#владелец = владелец;
        this.#баланс = начальныйБаланс;
    }
    
    // Публичный метод
    получитьБаланс() {
        return this.#баланс;
    }
    
    получитьВладельца() {
        return this.#владелец;
    }
    
    пополнить(сумма) {
        if (сумма > 0) {
            this.#баланс += сумма;
            console.log(`Счёт пополнен на ${сумма}. Баланс: ${this.#баланс}`);
        }
    }
    
    снять(сумма) {
        if (сумма > 0 && сумма <= this.#баланс) {
            this.#баланс -= сумма;
            console.log(`Снято ${сумма}. Баланс: ${this.#баланс}`);
            return true;
        }
        console.log("Недостаточно средств");
        return false;
    }
}

let счет = new БанковскийСчет("Иван", 1000);
// счет.#баланс — SyntaxError! Нельзя получить доступ извне
console.log(счет.получитьБаланс());  // 1000

3. Полиморфизм через наследование классов

// Базовый класс
class Фигура {
    constructor(название) {
        this.название = название;
    }
    
    // Метод, который будут переопределять
    площадь() {
        return 0;
    }
    
    описание() {
        return `${this.название} имеет площадь ${this.площадь()}`;
    }
}

// Дочерний класс — Прямоугольник
class Прямоугольник extends Фигура {
    constructor(ширина, высота) {
        super("Прямоугольник");  // Вызываем конструктор родителя
        this.ширина = ширина;
        this.высота = высота;
    }
    
    // Переопределяем метод площадь()
    площадь() {
        return this.ширина * this.высота;
    }
}

// Дочерний класс — Круг
class Круг extends Фигура {
    constructor(радиус) {
        super("Круг");
        this.радиус = радиус;
    }
    
    площадь() {
        return Math.PI * this.радиус ** 2;
    }
}

// Дочерний класс — Треугольник
class Треугольник extends Фигура {
    constructor(основание, высота) {
        super("Треугольник");
        this.основание = основание;
        this.высота = высота;
    }
    
    площадь() {
        return (this.основание * this.высота) / 2;
    }
}

// Полиморфная функция
function показатьПлощадь(фигура) {
    console.log(`${фигура.название}: ${фигура.площадь().toFixed(2)} кв.ед.`);
}

показатьПлощадь(new Прямоугольник(5, 10));  // Прямоугольник: 50.00 кв.ед.
показатьПлощадь(new Круг(7));               // Круг: 153.94 кв.ед.
показатьПлощадь(new Треугольник(6, 8));     // Треугольник: 24.00 кв.ед.

4. Полиморфизм через утиную типизацию (Duck Typing)

// В JavaScript не нужно явно указывать тип — 
// если объект имеет нужный метод, он подходит

const утка = {
    имя: "Утка",
    говорить() {
        return "Кря-кря!";
    }
};

const собака = {
    имя: "Собака",
    говорить() {
        return "Гав-гав!";
    }
};

const кошка = {
    имя: "Кошка",
    говорить() {
        return "Мяу-мяу!";
    }
};

// Функция принимает любой объект с методом говорить()
function заставитьГоворить(животное) {
    console.log(`${животное.имя} говорит: ${животное.говорить()}`);
}

заставитьГоворить(утка);    // Утка говорит: Кря-кря!
заставитьГоворить(собака);  // Собака говорит: Гав-гав!
заставитьГоворить(кошка);   // Кошка говорит: Мяу-мяу!

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

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

// Простейший пример: счётчик с защищённым значением
class Счетчик {
    #значение = 0;  // Приватное поле
    
    увеличить() {
        this.#значение++;
    }
    
    получитьЗначение() {
        return this.#значение;
    }
    
    сбросить() {
        this.#значение = 0;
    }
}

const счетчик = new Счетчик();
счетчик.увеличить();
счетчик.увеличить();
счетчик.увеличить();
console.log(счетчик.получитьЗначение());  // 3
// счетчик.#значение — ошибка! Нельзя изменить напрямую

Пример 2: Инкапсуляция с валидацией данных

// Класс для хранения возраста с проверкой
class Человек {
    #возраст;
    
    constructor(имя, возраст) {
        this.имя = имя;
        this.#установитьВозраст(возраст);  // Используем приватный метод
    }
    
    // Приватный метод для установки возраста с проверкой
    #установитьВозраст(возраст) {
        if (возраст >= 0 && возраст <= 150) {
            this.#возраст = возраст;
        } else {
            this.#возраст = 0;
            console.log("Некорректный возраст, установлено 0");
        }
    }
    
    // Публичный метод для получения возраста
    получитьВозраст() {
        return this.#возраст;
    }
    
    // Публичный метод для изменения возраста
    отметитьДеньРождения() {
        this.#возраст++;
        console.log(`С днём рождения! Теперь ${this.#возраст} лет`);
    }
}

const человек = new Человек("Анна", 25);
console.log(человек.получитьВозраст());  // 25
человек.отметитьДеньРождения();          // С днём рождения! Теперь 26 лет
// человек.#возраст = 200 — ошибка! Защищено

Пример 3: Полиморфизм с животными

// Базовый класс для всех животных
class Животное {
    constructor(имя) {
        this.имя = имя;
    }
    
    // Базовый метод — будет переопределён
    издатьЗвук() {
        return "...";
    }
    
    представить() {
        return `${this.имя} говорит: ${this.издатьЗвук()}`;
    }
}

class Собака extends Животное {
    издатьЗвук() {
        return "Гав-гав!";
    }
}

class Кошка extends Животное {
    издатьЗвук() {
        return "Мяу-мяу!";
    }
}

class Корова extends Животное {
    издатьЗвук() {
        return "Му-у-у!";
    }
}

// Функция, использующая полиморфизм
function хорЖивотных(животные) {
    for (let животное of животные) {
        console.log(животное.представить());
    }
}

const животные = [
    new Собака("Бобик"),
    new Кошка("Мурка"),
    new Корова("Зорька")
];

хорЖивотных(животные);
// Бобик говорит: Гав-гав!
// Мурка говорит: Мяу-мяу!
// Зорька говорит: Му-у-у!

Пример 4: Инкапсуляция + полиморфизм — система оплаты

// Базовый класс для способов оплаты
class СпособОплаты {
    #название;
    
    constructor(название) {
        this.#название = название;
    }
    
    получитьНазвание() {
        return this.#название;
    }
    
    // Абстрактный метод — будет переопределён
    оплатить(сумма) {
        throw new Error("Метод оплатить() должен быть переопределён");
    }
}

class Наличные extends СпособОплаты {
    constructor() {
        super("Наличные");
    }
    
    оплатить(сумма) {
        console.log(`💵 Оплачено наличными: ${сумма} руб.`);
        return true;
    }
}

class БанковскаяКарта extends СпособОплаты {
    #номерКарты;
    #пинКод;
    
    constructor(номерКарты, пинКод) {
        super("Банковская карта");
        this.#номерКарты = номерКарты;
        this.#пинКод = пинКод;
    }
    
    оплатить(сумма) {
        console.log(`💳 Оплачено картой ${this.#номерКарты.slice(-4)}: ${сумма} руб.`);
        return true;
    }
    
    // Приватный метод для проверки ПИН-кода
    #проверитьПин(введенныйПин) {
        return this.#пинКод === введенныйПин;
    }
    
    // Публичный метод с проверкой
    оплатитьСПином(сумма, пин) {
        if (this.#проверитьПин(пин)) {
            return this.оплатить(сумма);
        }
        console.log("❌ Неверный ПИН-код");
        return false;
    }
}

class Криптовалюта extends СпособОплаты {
    #кошелек;
    
    constructor(адресКошелька) {
        super("Криптовалюта");
        this.#кошелек = адресКошелька;
    }
    
    оплатить(сумма) {
        console.log(`₿ Оплачено криптовалютой с кошелька ${this.#кошелек.slice(0, 6)}...: ${сумма} USD`);
        return true;
    }
}

// Полиморфная функция для обработки платежа
function обработатьПлатеж(способ, сумма) {
    console.log(`Обработка платежа через ${способ.получитьНазвание()}...`);
    return способ.оплатить(сумма);
}

// Использование
const наличные = new Наличные();
const карта = new БанковскаяКарта("1234567890123456", "1234");
const крипта = new Криптовалюта("0xABC123DEF456");

обработатьПлатеж(наличные, 500);     // 💵 Оплачено наличными: 500 руб.
обработатьПлатеж(карта, 1500);       // 💳 Оплачено картой 3456: 1500 руб.
обработатьПлатеж(крипта, 200);       // ₿ Оплачено криптовалютой...

// Проверка ПИН-кода
карта.оплатитьСПином(3000, "1234");  // 💳 Оплачено картой 3456: 3000 руб.
карта.оплатитьСПином(3000, "0000");  // ❌ Неверный ПИН-код

Пример 5: Инкапсуляция с геттерами и сеттерами

// Использование get и set для контроля доступа
class Термометр {
    #градусыЦельсия;
    
    constructor(градусыЦельсия = 0) {
        this.#градусыЦельсия = градусыЦельсия;
    }
    
    // Геттер — получает значение
    get цельсий() {
        return this.#градусыЦельсия;
    }
    
    // Сеттер — устанавливает с проверкой
    set цельсий(значение) {
        if (значение < -273.15) {
            console.log("Температура ниже абсолютного нуля невозможна!");
            this.#градусыЦельсия = -273.15;
        } else {
            this.#градусыЦельсия = значение;
        }
    }
    
    // Вычисляемое свойство — конвертация в Фаренгейты
    get фаренгейт() {
        return this.#градусыЦельсия * 9/5 + 32;
    }
    
    set фаренгейт(значение) {
        this.цельсий = (значение - 32) * 5/9;
    }
}

const термометр = new Термометр(25);
console.log(термометр.цельсий);      // 25 (используем геттер)
console.log(термометр.фаренгейт);    // 77 (вычисляется автоматически)

термометр.цельсий = 100;             // Используем сеттер
console.log(термометр.фаренгейт);    // 212

термометр.цельсий = -300;            // Ошибка! Ниже абсолютного нуля
console.log(термометр.цельсий);      // -273.15 (защита сработала)

термометр.фаренгейт = 32;           // Устанавливаем через Фаренгейты
console.log(термометр.цельсий);      // 0 (конвертировалось)

Пример 6: Полиморфизм с интерфейсами (через проверку методов)

// В JavaScript нет интерфейсов, но мы можем проверять наличие методов
class Птица {
    constructor(имя) {
        this.имя = имя;
    }
    
    летать() {
        return `${this.имя} летит`;
    }
}

class Самолет {
    constructor(модель) {
        this.модель = модель;
    }
    
    летать() {
        return `Самолёт ${this.модель} летит`;
    }
}

class Ракета {
    constructor(название) {
        this.название = название;
    }
    
    летать() {
        return `Ракета ${this.название} взлетает!`;
    }
}

// Функция проверяет, есть ли метод летать()
function запуститьВПолёт(объект) {
    if (typeof объект.летать === 'function') {
        console.log(объект.летать());
    } else {
        console.log("Этот объект не умеет летать!");
    }
}

запуститьВПолёт(new Птица("Орёл"));     // Орёл летит
запуститьВПолёт(new Самолет("Боинг"));  // Самолёт Боинг летит
запуститьВПолёт(new Ракета("Союз"));    // Ракета Союз взлетает!
запуститьВПолёт({имя: "Камень"});       // Этот объект не умеет летать!

Пример 7: Комплексный пример — система управления библиотекой

// Полноценный пример с инкапсуляцией и полиморфизмом

// Базовый класс для всех материалов библиотеки
class БиблиотечныйМатериал {
    #id;
    #название;
    #годИздания;
    #доступен = true;
    
    constructor(id, название, годИздания) {
        this.#id = id;
        this.#название = название;
        this.#годИздания = годИздания;
    }
    
    // Геттеры для доступа к приватным полям
    получитьId() { return this.#id; }
    получитьНазвание() { return this.#название; }
    получитьГод() { return this.#годИздания; }
    isДоступен() { return this.#доступен; }
    
    // Публичные методы для управления доступностью
    выдать() {
        if (this.#доступен) {
            this.#доступен = false;
            return true;
        }
        return false;
    }
    
    вернуть() {
        this.#доступен = true;
    }
    
    // Полиморфный метод — будет переопределён
    получитьОписание() {
        return `${this.#название} (${this.#годИздания})`;
    }
}

// Класс для книг
class Книга extends БиблиотечныйМатериал {
    #автор;
    #страницы;
    
    constructor(id, название, год, автор, страницы) {
        super(id, название, год);
        this.#автор = автор;
        this.#страницы = страницы;
    }
    
    получитьАвтора() { return this.#автор; }
    
    // Переопределяем метод
    получитьОписание() {
        return `📚 Книга: "${super.получитьНазвание()}" — ${this.#автор}, ${super.получитьГод()}г., ${this.#страницы} стр.`;
    }
}

// Класс для журналов
class Журнал extends БиблиотечныйМатериал {
    #номерВыпуска;
    
    constructor(id, название, год, номерВыпуска) {
        super(id, название, год);
        this.#номерВыпуска = номерВыпуска;
    }
    
    получитьНомерВыпуска() { return this.#номерВыпуска; }
    
    получитьОписание() {
        return `📰 Журнал: "${super.получитьНазвание()}" №${this.#номерВыпуска}, ${super.получитьГод()}г.`;
    }
}

// Класс для DVD
class DVD extends БиблиотечныйМатериал {
    #режиссер;
    #длительность; // в минутах
    
    constructor(id, название, год, режиссер, длительность) {
        super(id, название, год);
        this.#режиссер = режиссер;
        this.#длительность = длительность;
    }
    
    получитьРежиссера() { return this.#режиссер; }
    
    получитьОписание() {
        return `🎬 DVD: "${super.получитьНазвание()}" — реж. ${this.#режиссер}, ${super.получитьГод()}г., ${this.#длительность} мин.`;
    }
}

// Класс для управления библиотекой
class Библиотека {
    #материалы = [];
    #читатели = [];
    
    // Приватный метод для генерации ID
    #сгенерироватьId() {
        return this.#материалы.length + 1;
    }
    
    добавитьМатериал(материал) {
        this.#материалы.push(материал);
        console.log(`✅ Добавлен: ${материал.получитьОписание()}`);
    }
    
    // Полиморфная функция — работает с любым материалом
    показатьВсеМатериалы() {
        console.log("
📋 Каталог библиотеки:");
        this.#материалы.forEach((материал, индекс) => {
            const статус = материал.isДоступен() ? "✅ Доступен" : "❌ Выдан";
            console.log(`${индекс + 1}. ${материал.получитьОписание()} — ${статус}`);
        });
    }
    
    выдатьМатериал(id) {
        const материал = this.#материалы.find(m => m.получитьId() === id);
        if (материал) {
            if (материал.выдать()) {
                console.log(`📤 Выдан: ${материал.получитьОписание()}`);
            } else {
                console.log(`❌ Материал уже выдан: ${материал.получитьОписание()}`);
            }
        } else {
            console.log("❌ Материал не найден");
        }
    }
    
    вернутьМатериал(id) {
        const материал = this.#материалы.find(m => m.получитьId() === id);
        if (материал) {
            материал.вернуть();
            console.log(`📥 Возвращён: ${материал.получитьОписание()}`);
        }
    }
}

// Демонстрация работы
const библиотека = new Библиотека();

// Добавляем разные материалы
библиотека.добавитьМатериал(new Книга(1, "Война и мир", 1869, "Л.Н. Толстой", 1225));
библиотека.добавитьМатериал(new Журнал(2, "Наука и жизнь", 2023, 5));
библиотека.добавитьМатериал(new DVD(3, "Матрица", 1999, "Братья Вачовски", 136));

// Показываем каталог
библиотека.показатьВсеМатериалы();

// Выдаём и возвращаем
библиотека.выдатьМатериал(1);
библиотека.выдатьМатериал(1);  // Попытка выдать уже выданное
библиотека.вернутьМатериал(1);
библиотека.показатьВсеМатериалы();

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

Характеристика JavaScript (Node.js) Python
Приватные поля Используется символ # (с ES2022): #поле Используется двойное подчёркивание: __поле (name mangling)
Геттеры/сеттеры Через ключевые слова get и set Через декораторы @property и @поле.setter
Наследование Через extends, вызов родителя через super() Через class Дочерний(Родитель), вызов через super().__init__()
Полиморфизм Утиная типизация (duck typing) — проверка наличия метода Утиная типизация + абстрактные классы через ABC
Абстрактные классы Нет встроенной поддержки (можно эмулировать) Есть через модуль abc
Множественное наследование Нет (только через приме