Що таке 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 potential | Низький (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]]