open atlas
↑ К треку
Паттерны и качество кода CP · 07 · 02

Закон Деметры

Закон Деметры — общайся только с прямыми соседями, не тянись сквозь цепочки a.getB().getC(). Цепочки-крушения связывают тебя с формой всего графа объектов; «говори, не спрашивай» переносит поведение к данным и сжимает радиус поражения структурного изменения.

CP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Ты пишешь одну невинную строку: order.getCustomer().getAddress().getCountry().getCode(). Она компилируется, она работает, она уезжает в прод. Через полгода кто-то разбивает Address на BillingAddress и ShippingAddress, и эта строка — вместе с сорока другими ровно такой же формы — ломается. Ни одна из них не использовала адрес; они лишь прошли сквозь него, чтобы добраться до кода страны. И всё же каждая втихую запомнила весь путь целиком.

Вот цена разговоров с незнакомцами. Метод, который тянется на три-четыре объекта вглубь, зависит не только от своего соседа — он зависит от внутренней формы соседа своего соседа, и так до самого дна. Закон Деметры — это правило, которое не даёт тебе подписать такой контракт по случайности.

Цель

После этого урока ты можешь сформулировать закон Деметры как конкретное правило «кого мне можно вызывать?», а не расплывчатый лозунг; распознать цепочку-крушение и объяснить точно, к каким структурным изменениям она тебя привязывает; применить «говори, не спрашивай», чтобы перенести поведение туда, где живут данные, и цепочка исчезла; и — что критично — знать, когда не применять закон, чтобы не наплодить армию сквозных методов-обёрток (запах Middle Man) в погоне за метрикой.

1

Закон Деметры — это точное правило, а не «избегай точек». Метод 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.
  }
}

Правило про возвращаемые значения. В тот миг, когда ты вызываешь метод на чём-то, что вернул тебе другой метод, ты переступил за круг друзей и начал зависеть от внутренностей незнакомца.

2

Цепочка-крушение связывает вызывающего с формой всего пути, а не только с его конечной точкой. Рассмотрим 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, а каждая точка вызова, которая когда-либо проходила сквозь страну, чтобы добраться до кода. Цепочка превратила локальное изменение в глобальное. Вот почему дорогостоящей оказывается структурная связанность, а не поведенческая: поведение можно подменить моком, а форму ты намертво вшил в вызывающего.

3

Лечение — «говори, не спрашивай»: перенеси поведение туда, где живут данные. Вместо того чтобы спрашивать у объекта его части, чтобы что-то посчитать, скажи объекту, что тебе нужно, и пусть он посчитает это частями, которыми уже владеет. Цепочка схлопывается, потому что каждый объект делает ровно один шаг.

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, замени всю цепочку на кэш: вызывающий ничего не заметит. Ты обменял широкую, хрупкую зависимость на узкую, стабильную.

4

Режим отказа: догматичный закон Деметры плодит запах 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.