Pull to refresh

Принципы SOLID на JS, теперь точно простым языком, но не очень коротко

Reading time9 min
Views28K

Здравствуй, Хабравчанин! Не так давно наткнулся на статью, в которой автор пытается объяснить, что такое принципы 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.

Tags:
Hubs:
Total votes 15: ↑10 and ↓5+5
Comments10

Articles