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