ООП: инкапсуляция, наследование, полиморфизм, композиция и DI
Frontend Engineer · Agro
Что такое ООП и зачем оно вообще нужно?
Ну смотрите, раньше все писали в процедурном стиле: были просто данные и функции-процедуры, которые их как-то переваривали. Пока проект маленький — всё ок, но когда кода становится много, управлять этим «спагетти» становится невозможно: сложно делать декомпозицию и что-то менять, не ломая всё остальное.
Тут и приходит ООП (Объектно-ориентированное программирование). Мы начинаем мыслить не просто процедурами, а классами и объектами
- Класс — это «чертеж» или описание характеристик (свойств) и действий (методов).
- Объект — это конкретный «экземпляр», созданный по этому чертежу, где у каждой характеристики есть свое значение (например, класс «Человек», а объект — «Вася»)
Нюанс тут в том, что ООП — это не “серебряная пуля”, хотя когда-то комьюнити восприняло его именно так, как что-то способное решить вообще все проблемы архитектуры. На деле ООП держится на трёх (иногда говорят четырёх) столпах — инкапсуляция, наследование, полиморфизм
Что такое инкапсуляция ?
Ну смотрите, инкапсуляция на самом деле очень простое понятие, которое почему-то любят усложнять. Суть в том, что класс — это как капсула, которая объединяет в себе данные (свойства) и методы (поведение) для работы с этими данными
Но рядом с инкапсуляцией всегда идёт сокрытие (information hiding), и вот тут уже чуть сложнее. Смысл в том, что часть данных объекта — публичная (с ней можно работать откуда угодно), а часть — закрытая, до которой снаружи дотянуться нельзя. Для этого есть модификаторы доступа public/private/protected
UPD: Нюанс тут в том, что просто пометить свойство private — это полдела. Часто нужно дать контролируемый доступ через геттер/сеттер, причём сеттер — это не просто “присвоить значение”, а место для валидации
Что такое Наследование ?
Ну смотрите, наследование — это механизм, который позволяет одному классу взять себе все свойства и методы другого класса, и при этом ещё добавить что-то своё сверху. То есть классу не нужно заново описывать то, что уже описано в родителе — он это просто получает “по умолчанию”.
Извините но вот классический пример это: вот есть класс Person (имя, фамилия, возраст), от него наследуется Employee (добавляет ИНН, ЗП, номер паспорта), а от Employee наследуется Developer (добавляет язык программирования, номер команды).
class Person {
constructor(
protected firstName: string,
protected lastName: string,
protected age: number,
) {}
}
class Employee extends Person {
constructor(
firstName: string,
lastName: string,
age: number,
private inn: string,
) {
super(firstName, lastName, age); // сначала родительский конструктор
this.inn = inn;
}
}
class Developer extends Employee {
constructor(
firstName: string,
lastName: string,
age: number,
inn: string,
private language: string,
) {
super(firstName, lastName, age, inn);
}
}
Что такое Полиморфизм ?
Ну смотрите, полиморфизм — дословно “много форм”, и суть в том, что мы работаем с разными объектами через один и тот же интерфейс/метод, но при этом каждый объект ведёт себя по-своему при вызове этого метода. Хочется еще добавить что полиморфизм по большой части всегда идет вместе с одним из 3 всадников: наследованием, абстракцией, интерфейсы. То есть отдельно рассматривать полиморфизом смысла нет мне кажется.
Классический пример: есть иерархия Person → Employee → Developer, и у каждого переопределён метод greeting():
class Person {
greeting() {
console.log('Я человек');
}
}
class Employee extends Person {
greeting() {
console.log('Я работник');
}
}
class Developer extends Employee {
greeting() {
console.log('Я разработчик');
}
}
const entities: Person[] = [new Person(), new Employee(), new Developer()];
entities.forEach((e) => e.greeting());
// Я человек
// Я работник
// Я разработчик
А какая разница между инкапсуляцией и сокрытием?
Все довольно просто и на самом деле они находятся по соседству. Так вот
- Когда у нас есть состояние и поведение в контексте одной абстракции это: Инкапсуляция
- А если мы это состояние как то изолируем чтоб никто допустим через точку не лез и не мог менять то это: Сокрытие.
Очень близкие понятия на самом деле
В чем разница с SOLID(Принцип проектирования)?
Ну смотрите, тут важно не путать:
- ООП — это фундамент. Это сам инструментарий, те самые кирпичи: классы, интерфейсы и три столпа (инкапсуляция, наследование, полиморфизм).
- SOLID — это правила проектирования поверх этого фундамента
Прикол в том, что можно использовать ООП и при этом написать ужасный код. SOLID — это 5 по сути принципов, которые говорят, ну как именно нужно организовывать классы и зависимости, чтобы приложение было гибким. Например, тотже самый Dependency Injection (внедрение зависимостей) — это как раз про гибкость: мы передаем зависимости в конструктор сервиса к примеру, и сервису становится плевать, работает он с Postgres или Mongo
Композиция
Ну смотрите, суть композиции — класс-владелец сам создаёт вложенный объект (обычно прямо в конструкторе) и полностью управляет его жизненным циклом. Это отношение “часть-целое”, причём часть без целого просто не имеет смысла — если умирает владелец, умирают и его части.
class Engine {
private isRunning = false;
start() {
this.isRunning = true;
console.log('двигатель заведён');
}
}
class Wheel {
drive() {
console.log('колесо крутится');
}
}
class Car {
private engine: Engine;
private wheels: Wheel[];
constructor() {
this.engine = new Engine(); // создан ВНУТРИ, никто снаружи не передаёт
this.wheels = Array.from({ length: 4 }, () => new Wheel());
}
drive() {
this.engine.start();
this.wheels.forEach((w) => w.drive()); // делегирование — Car не делает работу сам
}
}
Тут ключевой момент — new Engine() создаётся внутри Car, никто снаружи это не контролирует и не может подменить. Метод drive() у Car не выполняет логику сам, а раздаёт вызов вложенным объектам — это и называется делегированием.
Нюанс тут в том, что композиция создаёт максимально жёсткую связь — Car физически не может существовать без своего конкретного Engine, и протестировать Car в изоляции тяжело: Engine не подменить моком, он захардкожен прямо в конструкторе. Захочешь завтра другой тип двигателя (электро вместо бензинового) — придётся лезть внутрь Car и переписывать конструктор.
Trade-off: композиция даёт простоту и предсказуемость — не нужно думать “а вдруг кто-то снаружи подсунет странный Engine”, всё под полным контролем владельца, чистая инкапсуляция. Цена — низкая гибкость и плохая тестируемость. Обычно композицию используют там, где части реально неотделимы по смыслу (например value object внутри агрегата в DDD), а не для сервисов с бизнес-логикой, где нужна гибкость.
Агрегация
Ну смотрите, суть та же самая — “объект содержит объект другого класса”, но принципиальная разница в том, что владелец не создаёт объект и не управляет его жизнью. Объект приходит откуда-то извне (обычно через конструктор) и продолжает жить своей жизнью независимо от владельца.
class Freshener {
constructor(private scent: string) {}
refresh() {
console.log(`пахнет ${this.scent}`);
}
}
class Car {
constructor(private freshener: Freshener) {}
airFreshen() {
this.freshener.refresh();
}
}
const treeScent = new Freshener('ёлочка');
const car = new Car(treeScent);
// удалим car — освежитель продолжит жить своей жизнью
// его же можно передать в другую машину
const anotherCar = new Car(treeScent);
Freshener создаётся снаружи Car и может переиспользоваться где угодно — удаление car никак его не затронет.
Нюанс тут в том, что по коду агрегация и композиция выглядят почти одинаково — что там что там поле плюс возможно конструктор. Разница чисто семантическая, и на собесе как раз этим и подлавливают: “композиция это или агрегация?” — надо смотреть не на синтаксис, а на то, кто владеет жизненным циклом: создаётся объект внутри или передаётся снаружи.
Trade-off: агрегация даёт больше гибкости — объект можно шарить между несколькими владельцами, переиспользовать, подменять — но платишь тем, что владелец теряет полный контроль: он не знает, изменится ли переданный объект где-то ещё снаружи. Это уже шаг в сторону слабой связности, но ещё не полноценный DI в архитектурном смысле — это просто общий термин из ООП про совместное использование любого объекта (хоть освежителя воздуха, хоть сервиса).
Dependency Injection(способ организации зависимостей)
Ну смотрите, тут важно сразу отделить одну вещь: DI — это не третий, отдельный вид отношения между объектами. Это тот же принцип, что и агрегация (“объект передан снаружи, не создан внутри”), просто термин применяется конкретно к функциональным зависимостям — сервисам, репозиториям, клиентам, а не к произвольным объектам вроде освежителя воздуха.
class MongoUserRepository {
getUsers() {
return [
/* запрос в mongo */
];
}
}
class UserService {
constructor(private repo: MongoUserRepository) {}
// структурно — это агрегация (repo передан снаружи)
// архитектурно — это уже DI (UserService не создаёт себе зависимость сам)
}
Тут важный момент: DI не требует абстракции сам по себе. Выше уже полноценный DI, просто зависимость типизирована конкретным классом, а не интерфейсом — и это нормально, DI просто про то, что зависимость приходит снаружи, а не создаётся внутри конструктора через new.
Нюанс тут в том, что DI бывает разных видов — через конструктор (constructor injection, самый распространённый, потому что явно фиксирует обязательные зависимости в сигнатуре), через сеттер (property injection) или через метод. И отдельно стоит различать DI как паттерн и DI-контейнер как инструмент: код выше — уже DI без единого фреймворка. Контейнер (в NestJS — через @Injectable()) нужен не чтобы “включить” DI, а чтобы автоматизировать сборку графа зависимостей, когда сервисов много и вручную городить new по всему приложению становится больно, плюс контейнер управляет lifecycle (singleton/request-scoped/transient).
Trade-off: DI даёт тестируемость (в тесте можно подставить фейковую реализацию) и убирает жёсткую привязку “кто создаёт кого”, но платишь тем, что понять “а какая конкретно реализация используется в runtime” уже не всегда видно прямо в коде сервиса — приходится смотреть туда, где происходит создание (composition root или конфиг контейнера).
Агрегация vs Dependency Injection
Ну смотрите, если совсем просто — это одно и то же, просто разные слова для разных ситуаций.
Агрегация и DI — это одно и то же по механизму, просто:
- Агрегация — термин из классического ООП (описывает отношение между объектами вообще)
- DI — тот же механизм, но термин из мира архитектуры/паттернов проектирования (описывает конкретно передачу зависимостей сервисам)
Никакой технической разницы нет, разница только в том, о чём мы говорим: про объекты вообще — агрегация, про зависимости сервисов конкретно — DI. Один паттерн — два словаря, в зависимости от контекста разговора.
Dependency Inversion Principle(Принцип проектирования)
Ну смотрите, а вот DIP — это уже не механизм, а принцип проектирования: высокоуровневые модули (бизнес-логика) не должны зависеть от низкоуровневых модулей (конкретных реализаций — БД, HTTP-клиенты), обе стороны должны зависеть от абстракции.
// БЕЗ DIP — зависимость идёт "вниз" напрямую
class UserService {
private repo = new MongoUserRepository(); // жёстко завязан на конкретику
}
// С DIP — зависимость инвертирована через абстракцию
interface UserRepository {
getUsers(): User[];
}
class MongoUserRepository implements UserRepository {
getUsers() {
return [
/* mongo */
];
}
}
class UserService {
constructor(private repo: UserRepository) {} // тип — интерфейс
}
Смотри что произошло: раньше стрелка зависимости шла UserService → MongoUserRepository (высокий уровень знает про конкретный низкий). Теперь обе стороны — и UserService, и MongoUserRepository — зависят от UserRepository, а стрелка от реализации идёт вверх, к абстракции. Отсюда и “инверсия”.
Нюанс тут в том, и это ключевая точка, которая связывает всё вместе: DIP появляется только когда DI комбинируется с типизацией через интерфейс. Без интерфейса — это просто DI (агрегация конкретной зависимости, из примера выше). DIP физически невозможен без DI — нельзя инвертировать зависимость, если её вообще создают внутри объекта как в композиции — но DI прекрасно может существовать без DIP.
Trade-off: DIP даёт возможность полностью менять реализацию (Mongo → Postgres → in-memory мок для тестов) вообще не трогая бизнес-логику, максимальная слабая связанность между слоями. Цена — лишняя абстракция там, где вариативность реализации никогда не понадобится: если у тебя одна БД и это железно не изменится, городить интерфейс “на будущее” — чистый overengineering, YAGNI. DIP оправдан, когда реально ожидается вариативность реализации или нужна изоляция для тестов — а не как рефлекс “на каждый класс пиши интерфейс”.