SOLID принципы часть 1

Spread the love

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

И так в этой статье я расскажу про пять архитектурных принципов программирования, которые помогут сделать ваш код гибким, понятным и легко поддерживаемым. Все о чем будет рассказано далее находится в книги Роберт К. Мартин Гибкая разработка программ. Принципы, примеры, практика (Agile Software Development Principles, Patterns and Practices )

Что такое Solid принципы?

Solid – переводится с английского как твердый, крепкий прочный, но с другой стороны SOLID это акроним, где каждая буква этого слова это отдельный принцип:

S – Single Responsible Principle (SRP)
O – Open Closed Principle (OCP)
L – Liskov Substitution Principle (LSP)
I – Interface Segregation Principle (ISP)
D – Dependency Inversion Principle (DIP)

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

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

В книги Роберта Мартина описано 7 признаков плохого кода:

  • Закрепощенность Любое изменения вызывает эффект “снежного кома”
  • Неустойчивость. Изменения ломают систему в коде не имеющем прямого отношения к изменяемому компоненту
  • Неподвижность. Невозможность разделить код на компоненты и использовать в другом месте
  • Вязкость. Проще сломать систему, чем сделать в ней что то правильное
  • Неоправданная сложность. Проект перенасыщен сложными конструкциями и решениями
  • Неоправданные повторения. Наличия повторяющихся структур в коде, которые можно выделить в отдельные абстракции
  • Неопределенность. Трудность чтения и понимание проекта

И так начнем описание принципов.

Принцип персональной ответственности (SRP Single Responsibility Principle)

Каноническое определение принципа:

“Существует лишь одна причина, приводящая к изменению класса”

Том Де-Марко (1979) и Мейлер Пейдж-Джонсан (1988)

То есть этот принцип говорит, что каждый класс должен иметь лишь одну ответственность, то есть он должен отвечать только за одну вещь.

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

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

Рассмотрим пример. Пусть у нас есть один класс Product, в котором определены все необходимые методы для работы с сущностью продукта, с базой и методы отображения продукта.

class Product {
  function get(name) {}
  function set(name, value) {}
  function save() {}
  function update() {}
  function delete() {}
  function show() {}
  function print() {}
}

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

class ProductEntity {
  function get(name) {}
  function set(name, value) {}
}

class ProductRepository {
  function save() {}
  function update() {}
  function delete() {}
}

class ProductView {
  function show() {}
  function print() {}
}

Как мы можем видеть код стал намного более понятным.

Рассмотрим еще один пример, класс User:

// сохранение данных
user = new User()
user.firstname = 'James'
user.lastname = 'Bond'
user.save()

// получение данных из базы
user2 = new User()
user2.getByFirstName('John')

// получение данных их связанной таблицы
role = user2.role

При такой архитектуре все будет хорошо работать если проект будет небольшим. Но с ростом проекта увеличением количества требуемых методов, будет расти и сложность архитектуры, все очень быстро может стать запутанным, поэтому лучше это переделать. Разделить на сущность пользователя класс UserEntity и класс для работы с базой данных UserRepository

// сохранение данных
user = new UserEntity()
user.firstname = 'James'
user.lastname = 'Bond'
userRepository = new UserRepository()
userRepository.save(user)

// получение данных
userRepository2 = new UserRepository()
userRepository2.getByFirstName('John')

// получение данных их связанной таблицы
role = userRepository2.role

Еще один пример. Пусть у нас есть класс, который отвечает как за генерацию отчета и за его отправку определенному пользователю

 class FinancialReportMailer
  
  function FinancialReportMailer(transactions, account)
    this.transactions = transactions
    this.account = account
    this.report = ''

  function generate_report()
    // generate report
    this.report = make_report


  function send_report()
    // send report this.report


mailer = FinancialReportMailer(transactions, account)
mailer.generate_report()
mailer.send_report()

Класс FinancialReportMailer, показанный выше, выполняет две задачи: генерация отчета (метод «generate_report!») И отправка отчета (метод «send_report»). Класс выглядит довольно простым. Однако расширение этого класса в будущем может быть проблематичным, поскольку нам, вероятно, придется изменить логику класса. Принцип SRP говорит нам, что класс должен реализовывать одну единственную задачу, и поэтому в соответствии с этим принципом мы должны разделить класс FinancialReportMailer на два класса.

Давайте посмотрим, как выглядит этот код после того, как мы реорганизовали его для соответствия требованиям SRP:

class FinancialReportMailer
  function deliver(report, account)
    // send report
 

class FinancialReportGenerator
  function generate()
    // generate report
  

report = FinancialReportGenerator().generate()
FinancialReportMailer.deliver(report, account)

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

Этот принцип применяется, когда:

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

Не используйте этот принцип когда:

  • Заранее известно о неизменяемости кода в конкретном месте
  • Решение сильно усложняет разработку и поддержку кода. Всегда нужно сохранять баланс между количеством классов и их необходимостью.

Пример когда следовать этому принципу не нужно.

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

