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

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

Ремарка: изначально принципы SOLID были определены для объектно-ориентированной парадигмы программирования, и только после последующего скачка популярности функционального программирования стало ясно, что данные принципы применимы не столько к парадигме, сколько к процессу разработки ПО в целом.

Прелюдия: интерфейс

В это статье иногда используется слово "интерфейс". Не дайте себя обмануть! Слово "интерфейс" здесь используется в значении "набор публичных свойств и методов объекта", что особенно применимо к языку JavaScript, т.к. язык не поддерживает такую синтаксическую структуру как interface.

Буква S: Single responsibility principle

Изначальное определение дяди Боба звучало так: "Класс должен иметь только одну причину для изменения" (взято с википедии). Определение очень краткое, ёмкое, однако, совсем не понятное. Что значит одна причина? Причин может быть много: так хочет начальник, неожиданный рефакторинг, ретроградный меркурий. Конечно же Роберт Мартин имел в виду что-то более конкретное и применимое к логике и цели класса, даже примеры в своей книге привел, но частенько авторы статей в интернете, как матерые учителя литературы, пускаются в глубочайшие раздумья по поводу того, что же наш любимый автор имел в виду. И додумывают! Изначальное определение приобретает вид: "Класс должен иметь только одну ответственность". Наверное, так проще объяснять, но в таком виде определение теряет свою многогранность, объем и, на самом деле, смысл. Ведь мало того, что такое определение совершенно не решает проблему предыдущего, так еще и добавляет больше неясности своим простым, но слишком прямолинейным определением. Лично мне такое не по душе и, надеюсь, вам тоже.

Дяде Бобу такое тоже не пришлось по душе, поэтому в "Чистой архитектуре" он однозначно указал: "Модуль должен отвечать перед одним и только одним актором". Актор — группа, состоящая из одного или нескольких лиц, желающих изменения поведения программного модуля. Здесь появилось очень важное слово: "поведение". Что это значит? Это значит, что мы рассматриваем требование к декомпозиции модуля исключительно со стороны конечного потребителя функциональности. Очевидный пример: пользователь. Пользователь захотел, чтобы была возможность добавить в профиль биографию и посмотреть ее. Или список любимых книг. Или возраст. Или все сразу. В общем, чтобы наполнить класс пользовательской логики, нужно думать как пользователь, выглядеть как пользователь, быть пользователем. Модуль, который реализует эту функциональность должен относится только к сущности пользователя. В этом и заключается SRP.

д// Было
class User {
  constructor(){
    // Super cool implementation here
  };
  getName()
    // Super cool implementation here
  };
  getFriends(){
    // Super cool implementation here
  };
}

// Стало
class User {
  constructor(){
    // Super cool implementation here
  };
  getName(){
    // Super cool implementation here
  };
  getFriends(){
    // Super cool implementation here
  };
  getBio(){
    // Super cool implementation here
  };
  getBooks(){
    // Super cool implementation here
  };
  getAge(){
    // Super cool implementation here
  };
}

// Упс... Установка значений не вписалась в бюджет

Но актор это не только пользователь продукта, но еще и пользователь инфраструктуры, т.е. программист. Допустим, нам нужно реализовать (не кидайте тапками) ActiveRecord для описания модели сущности пользователя для взаимодействия с БД. Требуется добавить возможность обновления данных пользователя. Актором в этом случае будет выступать часть программы, которая будет опираться на нашу модель, например представление или транспортный слой.

// Было
class UserModel {
  create(){};
  read(){};
  delelte(){};
}

// Стало
class UserModel {
  create(){};
  read(){};
  update(){};
  delelte(){};
}

А можем ли мы добавить сюда валидацию входящих данных? Вот тут вопрос действительно философский. Короткий ответ: нет, это нарушит SRP (почему?). Длинный ответ: иногда все же да, если мы предполагаем, что наша валидация может быть использована только с данной конкретной моделью одним и тем же актором и нигде более, при этом считаем, что валидация не может быть изменена.

Резюме: в случае с SRP смотрим на конечного потребителя нашего кода, если он один, то это хорошо.

Буква O: Open-closed principle

Начнем с определения с нашей любимой википедии: при́нцип откры́тости/закры́тости (англ. open–closed principle, OCP) — принцип ООП, устанавливающий следующее положение: "программные сущности (классы, модули, функции и т. п.) должны быть открыты для расширения, но закрыты для изменения". Данный принцип является прямым продолжением гениальной идеи "работает — не трогай" и, как мы видим, дает указания именно по расширению функциональности. "Как мы решаем проблему расширения при условии, что разные акторы делят одну и ту же функциональность, а код дублировать плохо?", — именно на этот вопрос отвечает OCP.

Давайте тоже далеко ходить не будем и попробуем решить проблему пользователя и модератора. Модератор - это такой пользователь, который может делать bonk банхаммером.

Давайте решим проблему в лоб: расширим базовый класс.

// Было
class User {
  userMethod(){};
}

// Стало
class User {
  userMethod(){};
  isModerator(){};
  ban(){};
}

