Что такое Law of Demeter (принцип наименьшего знания)?
Вы хотите отправить письмо коллеге. Вы не идёте в отдел кадров, потом в бухгалтерию, потом в IT-отдел, чтобы найти его адрес. Вы просто просите своего менеджера: «Отправь письмо...
🟢 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) был добавлен.
Решение:
- Введены делегирующие методы:
notification.getHeaderCssClass(),notification.getBodyCssClass(). - DTO отделены от доменных объектов — DTO допускают цепочки, доменные — нет.
- 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
- Делегирование в hot path — один invokevirtual вместо трёх даёт JIT-friendly код.
- Immutable цепочки — если цепочка неизбежна, используйте immutable объекты (null не возможен, race condition исключён).
- DTO vs Domain — строго разделяйте: DTO допускают цепочки, доменные объекты — делегируют.
- ArchUnit в CI — автоматический блокиратор train wreck в PR.
- Правило «максимум 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 принципам]]