Prawo Demeter - okładka

Prawo Demeter

Opublikowano Kategorie Czysty kodCzas czytania 9min

Prawo Demeter (Law of Demeter występujące też pod skrótem LoD) to jedno z wielu praw, które często przewijają się w materiałach o szeroko pojętych dobrych praktykach programowania obiektowego. Prawo Demeter nazywane jest również:

  • Prawem (zasadą) najmniejszej wiedzy („The Principle of Least Knowledge”);
  • Regułą ograniczania interakcji;
  • Regułą „rozmawiaj tylko z przyjaciółmi”.

Jego przesłanie można skrócić do „don’t talk to strangers”. Jednak takie zdefiniowanie tematu niedoświadczonemu programiście lub programistce nie powie zbyt wiele. W tym artykule przedstawię Ci istotę prawa Demeter. Na przykładzie pokażę jak można je złamać. Zobaczysz również jak można zrefaktoryzować taki kod, by prawo Demeter zostało spełnione.

Teoria i historia powstania prawa Demeter

Prawo Demeter zostało zdefiniowane na Northeastern University przez Iana Hollanda w 1987 roku. Autor w tym czasie pracował nad projektem o takiej samej nazwie. Jeśli interesuje Cię jak Ian Holland oraz Karl Liebherr opisali to prawo, to odsyłam Cię do artykułu Assuring Good Style for Object-Oriented Programs, gdzie znajdziesz jego bardzo szczegółową definicję. Na potrzeby większości programistów wystarczy, moim zdaniem znacznie bardziej przystępna, definicja z artykułu Davida Bocka:

A method of an object should invoke only the methods of the following kinds of objects:
1. itself
2. its parameters
3. any objects it creates/instantiates
4. its direct component objects

Obiekt powinien komunikować się tylko z bezpośrednio powiązanymi obiektami. Częstym symptomem świadczącym o tym, że kod może (ale nie musi!) łamać prawo Demeter jest „łańcuchowe” odwoływanie się do metod. W kodzie może to wyglądać na przykład tak:


company.getDepartment('name').getEmployees().getEmployee('id').promote('role');

W podanym przykładzie obiekt company wystawia metodę getDepartment zwracającą obiekt department. Ten zaś zawiera kolekcję pracowników, z której można pobrać konkretnego pracownika i wywoływać jego metody.

Zachęcam jednak do nieco szerszego spojrzenia na problem niż „szukanie kropek”. Łańcuchowe odwołanie może być ukryte też pod tymczasową zmienną.


const department = company.getDepartment('name');
const employees = department.getEmployees();
const employee = employees.getEmployee('id');
employee.promote('role');

Warto spojrzeć na kod pod kątem szukania problemu, jaki rozwiązuje zastosowanie prawa Demeter. Ideą stojącą za prawem Demeter jest zwiększenie elastyczności kodu i zniwelowanie silnych powiązań (coupling) między fragmentami aplikacji. Wysoki coupling jest źródłem wielu problemów:

  • większa wrażliwość kodu na zmiany. Zmiany w jednym miejscu mogą powodować konieczność dostosowania innych miejsc. Utrudnia to refaktoryzację kodu i dodawanie nowych funkcji;
  • zrozumienie kodu może być trudniejsze;
  • wysoki coupling zwiększa szansę na problemy z testowaniem np. z izolacją testowanych komponentów czy aranżacją testów;
  • zmniejsza się ogólna elastyczność kodu. Sprawia to, że np. podmiana jednego komponentu na drugi czy ponowne użycie go w innym miejscu może być trudniejsze.

Opisane powody sprawiają, że ogólną tendencją jest dążenie do zmniejszania couplingu. Prawo Demeter jest jednym z narzędzi, które ma w tym pomóc.

Bock wskazał również kilka wzorców projektowych, które są pomocne w stosowaniu prawa Demeter. Są to: Adapter, Proxy, DekoratorFasada.

Prawo Demeter w praktyce

Do przedstawienia prawa Demeter w praktyce przygotowałem prosty przykład w TypeScript. Kod definiuje dwie klasy. Klasa Invoice jest reprezentacją faktury, a klasa LineItem to pozycja na fakturze. Dla uproszczenia pominąłem pola na fakturze niezwiązane z istotą przedstawionego problemu takie jak numer faktury, adres, NIP itp.

Celem jest zwrócenie informacji o wartości netto faktury, podatku do zapłacenia oraz wartości brutto. Kod przedstawiony na dołączonym przykładzie intencjonalnie został przygotowany tak, by łamał prawo Demeter. Zapoznaj się z nim i zanim przejdziesz dalej, przeanalizuj go samodzielnie. Spróbuj znaleźć miejsce, gdzie następuje złamanie prawa Demeter.


