ООП в TypeScript

OOPTypeScriptJavaScript

Отличие процедурного подхода от объектно-ориентированного.

Процедурный подход подразумевал под собой обычно написание функции и определение того, когда функция будет вызваться и с какими параметрами

const width = 5;
const height = 10;
 
function clacRectArea(width, height) {
    return width * height;
}
 
clacRectArea(width, height);

Однако использовать процедурный подход в программировании было тяжело, так как расширять такое приложение было крайне трудозатратным

Классы. Объекты. Свойства. Методы. Конструктор.

Класс - это описание характеристик будущего объекта. Объект - это конкретный экземпляр класса, где характеристики уже имеют свои значения

Конкретно характеристики в контексте ООП - это свойства, а его функции - это методы

Мы имеем конкретный объект Rectangle. У него есть два свойства: высота и ширина. Объектам обычно прописывается конструктор, который принимает в себя начальные значения для свойств класса. Так же у объекта есть свои методы, которые он может исполнить. Мы можем создать столько инстанцев класса, сколько нам нужно. Так же мы можем добавить нужное нам количество методов и свойств. Однако нужно помнить, что мы должны создавать объекты под конкретные задачи без лишних методов и свойств не характерных для данного объекта. this - это всегда обращение к данному объекту

class Rectangle {
    width: number;
    height: number;
 
    constructor(w, h) {
        this.width = w;
        this.height = h;
    }
 
    clacArea() {
        return this.width * this.height;
    }
}
 
const rect = new Rectangle(5, 10);
rect.clacArea();

В ООП главными парадигмами являются данные три:

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

Инкапсуляция - это парадигма, которая говорит нам, что данные и функции могут быть связаны, а данные внутри класса будут недоступны для изменений вне класса.

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

Модификатор - это определённый элемент, который определяет доступность свойства или метода изнутри класса. Конкретно private - ограничивает область доступности данных полей внутри класса, а public делает поля доступными для просмотра и изменения и вне класса.

Приватные свойства по соглашению в ТС начинают с ” _ ” Для получения или изменения приватного свойства обычно используют геттеры (получение значения) и сеттеры (помещение значения). Если у нас будет отсутствовать сеттер, то свойство изменить мы не сможем (свойство автоматически станет readonly), если будет отсутствовать геттер, то получить значение свойства у нас так же не получится

class Rectangle {
    // Приватные поля
    private _width: number;
    private _height: number;
 
    constructor(w, h) {
        this._width = w;
        this._height = h;
    }
 
    // Получение и установка этих полей
    get width() {
        return this._width;
    }
 
    set width(value) {
        if (value <= 0) {
            this._width = 1;
        } else {
            this._width = value;
        }
    }
 
    clacArea() {
        return this._width * this._height;
    }
}
 
const rect = new Rectangle(5, 10);
rect.clacArea();

Так же стоит упомянуть, что по умолчанию в ТС присваивается всем полям публичный модификатор. Хорошей практикой является явное указание публичных/приватных модификаторов

public get width() {
	return this._width;
}
 
public set width(value) {
	if (value <= 0) {
		this._width = 1;
	} else {
		this._width = value;
	}
}

Так же ещё один пример. Нам не стоит редактировать таблицу базы данных напрямую. Вместо этого будет правильным решением сделать отдельный метод для заполнения таблицы нашей БД Если нам потребуется очистить нашу БД, то так же лучше создать отдельный метод для этих действий

Наследование.

Наследование подразумевает под собой переиспользование кода за счёт передачи его дочернему элементу.

То есть, например, мы имеем человека этот человек может быть работником, а уже работника можно конкретизировать до программиста

Пример экстенда класса от других классов (персона - рабочий)

Ну и расширяемся дальше по прямой. Через метод супер вызывается конструктор родителя и уже в него передаются аргументы

И вот наследование методов от родительского

Полиморфизм. Параметрический и ad-hoc

Полиморфизм - "один интерфейс - множество реализаций".

Выделяют два вида полиморфизма: -Параметрический (истинный) -Ad-hoc (мнимый)

Ad-hoc. Представляет из себя перегрузку методов, когда мы передаём аргументы разных типов данных. Такой способ не работает в ТС

class Calculator {  
    add(a: number, b: number): number {  
        return a + b;  
    }  
  
    add(a: string, b: string): string {  
        return a + b;  
    }  
}  
 
const calculator = new Calculator();
 
calculator.add(5, 5);  
calculator.add("5", "5");

Параметрический полиморфизм. Уже он представляет из себя переопределение унаследованных методов на те, которые подойдут определённому объекту класса

Тут показана реализация перебора массива персон (который имеет тип массива родительского класса) и вызов у каждого из них метода приветствия

Агрегация и композиция.

Чуть выше было рассмотрено взаимодействие классов через наследование

Композиция подразумевает под собой то, что объекты не могут существовать отдельно друг без друга (автомобиль не может существовать отдельно от двигателя и колёс, так как он из них состоит)

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

И вот реальный пример композиции. Машина не может существовать без колёс и двигателя (обязательно в конструкторе нужно их создать)

class Wheel {  
    drive() {  
        console.log("Колесо едет");  
    }  
}  
  
class Engine {  
    drive() {  
        console.log("Двигатель заведён");  
    }  
}  
  
  
class Car {  
    engine: Engine;  
    wheels: Wheel[];  
  
