SOLID принципы. Рефакторинг

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

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

В статье описаны пять основных принципов, которым должен соответствовать хороший объектно-ориентированный проект — SOLID. Кроме того, показаны некоторые приемы рефакторинга.

Рефакторинг

В литературе достаточно подробно описаны методы рефакторинга [2, 3, 4], зачастую ими являются весьма нехитрые приемы [2], например:

  1. если функция не используется — удалите функцию;
  2. если название функции не отражает намерения программиста — переименуйте функцию.

Процессы написания нового кода и рефакторинга должны быть разделены — эту мысль замечательно выразил Кент Бек:

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

Рефакторинг должен проводиться постоянно, даже во время изучения кода. Однако он выделяется в качестве отдельного этапа TDD (Test Driven Development, разработки через тестирование) [5].

Даже у опытных программистов нередко бывает предчувствие, что в программе что-то не так, но не удается определить что именно. Фаулер замечательным образом систематизировал методы рефакторинга, а также отметил признаки плохого кода — так называемые «запахи» [2], однако более фундаментальными для объектно-ориентированного подхода являются принципы SOLID.

Принципы чистого кода (SOLID)

Аббревиатура SOLID была предложена Робертом Мартином и обозначает пять основных принципов объектно-ориентированного проектирования. Ссылки на многие из этих принципов можно встретить в книгах Кента Бека [4], Герба Саттера [6],  Скота Маерса [7], Мак-Колм Смита [8] и других исследователей.

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

LSP — Liskov Substitution Principle

Наследование является одним из базовых механизмов объектно-ориентированного программирования. Очень подробно этот механизм рассматривается Б. Мейером — он выделяет 12 видов наследования [10], однако, на практике чаще всего применяют наследование, реализующее отношения «является» (is-a, открытое наследование) или «реализуется посредством» (наследование реализации, закрытое наследование) [3, 6, 7]. Мак-Колм рассматривает наследование как базовый шаблон проектирования, при этом имеет ввиду is-a-наследование [8], именно к этому виду относится принцип LSP.

Принцип подстановки изначально сформулирован Барбарой Лисков и регламентирует правильное использование механизма наследования. Выделяются некоторый базовый тип, его подтип (класс-наследник). Согласно принципу LSP, программы должны быть написаны таким образом, чтобы в любом месте вместо базового типа мог быть подставлен подтип. Это означает, что классы наследники должны реализовывать интерфейс согласованно с интерфейсом базового класса.

В качестве примера рассмотрим классы геометрических фигур — точка, окружность, сфера.

Мы могли бы реализовать три класса, независимо друг от друга, но тогда каждый из них содержал бы данные с координатами и соответствующий набор функций — т.е. в нашей программе появился бы повторяющийся код. Согласно Фаулеру «дублирование кода свидетельствует об упущенной возможности для абстракции» [2], т.е. среди трех классов нам надо найти наиболее общий и применить механизм наследования, сделать это можно различными способами:

 

Liskov-Substitution-Principle-example

Liskov Substitution Principle example

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

  • добавление функции вычисления площади в класс окружности, будет означать появление такой же функции у сферы. Однако, у сферы нет площади;
  • добавление нового класса точки трехмерного пространства (Point3D). С одной стороны мы не можем создать такой класс на базе сферы, т.к. у Point3D не должно быть радиуса. С другой стороны при наследовании трехмерной точки от двумерной, мы получим дублирование кода в класса 3DPoint и Sphere. Устранить дублирование сложно потому, что теперь сфера должна наследовать трехмерную точку и окружность, однако они имеют общий базовый класс, а значит мы пришли к проблеме ромбовидного наследования.

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

Другой программист мог бы заметить, что точка — «является» окружностью без радиуса, а окружность — сферой без третьей координаты. Однако, в этом случае у точки появится открытый интерфейс окружности, позволяющий получить радиус, что нельзя считать корректным. Скрыть интерфейс базового класса можно за счет использования закрытого наследования, однако оно задает отношение «реализуется посредством», поэтому принцип LSP на него не распространяется. Любой вид наследования является очень сильной связью между классами и создает множество проблем. К счастью, отношение «реализуется посредством», может быть определено посредством композиции, что всегда является более гибким и предпочтительным решением.

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

Таким образом, принцип подстановки Лисков требует использования открытого наследования лишь для реализации отношения «является разновидностью». На примере показано, что мы не можем установить факт нарушения принципа LSP до тех пор, пока не узнаем как именно будут использоваться наши классы.

