Принципы проектирования SOLID: Упрощённое объяснение

Принципы SOLID направляют разработчиков на создание хорошо структурированного программного обеспечения. Введённые Робертом С. Мартином, они являются неотъемлемыми для надёжного объектно-ориентированного дизайна.

Solid Design Principles

Введение

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

S: Принцип единственной ответственности (Single Responsibility Principle, SRP)

Single Responsibility Principle

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

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

Почему это полезно? Подумайте, что будет, если инструмент на вашем швейцарском армейском ноже сломается. Если каждый инструмент отдельный, вам нужно будет починить только эту одну часть. Аналогично, в программировании, если класс имеет только одну ответственность, его намного проще поддерживать и обновлять, не затрагивая другие части вашего приложения. Это приводит к более чистому, более организованному коду, который легче понять и менее склонен к ошибкам.

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

Плохой пример: Класс с несколькими обязанностями

interface Device {
  display(data: string): void;
}

class WindowsConsole implements Device {
  display(text: string): void {
    console.log(text);
  }
}

class Book {
  private bookName: string;
  private authorName: string;
  private content: string;

  constructor(bookName: string, authorName: string, content: string) {
    this.bookName = bookName;
    this.authorName = authorName;
    this.content = content;
  }

  getBookName(): string {
    return this.bookName;
  }

  setBookName(bookName: string): void {
    this.bookName = bookName;
  }

  getAuthorName(): string {
    return this.authorName;
  }

  setAuthorName(authorName: string): void {
    this.authorName = authorName;
  }

  getContent(): string {
    return this.content;
  }

  setContent(content: string): void {
    this.content = content;
  }

  // Bad example: A class with multiple responsibilities
  show(device: Device): void {
    const text = `Name: ${this.bookName}, author: ${this.authorName}, content: ${this.content}`;
    device.display(text);
  }
}

// Usage
const book = new Book("Book Name", "Author Name", "text text text");
const device = new WindowsConsole();
book.show(device);

Хороший пример: Разделение обязанностей на разные классы

interface Device {
  display(data: string): void;
}

class WindowsConsole implements Device {
  display(text: string): void {
    console.log(text);
  }
}

class BookDisplay {
  show(book: Book, device: Device): void {
    const text = `Name: ${book.getBookName()}, author: ${book.getAuthorName()}, content: ${book.getContent()}`;
    device.display(text);
  }
}

class Book {
  private bookName: string;
  private authorName: string;
  private content: string;

  constructor(bookName: string, authorName: string, content: string) {
    this.bookName = bookName;
    this.authorName = authorName;
    this.content = content;
  }

  getBookName(): string {
    return this.bookName;
  }

  setBookName(bookName: string): void {
    this.bookName = bookName;
  }

  getAuthorName(): string {
    return this.authorName;
  }

  setAuthorName(authorName: string): void {
    this.authorName = authorName;
  }

  getContent(): string {
    return this.content;
  }

  setContent(content: string): void {
    this.content = content;
  }
}

// Usage
const book = new Book("Book Name", "Author Name", "text text text");
const device: Device = new WindowsConsole();
const bookDisplay = new BookDisplay();
bookDisplay.show(book, device);

O: Принцип открытости/закрытости (Open/Closed Principle)

Open/Closed Principle

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

Плохой пример: Класс, который открыт для модификации

enum ReportingType {
  CSV = "CSV",
  XML = "XML",
  EXCEL = "EXCEL"
}

class ReportingService {
  private typeReport: ReportingType;

  constructor(typeReport: ReportingType) {
    this.typeReport = typeReport;
  }

  getTypeReport(): ReportingType {
    return this.typeReport;
  }

  setTypeReport(typeReport: ReportingType): void {
    this.typeReport = typeReport;
  }

  generateReportBasedOnType(reportingType: ReportingType): void {
    if (reportingType === ReportingType.CSV) {
      this.generateCSVReport();
    } else if (reportingType === ReportingType.XML) {
      this.generateXMLReport();
    }
  }

  private generateCSVReport(): void {
    console.log("Generate CSV Report");
  }

  private generateXMLReport(): void {
    console.log("Generate XML Report");
  }
}

// Usage
const rs = new ReportingService(ReportingType.CSV);

// Generate CSV report
rs.generateReportBasedOnType(ReportingType.CSV);

// Generate XML report
rs.generateReportBasedOnType(ReportingType.XML);

Хороший пример: Класс, открытый для расширения и закрытый для модификации

interface Reporting {
  generateReport(): void;
}

class XMLReporting implements Reporting {
  generateReport(): void {
    console.log("Generate XML Report");
  }
}

class CSVReporting implements Reporting {
  generateReport(): void {
    console.log("Generate CSV Report");
  }
}

