Пишем гибкий код, используя SOLID
От переводчика: опубликовали для вас статью Северина Переса об использовании принципов SOLID в программировании. Информация из статьи будет полезна как новичкам, так и программистам с опытом.
Если вы занимаетесь разработкой, то, скорее всего, слышали о принципах SOLID. Они дают возможность программисту писать чистый, хорошо структурированный и легко обслуживаемый код. Стоит отметить, что в программировании есть несколько подходов к тому, как правильно выполнять ту либо иную работу. У разных специалистов — разные идеи и понимание «правильного пути», все зависит от опыта каждого. Тем не менее, идеи, провозглашенные в SOLID, принимаются практически всеми представителями ИТ-сообщества. Они стали отправной точкой для появления и развития множества хороших методов управления разработкой.
Давайте разберемся с тем, что такое принципы SOLID и как они помогают нам.
Skillbox рекомендует: Практический курс «Мобильный разработчик PRO».
Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».
Что такое SOLID?
Этот термин — аббревиатура, каждая буква термина — начало названия определенного принципа:
- Single Responsibility Principle (принцип единственной ответственности). У каждого класса — одна ответственность, то есть только одно выполнение задачи.
- The Open/Closed Principle (принцип открытости/закрытости). Классы и другие элементы должны быть открыты для расширения, но закрыты для модификации.
- The Liskov Substitution Principle (принцип подстановки Лисков). Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.
- The Interface Segregation Principle (принцип разделения интерфейса). Программные сущности не должны зависеть от методов, которые они не используют.
- The Dependency Inversion Principle (принцип инверсии зависимостей). Модули верхних уровней не должны зависеть от модулей нижних уровней.
Принцип единственной ответственности
Принцип единой ответственности (SRP) гласит, что каждый класс или модуль в программе должен нести ответственность только за одну часть функциональности этой программы. Кроме того, элементы этой ответственности должны быть закреплены за своим классом, а не распределены по несвязанным классам. Разработчик и главный евангелист SRP, Роберт С. Мартин, описывает ответственность как причину перемен. Изначально он предложил этот термин в качестве одного из элементов своей работы «Принципы объектно-ориентированного проектирования». В концепцию вошло многое из закономерности связности, которая была определена ранее Томом Демарко.
Еще в концепцию вошли несколько понятий, сформулированных Дэвидом Парнасом. Два основных — инкапсуляция и сокрытие информации. Парнас утверждал, что разделение системы на отдельные модули не должно базироваться на анализе блок-схем либо потоков исполнения. Любой из модулей должен содержать определенное решение, которое предоставляет минимум информации клиентам.
Кстати, Мартин приводил интересный пример с высшими менеджерами компании (COO, CTO, CFO), каждый из которых применяет специфическое программное обеспечение для бизнеса с разной целью. В итоге любой из них может внедрять изменения в ПО, не затрагивая интересы других менеджеров.
Божественный объект
Как обычно, лучший способ изучить SRP — это увидеть все в действии. Давайте посмотрим на участок программы, которая НЕ соответствует принципу единой ответственности. Это Ruby-код, описывающий поведение и атрибуты космической станции.
Просмотрите пример и попробуйте определить следующее:
Обязанности тех объектов, которые провозглашены в классе SpaceStation.
Тех, кто может быть заинтересован в работе космической станции.
class SpaceStation
def initialize
@supplies = {}
@fuel = 0
end
def run_sensors
puts "----- Sensor Action -----"
puts "Running sensors!"
end
def load_supplies(type, quantity)
puts "----- Supply Action -----"
puts "Loading #{quantity} units of #{type} in the supply hold."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Supply Action -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Using #{quantity} of #{type} from the supply hold."
@supplies[type] -= quantity
else
puts "Supply Error: Insufficient #{type} in the supply hold."
end
end
def report_supplies
puts "----- Supply Report -----"
if @supplies.keys.length > 0
@supplies.each do |type, quantity|
puts "#{type} avalilable: #{quantity} units"
end
else
puts "Supply hold is empty."
end
end
def load_fuel(quantity)
puts "----- Fuel Action -----"
puts "Loading #{quantity} units of fuel in the tank."
@fuel += quantity
end
def report_fuel
puts "----- Fuel Report -----"
puts "#{@fuel} units of fuel available."
end
def activate_thrusters
puts "----- Thruster Action -----"
if @fuel >= 10
puts "Thrusting action successful."
@fuel -= 10
else
puts "Thruster Error: Insufficient fuel available."
end
end
end
Собственно, наша космическая станция нефункциональна (думаю, что не получу звонок от НАСА в ближайшем обозримом будущем), но здесь есть что проанализироать.
Так, у класса SpaceStation есть несколько различных ответственностей (или задач). Все они могут быть разбиты по типам:
- сенсоры;
- снабжение (расходники);
- горючее;
- ускорители.
Несмотря на то, что никто из сотрудников станции не определен в классе, мы можем с легкостью представить, кто за что отвечает. Скорее всего, научный работник контролирует сенсоры, логист отвечает за снабжение ресурсами, инженер отвечает за запасы топлива, а пилот контролирует ускорители.
Можем ли мы сказать, что эта программа не соответствует SRP? Да, конечно. Но класс SpaceStation является типичным «божественным объектом», который знает обо всем и делает все. Это основной антишаблон в объектно-ориентированном программировании. Для новичка такие объекты чрезвычайно сложны в обслуживании. Пока что программа очень простая, да, но представьте, что произойдет, если мы добавим новые функции. Возможно, нашей космической станции понадобится медпункт или переговорная комната. И чем больше будет функций, тем сильнее вырастет SpaceStation. Ну, а поскольку этот объект будет связан с другими, то обслуживание всего комплекса станет еще более сложным. В итоге мы можем нарушить работу, к примеру, ускорителей. Если научный сотрудник запросит изменений в работе с сенсорами, то это вполне может повлиять на системы связи станции.
Нарушение SRP-принципа может дать кратковременную тактическую победу, но в итоге мы «проиграем войну», обслуживать такого монстра в будущем станет весьма непросто. Лучше всего разделить программу на отдельные участки кода, каждый из которых отвечает за выполнение определенной операции. Понимая это, давайте изменим класс SpaceStation.
Распределим ответственность
Выше мы определили четыре типа операций, которые контролируются классом 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 "----- Sensor Action -----"
puts "Running sensors!"
end
end
class SupplyHold
attr_accessor :supplies
def initialize
@supplies = {}
end
def load_supplies(type, quantity)
puts "----- Supply Action -----"
puts "Loading #{quantity} units of #{type} in the supply hold."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Supply Action -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Using #{quantity} of #{type} from the supply hold."
@supplies[type] -= quantity
else
puts "Supply Error: Insufficient #{type} in the supply hold."
end
end
def report_supplies
puts "----- Supply Report -----"
if @supplies.keys.length > 0
@supplies.each do |type, quantity|
puts "#{type} avalilable: #{quantity} units"
end
else
puts "Supply hold is empty."
end
end
end
class FuelTank
attr_accessor :fuel
def initialize
@fuel = 0
end
def get_fuel_levels
@fuel
end
def load_fuel(quantity)
puts "----- Fuel Action -----"
puts "Loading #{quantity} units of fuel in the tank."
@fuel += quantity
end
def use_fuel(quantity)
puts "----- Fuel Action -----"
puts "Using #{quantity} units of fuel from the tank."
@fuel -= quantity
end
def report_fuel
puts "----- Fuel Report -----"
puts "#{@fuel} units of fuel available."
end
end
class Thrusters
def initialize(fuel_tank)
@linked_fuel_tank = fuel_tank
end
def activate_thrusters
puts "----- Thruster Action -----"
if @linked_fuel_tank.get_fuel_levels >= 10
puts "Thrusting action successful."
@linked_fuel_tank.use_fuel(10)
else
puts "Thruster Error: Insufficient fuel available."
end
end
end
Изменений много, программа теперь выглядит определенно лучше. Сейчас наш класс SpaceStation стал, скорее, контейнером, в котором инициируются операции для зависимых частей, включая набор сенсоров, систему подачи расходных материалов, топливный бак, ускорители.
Для любой из переменных теперь есть соответствующий класс: Sensors; SupplyHold; FuelTank; Thrusters.
В этой версии кода есть несколько важных изменений. Дело в том, что отдельные функции не только инкапсулированы в собственные классы, они организованы таким образом, чтобы стать предсказуемыми и последовательными. Мы группируем сходные по функциональности элементы для следования принципа связности. Теперь, если нам понадобится изменить принцип работы системы, перейдя с хэш-структуры на массив, просто воспользуйтесь классом SupplyHold, затрагивать другие модули не придется. Таким образом, если офицер, ответственный за логистику, что-то изменит в своей секции, остальные элементы станции останутся нетронутыми. При этом класс SpaceStation даже не будет в курсе изменений.
Наши офицеры, работающие на космической станции, вероятно, рады изменениям, поскольку могут запрашивать те, которые необходимы именно им. Обратите внимание, что в коде есть такие методы, как report_supplies и report_fuel, содержащиеся в классах SupplyHold и FuelTank. Что случится, если Земля попросит изменить способ формирования отчетов? Необходимо будет изменить оба класса, 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 "----- Sensor Action -----"
puts "Running sensors!"
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 "----- Supply Action -----"
puts "Loading #{quantity} units of #{type} in the supply hold."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Supply Action -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Using #{quantity} of #{type} from the supply hold."
@supplies[type] -= quantity
else
puts "Supply Error: Insufficient #{type} in the supply hold."
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 "----- Fuel Action -----"
puts "Loading #{quantity} units of fuel in the tank."
@fuel += quantity
end
def use_fuel(quantity)
puts "----- Fuel Action -----"
puts "Using #{quantity} units of fuel from the tank."
@fuel -= quantity
end
end
class Thrusters
FUEL_PER_THRUST = 10
def initialize(fuel_tank)
@linked_fuel_tank = fuel_tank
end
def activate_thrusters
puts "----- Thruster Action -----"
if @linked_fuel_tank.get_fuel_levels >= FUEL_PER_THRUST
puts "Thrusting action successful."
@linked_fuel_tank.use_fuel(FUEL_PER_THRUST)
else
puts "Thruster Error: Insufficient fuel available."
end
end
end
class Reporter
def initialize(item, type)
@linked_item = item
@type = type
end
def report
puts "----- #{@type.capitalize} Report -----"
end
end
class FuelReporter < Reporter
def initialize(item)
super(item, "fuel")
end
def report
super
puts "#{@linked_item.get_fuel_levels} units of fuel available."
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} avalilable: #{quantity} units"
end
else
puts "Supply hold is empty."
end
end
end
iss = SpaceStation.new
iss.sensors.run_sensors
# ----- Sensor Action -----
# Running sensors!
iss.supply_hold.use_supplies("parts", 2)
# ----- Supply Action -----
# Supply Error: Insufficient parts in the supply hold.
iss.supply_hold.load_supplies("parts", 10)
# ----- Supply Action -----
# Loading 10 units of parts in the supply hold.
iss.supply_hold.use_supplies("parts", 2)
# ----- Supply Action -----
# Using 2 of parts from the supply hold.
iss.supply_reporter.report
# ----- Supply Report -----
# parts avalilable: 8 units
iss.thrusters.activate_thrusters
# ----- Thruster Action -----
# Thruster Error: Insufficient fuel available.
iss.fuel_tank.load_fuel(100)
# ----- Fuel Action -----
# Loading 100 units of fuel in the tank.
iss.thrusters.activate_thrusters
# ----- Thruster Action -----
# Thrusting action successful.
# ----- Fuel Action -----
# Using 10 units of fuel from the tank.
iss.fuel_reporter.report
# ----- Fuel Report -----
# 90 units of fuel available.
В этой, последней версии программы обязанности были разбиты на два новых класса, FuelReporter и SupplyReporter. Они оба являются дочерними по отношению к классу Reporter. Кроме того, мы добавили экземплярные переменные к классу SpaceStation с тем, чтобы при необходимости инициализировать нужный подкласс. Теперь, если Земля решит поменять еще что-то, то мы внесем правки в подклассы, а не в основной класс.
Конечно, некоторые классы у нас до сих пор зависят друг от друга. Так, объект SupplyReporter зависит от SupplyHold, а FuelReporter зависит от FuelTank. Само собой, ускорители должны быть связаны с топливным баком. Но здесь уже все выглядит логичным, а внесение изменений не будет особенно сложным — редактирование кода одного объекта не слишком повлияет на другой.
Таким образом, мы создали модульный код, где обязанности каждого из объектов/классов точно определены. Работать с таким кодом — не проблема, его обслуживание будет простой задачей. Весь «божественный объект» мы преобразовали в SRP.
Skillbox рекомендует: