Zdarzyło Ci się spotkać albo, co gorsza, samemu napisać kod o następującej strukturze bank.getAccountingDepartment().getAccountingTeam().getAccountant().process(invoice)? Ewidentnie jest z nim coś nie tak. Nie dość, że grzebie on w wewnętrznej implementacji wielu klas to jeszcze ma potencjalnie aż 3 miejsca na rzucenie wyjątku NullPointerException. Żeby nie było mało to nie wiadomo, które wywołanie faktycznie zwróciło null (od wersji 14 Javy zostało to poprawione). Przytoczony wcześniej kod jest świetnym przykładem, który nie spełnia prawa Demeter, czyli zasady minimalnej wiedzy albo reguły ograniczenia interakcji. Co to dokładnie oznacza? Zapraszam do lektury!

Rozmawiaj tylko z bliskimi przyjaciółmi

Wizualizacja prawa Demeter
Wizualizacja prawa Demeter, Źródło: https://ichi.pro/pl/prawo-demeter-83374083546302

Prawo Demeter jest wytyczną wytwarzania oprogramowania. Głosi ono, że dany obiekt nie powinien nic wiedzieć o szczegółach implementacyjnych innych obiektów. Takie podejście promuje mniejsze splątanie ze sobą kodu (luźno powiązane elementy - niski coupling). Sprawdźmy jak wygląda oficjalna formułka prawa Demeter.

For all classes C, and for all methods M attached to C, all objects to which M sends a message must be

  • M’s argument objects, including the self object or
  • The instance variable objects of C
    (Object created by M, or by functions or methods which M calls, and objects in global variables are considered as arguments of M.)

Oznacza to, że będąc w metodzie danego obiektu możemy używać metod:

  • tego obiektu (słowo kluczowe this)
  • przekazanych argumentów
  • pól tego obiektu
  • obiektów, które powstały w tej metodzie lub powstały przez funkcje czy metody wywołane przez M
  • globalnych pól (słowo kluczowe static)

Według tego opisu możemy używać metod obiektów, do których mamy bezpośredni dostęp. Zakaz w takim razie musi dotyczyć elementów będących własnością tych obiektów. Oczywiście, aby skorzystać z tych niedozwolonych elementów musimy mieć wcześniej do nich referencje. Tylko w jaki sposób? Pierwsze co przychodzi na myśl to przez popularne gettery. Tą kwestią jednak zajmiemy się później. Teraz przejdźmy natomiast do części praktycznej, aby lepiej zrozumieć o co chodzi w zasadzie minimalnej wiedzy.

Rozumienie prawa Demeter w praktyce

1
2
3
4
5
6
7
8
9
10
11
12
public class ExampleOne {

  public void apiMethod() {
    // implementation
    
    internalMethod(5);
  }

  private void internalMethod(int i) {
    // implementation
  }
}

Pierwszy przykład przedstawia wywołanie internalMethod z ciała metody apiMethod. Jest to działanie zgodne z prawem Demeter. Rozbijanie kodu na mniejsze fragmenty i pakowanie go w metody (prywatne, pakietowe czy publiczne) jest dozwolone, a wręcz nawet obligatoryjne przez wzgląd na czytelność. Z tego powodu ten zabieg musi być respektowany przez zasadę minimalnej wiedzy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ExampleTwo {

  private static final StaticExampleClass staticExampleClass = new StaticExampleClass();
  private ExampleClass exampleClass = new ExampleClass();

  public void nextApiMethod() {
    // implementation
    
    staticExampleClass.exampleStaticClassMethod();
    
    // implementation

    exampleClass.exampleClassMethod();
  }
}

W tym przypadku mamy do czynienia z wywołaniem metod pól klasy, zwykłych oraz statycznych (globalnych). To podejście jest również jak najbardziej prawidłowe. Działamy tylko na wystawionym przez obiekty API. Nie interesuje nas co się dzieje w środku tych metod, po prostu ich używamy.

1
2
3
4
5
6
7
8
9
10
11
public class ExampleThree {

  public void apiMethod(Argument argument) {
    argument.argumentMethod();
    
    // implementation

    InnerClass innerClass = new InnerClass();
    innerClass.innerClassMethod();
  }
}