Мои поздравления! Только что мы создали потенциальное место поломки нашего приложения, особенно, если внесли какие-то изменения в логику в поведение класса, который много где используется. Отлаживать такой код неприятно, откатить изменения — сложно. Что делать? Есть несколько вариантов: наследование, агрегация, композиция.

// Наследование
class Moderator extends User {
  isModerator(){};
  ban(){};
}

// Композиция
class ModeratorFeature {
  cosntructor(user){
    this.user = user;
  }
  isModerator(){};
  ban(){};
}

//Агрегация
class ModeratorFeature {
  cosntructor(){
    this.user = new User(id);
  }
  isModerator(){};
  ban(){};
}

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

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

Буква L: Liskov substitution principle

Опять маленькое определение от дяди Боба с википедии: "Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом". Сама Барбара Лисков определила это так: "Пусть q(x) является свойством, верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T". Это определение более громоздкое, но, что приятно, оно говорит с нами на самом выразительном языке — языке математики.

Давайте первый пример на языке математики и разберем. Пусть у нас есть функция f(x), с областью допустимых значений аргумента Q (множество рациональных чисел, любое число которое можно представить, как m/n). Тогда будет справедливо, что данная функция может принимать значение аргумента и Z (множество целых чисел), т.к. Z является подмножеством (по сути подтипом) Q.

Чем же так замечателен данный принцип? Если над подтипом разрешены те же самые операции, что и над типом, то мы можем использовать один вместо другого не задумываясь. Отправить СМС, сообщение в ВК или email? Нет разницы, если они реализуют общий метод send (сигнатура должна совпадать).

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

Теперь давайте разберем пример из реальной жизни. Есть некая система, ей необходимо выполнять SQL-запросы к БД. Небольшой минус: в зависимости от заказчика СУБД может меняться, что же делать? Давайте применим LSP!

class AbstractDB {
  execute(sql, params){
    throw new Error('Not implemented!')
  }
}

class PostgreDB extends AbstractDB {
  execute(sql, params){
    //execute pg
  }
}

class OracleDB extends AbstractDB {
  execute(sql, params){
    //execute oracle
  }
}

const pgSystem = new System(new PostgresDB())
const oracleSystem = new System(new OracleDB())

Таким образом мы имеем классы с совместимым интерфейсом, конструктор класса System может использовать их взаимозаменяемо не зная о реализации, а только о существовании метода execute с правильной сигнатурой. Давайте рассмотрим другой пример. В нашем приложении уже реализован отлов ошибок. Неожиданно нам понадобилось расширить его таким образом, чтобы можно было вместе с текстом ошибки передавать еще и код. В "новых" обработчиках необходимо реализовать логику с кодом ошибки, но если ошибка попадет в "старый" обработчик, то она должна быть обработана как обычная ошибка.

class StatusError extends Error {
  constructor(message, status = 500) {
    super(message);
    this.status = status;
  }
}

//старый обработчик
try {
  throw new StatusError("Жесть", 10);
} catch (err){
  console.log(err.message);
}

//Новый обработчик
try {
  throw new StatusError("Жесть", 10);
} catch (err){
  if(err.status){
    console.log(err.status);
  } else {
    console.log(err.message);
  }
}

Резюме: довольно интуитивный принцип, просит соблюдать однородность в реализации методов и полей в производных типах, обеспечивая тем самым совместимость новых реализаций со старыми или взаимозаменяемость реализаций. К сожалению к JS применяется со скрипом, с надеждой на благоразумие разработчика, ибо нет проверки типов как таковой. Можно проверить на то, имеет ли объект какое-то поле при помощи hasOwnProperty, а также проверить является ли объект экземпляром какого либо класса при помощи instanceof, но для полноценной типизации имеет смысл использовать typescript.

Буква I: Interface segregation principle

Определение с википедии: программные сущности не должны зависеть от методов, которые они не используют. Давайте теперь совсем просто — не нужно тащить в модуль то, что не модуль не использует просто ради того, чтобы все красиво наследовалось, например.

Напомню ключевой момент: в JS нет такой сущности как interface, поэтому мы будем говорить о частном случае: путанице с наследованием от базового класса.

Допустим, мы разрабатываем социальную сеть. Мы хотим реализовать три пользовательские роли:

  • читатель — может только реагировать на посты, а также писать комментарии

  • модератор — может все, что может пользователь, а еще банить и создавать посты

  • создатель контента — может все, что может пользователь, а еще работать с рекламной площадкой и создавать посты

Как мы видим, 2 из 3 ролей могут создавать посты. Очень велик соблазн сделать что-то подобное:

class BaseUser {
  react(){
    // Реализация реакции
  };
  comment(){
    // Реализация комментирования
  };
  writePost(){
    // Реализация написания поста
  };
}

class Reader extends BaseUser {
  writePost(){
    // Переопределяем метод на заглушку
  };
}

class Moderator extends BaseUser {
  ban(){
    // Реализация бана
  };
}

class ContentCreator extends BaseUser {
  ad(){
    // Реализация реализация работы с рекламной площадкой
  };
}

