Закон Деметры
Закон Деметры — общайся только с прямыми соседями, не тянись сквозь цепочки a.getB().getC(). Цепочки-крушения связывают тебя с формой всего графа объектов; «говори, не спрашивай» переносит поведение к данным и сжимает радиус поражения структурного изменения.
Ты пишешь одну невинную строку: order.getCustomer().getAddress().getCountry().getCode(). Она компилируется, она работает, она уезжает в прод. Через полгода кто-то разбивает Address на BillingAddress и ShippingAddress, и эта строка — вместе с сорока другими ровно такой же формы — ломается. Ни одна из них не использовала адрес; они лишь прошли сквозь него, чтобы добраться до кода страны. И всё же каждая втихую запомнила весь путь целиком.
Вот цена разговоров с незнакомцами. Метод, который тянется на три-четыре объекта вглубь, зависит не только от своего соседа — он зависит от внутренней формы соседа своего соседа, и так до самого дна. Закон Деметры — это правило, которое не даёт тебе подписать такой контракт по случайности.
После этого урока ты можешь сформулировать закон Деметры как конкретное правило «кого мне можно вызывать?», а не расплывчатый лозунг; распознать цепочку-крушение и объяснить точно, к каким структурным изменениям она тебя привязывает; применить «говори, не спрашивай», чтобы перенести поведение туда, где живут данные, и цепочка исчезла; и — что критично — знать, когда не применять закон, чтобы не наплодить армию сквозных методов-обёрток (запах Middle Man) в погоне за метрикой.
Закон Деметры — это точное правило, а не «избегай точек». Метод m на объекте O может вызывать методы только на маленьком, поимённо названном наборе объектов: на самом O (его собственные поля и методы), на параметрах, переданных в m, на любых объектах, которые создаёт m, и на прямых компонентах O. Он не может вызывать методы на объектах, возвращённых этими вызовами. Одним предложением: общайся со своими друзьями, а не с незнакомцами, которых тебе представили друзья.
class ReportService {
constructor(private readonly db: Database) {}
build(request: ReportRequest): Report {
this.normalise(request); // OK — own method
request.validate(); // OK — a parameter
const rows = this.db.query(); // OK — own field
const out = new ReportBuffer(); // OK — created here
return out.finish(); // OK — object we made
// NOT OK: this.db.getConnection().getPool().getStats()
// — getConnection() returns a stranger; we never met the pool.
}
}Правило про возвращаемые значения. В тот миг, когда ты вызываешь метод на чём-то, что вернул тебе другой метод, ты переступил за круг друзей и начал зависеть от внутренностей незнакомца.
Цепочка-крушение связывает вызывающего с формой всего пути, а не только с его конечной точкой. Рассмотрим order.getCustomer().getAddress().getCountry().getCode(). Этой строке важна одна вещь — код страны, — но структурно она привязала себя к четырём фактам: что Order отдаёт Customer, что Customer отдаёт Address, что Address отдаёт Country и что Country отдаёт getCode(). Измени любое звено — и вызывающий сломается.
// The caller "knows" the whole graph just to read one leaf:
const code = order.getCustomer().getAddress().getCountry().getCode();Теперь умножь на каждое место, где делается такой обход. Радиус поражения «переименуем Country.getCode() в getIsoCode()» — больше не Country, а каждая точка вызова, которая когда-либо проходила сквозь страну, чтобы добраться до кода. Цепочка превратила локальное изменение в глобальное. Вот почему дорогостоящей оказывается структурная связанность, а не поведенческая: поведение можно подменить моком, а форму ты намертво вшил в вызывающего.
Лечение — «говори, не спрашивай»: перенеси поведение туда, где живут данные. Вместо того чтобы спрашивать у объекта его части, чтобы что-то посчитать, скажи объекту, что тебе нужно, и пусть он посчитает это частями, которыми уже владеет. Цепочка схлопывается, потому что каждый объект делает ровно один шаг.
class Country { constructor(private code: string) {} isoCode() { return this.code; } }
class Address { constructor(private country: Country) {} countryCode() { return this.country.isoCode(); } }
class Customer { constructor(private address: Address) {} countryCode() { return this.address.countryCode(); } }
class Order { constructor(private customer: Customer) {} countryCode() { return this.customer.countryCode(); } }
// Caller now talks only to its friend:
const code = order.countryCode();Вызывающий зависит ровно от одного: что Order способен сообщить ему код страны. Как Order до него добирается — через клиента, адрес, страну — теперь личное дело Order. Переименуй Country.getCode, перестрой Address, замени всю цепочку на кэш: вызывающий ничего не заметит. Ты обменял широкую, хрупкую зависимость на узкую, стабильную.
Режим отказа: догматичный закон Деметры плодит запах Middle Man. Посмотри снова на Address.countryCode() и Customer.countryCode() — они не делают ничего, кроме как пробрасывают вызов на уровень ниже. Примени закон механически по большому графу — и ты сгенерируешь лес таких сквозных методов: каждый слой обрастает делегирующим методом ради каждого листа, который может понадобиться любому вызывающему. Теперь новое поле на Country означает добавление того же тривиального проводника в Address, Customer и Order. Ты заменил одно локализованное усиление изменения (цепочку) другим (каскадом обёрток). Когда класс делегирует почти всё и не добавляет ничего, это запах кода Middle Man, а лекарство — обратный рефакторинг — Remove Middle Man: пусть вызывающий разговаривает с делегатом напрямую.
// Middle Man: Order forwards everything to Customer and contributes no logic.
class Order {
constructor(private customer: Customer) {}
name() { return this.customer.name(); }
email() { return this.customer.email(); }
countryCode() { return this.customer.countryCode(); }
tier() { return this.customer.tier(); } // ...and on, and on
}Так что закон Деметры — это эвристика о связанности, а не синтаксический запрет на цепочки вызовов. Senior-вопрос — никогда не «есть ли две точки?», а «делает ли эта цепочка меня зависимым от структуры, о которой мне знать не положено, — и добавляет ли её сокрытие реальное поведение или всего лишь проводник?»
Нарушение, которое прячет настоящий баг, и лечение, которое его предотвращает. Подпрограмма скидки тянется сквозь граф заказа, чтобы решить вопрос бесплатной доставки:
function shippingFee(order: Order): number {
// Train wreck: walks order → customer → membership → plan
if (order.getCustomer().getMembership().getPlan().getName() === "premium") {
return 0;
}
return 5.0;
}Две проблемы. Первая — структурная связанность: эта строка знает, что у клиентов есть подписки, что у подписок есть планы и что у планов есть имена — четыре факта, которые ей никогда не должны были понадобиться, чтобы посчитать стоимость. Вторая — скрытый сбой: у гостевого оформления нет подписки, поэтому getMembership() возвращает null и цепочка бросает TypeError. Баг невидим, пока на оформление не зайдёт гость, потому что цепочка предполагает, что весь путь существует.
Теперь «говори, не спрашивай» — задай клиенту тот вопрос, который у тебя на самом деле есть:
class Customer {
constructor(private membership: Membership | null) {}
hasFreeShipping(): boolean {
return this.membership?.isPremium() ?? false;
}
}
class Membership {
constructor(private plan: Plan) {}
isPremium(): boolean { return this.plan.isPremium(); }
}
function shippingFee(order: Order): number {
return order.customer.hasFreeShipping() ? 0 : 5.0;
}Подпрограмма стоимости теперь зависит от одного — что клиент способен ответить «положена ли тебе бесплатная доставка?», — а случай null обработан один раз, внутри Customer, где знание «у гостя нет подписки» и должно жить. Перестрой планы, добавь второй премиум-уровень, поменяй правило целиком: shippingFee не изменится. Цепочка не просто связала нас; она протекла предусловием (membership ≠ null) в место, которому было не положено его обеспечивать. Перенос поведения к данным починил связанность и баг одним движением.
▸Почему это работает
Почему Деметра целится именно в возвращаемые значения, а не просто в «длинные строки»? Потому что возвращаемое значение — это объект, которому тебя не представляли: ты получил его, потянувшись мимо своего сотрудника в его результаты. Вызывать собственные поля, свои параметры и созданные тобой объекты — нормально, сколько бы их ни было: это отношения, которые ты объявил. Опасность — в неявной, необъявленной зависимости, которую ты подбираешь, когда начинаешь навигировать по чужому выводу. Поэтому закон иногда зовут принципом наименьшего знания: единица должна знать о как можно меньшем числе других единиц и ничего — о единицах, которые те единицы случайно держат.
▸Частая ошибка
Самая частая перекоррекция — считать целью «никаких цепочек, никогда» и пересчитывать точки, как линтер. Два законных исключения: fluent-билдеры/API (query.select(...).where(...).limit(10)) возвращают тот же концептуальный объект, настроенный шаг за шагом — незнакомца нет, поэтому цепочка нормальна. И простые структуры данных — DTO, JSON-конфиг, объекты-значения без поведения — существуют именно для того, чтобы по ним навигировать; config.server.tls.minVersion — это чтение данных, а не команда поведению, и оборачивать каждый лист в метод не покупает ничего. Деметра про то, чтобы не тянуться сквозь объекты с поведением, чтобы управлять их сотрудниками. Чтение поля из записи или цепочка билдера, который отдаёт сам себя, — не то, что запрещает закон.
Ревьюер помечает каждое выражение с несколькими точками в PR, включая config.database.pool.maxSize на простом объекте конфига и queryBuilder.from('t').where(...).limit(5) на fluent-билдере, и требует метод-обёртку для каждого. Какова senior-оценка?
Закон Деметры — принцип наименьшего знания — гласит, что метод может вызывать методы только на собственных полях, своих параметрах, созданных им объектах и прямых компонентах, но никогда — на объектах, которые эти вызовы вернули. Причина — структурная связанность: цепочка-крушение вроде a.getB().getC().getD() привязывает вызывающего к форме каждого звена, поэтому любая перестройка в любом месте пути расходится в широкий радиус поражения. Лечение — «говори, не спрашивай»: протолкни поведение вниз, туда, где живут данные, чтобы каждый объект делал шаг лишь к своему соседу, запирая структурное изменение на одной границе (и, как показал разбор, часто попутно убивая скрытый баг с null в цепочке). Но закон — это эвристика о связанности, а не синтаксический подсчёт точек: применённый догматично, он порождает запах Middle Man — каскады пробрасывающих обёрток, которые лишь перемещают усиление изменения. Fluent-билдеры, возвращающие себя, и простые структуры данных, по которым ты навигируешь, — законные исключения. Спрашивай не «сколько точек?», а «завишу ли я от структуры, о которой мне знать не положено?».
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.