Trzeci przykład przedstawia sytuację, w której korzystamy z metod przekazanego do apiMethod argumentu oraz utworzonego w niej obiektu. W obydwu przypadkach postępujemy zgodnie z regułą ograniczonej interakcji.

Co jednak w przypadku, gdy kod wygląda następująco?

1
2
3
4
5
6
7
8
9
public class BreakingDemeterLawClass {

  public void apiMethod(FirstClass firstClass) {
    FourthClass fourthClass = firstClass.getSecondClass().getThirdClass().getFourthClass();
    fourthClass.fourthClassMethod();
    
    // implementation
  }
}

Widać na pierwszy rzut oka, że ten kod jak nic łamie prawo Demeter. Metoda otrzymuje przekazany do niej obiekt jako argument, więc powinna móc korzystać z jego metod. Natomiast w kodzie dochodzimy coraz to głębiej w hierarchii zawierania. W ten sposób dobieramy się do obiektów, do których nie powinniśmy się dotykać. Jeśli masz, więc kontakt z wywołaniem łańcuchowym (chain call) to miej się na baczności. Być może jest to właśnie złamanie reguły ograniczenia interakcji. Należy także uważać na poniższą sytuacje, która jest identyczna pod względem funkcjonalnym jak wcześniejszy przykład, tylko ukrywa złamanie prawa Demeter.

1
2
3
4
5
6
7
8
9
10
11
public class BreakingDemeterLawClass {

  public void apiMethod(FirstClass firstClass) {
    SecondClass secondClass = firstClass.getSecondClass();
    ThirdClass thirdClass = secondClass.getThirdClass();
    FourthClass fourthClass = thirdClass.getFourthClass();
    fourthClass.fourthClassMethod();
    
    // implementation
  }
}

Nie mamy już styczności z wywołaniem łańcuchowym w kodzie, ale i tak dochodzi do naruszenia reguły. Nic pod względem funkcjonalnym się nie zmieniło. Dalej odwołujemy się do wnętrzności dostępnych obiektów, więc trzeba zawsze być czujnym na tego typu zabiegi.

Próby obejścia prawa Demeter przez metody wrapujące

Niektóre źródła podają, że można obejść ten problem korzystając z precyzyjniej nazwanych metod. Zamiast korzystać z wywołania łańcuchowego klasa FirstClass mogłaby mieć metodę getSecondThirdFourthClass, która od razu zwracałaby obiekt klasy FourthClass.

1
2
3
4
5
6
7
8
9
public class BreakingDemeterLawClass {

  public void apiMethod(FirstClass firstClass) {
    FourthClass fourthClass = firstClass.getSecondThirdFourthClass();
    fourthClass.fourthClassMethod();
    
    // implementation
  }
}

Zadanie rozwiązane! Wystawiamy fakturę i resztę dnia możemy spędzić grając w piłkarzyki. Jednak to podejście wygląda trochę podejrzanie. Można je porównać do zamiatania rzeczy pod dywan. Zajrzyjmy, więc od klasy FirstClass.

1
2
3
4
5
6
7
8
9
public class FirstClass {
  private final SecondClass secondClass;

  // ...

  FourthClass getSecondThirdFourthClass() {
    return secondClass.getThirdClass().getFourthClass();
  }
}

Witaj stary przyjacielu! Nasze wcześniejsze wywołanie łańcuchowe po prostu wyemigrowało. Nie mamy z nim bezpośredniego kontaktu podczas wywołania getSecondThirdFourthClass, ale ono nadal tam jest! Dlatego do tematu metod wrapujących trzeba również podejść z dużą dozą ostrożności.

Czy struktury danych łamią prawo Demeter?

Na start trzeba zastrzec, że powyższe przykłady dotyczyły obiektów a nie struktur danych. To od razu pewnie daje wskazówkę co do tej sekcji. Struktury danych są integralną częścią każdego rodzaju programowania - czy to proceduralnego, funkcjonalnego czy obiektowego. Skupiają swój żywot nie na zachowaniu, a na przechowywaniu informacji. Z tego powodu nie powinny respektować prawa Demeter, ponieważ jest ona wskazówką dla podejścia obiektowego. Dlatego poniższy kod jest jak najbardziej prawidłowy (poza rzuceniem potencjalnego wyjątku NullPointerException).