    constructor() {  
        this.engine = new Engine();  
        this.wheels = [];  
        this.addWheels(new Wheel(), 4);  
    }  
  
    addWheels(wheel: Wheel, count: number) {  
        for (let i = 0; i < count; i++) {  
            this.wheels.push(wheel);  
        }    
    }  
  
    drive() {  
        this.engine.drive();  
        for (let i = 0; i < this.wheels.length; i++) {  
            this.wheels[i].drive();  
        }    
    }  
}  
  
const bmw = new Car();  
bmw.drive();

И вот пример агрегации. Мы отдельно создали класс освежителя и добавили его в качестве аргумента конструктора. Стоит сказать, что при удалении машины, освежитель останется и будет жить своей жизнью. А уже колёса и двигатель исчезнут вслед за машиной.

И свою жизнь освежитель сможет пролежать в квартире

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

Интерфейсы представляют собой абстрактное представление функционала будущего объекта (например, класса). Можно представить, что это оглавление, которое описывает, что должно присутствовать в объекте.

Абстрактный класс - это уже конкретная схема класса со всеми методами, тем, что они должны возвращать и даже с логикой, которую можно передать через наследование

  • Интерфейсы позволяют эффективно проектировать систему.
  • Так же это представление работы полиморфизма.
  • Создать класс из интерфейса нельзя - можно только имплементировать существующий класс

И вот пример создания класса из интерфейса (тут очень сильно помогает alt+insert, который сам реализует все методы интерфейсов)

interface IReader {  
    get(url: string): string;  
    read: (text: string) => void;  
    delete: () => void;  
    create: () => void;  
    update: () => void;  
}  
  
class Repository implements IReader {  
    create(): void {  
    }  
    delete(): void {  
    }  
    read(text: string): void {  
    }  
    update(): void {  
    }  
    get(url: string): string {  
        return "";  
    }  
}

Однако если нам нужно будет из интерфейса репозитория сделать репозиторий юзера, то нам нужно будет немного дополнить интерфейс и уточнить его под юзера (хотя интерфейс мы можем создать для многих объектов). Для исправления ситуации в интерфейс мы можем передать дженерик (обобщённый/динамический тип)

И теперь мы можем использовать репозиторий и для реализации репозитория машин: класс Car, класс CarRepository, интерфейс Repository

Внедрение зависимостей. Dependency injection

На всех тех принципах, что были озвучены выше, строится огромное количество паттернов проектирования

И вот пример реализации паттерна “Внедрение зависимостей”. У нас есть два слоя реализации приложения: работа с базой данных и бизнес-логика. Первый слой работает с двумя разными репозиториями БД (реляционная и нереляционная). В идеале, нам нужно, чтобы 2ой слой не знал, с какой БД мы работаем и в нём нам нужно будет реализовать имплементацию

И вот мы реализовали логику, когда мы в зависимости от разных аргументов получаем разную реализацию. Получаем мы разную реализацию за счёт того, что передаём не какой-то объект, а интерфейс. А из этого интерфейса идут две других реализации работы с БД, что и позволяет нам передать в один класс две разных логики (двух разных БД) Реализован опять же полиморфизм, но с использованием интерфейса

class User {  
  
}  
 
// Сам интерфейс
interface UserRepository {  
    getUsers: () => User[];  
}  
 
// Создаём первый репозиторий через интерфейс
class MongoDBRepository implements UserRepository {  
    getUsers(): User[] {  
        console.log("Get users from MongoDB");  
        return [{age: 15, username: "MongoDB"}];  
    }  
}  
 
// И создаём репозиторий постреса через интерфейс
class PostresRepository implements UserRepository {  
    getUsers(): User[] {  
        console.log("Get users from PostreSQL");  
        return [{age: 15, username: "PostreSQL"}];  
    }  
}  
  
class UserService {  
    userRepository: UserRepository;  
 
	// Чтобы была возможность передать сюда один из репозиториев БД, нужно передать сюда интерфейс
    constructor(userRepository: UserRepository) { // Агрегация  
        this.userRepository = userRepository;  
    }  
  
    // Эту реализацию на проектах делают через БД  
    filterUserByAge(age: number) {  
        const users = this.userRepository.getUsers();  
        // filter logic ...  
        console.log(users);  
    }  
}  
  
const userServiceMongo = new UserService(new MongoDBRepository());  
userServiceMongo.filterUserByAge(15);  
const userServicePostres = new UserService(new MongoDBRepository());  
userServicePostres.filterUserByAge(15);

Паттерн singleton

Паттерн Singleton заставляет нас от одного класса создавать только один объект. Делать более одного экземпляра класса (а равно и подключения) - нельзя

Вот в примере, у нас создаются две разных БД. Внутри них генерируется уникальный идентификатор - их url. Это два разных поключения

Однако мы можем ограничить создание новых объектов. Можно сделать статичное свойство (то есть оно будет доступно без инициализации класса), в котором будет находиться значение нашего класса и этот инстанс будет из себя представлять всегда одинаковое статичное значение (= this). Это гарантирует постоянную одинаковость определённого значения класса Базы Данных

И теперь при каждой инициализации нашего класса, мы будем получать одно и то же значение определённого свойства (оно будет неизменным)