class ReportingService {
  generateReportBasedOnStrategy(reportingStrategy: Reporting): void {
    reportingStrategy.generateReport();
    console.log();
  }
}

// Usage
const rs = new ReportingService();

// Generate CSV report
const csvReportingStrategy: Reporting = new CSVReporting();
rs.generateReportBasedOnStrategy(csvReportingStrategy);

// Generate XML report
const xmlReportingStrategy: Reporting = new XMLReporting();
rs.generateReportBasedOnStrategy(xmlReportingStrategy);

L: Принцип подстановки Лисков (Liskov Substitution Principle, LSP)

Liskov Substitution Principle

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

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

Например, предположим, у вас есть класс под названием Bird с методом fly(). Если у вас есть подкласс под названием Sparrow, который расширяет Bird, вы должны иметь возможность использовать Sparrow там, где ожидается Bird, без проблем. Если Sparrow не может правильно выполнить функцию fly(), это нарушает LSP. Это как если бы конкретная модель кофемашины не готовила кофе, как ожидалось.

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

Плохой пример: Внешние процессы либо сломаются, либо будут вести себя неправильно, либо им потребуется слишком много информации.

abstract class Boiler {
  private currentTemperature: number;
  private desirableTemperature: number;

  constructor(currentTemperature: number, desirableTemperature: number) {
    this.currentTemperature = currentTemperature;
    this.desirableTemperature = desirableTemperature;
  }

  public setDesirableTemperature(temp: number): void {
    this.desirableTemperature = temp;
  }

  public getDesirableTemperature(): number {
    return this.desirableTemperature;
  }

  public getCurrentTemperature(): number {
    return this.currentTemperature;
  }

  public setCurrentTemperature(currentTemperature: number): void {
    this.currentTemperature = currentTemperature;
  }

  public abstract heat(): void;
}

class BoilerA extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature);
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 1);
  }
}

class BoilerB extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature);
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 2);
  }
}

class BoilerC extends Boiler {
  private desirableTemperature: number = 0;

  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature);
  }

  public setDesirableTemperature(temp: number): void {
    this.desirableTemperature = temp;
  }

  public getDesirableTemperature(): number {
    return this.desirableTemperature;
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 3);
  }
}

interface TemperatureController {
  setDevice(boiler: Boiler): void;

  getWaterTemperature(): number;

  heatWater(): void;
}

class AutomaticTemperatureController implements TemperatureController {
  private boiler: Boiler;

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler;
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature();
  }

  public heatWater(): void {
    this.boiler.heat();
  }
}

class ManualTemperatureController implements TemperatureController {
  private boiler: Boiler;

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler;
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature();
  }

  public heatWater(): void {
    this.boiler.heat();
  }
}

// Usage
const controller1: TemperatureController = new ManualTemperatureController();
const boilerA: BoilerA = new BoilerA(0, 37);
controller1.setDevice(boilerA);
controller1.heatWater();

console.log("Desirable Temperature: " + boilerA.getDesirableTemperature());
console.log("Temperature: " + controller1.getWaterTemperature());

const controller2: TemperatureController = new ManualTemperatureController();
const boilerB: BoilerB = new BoilerB(5, 40);
controller2.setDevice(boilerB);
controller2.heatWater();

console.log("Desirable Temperature: " + boilerB.getDesirableTemperature());
console.log("Temperature: " + controller2.getWaterTemperature());

/**
 * Violating Liskov Substitution Principle (Desirable Temperature is 0-zero, boiler will not heat water).
 *
 * @description:
 * If you don't follow the LSP, external processes will either break, behave improperly, or need to know too much information.
 */
const controller3: TemperatureController = new AutomaticTemperatureController();
const boilerC: BoilerC = new BoilerC(3, 39);
controller3.setDevice(boilerC);
console.log("Desirable Temperature: " + boilerC.getDesirableTemperature());
console.log("Temperature: " + controller3.getWaterTemperature());

Хороший пример

abstract class Boiler {
  private currentTemperature: number
  private desirableTemperature: number

  constructor(currentTemperature: number, desirableTemperature: number) {
    this.currentTemperature = currentTemperature
    this.desirableTemperature = desirableTemperature
  }

  public setDesirableTemperature(temp: number): void {
    this.desirableTemperature = temp
  }

  public getDesirableTemperature(): number {
    return this.desirableTemperature
  }

  public getCurrentTemperature(): number {
    return this.currentTemperature
  }

  public setCurrentTemperature(currentTemperature: number): void {
    this.currentTemperature = currentTemperature
  }

  public abstract heat(): void
}

class BoilerA extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature)
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 1)
  }
}

class BoilerB extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature)
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 2)
  }
}