1
2
3
4
5
6
7
8
9
10
11
public class NutrientsConverter {

  public ListOfNutrients convert(Shop shop) {
    ListOfNutrients listOfNutrients = new ListOfNutrients();
    listOfNutrients.setBanana(shop.getProducts().getFruits().getBanana());
    
    // implementation

    return listOfNutrients;
  }
}

Jaki problem jest z getterami?

Jak zapewne zauważyłeś łamanie reguły ograniczenia interakcji związane jest z używaniem getterów. Dlaczego tak się dzieje? Według konwencji tworząc getter dla danego obiektu zwracamy w nim referencję do prywatnego pola. Uzyskujemy, więc dostęp do obiektu, który nie powinien być w sferze naszych zainteresowań.

1
2
3
4
5
6
7
8
public class BreakingDemeterLawClass {

  public void apiMethod(FirstClass firstClass) {
    SecondClass secondClass = firstClass.getSecondClass();

    // implementation
  }
}

Wszystko niby wygląda poprawnie. Według prawa Demeter możemy wywoływać metody na otrzymanych argumentach. Gdzie, więc leży haczyk? Otrzymaliśmy SecondClass, z którym tak naprawdę nic nie możemy zrobić! Nie jest to ani przekazany do metody argument ani obiekt, do którego BreakingDemeterLawClass powinien mieć dostęp. Z tego powodu nawet wywołanie toString albo equals nie powinno mieć na nim miejsca.

Prawo Demeter jest strażnikiem programowania zorientowanego obiektowo. Przestrzega nas o tym, aby obiekty nie interesowały się nawzajem swoją wewnętrzną implementacją. Powinny delegować pomiędzy siebie zadania do wykonania, a nie pobierać dane i wykonywać coś na nich.

A co z fluent API?

Nasuwa się pewne pytanie. Czy klasy posiadające fluent API, jak np. wzorzec projektowy Builder, naruszają regułę ograniczenia interakcji? Przecież korzystając z niego wywołujemy dostępne metody łańcuchowo. Co prawda fluent API powstało po to, aby uprościć korzystanie z różnych bibliotek czy zbioru klas. Czy jego twórca świadomie złamał zasadę Demeter, aby ułatwić programowanie?

1
2
3
4
5
6
7
Animal animal = new AnimalBuilder()
  .withName("Burek")
  .withAge(6)
  .withSpecies(Species.Dog)
  .withGender(Gender.Male)
  .withWeight(32.4)
  .build();

Żeby zweryfikować czy powyższa konstrukcja żyje w zgodzie z zasadą opisywaną w tym artykule musimy przyjrzeć się poszczególnym wywołaniom metod. Sam początek new AnimalBuilder().withName("Burek") to nic innego jak utworzenie obiektu wewnątrz rozpatrywanej metody i wywołanie na niej metody. Jest to jeden z wcześniej opisywanych przypadków zawartych w prawie Demeter. Co natomiast z pozostałymi wywołania? Należy zajrzeć do ich wnętrza, aby przekonać się, że ciągle zwracają one ten sam obiekt klasy AnimalBuilder. Czyli w kółko wywołujemy metody na obiekcie, który stworzyliśmy. Kod przedstawiony powyżej można by zapisać w następujący sposób.

1
2
3
4
5
6
7
AnimalBuilder builder = new AnimalBuilder();
builder.withName("Burek");
builder.withAge(6);
builder.withSpecies(Species.Dog);
builder.withGender(Gender.Male);
builder.withWeight(32.4);
Animal animal = builder.build();

Teraz od razu widać, że zasada Demeter pozostała nienaruszona. Wychodzi na to, że nie zabrania ona korzystania z fluent API.

Podsumowanie

Prawo Demeter to naprawdę przemyślany zestaw zasad w odniesieniu do programowania obiektowego. Podążanie za nimi pozwala na pisanie kodu, który nie jest ze sobą splątany przez co jest łatwiejszy w utrzymaniu. Oczywiście jak zawsze zwracam uwagę na to, aby nie podążać ślepo za każdym prawem, ale raczej brać je jako wskazówki. Uważam, że zdarzają się sytuacje, w których warto złamać kilka zasad.

Źródła: