Конспект 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.new
—rim
, второй —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. Ещё больше постов о программировании, тестах и культуре разработки у меня в Телеграме.