class Invoice {
  public readonly lineItems: LineItem[] = [];

  public constructor( lineItems: LineItem[] ) {
    this.lineItems = lineItems;
  }
}

interface ILineItem {
  id: string;
  item: string;
  netValueInCents: number;
  taxValueInPercents: number;
  quantity: number;
}

class LineItem {
  private readonly _id: string;

  public readonly item: string;

  public readonly netValueInCents: number;

  public readonly taxValueInPercents: number;

  public readonly quantity: number;

  public constructor( params: ILineItem ) {
    this._id = params.id;
    this.item = params.item;
    this.netValueInCents = params.netValueInCents;
    this.taxValueInPercents = params.taxValueInPercents;
    this.quantity = params.quantity;
  }

  public getTaxValueInCents(): number {
    
    return this.netValueInCents * this.taxValueInPercents / 100;
  }

  public getTotalValueInCents(): number {
    return this.netValueInCents + this.getTaxValueInCents();
  }
}

const invoice = new Invoice( [
  new LineItem( {
    id: crypto.randomUUID(),
    quantity: 1,
    netValueInCents: 10000,
    taxValueInPercents: 23,
    item: 'Git Programming Book'
  } ),
  new LineItem( {
    id: crypto.randomUUID(),
    quantity: 1,
    netValueInCents: 5000,
    taxValueInPercents: 8,
    item: 'SOLID Programming E-book'
  } ),
  new LineItem( { 
    id: crypto.randomUUID(),
    quantity: 1,
    netValueInCents: 1200,
    taxValueInPercents: 23,
    item: 'Standard Shipping'
  } )
])

const {
  totalNetValueInCents,
  totalTaxValueInCents,
  totalValueInCents
} = invoice.lineItems.reduce( ( prev, curr ) => ({
  totalNetValueInCents: prev.totalNetValueInCents += curr.netValueInCents,
  totalTaxValueInCents: prev.totalTaxValueInCents += curr.getTaxValueInCents(),
  totalValueInCents: prev.totalValueInCents += curr.getTotalValueInCents(),
}), { totalNetValueInCents: 0, totalTaxValueInCents: 0, totalValueInCents: 0 } );

console.log( {
  totalNetValue: totalNetValueInCents / 100,
  totalTaxValue: totalTaxValueInCents / 100,
  totalValue: totalValueInCents / 100
} );
// {
//   "totalNetValue": 162,
//   "totalTaxValue": 29.76,
//   "totalValue": 191.76
// } 

Złamanie prawa Demeter występuje tu na poziomie dostępu do pozycji faktury (obiektów klasy LineItem). Problem dotyczy enkapsulacji obiektów. Kod działający na obiekcie klasy Invoice ma dostęp do obiektów klasy LineItem. Kod spoza klasy Invoice ma przez to nadmierną wiedzę o strukturze klasy LineItem. Sprawia to, że zmiana w klasie LineItem wymaga dostosowania nie tylko w klasie Invoice. Potencjalnie dotknięte są wszystkie miejsca, gdzie klasa Invoice jest wykorzystana.

Drugim aspektem jest podział odpowiedzialności w kodzie. Kod wykorzystujący klasę Invoice nie powinien musieć liczyć podatków na piechotę. Tę odpowiedzialność powinna mieć klasa Invoice. Przez to, że kod ma nadmiarową wiedzę, powstał łańcuch odwołań: invoice -> lineItems -> właściwości i metody obiektów klasy LineItem w metodzie reduce.

Opisane problemy sprawiły, że w kodzie powstał wysoki coupling między kodem wykorzystującym klasę Invoice a strukturą LineItem-ów. Taki coupling wraz z rozwojem obu miejsc w kodzie prawdopodobnie by tylko się zwiększał. W przyszłości mogłoby powodować poważniejsze problemy, niż tylko złamanie prawa Demeter 😉

Przeprowadziłem drobną refaktoryzację pierwotnie przedstawionego kodu. Obiekty klasy LineItem zostały schowane przed światem zewnętrznym w prywatnym polu. Odpowiedzialność liczenia kwot, bazująca na wartościach z obiektów klasy LineItem została przeniesiona do klasy Invoice. Dzięki temu klasa Invoice staje się czymś więcej niż workiem na LineItem-y.


interface ITotals {
  totalNetValue: number;
  totalTaxValue: number;
  totalValue: number;
}

class Invoice {
  private readonly _lineItems: LineItem[] = [];

