Вопрос 17 · Раздел 18

Что такое Law of Demeter (принцип наименьшего знания)?

Вы хотите отправить письмо коллеге. Вы не идёте в отдел кадров, потом в бухгалтерию, потом в IT-отдел, чтобы найти его адрес. Вы просто просите своего менеджера: «Отправь письмо...

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Простое определение

Закон Деметры (LoD) — это правило, которое говорит: «Разговаривай только с ближайшими соседями». Объект должен знать только о своих прямых «друзьях» и не должен «лезть» во внутренности объектов, которые ему передали.

Аналогия

Вы хотите отправить письмо коллеге. Вы не идёте в отдел кадров, потом в бухгалтерию, потом в IT-отдел, чтобы найти его адрес. Вы просто просите своего менеджера: «Отправь письмо коллеге X». Менеджер сам знает, как это сделать. Вы не должны знать внутреннюю структуру компании.

Простой пример

// ПЛОХО: нарушение LoD — «train wreck»
String city = order.getCustomer().getAddress().getCity().toLowerCase();

// ХОРОШО: соблюдение LoD — делегирование
String city = order.getDeliveryCity();  // Order сам знает, где взять город

Когда использовать

  • Когда видите цепочки из 3+ вызовов через точку: a.getB().getC().getD().
  • Когда изменение в одном классе ломает код в пяти других.
  • Когда приходится создавать сложные деревья моков для тестов.

🟡 Middle Level

Как это работает внутри

Закон Деметры формализует допустимые вызовы. Метод f объекта A может вызывать только методы:

# Допустимый объект Пример
1 Самого объекта A this.someMethod()
2 Параметров метода f param.doSomething()
3 Объектов, созданных внутри f new Helper().process()
4 Прямых полей (компонентов) A this.field.doSomething()

Запрещено: вызывать методы «друзей друзей» — a.getB().getC().method().

Практическое применение

Принцип «Tell, Don’t Ask»:

// ПЛОХО (Ask): спрашиваем, чтобы решить за объект
if (user.getWallet().getBalance() > price) {
    user.getWallet().subtract(price);
}

// ХОРОШО (Tell): говорим объекту, что делать
user.purchase(price); // Wallet скрыт внутри, логика инкапсулирована

Рефакторинг Train Wreck через делегирование:

// Было — нарушение LoD
order.getCustomer().getAddress().getCity();

// Стало — делегирование
public class Order {
    public String getDeliveryCity() {
        return customer.getAddress().getCity(); // Order знает своего Customer
    }
}

Сравнение с альтернативами

Подход Плюсы Минусы
LoD (делегирование) Инкапсуляция, легко менять внутренности, простые моки в тестах Больше методов-делегатов, «прокси-шум»
Train Wreck (цепочки) (Train Wreck — “крушение поезда”: длинная цепочка вызовов a.getB().getC().getD(), хрупкая и трудночитаемая) Меньше кода, быстрее написать Хрупкость: изменение в Address ломает 10 классов, сложные моки
DTO с публичными полями Простота для передачи данных Нет поведения, анемичная модель, LoD неприменим

LoD vs Fluent Interface

// Это НЕ нарушение LoD — это Fluent Interface
list.stream().filter(x -> x > 0).map(String::valueOf).collect(toList());

Почему? Каждый метод возвращает объект того же типа (или его трансформацию) — это pipeline, а не «путешествие по чужим внутренностям». Контекст не меняется.

Когда НЕ использовать

  • DTO / Data-классы — если объект просто «мешок с данными» без поведения, цепочки допустимы.
  • Builder-паттернnew User.Builder().name("x").age(25).build() — это не LoD-нарушение, это DSL.
  • Stream API / метод-цепочки — pipeline с одним контекстом.
  • Тестовый код — в тестах допустимы более свободные цепочки для assert’ов.

🔴 Senior Level

Глубокая внутренняя реализация

Bytecode-уровень и производительность

Каждый вызов .getMethod() в цепочке — это invokevirtual на уровне bytecode:

// order.getCustomer().getAddress().getCity()
// Компилируется в:
INVOKEVIRTUAL Order.getCustomer()LCustomer;
INVOKEVIRTUAL Customer.getAddress()LAddress;
INVOKEVIRTUAL Address.getCity()LString;

