Роберт С. Мартин создал набор рекомендаций по проектированию программного обеспечения, известных как принципы SOLID. Эти рекомендации помогают программистам создавать надежные, устойчивые к изменениям приложения, снижая при этом стоимость обновлений. В этой статье мы рассмотрим, как использовать эти принципы в JavaScript, и предоставим примеры кода, иллюстрирующие нашу точку зрения.

Каковы принципы SOLID?

  • Принцип единой ответственности
  • Принцип открытия-закрытия
  • Принцип подстановки Лисков
  • Принцип разделения интерфейсов
  • Принцип инверсии зависимости

Принцип единой ответственности

Итак, что такое принцип единой ответственности? Принцип единой ответственности — это один из пяти принципов SOLID, который применяется к классам, компонентам и микросервисам. Правило, которое направляет нас к этому принципу, звучит так: "классы должны нести одну ответственность — класс не должен изменяться более чем по одной причине".

Давайте рассмотрим простой пример, чтобы понять этот принцип. В следующем фрагменте кода JavaScript есть класс с именем UserSettings и одна функция, которая изменит настройки пользователя, и другая, которая проверит учетные данные пользователя.

//BAD
class UserSettings {
 constructor (user) {
   this.user = user;
 }

 changeSettings (settings) {
   if (this.verifyCredentials()) {
     //..
   }
 }

 verifyCredentials () {
   //..
 }
}

Как я упоминал выше, в SRP у одного класса должна быть одна обязанность. В этом случае UserSettingsclass используется для двух разных целей: для изменения настроек пользователя и для проверки учетных данных пользователя. Вместо того, чтобы использовать один класс для нескольких обязанностей, мы собираемся разделить его на два разных класса (UserSettings и UserAuth). Один класс предназначен для изменения настроек, а другой проверяет учетные данные пользователя.

//Good
class UserAuth {
 constructor (user) {
   this.user = user;
 }

 verifyCredentials () {
   //..
 }
}

class UserSettings {
 cosntructor (user) {
   this.user = user;
   this.auth = new UserAuth(user);
 }

 changeSettings (settings) {
   if (this.auth.verifyCredintials()) {
     //..
   }
 }
}

Принцип открытого-закрытого

OCP является вторым принципом принципов SOLID и гласит: в объектно-ориентированном программировании принцип открытости-закрытости (OCP) утверждает, что «программные объекты (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации»; то есть такая сущность может разрешить расширение своего поведения без изменения исходного кода.

Ниже у нас есть класс, который используется для расчета зарплаты разработчиков в одной компании.

class ManageDeveloperSalaries {
 constructor() {
   this.salaryRates = [
     { id: 1, seniority: 'intermediate', rate: 200 },
     { id: 2, seniority: 'senior', rate: 300 },
   ];
 }

 calculateSalaries(devId, hoursWorked) {
   let salaryObject = this.salaryRates.find((o) => o.id === devId);
   return hoursWorked * salaryObject.rate;
 }
}

const salary = new ManageDeveloperSalaries();
console.log("Salary : ", salary.calculateSalaries(1, 100));

Предположим, что в этой компании у нас появился новый разработчик со стажем junior. По OCP мы не можем модифицировать массив salaryRates, вместо модификации массива мы собираемся расширить функциональность класса ManageDeveloperSalariesпутемдобавления нового метода, который добавит новую ставку заработной платы для младших разработчиков.

class ManageDeveloperSalaries {
 constructor() {
   this.salaryRates = [
     { id: 1, seniority: 'intermediate', rate: 200 },
     { id: 2, seniority: 'senior', rate: 300 },
   ];
 }

 calculateSalaries(devId, hoursWorked) {
   let salaryObject = this.salaryRates.find((o) => o.id === devId);
   return hoursWorked * salaryObject.rate;
 }

addSalaryRate(id, seniority, rate) {
   this.salaryRates.push({ id: id, seniority: seniority, rate: rate });
 }
}

const salary = new ManageDeveloperSalaries();
console.log("Salary : ", salary.calculateSalaries(1, 100));

Принцип подстановки Лисков

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

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

Рассмотрим плохой пример, нарушающий принцип подстановки Лискова:

class Bird {
  fly() {
    // common implementation for flying
  }
}

class Ostrich extends Bird {
  fly() {
    throw new Error("Ostriches cannot fly!");
  }
}

В этом примере класс Bird имеет метод с именем fly(), который представляет обычное поведение при полете для разных типов птиц. Однако когда дело доходит до класса Ostrich, он нарушает LSP, выдавая ошибку в своем методе fly(). Таким образом, класс Ostrich не ведет себя так, как ожидалось от общего класса Bird. Если бы мы заменили экземпляр Ostrich экземпляром Bird в любой части кода, которая зависит от способности летать, это привело бы к непредвиденным ошибкам или неправильному поведению.

Рассмотрим пример, удовлетворяющий принципу подстановки Лискова:

class Shape {
  calculateArea() {
    // common implementation for calculating area
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius * this.radius;
  }
}

В этом примере класс Shape определяет метод calculateArea(), который вычисляет площадь фигуры. Классы Rectangle и Circle наследуются от Shape и переопределяют метод calculateArea() своими специфическими реализациями. Согласно LSP, мы можем безопасно заменить экземпляры Shape экземплярами Rectangle или Circle, не влияя на корректность программы. Эта приверженность LSP допускает полиморфизм и гибкость кода.

Принцип разделения интерфейсов

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

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

Давайте посмотрим на пример, который не удовлетворяет принципу разделения интерфейса:

// Interface
class Machine {
  print() {
    throw new Error("Method not implemented");
  }

  scan() {
    throw new Error("Method not implemented");
  }

  fax() {
    throw new Error("Method not implemented");
  }
}

class AllInOnePrinter implements Machine {
  print() {
    // Print the document
  }

  scan() {
    // Scan the document
  }

  fax() {
    // Fax the document
  }

В этом примере у нас есть Machine интерфейс, который включает в себя методы печати, сканирования и отправки факсов. Класс AllInOnePrinter реализует этот интерфейс, но может заставить клиентов зависеть от методов, которые им не нужны. Например, если клиент хочет только распечатать документ, он все равно вынужден иметь доступ к методам сканирования и отправки по факсу.

Это нарушает принцип разделения интерфейса, поскольку клиенты вынуждены зависеть от методов, которые им не нужны. Вместо этого мы должны разделить большой интерфейс на более мелкие, более конкретные интерфейсы, позволяя клиентам полагаться только на те методы, которые им нужны.

Давайте посмотрим на пример, который удовлетворяет принципу разделения интерфейса:

// Interface
class Printable {
  print() {
    throw new Error("Method not implemented");
  }
}

class Document implements Printable {
  print() {
    // Print the document
  }

  edit() {
    // Edit the document
  }
}

class Photo implements Printable {
  print() {
    // Print the photo
  }

  zoom() {
    // Zoom in/out the photo
  }
}

В этом примере у нас есть интерфейс Printable, который определяет единственный метод print(). Классы Document и Photo реализуют этот интерфейс и предоставляют свои собственные реализации для метода print(). Каждый реализующий класс зависит только от методов, требуемых интерфейсом.

Следуя Принципу разделения интерфейса, мы избегаем ситуации, когда клиенты, которым нужно только печатать документы, вынуждены зависеть от методов, связанных с обработкой фотографий (таких как zoom()). Клиенты могут полагаться на интерфейс Printable, не будучи обремененными ненужной функциональностью. Этот принцип способствует более модульной и гибкой структуре кода.

Принцип инверсии зависимости

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

Теперь давайте рассмотрим плохой пример, нарушающий принцип инверсии зависимостей:

// High-level module
class UserManager {
  constructor() {
    this.userRepository = new UserRepository();
  }

  getUser(userId) {
    return this.userRepository.getUser(userId);
  }
}

// Low-level module
class UserRepository {
  getUser(userId) {
    // Retrieve the user from the database
  }
}

В этом примере класс UserManager напрямую создает экземпляр UserRepository, используя ключевое слово new. Это нарушает принцип инверсии зависимостей, поскольку модуль высокого уровня тесно связан с модулем низкого уровня. Если бы мы захотели изменить реализацию базы данных или использовать другой источник данных, нам пришлось бы изменить класс UserManager, сделав его негибким и трудным в обслуживании.

Теперь давайте рассмотрим хороший пример, удовлетворяющий принципу инверсии зависимостей:

// High-level module
class UserManager {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  getUser(userId) {
    return this.userRepository.getUser(userId);
  }
}

// Low-level module
class UserRepository {
  getUser(userId) {
    // Retrieve the user from the database
  }
}

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

Класс UserManager принимает экземпляр UserRepository в качестве параметра конструктора. Полагаясь на абстракцию, он может вызывать метод getUser(), не зная конкретных деталей реализации. Это обеспечивает гибкость, поскольку различные реализации интерфейса UserRepository (например, фиктивный репозиторий для тестирования или другая реализация базы данных) можно легко заменить, не влияя на класс UserManager.

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