  public constructor( lineItems: LineItem[] ) {
    this._lineItems = lineItems;
  }

  public getTotals(): ITotals {
    const {
      totalNetValueInCents,
      totalTaxValueInCents,
      totalValueInCents
    } = this._lineItems.reduce( ( prev, curr ) => ({
      totalNetValueInCents: prev.totalNetValueInCents += curr.netValueInCents,
      totalTaxValueInCents: prev.totalTaxValueInCents += curr.getTaxValueInCents(),
      totalValueInCents: prev.totalValueInCents += curr.getTotalValueInCents(),
    }), { totalNetValueInCents: 0, totalTaxValueInCents: 0, totalValueInCents: 0 } );

    return {
      totalNetValue: totalNetValueInCents / 100,
      totalTaxValue: totalTaxValueInCents / 100,
      totalValue: totalValueInCents / 100
    }
  }
}

interface ILineItem {
  id: string;
  item: string;
  netValueInCents: number;
  taxValueInPercents: number;
  quantity: number;
}

class LineItem {
  private readonly _id: string;

  public readonly item: string;

  public readonly netValueInCents: number;

  public readonly taxValueInPercents: number;

  public readonly quantity: number;

  public constructor( params: ILineItem ) {
    this._id = params.id;
    this.item = params.item;
    this.netValueInCents = params.netValueInCents;
    this.taxValueInPercents = params.taxValueInPercents;
    this.quantity = params.quantity;
  }

  public getTaxValueInCents(): number {
    
    return this.netValueInCents * this.taxValueInPercents / 100;
  }

  public getTotalValueInCents(): number {
    return this.netValueInCents + this.getTaxValueInCents();
  }
}

const invoice = new Invoice( [
  new LineItem( {
    id: crypto.randomUUID(),
    quantity: 1,
    netValueInCents: 10000,
    taxValueInPercents: 23,
    item: 'Git Programming Book'
  } ),
  new LineItem( {
    id: crypto.randomUUID(),
    quantity: 1,
    netValueInCents: 5000,
    taxValueInPercents: 8,
    item: 'SOLID Programming E-book'
  } ),
  new LineItem( { 
    id: crypto.randomUUID(),
    quantity: 1,
    netValueInCents: 1200,
    taxValueInPercents: 23,
    item: 'Standard Shipping'
  } )
])

console.log( invoice.getTotals() );
// {
//   "totalNetValue": 162,
//   "totalTaxValue": 29.76,
//   "totalValue": 191.76
// } 

Kod po refaktoryzacji spełnia prawo Demeter. Komunikacja następuje tylko z klasą Invoice i użytkownik tej klasy nie ma bezpośredniego dostępu do pozycji na fakturze. Nie ma też zagnieżdżonych wywołań metod na obiektach zwróconych przez metody.

Podsumowanie

Prawo Demeter moim zdaniem, podobnie jak SOLID, KISS czy DRY, to jedno z tych pojęć, których nie da wykuć i od razu zastosować. Podobnie jak w przypadku wielu innych praktyk programowania, do jego praktycznego stosowania potrzeba praktyki. Doświadczenie pomoże Ci wyłapać co w kodzie „śmierdzi”. Wraz z nabieraniem doświadczenia w programowaniu będziesz intuicyjnie stosował(a) nie tylko prawo Demeter, ale dziesiątki innych, również nieznanych Ci praw.

Warto również pamiętać, że „good pracitces” i „reguły programowania” są dla nas, a nie my dla reguł. Warto wdrażać je w życie je wtedy, kiedy ma to racjonalne uzasadnienie. Czasami koszt dostosowania kodu by spełniał prawo Demeter, może przewyższać ewentualne korzyści.

Jeśli prawo Demeter jest dla Ciebie czymś nowym, to zachęcam, by przejrzeć swój kod pod jego kątem. W ramach treningu możesz zastanowić się, czy prawo Demeter jest w nim spełnione. Możesz też zrefaktoryzować fragment jakiegoś ze swoich projektów, gdzie prawo Demeter zostało złamane.

Prawu Demeter poświęcono też fragment w książce Pragmatyczny programista. Od czeladnika do mistrza autorstwa Andrew Hunta i Davida Thomasa, do której lektury gorąco zachęcam. Kupując z mojego linku, wspierasz działalność bloga.

Gdyby coś było dla Ciebie niejasne po lekturze tego artykułu, to zostaw komentarz. Możesz też zajrzeć do materiałów, które wykorzystałem przy tworzeniu tego artykułu.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

0 komentarzy
oceniany
najnowszy najstarszy
Inline Feedbacks
View all comments