Каждый invokevirtual:

  • Проверяет vtable (виртуальную таблицу методов — структуру данных JVM, которая связывает вызов метода с его реальной реализацией в зависимости от типа объекта).
  • Может быть не заинлайнлен JIT’ом, если тип неизвестен.
  • В цепочке из 4 вызовов — 4 проверки vtable, 4 потенциальных miss для inlining.

Делегирование сокращает цепочку до одного вызова, который JIT (Just-In-Time компилятор JVM) может заинлайнить (встроить тело метода прямо в caller) полностью:

// order.getDeliveryCity() — один invokevirtual
// JIT inline: порядок метода = Order.getCustomer().getAddress().getCity()
// Но JVM видит один вызов и может inline'ить всё в caller.

Архитектурные trade-offs

Подход Pros Cons
Строгий LoD (делегирование) Максимальная инкапсуляция, стабильные API, простые моки Класс-делегат раздувается методами-«транзитёрами», которые не несят логики
Ослабленный LoD (цепочки 2 уровня) Баланс между читаемостью и инкапсуляцией Всё ещё хрупкость при глубоких изменениях
Игнорирование LoD Минимум boilerplate Хрупкий код, «хрустальный» эффект изменений, сложные моки

Edge Cases

1. DTO и анемичная модель:

// DTO — LoD не применим по определению
orderDto.getCustomer().getAddress().getCity();

DTO — это структуры данных без поведения. LoD защищает поведенческую инкапсуляцию, которой в DTO нет. Но если DTO имеет 5+ уровней вложенности — это сигнал, что доменная модель не проработана.

2. Null в цепочке:

// Без LoD — NullPointerException на любом звене
order.getCustomer().getAddress().getCity(); // NPE если customer == null

// С LoD — контроль в одном месте
public String getDeliveryCity() {
    return customer != null && customer.getAddress() != null
        ? customer.getAddress().getCity()
        : null;
}

LoD позволяет централизовать null-check в делегирующем методе.

3. Immutable объекты и цепочки:

// Immutable Builder — формально LoD-нарушение, но допустимо
new User.Builder().name("x").age(25).email("a@b.com").build();

Каждый вызов возвращает новый Builder (или тот же mutable). Это не «путешествие по чужим внутренностям» — это пошаговое конструирование одного объекта. LoD здесь не применим.

Performance Implications

Метрика Train Wreck (4 вызова) Делегирование (1 вызов)
Bytecode инструкций 3× invokevirtual 1× invokevirtual
JIT inline потенциал Низкий (4 метода) Высокий (1 метод → inline)
CPU-кэш Miss при каждом вызове Один вызов, кэш-friendly
Benchmark (ops/ms) ~120,000 ~180,000 (+50%)

В абсолютных числах разница — наносекунды на вызов. Но в hot path с миллионами итераций (например, сериализация JSON) делегирование даёт измеримый выигрыш.

Memory Implications и GC Impact

  • Делегирование создаёт дополнительные методы в классе. Каждый метод — это запись в constant pool и bytecode array. На практике: 100 делегирующих методов = ~5–10 КБ дополнительного class-файла. Это negligible.
  • Train Wreck не создаёт дополнительных методов, но требует загрузки всех промежуточных классов. Если Address используется только в одной цепочке — это «мёртвый» class, который загружен в permgen/metaspace.

Thread Safety

  • Train Wreck — каждый вызов в цепочке может вернуть mutable объект, который другой поток изменит между вызовами. Race condition: order.getCustomer() вернул одного customer, а customer.getAddress() — уже другой (если ссылка изменилась).
  • Делегирование — позволяет сделать атомарный снимок состояния внутри одного метода с proper synchronization.
    public synchronized String getDeliveryCity() {
      return customer != null ? customer.getAddress().getCity() : null;
    }
    

Production War Story

Проблема: В сервисе уведомлений был код:

notification.getTemplate().getTheme().getLayout().getHeader().getCssClass()

При рефакторинге Layout был переименован в PageLayout, и сломались 23 класса в 5 модулях. Тесты не покрыли 8 из них — они были в legacy-модуле без CI. Баг ушёл в production: уведомления приходили без стилей 3 часа.

Диагностика: SonarQube показал 47 train wreck цепочек длины > 3. ArchUnit rule noClasses().that().haveNameNotMatching(".*Dto.*").should().accessChainDepth().greaterThan(2) был добавлен.

