X

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

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

Также рекомендуем:

Рубрика: Переводы
Темы: Для продолжающихЛучшая практикаПаттерны проектирования