Как с помощью принципа единственной ответственности писать гибкий и модульный код

Обложка поста

Перевод статьи «Writing Flexible Code with the Single Responsibility Principle»

Если вы занимались разработкой ПО, вам наверняка знакома аббревиатура 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-код, если так можно выразиться.

Не смешно? А здесь смешно: @ithumor