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

Роберт К. Мартин (Robert C. Martin) (дядя Боб) назвал пять основных принципов проектирования программного обеспечения SOLID. Такой акроним помогает людям легче запомнить их. Мне очень нравятся подобные мнемоники, потому что всем нам нужна помощь для фиксации в памяти необходимой информации. Чем проще закрепить знания, тем большему мы способны научиться.

Благодаря большому опыту проектирования программного обеспечения, эти принципы были разработаны для того, чтобы помочь создать ПО, которое можно поддерживать в работоспособном состоянии на протяжении длительного времени. Это благо для мира ОО (object-oriented — объектно-ориентированный), что об этих и подобных принципах так много говорят. Они были идентифицированы, переработаны, названы и кодифицированы. И теперь можно открыто говорить о них, при этом люди понимают, что вы имеете в виду. Такого рода вещей, как ни странно, совершенно не хватает в мире функционального программирования.

Почему это так? Возможно, дело в том, что в последние несколько десятилетий функциональное программирование не слишком активно использовалось в сфере разработки программного обеспечения. Кто-то может сказать, что подобные принципы не нужны в функциональном программировании. Независимо от причин, это распространенное чувство разочарования среди людей, которые переходят от ООП к ФП. Меня часто спрашивают: "Как мне структурировать свой код?" и "Где все рекомендации по проектированию?".

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

Сегодня я собираюсь пройтись по принципам SOLID и показать, как они проявляются в Clojure. Давайте проделаем это по порядку, взяв каждую букву.

Принцип единственной ответственности (Single Responsbility Principle)

Сколько должен сделать один класс? Принцип единой ответственности (SRP) гласит об одной вещи. И способ подсчета того, что он может сделать, — это подсчет причин, по которым он будет меняться. Например, если у вас есть класс, который отвечает за чтение записей из базы данных и отображение их пользователю, то это фактически 2 причины для изменения. Одна причина — если изменится схема базы данных. Другая — если меняется дизайн отображения. Это нарушение принципа, и вам следует подумать о разделении данного класса по двум направлениям.

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

(defn display-records []
  (let [records (sql/query "SELECT * FROM ...")
        record-string (str/join "\n" (for [r records]
                                       (str (:first-name r) (:last-name r) (:id r))))]
    (println record-string)))

Тем самым выполняется три действия, и это довольно очевидно нарушает SRP. Для устранения проблемы в Clojure необходимо осуществить рефакторинг в виде отдельных функций. ^1

(defn fetch-records []
  (sql/query "SELECT * FROM ..."))

(defn record->string [record]
  (str (:first-name record) (:last-name record) (:id record)))

(defn records->string [records]
  (str/join "\n" (map record->string records)))

Затем display-records просто связывают их вместе. Вам все же нужно то, что сделает все сразу. Сколько причин необходимо, чтобы это было изменено? Вам не нужно ничего менять, если меняется схема или, если изменится формат. Я оставлю это в качестве упражнения для вас.

(defn display-records []
  (-> (fetch-records)
      records->string
      println))

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

Как быть, если вы используете библиотеку, и вам нравится то, что она делает, но нужно реализовать это немного по-другому? Было бы ужасно, если просто поменять исходный код этой библиотеки. Что еще зависит от нее? Что может сломаться? Принцип открытости/закрытости (OCP) гласит, что мы должны иметь возможность расширять функциональность без изменения модуля.

OCP — это то, с чем Clojure справляется очень хорошо. В Clojure мы можем расширять существующие протоколы и расширять существующие классы, не ломая существующий код. Например, допустим, я написал хороший протокол под названием ToDate, который имеет один метод, преобразующий что-то в java.util.Date.

(defprotocol ToDate
  (to-date [x]))

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

(extend-protocol ToDate
  String ;; strings get parsed
  (to-date [s]
    (.parse (java.text.SimpleDateFormat. "ddMMyyyy") s))
  Long   ;; longs are unix timestamps
  (to-date [l]
    (java.util.Date. l))
  java.util.Date ;; Dates are just returned
  (to-date [d] d))

Только посмотрите! Теперь я могу запустить это:

(to-date "08082015")

;;=> #inst "2015-08-08T05:00:00.000-00:00"

Или это:

(to-date 0)

;;=> #inst "1970-01-01T00:00:00.000-00:00"

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

Являются ли очереди и стеки подклассами друг друга? У них одинаковый интерфейс (push и pop), но семантически они совершенно разные. Стеки — это Last-In-First-Out (последним пришёл — первым ушёл), а очереди — First-In-First-Out (первым пришёл — первым ушёл). Принцип подстановки Лисков (LSP) гласит, что подкласс должен обладать возможностью замены своего суперкласса [родительского класса]. Вы не можете заменить стек на очередь (или наоборот), поэтому они не являются подклассами друг друга.

LSP в основном касается иерархий подклассов, которые в Clojure встречаются редко. Но Clojure построен на иерархии классов Java. И основные типы, которые написаны на Java, хорошо спроектированы с учетом этого принципа.

Простой пример — разнообразие реализаций clojure.lang.APersistentMap. Каждая из них имеет различные характеристики производительности, но при этом сохраняет соответствующую семантику карт. Существуют:

  • PersistentArrayMap

  • PersistentHashMap

  • PersistentStructMap

  • PersistentTreeMap

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

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

Если я использую какой-то API и один из методов, применяемых мной, меняется, можно смириться с тем, что мне придется вносить изменения в свой код. Но если изменится один из методов, который я не использую, то будет очень неприятно, если мне придется что-то менять на своей стороне. Я не должен даже знать о существовании этих методов. Один из способов предотвратить это недоразумение — применение принципа разделения интерфейсов (Interface Segregation Principle, ISP). Он гласит, что вы должны разделить ваши интерфейсы на более мелкие, обычно с такой целью, чтобы у них была только одна причина для изменений. Теперь на клиентов повлияют только те изменения, которые имеют к ним отношение.

ISP превалирует в Clojure. Гораздо больше, чем в типичных системах Java. Просто посмотрите на размер интерфейсов в clojure.lang. Такие маленькие! Вот характерный пример:

class: clojure.lang.Associative

methods: containsKey, entryAt, assoc

Эти методы соответствуют типичным операциям карты containsKey, get и put соответственно. Эти три метода отличаются высокой степенью связанности. Для сравнения, в java.util.Map их 14. Теперь все функциональные возможности карт Java присутствуют в картах Clojure, просто они разделены на различные, многократно используемые интерфейсы.

Например, метод size java.util.Map является отдельным интерфейсом с 1 методом под названием clojure.lang.Counted. Clojure применяет ISP очень обстоятельно, а ClojureScript — еще чуть более детально.

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

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

Clojure использует DIP повсюду. Например, основная функция map не оперирует никакими фиксированными типами данных — только абстракциями. Она работает с абстракцией clojure.lang.IFn, которая является интерфейсом, реализуемым функциями. Она также работает с абстракцией seq, которая определяет порядки для коллекций, итерируемых и других типов. Это делает map не привязанной к какому-либо конкретному типу и, таким образом, более полезной в целом. Тот же принцип действует для многих основных библиотечных функций. Благодаря универсальному применению DIP, Clojure становится более эффективным, поскольку функции можно будет чаще использовать повторно.

Выводы

Принципы SOLID являются важным руководством для разработки программного обеспечения с длительным сроком службы. Они нацеливают нас на создание более полезных, многократно используемых компонентов. Однако в таких языковых сообществах, как Java, их приходится часто повторять, потому что Java не способствует простоте их применения. В Clojure эти принципы присутствуют повсюду. Одна из вещей, которые мне нравятся в Clojure, это то, что он, кажется, воплощает в себе многие наработки последних 20 лет в области программной инженерии. И это одна из тех вещей, о которых я люблю рассказывать в курсе PurelyFunctional.tv "Онлайн-наставничество". Одна из причин, по которой Clojure делает такие большие успехи, заключается в том, что он интегрировал хорошие инженерные принципы, такие как SOLID, иммутабельные (неизменяемые) значения и параллелизм непосредственно в ядро.


1. -> это идиома в Clojure (не синтаксис, а просто схема именования). Она означает преобразование: record->string и читается как "запись в строку".


Приходите на открытый урок, который пройдет в рамках курса "Clojure Developer". На этом уроке вы увидите, как классическая задача computer science — Game of Live — может быть реализована на Clojure. Также мы обсудим разные способы визуализации работы алгоритма, как представить состояние игры с помощью персистентных структур данных и как вести разработку интерактивно через REPL. Записаться можно по ссылке.

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