Но так делать нельзя, т.к. класс Reader имеет в себе неиспользуемые им методы. Что можно сделать? Неужели необходимо дублировать код? Что ж, JavaScript - язык возможностей, поэтому как вариант можно реализовать это следующим образом:

class BaseUser {
  react(){
    // Реализация реакции
  };
  comment(){
    // Реализация комментирования
  };
}

class Reader extends BaseUser {}

function writePost(){
  // Реализация написания поста, мы можем использовать здесь this,
  // как если бы работали из класса, при присваивании
  // функция получает необходимый контекст класса
};

class Moderator extends BaseUser {
  ban(){
    // Реализация бана
  };

  writePost = writePost;
}

class ContentCreator extends BaseUser {
  ad(){
    // Реализация реализация работы с рекламной площадкой
  };

  writePost = writePost;
}

Как видите, JS это необычный язык, поэтому можно определить метод как свойство, и из-за особенностей работы функций можно использовать this, он сам слинкуется с контекстом класса при присваивании. Это, кстати, была композиция. Вы, конечно, можете использовать композицию в более привычном виде: при помощи классов, если вас смущают такая реализация.

Резюме: не нужно тащить в модуль то, что не используешь, даже если это выглядит красиво. Лучше быть более конкретным и реализовывать только то, что модуль действительно использует.

Буква D: Dependency inversion principle

Принцип инверсии зависимостей. Классическое определение (википедия) звучит так:

  • A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

  • B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Этот принцип звучит очень логично, его легко понять, но нет ясности, что конкретно нужно делать. Если коротко - избавится от привязки конкретным реализациям, выделять общие интерфейсы и зависеть уже от них. Для примера рассмотрим уже приведенный ранее пример с работой с разными СУБД, но c другого конца. Давайте напишем класс System, который агностичен к СУБД, для этого предположим, что модули для работы с БД реализуют общий интерфейс согласно LSP. Тогда мы можем зависеть от абстрактного модуля БД, реализующего метод execute абстрактного класса.

class System {
  // ожидается, что будет реализован интерфейс класса AbstractDB
  constructor(db){
    this.db = db
  }

  action(){
    this.db.execute(`select * from users`)
  }
}

const pgSystem = new System(new PostgresDB())
const oracleSystem = new System(new OracleDB())

Подобный способ передачи зависимостей через конструктор называется constructor dependency injection.

К сожалению, весь функционал обозначения зависимостей, который может выдавить из себя чистый, не компилируемый и не транспилируемый js это JSDoc, и то такое средство требует использования некоторых расширений в ваших IDE/CE, что тоже не совсем честно. Как вариант, можно использовать уже упомянутые в статье instanceof и hasOwnProperty, однако это по сути значит натягивание совы на глобус и ощущается несколько искусственно.

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

Вместо заключения

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

Спасибо, что прочитали данную статью, надеюсь теперь вам стали чуточку понятнее принципы SOLID применительно к языку JavaScript.

Комментарии (9)


  1. dopusteam
    01.02.2023 08:14
    +7

    Очередная попытка объяснить SOLID на вымышленных примерах провалилась.

    Ваш первый пример из SRP уже нарушает SRP, ибо там пользователь помимо инфы о самом пользователе хранит ещё и книги и друзей. Вас смутило что то только когда в него добавили работу с БД.

    А можем ли мы добавить сюда валидацию входящих данных? Вот тут вопрос действительно философский. Короткий ответ: нет, это нарушит SRP (почему?)

    Почему это не можем? Очевидно любой публичный метод должен проверять входные данные, иначе как гарантировать, что туда не придёт мусор?

    В примере с OCP вообще неудачный пример. Как работает ModeratorFeature? Этот класс принимает на вход пользователя и что? Как он понимает, модератор это или нет? В классе пользователя есть какая то инфа получается про это? А как бан работает? Он что то меняет в пользователе? Получается у пользователя есть публичные поля\методы, связанные с проверкой, является ли пользователь модератором и публичный метод для бана. Кто помешает дёрнуть их напрямую? Как такой код в реальной разработке вообще использовать?

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

    Ну и так далее, дальше даже читать не хочется.

    P.S. обилие таких опечаток в коде как delelte, cosntructor и непонятно откуда взявшийся параметр id в конструкторе ModeratorFeature говорят о том, что код даже не запускался :(


    1. Alexandroppolus
      01.02.2023 10:16

      И как всегда, на десерт пункты 4 и 5. Их в принципе нельзя объяснить через JS - в языке нет абстракций (интерфейсов). Каждый раз в этих двух параграфах придумывается что-то своё.



    1. nin-jin
      01.02.2023 10:58
      +5

      Интересно, в каких случаях метод isModerator класса Moderator может вернуть false?


      1. lexxpavlov
        02.02.2023 13:20

        Замьютили модератора, временно.


        1. nin-jin
          02.02.2023 13:32

          Он от этого перестал быть модератором или лишился права банить?


  1. GC8
    02.02.2023 05:52

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


  1. varanio
    02.02.2023 09:15
    +1

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


  1. lexxpavlov
    02.02.2023 13:22
    -1

    В пунктах не хватает неправильного примера и правильного примера, как исправить неправильный. А так, статья полезная, спасибо.