Если вы занимались разработкой ПО, вам наверняка знакома аббревиатура SOLID.
Это свод принципов, призванный помочь разработчикам писать чистый, хорошо структурированный и легко читаемый код. Программисты представляют себе по-разному «правильный» подход к написанию приложений — это больше зависит от их личного опыта и предпочтений. Однако идеи SOLID широко распространены среди разработчиков ПО, более того, они были интегрированы в Agile.
Расшифровка аббревиатуры:
- Single Responsibility Principle (SRP) — принцип единственной ответственности. Каждый класс выполняет только свои задачи.
- Open-Closed Principle (OCP) — принцип открытости/закрытости — программные сущности (классы, модули, функции и пр.) должны быть открыты для расширения, но закрыты для модификации.
- Liskov Substitution Principle (LSP) — принцип подстановки Барбары Лисков гласит: «наследующий класс должен дополнять, а не изменять базовый».
- Interface Segregation Principle (ISP) — принцип разделения интерфейса: «много интерфейсов, специально предназначенных для клиентов, лучше, чем один интерфейс общего назначения».
- Dependency Inversion Principle (DIP) — принцип инверсии зависимостей: «абстракции не должны зависеть от деталей — детали должны зависеть от абстракций».
В данном материале мы рассмотрим SRP — принцип единственной ответственности.
Немного предыстории
Роберт Мартин изначально ввёл термин в качестве составляющей своего труда «Принципы объектно-ориентированного проектирования». В основу SRP Мартина легла закономерность связности, описанная Томом Демарко и Мейлиром Пейдж-Джонсоном.
Кроме того, в разработке ПО есть два схожих понятия – инкапсуляция и сокрытие информации. SRP включает в себя также и эти два (или одно) понятия от Дэвида Парнаса, который обозначил их примерно так: «декомпозиция системы на модули не должна основываться на анализе блок-схем или потоков исполнения. Вместо этого, каждый модуль должен содержать внутри некоторое решение (design decision), предоставляя минимальное количество информации о нём своим клиентам».
Суть SRP
Суть SRP в одном предложении: «соберите всё, изменяемое по одной и той же причине, но разделите изменяемое по разным причинам».
Если разные люди работают с одной и той же программой, то изменение части, с которой взаимодействует один человек, не должно влиять на ту часть, с которой работает другой.
«Божественный объект»
Лучший способ изучить SRP — увидеть его в действии. Ниже показан пример программы на Ruby, не соответствующей принципу единственной ответственности. Код описывает поведение и атрибуты космической станции. Посмотрите на него и попробуйте определить:
- обязанности объектов, конкретизированные в классе
SpaceStation
; - виды лиц, которые могут быть заинтересованы в деятельности космической станции.
class SpaceStation
def initialize
@supplies = {}
@fuel = 0
end
def run_sensors
puts "----- Отчёт об использовании сенсоров -----"
puts "Сенсоры запущены"
end
def load_supplies(type, quantity)
puts "----- Отчёт об использовании расходных материалов -----"
puts "Загрузка #{quantity} единиц #{type} на склад."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Отчёт об использовании расходных материалов -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Используется #{quantity} единиц #{type} со склада."
@supplies[type] -= quantity
else
puts "Ошибка! Не хватает #{type} на складе."
end
end
def report_supplies
puts "----- Отчёт о количестве расходных материалов -----"
if @supplies.keys.length > 0
@supplies.each do |type, quantity|
puts "Доступно единиц #{type}: #{quantity}"
end
else
puts "Склад пуст."
end
end
def load_fuel(quantity)
puts "----- Отчёт об использовании топлива -----"
puts "Заправка #{quantity} единиц топлива в бак."
@fuel += quantity
end
def report_fuel
puts "----- Отчёт о количестве топлива -----"
puts "#{@fuel} единиц топлива доступно."
end
def activate_thrusters
puts "----- Отчёт об использовании подруливающих двигателей -----"
if @fuel >= 10
puts "Подруливание выполнено успешно"
@fuel -= 10
else
puts "Ошибка! Не хватает топлива."
end
end
end
Видно, что класс SpaceStation
имеет несколько «функций» или «задач»:
- работа с сенсорами;
- использование расходных материалов;
- расход топлива;
- использование подруливающих двигателей.
Субъекты не указаны в коде, но можно предположить, что это:
- научный работник, управляющий сенсорами;
- специалист по материально-техническому обеспечению;
- лицо, ответственное за запасы топлива;
- пилот.
Программа полностью не соответствует принципам SRP, но в полной мере отражает суть «Божественного объекта» — основного анти-шаблона в объектно-ориентированном программировани. При таком подходе объект хранит слишком большое количество данных и содержит много методов, поэтому его роль становится «божественной» или всеобъемлющей. Вместо того, чтобы общаться друг с другом, объекты обращаются к всеобъемлющему, а так как на нём завязан весь проект (или его большая часть), то его обслуживание усложняется, увеличивая риск поломки существующей функциональности.
Обратимся к примеру с космической станцией. Представьте, если надо добавить медицинский отсек, а из-за этого произойдут какие-нибудь проблемы с топливным. Попробуйте представить, что для того, чтобы шагнуть, вам нужно выгнуть левую руку назад, повернуть голову вправо и нагнуться. Работает? Да. Терпимо? Возможно. А если нужно бежать? А если с вёдрами? То же самое и с проектом — он будет работать, но до определённого момента.
Нарушение SRP может быть выгодно в начале проекта, но это лишь краткосрочно. С ростом проекта будут увеличиваться финансовые и временны́е ресурсы для исправления существующих проблем. Влияющие друг на друга участки кода, его громоздкость и нечитаемость — главные проблемы, о которых надо подумать при игнорировании SRP.
Разбивка по обязанностям
Выше были определены 4 функции станции, которые управлялись классом SpaceStation
. Они и будут отправной точкой рефакторинга кода. Теперь программа чуть больше соответствует SRP.
class SpaceStation
attr_reader :sensors, :supply_hold, :fuel_tank, :thrusters
def initialize
@supply_hold = SupplyHold.new
@sensors = Sensors.new
@fuel_tank = FuelTank.new
@thrusters = Thrusters.new(@fuel_tank)
end
end
class Sensors
def run_sensors
puts "----- Отчёт об использовании сенсоров -----"
puts "Запуск сенсоров"
end
end
class SupplyHold
attr_accessor :supplies
def initialize
@supplies = {}
end
def load_supplies(type, quantity)
puts "----- Отчёт об использовании расходных материалов -----"
puts "Загрузка #{quantity} единиц #{type} на склад."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Отчёт об использовании расходных материалов -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Используется #{quantity} единиц #{type} со склада."
@supplies[type] -= quantity
else
puts "Ошибка! Не хватает #{type} на складе."
end
end
def report_supplies
puts "----- Отчёт о количестве расходных материалов -----"
if @supplies.keys.length > 0
@supplies.each do |type, quantity|
puts "Единиц #{type} доступно: #{quantity}"
end
else
puts "Склад пуст."
end
end
end
class FuelTank
attr_accessor :fuel
def initialize
@fuel = 0
end
def get_fuel_levels
@fuel
end
def load_fuel(quantity)
puts "----- Отчёт об использовании топлива -----"
puts "Заправка #{quantity} единиц топлива в бак."
@fuel += quantity
end
def use_fuel(quantity)
puts "----- Отчёт об использовании топлива -----"
puts "Используется #{quantity} единиц топлива из бака."
@fuel -= quantity
end
def report_fuel
puts "----- Отчёт о количестве топлива -----"
puts "#{@fuel} единиц топлива доступно."
end
end
class Thrusters
def initialize(fuel_tank)
@linked_fuel_tank = fuel_tank
end
def activate_thrusters
puts "----- Отчёт об использовании подруливающих двигателей -----"
if @linked_fuel_tank.get_fuel_levels >= 10
puts "Подруливание выполнено успешно."
@linked_fuel_tank.use_fuel(10)
else
puts "Ошибка! Не хватает топлива."
end
end
end
Теперь класс SpaceStation
скорее служит контейнером, внутри которого выполняются операции для подчинённых частей:
- набора сенсоров;
- системы подачи расходных материалов;
- топливного бака;
- подруливающих двигателей.
Каждая из частей принимает форму поля, задаваемого при инициализации космической станции. Для каждой переменной есть соответствующий класс:
Sensors
(сенсоры);SupplyHold
(поставки расходных материалов);FuelTank
(топливный бак);Thrusters
(подруливающие двигатели).
В этой версии кода есть некоторые важные отличия от предыдущей, а именно: отдельные элементы функциональности не только инкапсулированы в собственные классы, но и организованы таким образом, чтобы быть предсказуемыми и последовательными. Идея заключается в группировке сходных по функциональности элементов, для следования принципу связности, и в изолировании данных таким образом, чтобы они были доступны только для соответствующих субъектов. Если понадобится изменить принцип работы системы поставки с хэш-структуры на массив, то это легко можно сделать, используя класс SupplyHold
. Таким образом другие модули не будут затронуты. Причём класс SpaceStation
даже не будет догадываться о действиях, производимых в модулях.
Заметьте, что сейчас в коде выше есть методы report_supplies
и report_fuel
, содержащиеся в классах SupplyHold
и FuelTank
. Что, если с Земли попросят изменить механизм загрузки отчётов? Придётся редактировать оба предыдущих класса. А если руководство решит изменить технологию доставки топлива и расходных материалов? Кажется, придётся повторно изменять те же классы. Похоже на нарушение SRP. Надо бы исправить.
class SpaceStation
attr_reader :sensors, :supply_hold, :supply_reporter,
:fuel_tank, :fuel_reporter, :thrusters
def initialize
@sensors = Sensors.new
@supply_hold = SupplyHold.new
@supply_reporter = SupplyReporter.new(@supply_hold)
@fuel_tank = FuelTank.new
@fuel_reporter = FuelReporter.new(@fuel_tank)
@thrusters = Thrusters.new(@fuel_tank)
end
end
class Sensors
def run_sensors
puts "----- Отчёт об использовании сенсоров -----"
puts "Сенсоры запущены"
end
end
class SupplyHold
attr_accessor :supplies
attr_reader :reporter
def initialize
@supplies = {}
end
def get_supplies
@supplies
end
def load_supplies(type, quantity)
puts "----- Отчёт об использовании расходных материалов -----"
puts "Загрузка #{quantity} единиц #{type} на склад."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Отчёт об использовании расходных материалов -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Используется #{quantity} (слово в падеже) #{type} со склада."
@supplies[type] -= quantity
else
puts "Ошибка! Не хватает #{type} на складе."
end
end
end
class FuelTank
attr_accessor :fuel
attr_reader :reporter
def initialize
@fuel = 0
end
def get_fuel_levels
@fuel
end
def load_fuel(quantity)
puts "----- Отчёт об использовании топлива -----"
puts "Заправка #{quantity} единиц топлива в бак."
@fuel += quantity
end
def use_fuel(quantity)
puts "----- Отчёт об использовании топлива -----"
puts "Используется #{quantity} единиц топлива из бака."
@fuel -= quantity
end
end
class Thrusters
FUEL_PER_THRUST = 10
def initialize(fuel_tank)
@linked_fuel_tank = fuel_tank
end
def activate_thrusters
puts "----- Отчёт об использовании подруливающих двигателей -----"
if @linked_fuel_tank.get_fuel_levels >= FUEL_PER_THRUST
puts "Thrusting action successful."
@linked_fuel_tank.use_fuel(FUEL_PER_THRUST)
else
puts "Ошибка! Мало топлива для использования подруливающих двигателей."
end
end
end
class Reporter
def initialize(item, type)
@linked_item = item
@type = type
end
def report
puts "----- #{@type.capitalize} Отчёт -----"
end
end
class FuelReporter < Reporter
def initialize(item)
super(item, "топливо")
end
def report
super
puts "#{@linked_item.get_fuel_levels} единиц топлива доступно."
end
end
class SupplyReporter < Reporter
def initialize(item)
super(item, "supply")
end
def report
super
if @linked_item.get_supplies.keys.length > 0
@linked_item.get_supplies.each do |type, quantity|
puts "#{type} доступно: #{quantity} единиц"
end
else
puts "Склад пуст."
end
end
end
iss = SpaceStation.new
iss.sensors.run_sensors
# ----- Отчёт об использовании сенсоров -----
# Сенсоры запущены
iss.supply_hold.use_supplies("parts", 2)
# ----- Отчёт об использовании расходных материалов -----
# Ошибка! Мало расходных материалов.
iss.supply_hold.load_supplies("parts", 10)
# ----- Отчёт об использовании расходных материалов -----
# Загрузка 10 единиц на склад.
iss.supply_hold.use_supplies("parts", 2)
# ----- Отчёт об использовании расходных материалов -----
# Используется 2 единицы со склада.
iss.supply_reporter.report
# ----- Отчёт о количестве расходных материалов -----
# Доступно единиц: 8
iss.thrusters.activate_thrusters
# ----- Thruster Action -----
# Ошибка! Мало топлива.
iss.fuel_tank.load_fuel(100)
# ----- Отчёт об использовании топлива -----
# Заправка 100 единиц топлива в бак.
iss.thrusters.activate_thrusters
# ----- Отчёт об использовании подруливающих двигателей -----
# Подруливающие двигатели работают без ошибок.
# ----- Отчёт об использовании топлива -----
# Используется 10 единиц топлива из бака.
iss.fuel_reporter.report
# ----- Отчёт о количестве топлива -----
# 90 единиц топлива доступно.
В последней версии программы обязанности «модулей» были разбиты на два дочерних класса FuelReporter
и SupplyReporter
, объединённых под родительским классом Reporter
. Далее были добавлены экземплярные переменные к классу SpaceStation
, чтобы запустить соответствующий Reporter
. Если руководству с Земли понадобится ещё что-то изменить, то можно внести правки в подклассы, не влияя на работу объектов (классов), о которых они докладывают.
Конечно, до сих пор есть некоторая связь между разными классами. Например, SupplyReporter
зависит от SupplyHold
, так же зависим и FuelReporter
от FuelTank
. Кроме того, подруливающие двигатели тоже должны быть связаны с топливным баком. Все эти связи кажутся довольно логичными и на этом уровне уже можно изменять код одного объекта, не влияя на другой (либо влияя незначительно).
В итоге код программы стал более «модульным» и обязанности объектов ясным образом были обозначены. Вероятность «поломки» кода значительно уменьшена, а работать с ним стало приятнее, так как «божественный объект» (которым был весь код до второй редакции) был преобразован в SRP-код, если так можно выразиться.
Перевод статьи «Writing Flexible Code with the Single Responsibility Principle»