DIP — Dependency Inversion Principle

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

Отношение композиции означает, что объекты одного из классов включают экземпляр другого класса. Такая зависимость является более слабой чем наследование, но все равно очень сильной. Более слабым, а значит гибким, является отношение агрегации — при этом объект-контейнер содержит ссылку на вложенный класс.

И композиция, и агрегация выражают отношение «часть-целое». При использовании композиции зависимость между классами является более сильной, т.к. мы не можем изменить ее во время выполнения программы, но при использовании агрегации для этого достаточно изменить ссылку.

Допустим, мы разработали класс TextReceiver, который принимает по какому-либо каналу связи текст и расшифровывает его. При этом TextReceiver реализуется посредством класса TextDecription, ответственного за расшифровку текста, в связи с этим мы могли бы использовать композицию (верхняя часть рисунка):

strategy_pattern_example

Strategy pattern example

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

Само по себе использование агрегации вместо композиции решило бы не все проблемы, т.к. новый класс должен был бы наследовать TextDecription, но наследование нарушало бы принцип подстановки Лисков, ведь алгоритм шифрования DES не является разновидностью алгоритма XOR. Чтобы оба принципа были соблюдены — необходимо создавать зависимость от абстрактного класса.

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

Подобная замена композиции агрегацией лежит в основе шаблона проектирования стратегия (Strategy) [8, 11], однако принцип DIP является более общим. Согласно формулировке  принципа инверсии зависимостей от Роберта Мартина:

  • не должно существовать прямых зависимостей между конкретными модулями. Модули должны зависеть лишь от абстракций;
  • абстракции не должны зависеть от деталей [3].

Таким образом, Мартин говорит о любых зависимостях, а не только об отношении «часть-целое», т.е. он не сводит принцип к замене композиции агрегацией. В качестве примера возьмем игру, где персонаж перемещается по лабиринту с артефактами:

Dependency_Inversion_Principle_example

Dependency Inversion Principle example

В верхней части рисунка приведена диаграмма, на которой персонаж умеет обрабатывать игровые элементы любого типа. Клиентский код выбирает очередную клетку на пути движения персонажа и передает ее для обработки, в результате каким-либо образом изменяется состояние. Между конкретными классами артефактов и персонажем имеются зависимости в связи с этим нарушается принцип DIP, в следствии этого осложняется сопровождение кода — при добавлении нового типа игрового объекта, изменения должны коснуться и класса Person.

В нижней части слайда показано другое решение — теперь клиентский код передает состояние персонажа артефакту для обработки. Зависимости между артефактами и состоянием персонажа не нарушают принцип инверсии зависимостей если мы уверены, что не будем создавать разновидности состояний (наследовать класс State). Объекты таких классов, как State называются объектами данных, зависимости от них также не страшны, как и от классов стандартной библиотеки.

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

OCP — Open/Closed principle

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

Открытым называется то, что можно менять. Закрытое изменять нельзя. Существуют различные подходы к понимаю того, каким образом класс должен закрываться — вплоть до требования распространять закрытые модули в скомпилированном виде, но обычно «закрытие» является чисто административным решением.

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

Очень важным требованием принципа открытости/закрытости является сохранение устойчивости интерфейса к расширению системы. Второй пример к принципу DIP иллюстрирует эту проблему: до тех пор, пока класс Person содержал отдельный метод для каждого типа артефакта — его интерфейс нельзя было назвать устойчивым к изменениям. Для решения проблемы мы могли бы создать абстракцию — базовый класс для всех артефактов и добавить в класс Person метод, принимающий эту абстракцию. Новый метод должен выполнять какие-либо конкретные действия в зависимости от типа, при добавлении нового вида артефакта — изменения коснутся лишь реализации этого метода, значит интерфейс можно считать стабильным.

SRP — Single Responsibility Principle

Роберт Мартин выделяет правило одной операции, заключающееся в том, что каждая функция должна решать всегда лишь одну задачу [3]. Принцип единой обязанности — это тоже самое правило, распространенное на класс/модуль.

Фаулер описывает принцип SRP через понятие зоны ответственности [2], под которой он имеет ввиду некоторый контекст в котором работает класс или отдельная функция. Внесения изменений в зону ответственности может повлечь необходимость изменения нашего класса. Класс каким либо образом изменяет свою зону ответственности — это является его обязанностью. Если принцип SRP не нарушается, то каждый класс имеет единственную обязанность, а значит — единственную зону ответственности и единственную причину для изменения. В связи с этим, Фаулер считает необходимость частого внесения изменений в класс «запахом» нарушения принципа единой обязанности.

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