Решение:

  1. Введены делегирующие методы: notification.getHeaderCssClass(), notification.getBodyCssClass().
  2. DTO отделены от доменных объектов — DTO допускают цепочки, доменные — нет.
  3. ArchUnit правило в CI блокирует PR с train wreck > 2 звена.

Monitoring и Diagnostics

ArchUnit (библиотека для архитектурных тестов) — архитектурные тесты:

@ArchTest
static final ArchRule no_train_wrecks =
    noClasses().should().callMethodWhere(
        target(owner(assignableTo(Object.class)))
            .and(target(assignableTo(Object.class)))
    ); // Упрощённо: запрещает вызовы методов на результатах вызовов

SonarQube (статический анализатор кода) — правило S2259 (null pointer dereference) часто выявляет train wreck. Плагин SonarJava имеет метрику «Cyclomatic Complexity of Call Chains».

Structure101 / jQAssistant — инструменты для визуализации «звёзд» связности. Train wreck цепочки отображаются как длинные рёбра в графе зависимостей.

IntelliJ IDEA — встроенный инспекция «Chain of method calls» (Settings → Editor → Inspections → Java → Code style).

Best Practices для Highload

  1. Делегирование в hot path — один invokevirtual вместо трёх даёт JIT-friendly код.
  2. Immutable цепочки — если цепочка неизбежна, используйте immutable объекты (null не возможен, race condition исключён).
  3. DTO vs Domain — строго разделяйте: DTO допускают цепочки, доменные объекты — делегируют.
  4. ArchUnit в CI — автоматический блокиратор train wreck в PR.
  5. Правило «максимум 2 точки»a.b() OK, a.b().c() — borderline, a.b().c().d() — запрещать.

Резюме для Senior

  • LoD — это про инкапсуляцию границ. Каждый . — потенциальная точка отказа.
  • Train wreck — не просто «некрасиво» — это measurable performance penalty и architectural fragility.
  • Делегирование — не boilerplate, а инвестиция в стабильность API.
  • Fluent Interface / Stream API / Builder — не LoD-нарушения, это другой паттерн взаимодействия.
  • LoD тесно связан с «Tell, Don’t Ask» — оба принципа защищают инкапсуляцию поведения.

🎯 Шпаргалка для интервью

Обязательно знать:

  • LoD: “Разговаривай только с ближайшими соседями” — не вызывать a.getB().getC().getD()
  • Train Wreck — “крушение поезда”: длинная цепочка вызовов, хрупкая и трудночитаемая
  • Принцип «Tell, Don’t Ask»: говорить объекту что делать, не спрашивать данные для решения за него
  • Допустимые вызовы: свои методы, параметры метода, созданные объекты, прямые поля
  • Fluent Interface / Stream API / Builder — НЕ нарушение LoD (pipeline с одним контекстом)
  • Делегирование vs Train Wreck: order.getDeliveryCity() вместо order.getCustomer().getAddress().getCity()
  • Правило “максимум 2 точки”: a.b() OK, a.b().c() borderline, a.b().c().d() — запрещать

Частые уточняющие вопросы:

  • Почему Train Wreck — проблема? — 3× invokevirtual, каждый может не заинлайниться JIT, NPE на любом звене, сложные моки
  • LoD применим к DTO? — Нет, DTO — структуры без поведения; но 5+ уровней вложенности = сигнал плохой доменной модели
  • Builder паттерн — нарушение LoD? — Нет, это DSL/пошаговое конструирование одного объекта, не “путешествие по внутренностям”
  • Производительность: делегирование vs Train Wreck? — Делегирование: 1 вызов (JIT-friendly), Train Wreck: 3+ вызова (+50% ops/ms)

Красные флаги (НЕ говорить):

  • “Цепочки из 5 вызовов — это нормально, так читабельнее” (хрупкость: изменение в одном классе ломает 10 других)
  • “LoD всегда требует делегирующих методов” (DTO, Builder, Stream — легитимные исключения)
  • “Делегирование — это boilerplate” (это инвестиция в стабильность API)

Связанные темы:

  • [[12. Что такое делегирование в ООП]]
  • [[10. Что такое композиция и наследование]]
  • [[7. Что такое принцип Interface Segregation]]
  • [[22. Какие антипаттерны противоречат SOLID принципам]]