Конспект POODR. Managing Dependencies

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

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

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

Управление зависимостями

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

Знание получателя создает зависимость между объектами. Когда объект «А» зависит от объекта «Б», изменения в «Б» влекут за собой и изменения в «А».

Распознавание зависимостей

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

Зависимости объектов:

  • имя другого класса. Gear ждет, что класс Wheel существует.
  • сообщение, которое объект отправляет кому-то помимо self. Gear ждет, что Wheel отвечает на diameter.
  • аргументы сообщения. Gear знает, что Wheel.new принимает rim и tire.
  • порядок аргументов. Gear знает, что первый аргумент Wheel.newrim, второй — tire.

Каждая из этих зависимостей — шанс того, что Gear изменится из-за изменений в Wheel.

Части зависимостей не избежать: объекты ведь должны как-то взаимодействовать. Но большая часть из примера выше — лишние. Из-за них изменения в коде становятся каскадными: поменяли Wheel, затем Gear и так далее.

Управлять зависимостями нужно так, чтобы их было как можно меньше. Класс должен знать только то, что необходимо для его работы.

Связь между объектами

Зависимости привязывают Gear к Wheel. Чем больше Gear знает о Wheel, тем сильнее они связаны. Если нужно использовать Gear где-то еще, вы тащите его вместе с Wheel. Когда тестируете Gear, тестируете и Wheel.

When two (or three or more) objects are so tightly coupled that they behave as a unit, it’s impossible to reuse just one. Changes to one object force changes to all. Left unchecked, unmanaged dependencies cause an entire application to become an entangled mess. A day will come when it’s easier to rewrite everything than to change anything.

Другие зависимости

Другой тип зависимостей — объект, знающий объект, который знает другой объект, у которого есть нужный метод. Изменения в любом месте этой цепочки приведут к изменениям в самом первом объекте. Это — Law of Demeter.

Кроме того, есть зависимости между кодом и его тестами. Тесты, сильно привязанные к классу, ломаются от изменений в классе. Такие тесты дорого поддерживать.

Как уменьшить количество зависимостей

То, что Wheel используется явно в Gear, значит, что Gear отказывается работать с чем-либо, кроме Wheel. Если в приложении появятся Disk, Cylinder, вычислить gear_inches для них не получится.

Но для gear_inches имеет значение не тип класса, а сообщение — diameter. Значит, Gear ничего не должен знать о Wheel и его инициализации. Все что нужно gear_inches — это объект, отвечающий на diameter.

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, wheel)
    @chainring = chainring
    @cog       = cog
    @wheel     = wheel
  end

  def gear_inches
    ratio * wheel.diameter
  end
  # ...
end

# Gear expects a 'Duck' that knows 'diameter'
Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches

Извлечение Wheel из Gear разорвало зависимость между ними. До рефакторинга Gear зависел от Wheel, от его инициализации и порядка аргументов. После — зависит только от diameter.

This technique is known as dependency injection. …

Using dependency injection to shape code relies on your ability to recognize that the responsibility for knowing the name of a class and the responsibility for knowing the name of a message to send to that class may belong in different objects. Just because Gear needs to send diameter somewhere does not mean that Gear should know about Wheel.

Изоляцией

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

If prevented from achieving perfection, your goals should switch to improving the overall situation by leaving the code better than you found it.

Think of every dependency as an alien bacterium that’s trying to infect your class. Give your class a vigorous immune system; quarantine each dependency. Dependencies are foreign invaders that represent vulnerabilities, and they should be concise, explicit, and isolated.

Если нет возможности избавиться от Wheel в Gear, изолируйте создание Wheel:

class Gear
  # ...

  def gear_inches
    ratio * wheel.diameter
  end

  def wheel
    @wheel ||= Wheel.new(rim, tire)
  end
end

Gear все еще знает слишком много и привязан к Wheel. Но количество зависимостей у gear_inches уменьшилось, и мы явно обозначили зависимость Gear от Wheel.

These coding styles reduce the number of dependencies in gear_inches while publicly exposing Gear’s dependency on Wheel. They reveal dependencies instead of concealing them, lowering the barriers to reuse and making the code easier to refactor when circumstances allow. This change makes the code more agile; it can more easily adapt to the unknown future.

Представьте, что gear_inches стал сложнее и wheel.diameter спрятан внутри него:

def gear_inches
  #... a few lines of scary math
  foo = some_intermediate_result * wheel.diameter
  #... more lines of scary math