ISP — Interface Segregation Principle

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

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

Interface_Segregation_Principle_example

Interface Segregation Principle example

В верхней части диаграммы приведен класс автомобиля. Клиенты могут смотреть на него по-разному, например инспектор ГИБДД видит лишь номер двигателя и скорость, а жена водителя — бардачок и магнитолу. Мы не можем разделить класс на несколько частей, т.к. водителю нужно отслеживать текущую скорость (как инспектору) и хранить в бардачке документы (как это делает жена). Тем не менее, класс может выступать в разных ролях.

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

Применимость SOLID и рефакторинга

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

В двух фирмах из четырех, где я работал, менеджеры были именно такими — где-то удавалось убедить их обманом в необходимости рефакторинга под предлогом внедрения новых фич, но не везде. Один проект был начат с чистого листа через 4 года разработки. Если в ваши менеджеры ведут себя таким образом, а сроки сдачи всегда «вчера» — скорее всего вам не пригодятся знания по рефакторингу, SOLID, да и любая другая информация для развития профессиональных качеств программиста.

Литература по рефакторингу и SOLID:

  1. Теория чистого кода. Стиль кодирования [Электронный ресурс] – режим доступа: https://pro-prof.com/archives/1584. Дата обращения: 21.04.2015.
  2. Фаулер М., Бек К., Брант Д., Робертс Д., Апдайк У. Рефакторинг: улучшение существующего кода — Спб: Символ-Плюс, 2009. — 432 с
  3. Мартин Р. Чистый код. Создание, анализ и рефакторинг. Библиотека программиста. – СПб.: Питер, 2014. – 464 с.
  4. Beck K. Don’t Cross the Beams: Avoiding Interference Between Horizontal and Vertical Refactorings [Электронный ресурс] – режим доступа: http://www.threeriversinstitute.org/blog/?p=594. Дата обращения: 21.04.2015.
  5. Юнит-тестирование. Пример. Boost Unit Test [Электронный ресурс] – режим доступа: https://pro-prof.com/archives/1549. Дата обращения: 21.04.2015.
  6. Герб Саттер. Решение сложных задач на C++. — М.: Издательский дом «Вильямс», 2002. — 400 с. — ISBN 5-8459-0352-1
  7. Майерс С. Эффективное использование C++. 50 рекомендаций по улучшению ваших программ и проектов. — СПб: Питер, 2006.
  8. Джейсон Мак-Колм Смит Элементарные шаблоны проектирования : Пер. с англ. — М. : ООО “И.Д. Вильямс”, 2013. — 304 с.
  9. Тепляков С. Liskov Substitution Principle [Электронный ресурс] – режим доступа: http://sergeyteplyakov.blogspot.ca/2014/09/liskov-substitution-principle.html. Дата обращения: 21.04.2015.
  10. Мейер Б. Объектно-ориентированное конструирование программных систем. М.: Издательско-торговый дом «Русская Редакция», «Интернет-университет информационных технологий», 2005. 1232 с.: ил.
  11. Э. Гамма Приемы объектно-ориентированного проектирования. Паттерны проектирования / Э. Гамма, Р. Хелм, Р. Джонсон, Д. Влиссидес. – СПб.: Питер, 2009. – 366 с.

2 thoughts on “SOLID принципы. Рефакторинг

    1. admin Post author

      В статье рассматривается ситуация с неудачным наследованием — когда сфера описывается как разновидность окружности с дополнительной координатой. Такое наследование дает возможность клиенту обращаться к сфере как к окружности, поэтому все, что справедливо для класса Circle — должно работать для Sphere.
      У сферы нет площади, т.к. площадь по определению является свойством плоской фигуры. У сферы есть площадь поверхности, но вычисление площади поверхности вместо площади даст неожиданный для пользователя результат (ведь пользователь может обратиться к сфере по указателю на окружность — именно для такого поведения обычно применяют открытое наследование).
      Возможно, пример не самый удачный. Но попробуйте представить себе функцию, которая должна выполнять вращение фигуры относительно точки. Точка в нашем примере является объектом на плоскости (имеет лишь две координаты), поэтому мы сможем выполнить вращение окружности. Но вращение трехмерной фигуры, которой является сфера вокруг двумерной точки невозможно. Т.е. не все, что справедливо для окружности — работает для сферы, поэтому такое наследование не будет являться правильным.

      Reply

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

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

*

code