Конспект POODR. Designing Classes with a Single Responsibility

Некоторые ребята уверены, что ООП — это про инкапсуляцию, наследование и полиморфизм, а сами объекты — просто такая обертка над данными. Это не так, ООП — это про сообщения, которые объекты отправляют друг другу. А лучше всего об этом рассказано в POODR, Practical Object-Oriented Design in Ruby Сэнди Метц.

Это настолько полезная книга, что я перечитываю ее каждый год. Чтобы перестать уже ее перечитывать, хочу закрепить знания с помощью конспекта. В этом посте — конспект второй главы, Designing Classes with a Single Responsibility.

Осторожно: это мой субъективный конспект. Не забудьте прочитать оригинал, книга того стоит.

Дизайн классов

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

At this stage your first obligation is to take a deep breath and insist that it be simple. Your goal is to model your application, using classes, such that it does what it is supposed to do right now and is also easy to change later.

Приложение изменится, появятся новые детали и классы придется менять. Дизайн — это не о совершенстве, а о легкости внесения изменений.

You will never know less than you know right now. If your application succeeds many of the decisions you make today will need to be changed later. When that day comes, your ability to successfully make those changes will be determined by your application’s design.

«Легко менять» — это, когда:

  • у изменений нет сайд-эффектов;
  • малые изменения в требованиях → малые изменения в коде;
  • код легко использовать в других местах;
  • самый простой способ внести изменения — добавить код, который так же легко менять.

Код, который легко менять:

  • Прозрачный (Transparent). Последствия изменений очевидны в коде и его зависимостях.
  • Рациональный (Reasonable). Стоимость изменений соразмерна прибыли от этих изменений.
  • Удобный (Usable). Применим в новых, нестандартных контекстах.
  • Образцовый (Exemplary). Воодушевляет тех, кто работает с ним, писать код прозрачным, рациональным и удобным.

Первый шаг к таком коду — принцип единственной ответственности.

Единственная ответственность

Класс должен делать как можно меньше, иметь одну единственную ответственность.

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

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

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

Because the class you’re reusing is confused about what it does and contains several tangled up responsibilities, it has many reasons to change. It may change for a reason that is unrelated to your use of it, and each time it changes there’s a possibility of breaking every class that depends on it. You increase your application’s chance of breaking unexpectedly if you depend on classes that do too much.

Как определить ответственности класса

К нам пришел друг за приложением, считающим всякие штуки для велосипедистов:

class Gear
  # Gear - передача в велосипеде.
  #
  # * chainring — количество зубьев в передней звездочке
  # * rog - количество зубьев в задней звездочке
  # * rim - диаметр обода
  # * tire - диаметр шины
  #
  attr_reader :chainring, :cog, :rim, :tire

  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog       = cog
    @rim       = rim
    @tire      = tire
  end

  # Передаточное число. Например, для 52/11 - 4.73.
  # Каждый раз, когда педали делают полный оборот,
  # заднее колесо делает почти 5 оборотов.
  #
  # Чем выше число, тем труднее крутить. Чем ниже, тем легче.
  def ratio
    chainring / cog.to_f
  end
 
  # Велосипедисты в США для сравнения велосипедов используют "gear inches":
  #
  #     диаметр колеса * передаточное число
  #
  def gear_inches
    # tire goes around rim twice for diameter
    ratio * (rim + (tire * 2))
  end
end

Чтобы понять, сколько у класса ответственностей, попробуйте описать его одним предложением. Если в предложении есть союзы (и, или) — у класса несколько ответственностей.

Попробуйте и поговорить с классом. “Please Mr. Gear, what is your ratio?” — нормально, “Please Mr. Gear, what is your tire (size)?” — звучит дебильно.

Эта концепция — связность (cohesion). Когда все в классе относится к его ответственности — это сильно связанный класс с единственной ответственностью. Это хороший класс.

Как описать ответственность Gear? «Вычислять передаточное число»? Зачем тогда #gear_inches? Класс делает слишком много.

Когда менять дизайн

Мы знаем, что с Gear что-то не так. Может, Gear — это вообще Bicycle? Или где-то спрятался Wheel? Как принять решение?

Если знаете, какие требования появятся к этому коду, можете принять решение сейчас.

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

Do not feel compelled to make design decisions prematurely. Resist, even if you fear your code would dismay the design gurus. When faced with an imperfect and muddled class like Gear, ask yourself: “What is the future cost of doing nothing today?”

When the future cost of doing nothing is the same as the current cost, postpone the decision. Make the decision only when you must with the information you have at that time.

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

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

С другой стороны, Gear неудобный и необразцовый. У него несколько ответственностей, его тяжело повторно использовать. Это не тот образец кода, на который стоит ориентироваться. Можно было бы и сейчас изменить.