end

Сложный gear_inches зависит от Gear, отвечающего на wheel, и от wheel, отвечающего на diameter. Такая внешняя зависимость делает gear_inches хрупким. Изолируйте внешние зависимости:

def gear_inches
  #... a few lines of scary math
  foo = some_intermediate_result * diameter
  #... more lines of scary math
end

def diameter
  wheel.diameter
end

До изменения gear_inches знал, что у wheel есть diameter и зависел от исходящего сообщения wheel.diameter. После изменения gear_inches — более абстрактный, зависит от сообщения, отправляемого себе (self). Если в Wheel поменяется название diameter или возвращаемое значение, изменения в Gear сведутся к одному методу, diameter.

Изоляция исходящих сообщений нужна, когда класс ссылается на методы, которые могут измениться.

От порядка аргументов

Gear#initialize принимает три обязательных аргумента, chainring, cog, wheel. Аргументы должны быть переданы в правильном порядке и никак иначе.

Пользователи Gear зависят от порядка аргументов в initialize. Если он поменяется, все классы, использующие Gear, тоже должны будут измениться.

В таких случаях используйте хэш вместо фиксированного аргументов:

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args[:chainring]
    @cog       = args[:cog]
    @wheel     = args[:wheel]
  end
end

Такой вариант убирает зависимость от порядка аргументов и добавляет ясности в вызовы Gear#initialize: new Gear(chainring: 40, cog: 18, wheel: 25).

Если нет контроля над Gear (например, это часть фреймворка), а приложение во многих местах использует его, изолируйте создание новых Gear:

# When Gear is part of an external interface
module SomeFramework
  class Gear
    attr_reader :chainring, :cog, :wheel
    def initialize(chainring, cog, wheel)
      @chainring = chainring
      @cog       = cog
      @wheel     = wheel
    end
  # ...
  end
end

# wrap the interface to protect yourself from changes
module GearWrapper
  def self.gear(args)
    SomeFramework::Gear.new(args[:chainring],
                            args[:cog],
                            args[:wheel])
  end
end

Направление зависимостей

В примерах выше Gear зависит от Wheel или diameter. Эту зависимость можно развернуть в обратную сторону:

class Gear
  attr_reader :chainring, :cog
  def initialize(chainring, cog)
    @chainring = chainring
    @cog       = cog
  end

  def gear_inches(diameter)
    ratio * diameter
  end

  def ratio
    chainring / cog.to_f
  end
  # ...
end

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

  def diameter
    rim + (tire * 2)
  end

  def gear_inches
    gear.gear_inches(diameter)
  end
  # ...
end

Wheel.new(26, 1.5, 52, 11).gear_inches

Выбирайте направление зависимости так, чтобы зависимость менялась реже, чем текущий класс:

Pretend for a moment that your classes are people. If you were to give them advice about how to behave you would tell them to depend on things that change less often than you do.

Это правило базируется на трех аксиомах:

  • некоторые классы изменяются чаще других;
  • конкретные классы меняются чаще абстрактных;
  • изменения в классе, от которого зависит куча других классов, дают лавину изменений в этих классах.

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

The second idea concerns itself with the concreteness and abstractness of code. The term abstract is used here just as Merriam-Webster defines it, as “disassociated from any specific instance,” and, as so many things in Ruby, represents an idea about code as opposed to a specific technical restriction.

The wonderful thing about abstractions is that they represent common, stable qualities. They are less likely to change than are the concrete classes from which they were extracted. Depending on an abstraction is always safer than depending on a concretion because by its very nature, the abstraction is more stable

Зона A — классы, которые вряд ли будут меняться, но от них зависит много других классов в системе. Зона А — территория абстрактных классов и интерфейсов.

Зона B — нейтральные классы, мало зависимостей, вряд ли будут меняться.

Зона C — противоположность A, классы, которые будут меняться, но от них мало что зависит в системе.

В хорошем приложении классы будут раскиданы по зонам A, B и C. Зона D — зона смерти. В ней классы, которые будут меняться, и от которых зависит много других классов в системе. Любые изменения в этой зоне слишком дорогие.

The key to managing dependencies is to control their direction. The road to maintenance nirvana is paved with classes that depend on things that change less often than they do.

Важно распознавать и контролировать зависимости между объектами. Если от зависимости не избавиться, ее стоит изолировать. Зависеть лучше от абстрактных, редко меняющихся, объектов.

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