class BoilerC extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature)
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 3)
  }
}

interface TemperatureController {
  setDevice(boiler: Boiler): void

  getWaterTemperature(): number

  heatWater(): void
}

class ManualTemperatureController implements TemperatureController {
  private boiler: Boiler

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature()
  }

  public heatWater(): void {
    this.boiler.heat()
  }
}

class AutomaticTemperatureController implements TemperatureController {
  private boiler: Boiler

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature()
  }

  public heatWater(): void {
    this.boiler.heat()
  }
}

// Usage
const controller1: TemperatureController = new ManualTemperatureController()
const boilerA: BoilerA = new BoilerA(0, 37)
controller1.setDevice(boilerA)
controller1.heatWater()

console.log('Desirable Temperature: ' + boilerA.getDesirableTemperature())
console.log('Temperature: ' + controller1.getWaterTemperature())

const controller2: TemperatureController = new ManualTemperatureController()
const boilerB: BoilerB = new BoilerB(5, 40)
controller2.setDevice(boilerB)
controller2.heatWater()

console.log('Desirable Temperature: ' + boilerB.getDesirableTemperature())
console.log('Temperature: ' + controller2.getWaterTemperature())

const controller3: TemperatureController = new AutomaticTemperatureController()
const boilerC: BoilerC = new BoilerC(3, 39)
controller3.setDevice(boilerC)

console.log('Desirable Temperature: ' + boilerC.getDesirableTemperature())
console.log('Temperature: ' + controller3.getWaterTemperature())

I: Принцип разделения интерфейса (Interface Segregation Principle, ISP)

Interface Segregation Principle

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

В программировании ‘интерфейс’ похож на меню функций, которые может выполнять класс. Принцип разделения интерфейса говорит, что вместо того, чтобы иметь один большой интерфейс с множеством методов, лучше создать меньшие, более конкретные интерфейсы. Таким образом, класс (как вы на шведском столе) может выбирать, какие конкретные функциональности ему нужны, не будучи обремененным теми, которые ему не используются.

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

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

Плохой пример: Большой интерфейс, включающий ненужные методы

interface Animal {
  eat(): void;

  sleep(): void;

  fly(): void;

  swim(): void;
}

class Bird implements Animal {
  public eat(): void {
    // Implementation...
  }

  public sleep(): void {
    // Implementation...
  }

  public fly(): void {
    // Implementation...
  }

  public swim(): void {
    // Implementation...
  }
}

Хороший пример: Маленькие интерфейсы, которые больше соответствуют потребностям каждого клиента

interface IEatable {
  eat(): void;
}

interface ISleepable {
  sleep(): void;
}

interface IFlyable {
  fly(): void;
}

class Bird implements IEatable, ISleepable, IFlyable {
  public eat(): void {
    // Implementation...
  }

  public sleep(): void {
    // Implementation...
  }

  public fly(): void {
    // Implementation...
  }
}

D: Принцип инверсии зависимостей (Dependency Inversion Principle, DIP)

Dependency Inversion Principle

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

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

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

Плохой пример: Высокоуровневый модуль, зависящий от низкоуровневого модуля

enum MailerType {
  SMTP,
  SEND_GRID
}

class SendGridMailer {
  send(): void {
    console.log("Send: SendGridMailer");
  }
}

class SmtpMailer {
  send(): void {
    console.log("Send: SmtpMailer");
  }
}

class MailerService {
  private smtpMailer: SmtpMailer;
  private sendGridMailer: SendGridMailer;

  constructor() {
    this.smtpMailer = new SmtpMailer();
    this.sendGridMailer = new SendGridMailer();
  }

  send(mailerType: MailerType): void {
    if (mailerType === MailerType.SMTP) {
      this.smtpMailer.send();
    } else if (mailerType === MailerType.SEND_GRID) {
      this.sendGridMailer.send();
    }
  }
}

const mailerService = new MailerService();
mailerService.send(MailerType.SMTP);
mailerService.send(MailerType.SEND_GRID);

Хороший пример: Как высокоуровневый модуль, так и низкоуровневый модуль в зависимости от абстракции

interface Mailer {
  send(): void;
}

class SendGridMailer implements Mailer {
  send(): void {
    console.log("Send: SendGridMailer");
  }
}

class SmtpMailer implements Mailer {
  send(): void {
    console.log("Send: SmtpMailer");
  }
}

class MailerService {
  private mailer: Mailer;

  constructor(mailer: Mailer) {
    this.mailer = mailer;
  }

  send(): void {
    this.mailer.send();
  }
}

const smtpMailer: Mailer = new SmtpMailer();
const mailerService: MailerService = new MailerService(smtpMailer);
mailerService.send();

Заключение

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

Мы поможем развитию вашего бизнеса!