Спросите себя: «Что будет, если я ничего не стану менять сегодня? Какова цена?».

Этот конфликт между «изменить дизайн сейчас» и «изменить дизайн позже» — вечный. Нет приложений с идеальным дизайном. Хороший программист снижает издержки, принимая дизайнерские решения на основе того, что нужно сейчас, и что может потребоваться в будущем.

Код, устойчивый к изменениям

Опирайтесь на поведение, а не на данные

Поведение зашито в метод. Вы используете его, отправляя сообщения — вызывая метод.

Когда у класса одна ответственность, каждое поведение определено один раз (DRY). Тогда любое изменение поведения — изменение кода в одном месте.

Кроме поведения объекты хранят данные в переменных экземпляра (строки, числа, хэши). Мы обращаемся к данным напрямую через @foo или через вспомогательные методы (attr_accessor).

Используйте вспомогательные методы вместо обращения напрямую:

# плохо
def ratio
  @chainring / @cog.to_f
end
 
# хорошо
attr_reader :chainring, :cog
def ratio
  chainring / cog.to_f
end

Теперь cog — поведение, определенное один раз, а не данные, на которые может быть много ссылок. Если мы использовали @cog в десяти местах, и внезапно условия поменялись, придется сделать 10 изменений. Если cog — метод, потребуется лишь одно изменение:

def cog
  @cog * (foo? ? bar_adjustment : baz_adjustment)
end

Данные часто имеют поведение, о котором вы пока не знаете. Прячьте данные, тогда код будет легче менять.

Regardless of how far your thoughts move in this direction, you should hide data from yourself. Doing so protects the code from being affected by unexpected changes. Data very often has behavior that you don’t yet know about. Send messages to access variables, even if you think of them as data.

Прячьте структуры данных

Рассчитывать на определенную структуру еще хуже, чем работать с данными напрямую:

class ObscuringReferences
  attr_reader :data
  def initialize(data)
    @data = data
  end

  def diameters
    # 0 is rim, 1 is tire
    data.collect { |cell| cell[0] + (cell[1] * 2) }
  end
end

@data — структура данных. Чтобы правильно работать с ней, методы должны знать ее устройство: что и по какому индексу располагается.

diameters знает не только, как считать диаметр, но и где искать данные о диаметре обода и шины.

diameters зависит от структуры @data, если она поменяется — придется поменять и diameters.

Прячьте сложные структуры данных за объектами:

class RevealingReferences
  attr_reader :wheels
  def initialize(data)
    @wheels = wheelify(data)
  end

  def diameters
    wheels.collect { |wheel| wheel.rim + (wheel.tire * 2) }
  end
  # ... now everyone can send rim/tire to wheel

  Wheel = Struct.new(:rim, :tire)
  def wheelify(data)
    data.collect { |cell| Wheel.new(cell[0], cell[1]) }
  end
end

Теперь diameters не знает ничего об устройстве @data.

diameters знает лишь, что wheelsEnumerable, а каждый его элемент отвечает на сообщения rim и tire. Все знание структуры @data теперь изолировано в wheelify. Если структура поменяется, код изменится в одном месте — в wheelify.

Единственная ответственность в методах

Методы, как и классы, должны иметь единственную ответственность: проще менять, проще использовать повторно.

def gear_inches
  ratio * (rim + (tire * 2))
end

В этом методе несколько ответственностей: внутри gear_inches спрятано вычисление диаметра колеса:

def gear_inches
  ratio * diameter
end
 
def diameter
  rim + (tire * 2)
end

Вытащенный diameter помог определить ответственности класса и вскрыл проблему: ок, Gear вычисляет gear_inches, но diameter он точно не должен вычислять.

Методы с единственной ответственностью помогают с рефакторингом и пониманием кода, обозначая скрытые до этого ответственности. Их легко использовать повторно и вытаскивать в отдельные классы.

В Gear спрятался еще один класс, Wheel. Если возможно — вытащите его, если нет — используйте Struct:

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog       = cog
    @wheel     = Wheel.new(rim, tire)
  end

  def ratio
    chainring / cog.to_f
  end
 
  def gear_inches
    ratio * wheel.diameter
  end

  Wheel = Struct.new(:rim, :tire) do
    def diameter
      rim + (tire * 2)
    end
  end
end

Если у вас есть класс с множеством ответственностей, разделяйте их, вытаскивая отдельные классы. Если дополнительные ответственности класса пока нельзя вытащить, изолируйте их.

Код, который легко менять, начинается с классов с единственной ответственностью. Их легко менять без последствий и повторно использовать без копипасты.

P. S. Ещё больше постов о программировании, тестах и культуре разработки у меня в Телеграме.