Питання 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 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) був доданий.

Рішення:

  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]]