class Modem {
  function dial(){}
  function hangup() {}
  function send(request) {}
  function receive() {}
}

Если мы будем придерживаться принципа SRP, то нам нужно будет разделить этот класс на два класса DataChannel для организации соединения и Connection для передачи данных.

class DataChannel {
  function dial(){}
  function hangup() {}
}

class Connection {
  function send(request) {}
  function receive() {}
}

Но так как мы заранее знаем, что мы никогда не будем менять класс Modem, то в данном случае все таки выгоднее оставить все как есть, иначе в будущем наш код будет иметь избыточную сложность

Суть принципа

На каждый объект должна быть возложена одна единственная обязанность

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

Каноническое определение принципа:

Программные объекты (классы, модули, функции и т.п.) должны быть открыты для расширения, но в то же время закрыты для модификации.

Бертран Майер (1988)

Как что-то может быть открытым и закрытым одновременно?!
Класс следует OCP, если он удовлетворяет этим двум критериям:

Открыт для расширения

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

Закрыт для модификации

При добавление нового функционала нельзя вносить изменения в исходный код такого класса. То есть весь новый код не должен затрагивать старый.

Цель принципа
Разработчики всегда должны писать новый код, а не переписывает старый. Тестирование кода должно происходит единожды. И разработчики не должны тратить время на частый рефакторинг

Рассмотрим простой пример. Пусть у нас есть класс Order и метод getTotalPrice, которые вначале получает все позиции корзины потом получает возможные скидки, а потом рассчитывает итоговую сумму.

class Order {
  function getTotalPrice() {
    // получение всех позиций корзины
    // получение накопительности скидки текущего клиента
    // расчет итоговой цены
  }
}

В начале перепишем его, так что бы он удовлетворял принципу единой обязанности SIP

class BaseOrderAlgorithm {
  function getTotalPrice() {
    products = this.getProducts()
    discount = this.getDiscount()
    return this.calculate(products, discount)
  }

  function getProducts() {}
  function getDiscount() {}
  function calculate(products, discount) {}
}

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

Первое решение для соответствия принципу OCP. Мы можем просто унаследовать дочерний класс MainOrderAlgorithm от BaseOrderAlgorithm и переопределить внутренние методы расчета getProducts и getDiscount. Таким образом мы сохраним старый код и добавим новый.

class MainOrderAlgorithm extends(BaseOrderAlgorithm) {
  function getProducts() {}
  function getDiscount() {}
}

Второе решение для соответствия принципу OCP.

class Order {
  function Order(IProduct product, IDiscount discount) {
    this.product = product
    this.discount = discount
  }
  function getTotalPrice() {
    products = this.product.getProducts()
    discount = this.discount.getDiscount()
    return this.calculate(products, discount)
  }
  function calculate(products, discount) {}
}

Через конструктор класса передаем отдельные классы product и discount И таким образом мы написали один раз класс Order и можем написать множество классов для обработки.

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

Возможная проблемы с принципом OCP

Пусть у нас есть некая фабрика которая на основе неких параметров создает новый класс

switch (name) {
  case 'Shape':
    return new Shape()
  case 'Rectangle':
    return new Rectangle()
}

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

Возможное решение

Полностью избавиться от подобных классов/методов почти невозможно поэтому:

  • Изолируйте такой код в классы фабрики
  • В таких ситуациях используйте внедрение зависимостей
  • Старайтесь зависеть от интерфейсов и абстракций а не от от конкретных классов (более подробно об этом позже)

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

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

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

Пример с большим количеством if/case:

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

class Logger
  function Logger(format, delivery)
    this.log(this.format(format), this.delivery(delivery))
  
  function log(string)
    this.deliver(this.format(string))

  function format(string):
    case:
      string == raw:
        // краткий формат
      string == with_date:
        // формат с датами
      string == with_date_and_details:
        // детализированный формат с датами
    else
      raise NotImplementedError
    

  function deliver(text):
    case:
      text == by_email:
        // отправка по почте
      text == by_sms:
        // отправка по смс
      text == to_stdout:
        // вывод в консоль
    else
      raise NotImplementedError
    

logger = Logger('raw', 'by_sms')
logger.log('Emergency error! Please fix me!')

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

class Logger
  function Logger(formatter, sender)
    this.formatter = formatter
    this.sender = sender

  function log(string)
    this.sender.deliver(this.formatter.format(string))


class LogSms
  function deliver(text):
    // отправка по СМС
    
class LogMailer
  function deliver(text):
    // отправка по почте
    
class LogWriter
  function deliver(log)
    // вывод в консоль
    
class DateFormatter
  function format(string)
    // формат с датой

class DateDetailsFormatter
  function format(string)
    // детализированный формат


class RawFormatter
  def format(string)
    // краткая форма формата

logger = Logger.new(RawFormatter(), LogSms())
logger.log('Emergency error! Please fix me!')

Суть принципа
Изменения в программе должны происходить при написание нового кода, а не модификации старого.

Далее в следующей статье я продолжу описание принципов.


Spread the love

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *