📐 Розділ 18 · Питання #17

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

Об'єкт не повинен знати про внутрішню будову інших об'єктів, з якими він взаємодіє. Код, що викликає, має звертатися лише до свого безпосереднього сусіда і не повинен мандрувати...


🟢 Junior Level

Закон Деметри (Law of Demeter, LoD), також відомий як Принцип найменшого знання (Principle of Least Knowledge) — це базове правило об’єктно-орієнтованого проєктування, сформульоване в 1987 році Яном Голландом:

«Розмовляй лише з найближчими друзями і не розмовляй із незнайомцями.»

Що це означає простими словами

Об’єкт не повинен знати про внутрішню будову інших об’єктів, з якими він взаємодіє. Код, що викликає, має звертатися лише до свого безпосереднього сусіда і не повинен мандрувати ланцюжком чужих зв’язків углиб («через замкову шпарину»).

Життєва аналогія

Купівля газети в кіоску:

  • Порушення Закону Деметри: Покупець підходить до продавця, продавець мовчки залазить рукою в задню кишеню покупця, дістає звідти гаманець, відкриває відділення для купюр, бере 100 гривень і кладе гаманець назад.
  • Дотримання Закону Деметри: Покупець віддає 100 гривень продавцю (customer.pay(amount)). Продавець не знає і не повинен знати, чи є в покупця гаманець, кишеня або кредитна картка.

Антипатерн «Аварія поїзда» (Train Wreck)

Коли Закон Деметри порушується, у кодовій базі з’являються довгі ланцюжки викликів через крапку:

// Погано: "Аварія поїзда" (Train Wreck)
// Сервіс знає структуру замовлення, клієнта, адреси та міста!
String cityName = order.getCustomer().getAddress().getCity().getName().toUpperCase();

Чим небезпечний такий код:

  1. Катастрофічна крихкість: Якщо в класі Customer поле address перейменують або винесуть у список addresses, зламаються десятки сервісів по всій кодовій базі.
  2. Ризик NullPointerException на кожній ланці: Якщо хоча б один метод у ланцюжку поверне null (customer == null або address == null), увесь застосунок впаде з помилкою.
  3. Неможливість мокування: Щоб написати юніт-тест, розробник змушений писати 4 рівні вкладених моків: when(order.getCustomer()).thenReturn(customer); when(customer.getAddress()).thenReturn(address)....

Виправлення за законом Деметри: «Tell, Don’t Ask»

Замість того щоб випитувати нутрощі об’єкта та приймати рішення за нього, ми доручаємо дію самому об’єкту:

// Добре: сервіс звертається ТІЛЬКИ до прямого сусіда (Order)
String cityName = order.getDeliveryCityName();

Усередині класу Order запит делегується:

public class Order {
    private Customer customer;

    public String getDeliveryCityName() {
        return (customer != null) ? customer.getCityName() : "Unknown";
    }
}

🟡 Middle Level

Формальні 4 правила Лібергерра та Голланда

Формально метод M об’єкта A має право викликати методи лише наступних 5 категорій сутностей:

# Категорія дозволеного об’єкта Приклад у Java
1 Самого об’єкта A (this) this.calculateSubtotal()
2 Об’єктів, переданих у параметрах методу M public void process(PaymentRequest req) { req.validate(); }
3 Екземплярів, створених безпосередньо всередині методу M StringBuilder sb = new StringBuilder(); sb.append("...");
4 Прямих полів екземпляра об’єкта A (компонентів композиції) this.orderRepository.save(order);
5 Глобальних констант або статичних змінних Math.max(a, b);

❌ Суворо заборонено: Викликати методи об’єкта, який був повернутий у результаті виклику іншого методу: a.getB().getC().doAction().


Закон Деметри проти Fluent API та Stream API

Одне з головних запитань на співбесіді: «Чи порушує Stream API або StringBuilder закон Деметри?»

// Чи порушує це LoD?
List<String> names = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .toList();

Відповідь: НІ, не порушує! Закон Деметри забороняє навігацію чужою об’єктною структурою (Structural Navigation). У ланцюжках Stream API, StringBuilder, Optional або UserBuilder ми працюємо в межах єдиного контексту конвеєра трансформації даних (Pipeline / Fluent Interface). Кожен проміжний крок повертає об’єкт того самого концептуального типу або обгортку, не розкриваючи внутрішніх приватних графів залежностей сторонніх сервісів.


LoD та DTO (Data Transfer Objects)

Чи застосовний Закон Деметри до об’єктів DTO або структур даних? Роберт Мартін у книзі «Clean Code» проводить сувору межу:

  • Об’єкти (Objects): Приховують свої дані за абстракціями та виставляють назовні поведінку. До них Закон Деметри застосовується на 100%.
  • Структури даних (Data Structures / DTO / Java Records): Не мають поведінки, їхня мета — прозоро переносити сирі дані. До них Закон Деметри не застосовується, оскільки в DTO немає інкапсуляції поведінки, яку можна було б порушити. Ланцюжок orderDto.customer().address().city() є абсолютно легітимним.

🔴 Senior Level

Вплив порушення LoD на продуктивність JVM та JIT-компілятор

Кожен виклик методу в ланцюжку a.getB().getC().getD() транслюється в байткод invokevirtual або invokeinterface:

INVOKEVIRTUAL Order.getCustomer()LCustomer;
INVOKEVIRTUAL Customer.getAddress()LAddress;
INVOKEVIRTUAL Address.getCity()LCity;
INVOKEVIRTUAL City.getName()Ljava/lang/String;

Проблеми машинного рівня:

  1. Зрив Method Inlining компілятором C2: JIT-компілятор HotSpot має ліміти глибини інлайнінгу (MaxInlineLevel = 9) та максимального розміру байткоду (InlineSmallCode = 1000). Довгі ланцюжки викликів через інтерфейси спричиняють девіртуалізаційний параліч, змушуючи CPU виконувати 4 послідовні пошуки за таблицями VTable/ITable.
  2. Pointer Chasing та Cache Misses у CPU: Кожне розіменування посилання в довгому ланцюжку — це стрибок у випадкову область оперативної пам’яті купи (Heap). Якщо об’єкти Order, Customer, Address та City були створені в різний час і рознесені по сторінках пам’яті, процесор отримує до 4 послідовних промахів кешу (L1/L2 Cache Misses), зупиняючи виконання конвеєра на сотні тактів.

Багатопотокові ризики: Стан гонки (Race Condition) у ланцюжках

У конкурентному багатопотоковому середовищі порушення Закону Деметри породжує приховані фатальні дефекти:

// Потік 1:
if (account.getProfile().getSettings().isTwoFactorEnabled()) {
    // У цей момент Потік 2 замінює Settings на новий об'єкт!
    account.getProfile().getSettings().verifyCode(code); // NPE або перевірка старого об'єкта!
}

Між викликами getProfile() та getSettings() стан зовнішнього об’єкта може змінитися іншим потоком. Дотримання LoD усуває проблему: Викликається атомарний метод account.verifyTwoFactor(code), всередині якого об’єкт синхронізує доступ до своїх внутрішніх полів у межах однієї критичної секції.


4 Tricky Questions

1. Чому Закон Деметри називають «Законом», хоча формально це евристичний принцип проєктування?

Відповідь: Термін виник у 1987 році в Північно-Східному університеті (США) у межах дослідницького проєкту The Demeter Project, названого на честь Деметри — давньогрецької богині землеробства та порядку. Дослідники назвали відкрите правило «Законом» через суворість його дотримання у створюваній системі: компілятор Demeter автоматично забороняв виклики, що порушували межі безпосереднього сусіда. У сучасній індустрії це рекомендація (евристика), а не непорушний закон.


2. Чи призводить суворе слідування Закону Деметри до антипатерна Middle Man (Посередник)?

Відповідь: Так, це головний архітектурний trade-off Закону Деметри. Якщо сліпо боротися з кожним ланцюжком викликів, проміжні класи починають обростати сотнями методів-транзитерів (методів-прокладок), які просто перенаправляють виклики далі (order.getCityName(), order.getStreetName(), order.getHouseNumber(), order.getPostalCode()). Клас перетворюється на антипатерн Middle Man. Рішення: Якщо клієнту потрібна вся група даних адреси цілком, слід передавати сам об’єкт-значення (Value Object) Address як єдиний параметр, або використовувати DTO-проєкції.


3. Чи порушує Spring Framework Закон Деметри під час виклику context.getBean(Service.class).doSomething()?

Відповідь: Так, виклик applicationContext.getBean(...).method() порушує Закон Деметри (і одночасно є антипатерном Service Locator). Клас бере залежність у контексту і відразу викликає в неї методи. Правильний підхід у дусі Spring — використання Dependency Injection (DI) через конструктор. У цьому випадку контейнер впроваджує залежність безпосередньо в поле класу, і метод сервісу спілкується зі своїм прямим полем (правило #4 Закону Деметри), не звертаючись до контейнера.


4. Чи порушує патерн Builder Закон Деметри?

Відповідь: Ні. При конструюванні об’єкта через Builder:

User user = User.builder().name("Ivan").age(30).build();

Усі виклики .name() та .age() виконуються над одним і тим самим об’єктом білдера, який повертає сам себе (return this). Тут немає навігації вкладеними нутрощами чужих незалежних бізнес-сутностей, це покрокова конфігурація одного об’єкта перед його створенням.


🎯 Шпаргалка для інтерв’ю

Головні тези (30 секунд на відповідь):

  • Суть: Об’єкт повинен взаємодіяти лише зі своїми прямими сусідами (полями, аргументами, локально створеними об’єктами) і не лізти в нутрощі чужих структур.
  • Головний антипатерн: «Аварія поїзда» (Train Wreck) вигляду order.getCustomer().getAddress().getCity().
  • Наслідок порушення: Крихкість (зміна в одному класі ламає 10 сервісів), засилля вкладених моків у тестах, високий ризик NullPointerException.
  • Принцип «Tell, Don’t Ask»: Замість запиту даних назовні віддайте команду об’єкту виконати дію самому.
  • Винятки: Fluent API, Stream API, Builder та структури даних (DTO / Records).

Чого уникати (Red Flags):

  • ❌ Називати виклики Stream API або StringBuilder порушенням Закону Деметри (це конвеєри даних, а не структурна навігація).
  • ❌ Доводити LoD до фанатизму, створюючи 50 методів-делегатів у класі на кожен чих (ризик антипатерна Middle Man).
  • ❌ Застосовувати LoD до анемічних DTO-об’єктів, позбавлених поведінки.

Пов’язані теми