Artykuły | 30 wrzesień, 2020

Od kaczek do delegacji, czyli problemy z dziedziczeniem w Javie

Poznaj problemy związane z dziedziczeniem i hierarchią klas w Javie oraz alternatywne podejścia, które te problemy rozwiązują.

Od kaczek do delegacji, czyli problemy z dziedziczeniem w Javie

Wstęp

Programując w Javie (a właściwie w każdym języku zorientowanym obiektowo), co chwilę korzystamy z dobrodziejstw tego, co oferuje nam programowanie obiektowe: klasy, interfejsy, polimorfizm, dziedziczenie. I można by tak jeszcze długo wymieniać. Szczególnie ta ostatnia opcja (dziedziczenie) pozwala nam w łatwy sposób opisać wiele podobnych do siebie bytów w prosty sposób – tę część, która jest dla nich wspólna, deklarujemy tylko raz w klasie nadrzędnej; natomiast część zmienną umieszczamy w kodzie klas podrzędnych po niej dziedziczących.

Okazuje się jednak, iż zarówno w życiu codziennym (spadek z długami po dalekim krewnym, o którym nawet nie mieliśmy pojęcia!), jak i w programowaniu, dziedziczenie może nam przysporzyć wielu niespodziewanych trudności. Na czym polega problem? Aby to wyjaśnić, posłużymy się na początek rysunkiem odnoszącym się do zasady, o której będziemy mówić w dalszej części artykułu. Następnie zmienimy rysunek w prostą aplikację złożoną z kilku klas. Spójrzmy na dwa poniższe rodzaje kaczek:

 class=
Rysunek 1: Złe użycie dziedziczenia na przykładzie kaczek

A teraz spróbujmy zapisać powyższy obrazek za pomocą klas Javy. Teoretycznie powinny nam do tego wystarczyć dwie klasy – jedna reprezentująca żywą kaczkę, a druga kaczkę elektryczną. Żywa kaczka potrafi pływać i kwakać:

 class=
Listing 1: Klasa Duck

Natomiast kaczka elektryczna zachowuje się tak jak żywa (i dlatego po niej dziedziczy)… O ile oczywiście ktoś pamiętał i włożył do niej baterie! W przeciwnym razie reaguje niespodziewanymi wyjątkami.

 class=
Listing 2: Klasa ElectricDuck

Teraz (ponieważ jesteśmy w XXI wieku) zamiast tworzyć klasę z metodą main, napiszmy test, który sprawdzi poprawność działania klasy Duck:

 class=
Listing 3: Klasa DuckTest

Stworzony test celowo sparametryzowaliśmy tak, aby dało się przekazać do niego różne obiekty klasy Duck. Skoro bowiem przyjęliśmy, iż klasa Electric Duck dziedziczy po klasie Duck, to przekazanie do testu obiektu tej klasy nie powinno niczego zepsuć. Tymczasem czeka nas bardzo nieprzyjemna niespodzianka:

 class=
Listing 4: Wyniki testów klasy Duck

Wygląda na to, że obiekt klasy ElectricDuck zachowuje się niestety inaczej niż obiekt klasy Duck. To bardzo niedobrze, gdyż w innej części systemu możemy mieć metody, które przyjmują obiekt kaczki i jeżeli przekażemy w takim miejscu kaczkę elektryczną (a możemy to zrobić, gdyż kaczka elektryczna dziedziczy po żywej), to system zachowa się w sposób nieprzewidziany! W takiej sytuacji pierwsze rozwiązanie, jakie przychodzi nam do głowy, to sprawdzenie, jaki typ kaczki otrzymaliśmy w rzeczywistości:

 class=
Listing 5: Naiwny sposób kontroli typu kaczki

Jednakże takie podejście:

  • po pierwsze – tak naprawdę nie pozwala nam skorzystać z zalet polimorfizmu
  • po drugie – powoduje, że z każdym kolejnym typem kaczki nasz kod brzydko się rozrasta o nowy if

Wygląda na to, że sytuacja, do której doprowadziliśmy, ewidentnie złamała jakąś regułę programowania obiektowego. Nietrudno domyślić się, że chodzi tu o jedną z zasad SOLID, a konkretnie o odpowiadającą literze L – zasadę podstawienia Liskov. Zapoznajmy się z nią bliżej.

Reguły pani Barbary Liskov

Nazwa tej zasady pochodzi od nazwiska bardzo zasłużonej dla informatyki pani profesor Barbary Liskov [1]. Sama definicja zasady podstawienia Liskov jest dość prosta [2]:

Funkcje, które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów.

Powyższe zdanie nie wygląda ani na zbyt skomplikowane, ani na trudne do wykorzystania w praktyce. Okazuje się jednak, że już w bardzo prostym przypadku mamy problem z powyższą regułą. Dlaczego? Odpowiedź tkwi w warunkach, które muszą być spełnione przez podklasę w stosunku do nadklasy, aby ta reguła była faktycznie spełniona. A oto one [3]:

  • Typ zwracany przez metodę w podklasie musi być taki sam jak w klasie bazowej albo być jego podtypem
  • Typy argumentów przekazywane do metody w podklasie muszą być takie same jak w klasie bazowej – albo być ich nadtypem
  • Metoda podklasy może rzucać wyjątek tylko wtedy, jeśli czyni to metoda w klasie bazowej. Typ wyjątku musi być taki sam jak w klasie bazowej albo musi być jego podtypem
  • Warunki wejściowe metody w podklasie nie mogą być silniejsze niż warunki wejściowe w klasie bazowej
  • Warunki wyjściowe metody w podklasie nie mogą być słabsze niż warunki wyjściowe w klasie bazowej
  • Metoda w podklasie nie może niszczyć niezmienników klasy bazowej

O ile spełnienie trzech pierwszych warunków skontroluje za nas kompilator Javy (w przypadku drugiego warunku nawet nadmiarowo – do metody w podklasie można przekazać tylko ten sam typ argumentu, a nie nadtyp), o tyle niestety o spełnienie pozostałych warunków musimy już zadbać sami. Tu pojawia się relatywnie wysokie ryzyko popełnienia błędu. Nietrudno domyśleć się, że w naszym konkretnym przypadku złamaliśmy czwarty warunek, ponieważ w klasie Duck metoda getVoice() nie posiada żadnych warunków wejściowych – można ją wywołać w dowolnym momencie.

Natomiast w klasie Electric Duck nie jest to już niestety prawda – metoda nie wyrzuci wyjątku tylko wtedy, gdy wcześniej wywołamy metodę insertBatteries(). O czym kod korzystający z takiego obiektu może nawet „nie wiedzieć”, gdyż taka metoda nie występuje w ogóle w klasie Duck!

Jak widać, ze względu na łatwą możliwość złamania reguł, poprawne dziedziczenie w Javie wcale nie jest proste do przeprowadzenia. Co gorsza, kompilator nie zawsze wykryje, że miało to miejsce, a to skazuje nas na weryfikację kodu w sposób ręczny (a więc – zawodny). Jakie mamy wobec tego możliwości rozwiązania problemu?

Czysta abstrakcja

Pierwszą metodą, jaką moglibyśmy zastosować, jest modyfikacja istniejącej hierarchii klas tak, aby klasy reprezentujące konkretne byty (w naszym przypadku żywa kaczka i kaczka elektryczna) dziedziczyły po klasie abstrakcyjnej, do której przenosimy maksymalnie wiele wspólnego kodu. Ktoś może zapytać: „Co to właściwie daje, skoro dalej zostajemy przy dziedziczeniu, a kaczka elektryczna dalej będzie zachowywać się inaczej niż żywa?”. Otóż cały haczyk tego rozwiązania polega na tym, iż nie będzie można powiedzieć, że klasa dziedzicząca „psuje” zachowanie klasy bazowej, gdyż obiekty klasy bazowej… po prostu nie będą istniały (wszak jest abstrakcyjna)! A skoro nie będą istniały – to i nie będzie czego zepsuć.

Proste – czyż nie? Oczywiście puryści programowania obiektowego zapewne stwierdzą, że takie tłumaczenie jest mocno naciągane… Ale to nie matematyka – tu nie ma jednego słusznego rozwiązania. Można mieć raczej zastrzeżenia co do innych wad tego podejścia – ale o nich opowiemy później. Pokażmy zatem nasz kod w akcji! Zacznijmy od klasy abstrakcyjnej, do której przeniesiemy wspólny kod. Nazwiemy ją w sposób mało wysublimowany – Base Duck. Po tej klasie będą dziedziczyć klasy reprezentujące żywe kaczki:

 class=
Listing 6: Klasa abstrakcyjna BaseDuck

Klasa reprezentująca żywą kaczkę w tej sytuacji staje się praktycznie pusta (gdyż funkcjonalność przeszła do klasy abstrakcyjnej) – wygląda jak wydmuszka:

 class=
Listing 7: Praktycznie pusta klasa Duck

Klasa odpowiedzialna za kaczkę elektryczną zmienia się nieco mniej, ale i w niej będą odwołania do klasy abstrakcyjnej:

 class=
Listing 8: Zmodyfikowana klasa ElectricDuck

Nieco zmieniają się również testy do klasy Duck. Ponieważ klasa ElectricDuck nie dziedziczy już po tej klasie, nie możemy już przetestować jej zachowania w tym miejscu:

 class=
Listing 9: Zmodyfikowana klasa DuckTest

W związku z powyższym klasę ElectricDuck musimy przetestować oddzielnie:

 class=
Listing 10: Nowa klasa ElectricDuckTest

Może pojawić się w tym miejscu wątpliwość: „Czy nie jest błędem, że tak naprawdę zwiększyła się nam ilość kodu w testach?”. Wbrew pozorom, nie powinno nas to niepokoić. Duża ilość kodu testowego (jeżeli tylko napisanego czysto), nie jest uważana za błąd, a w niektórych metodykach (np. TDD) – jest to normalne zjawisko. Jak widać, rozwiązanie z klasą abstrakcyjną nie wydaje się być zbytnio skomplikowane oraz nie wymaga dużych zmian w kodzie. W wielu przypadkach to wystarcza.

Wada tego podejścia

Sygnalizowałem wcześniej, że to rozwiązanie ma wady. Otóż każda klasa reprezentująca w naszej hierarchii konkretny byt ma narzucone z góry, iż musi dziedziczyć po klasie abstrakcyjnej. To zamyka możliwość dziedziczenia po czymkolwiek innym (Java nie posiada dziedziczenia wielobazowego). Możemy boleśnie odczuć skutki na własnej skórze w momencie, gdy takie dziedziczenie będzie nam potrzebne przy integracji z jakimś frameworkiem lub biblioteką. Jest na to jednak sposób – o tym, jak poradzić sobie w sytuacji, gdy nie możemy sobie pozwolić na zablokowanie dziedziczenia dla naszych klas, napiszę w kolejnej części artykułu.

Kompozytor wyjeżdża w delegację

Tak jak wspominałem na początku, dziedziczenie często stosujemy po to, aby uniknąć wielokrotnego pisania kodu. Kod staje się składową klasy bazowej, a klasy dziedziczące mogą dzięki temu również z niego skorzystać. Okazuje się jednak, że podobny efekt można osiągnąć poprzez ekstrakcję wspólnego kodu do oddzielnego obiektu, a następnie uczynienie tego obiektu składnikiem klasy. W efekcie można powiedzieć, że klasa nie dziedziczy jakiejś funkcjonalności, ale ta funkcjonalność jest w nią wkomponowana. Bardziej szczegółowo można takie podejście podzielić na dwa nurty: agregację i kompozycję [4].

My w dalszej części artykułu będziemy używać sformułowania „kompozycja”, aczkolwiek może być ono nie do końca ścisłe. Dodatkowo wkomponowany obiekt będziemy od tej pory nazywać „delegatem” – bo tak naprawdę delegujemy do niego odpowiedzialność za wykonanie części zadań naszej klasy. Tego typu podejście nazywane jest często kompozycją ponad dziedziczeniem [5]. W jaki sposób zrealizowalibyśmy to w przypadku naszych kaczek? Przede wszystkim należałoby stworzyć nasz obiekt – delegata:

 class=
Listing 11: Delegat zachowania kaczki

Zauważmy, że zawiera on mniej więcej to samo, co mamy w klasie abstrakcyjnej BaseDuck. Nic w tym dziwnego – w końcu delegat ma tę klasę zastąpić. A skoro tak – to w takim razie usuwamy ją, gdyż nie jest już nam potrzebna. Po usunięciu klasy abstrakcyjnej musimy zmodyfikować klasę Duck. Niezbędne jest dodanie metod, które dotychczas były zdefiniowane w klasie abstrakcyjnej, oraz ich implementacja z użyciem delegata:

 class=
Listing 12: Kaczka z delegatem

I podobnie robimy w klasie ElectricDuck:

 class=
Listing 13: Kaczka elektryczna z delegatem

I to na razie wszystko! Jeśli uruchomimy nasze testy, to dalej powinny działać. Potrzebna będzie tylko jedna zmiana – klasy, z której wykonujemy statyczne importy.

 class=
Listing 14: Klasa DuckTest po drobnej zmianie

Nietrudno zauważyć, iż kompozycja ma wiele zalet w porównaniu do klasycznego dziedziczenia:

  • Jest o wiele bardziej elastyczna i naturalna – o wiele łatwiej jest opisać strukturę obiektów w systemie, używając kompozycji niż dziedziczenia – często to drugie jest tworzone trochę „na siłę”
  • Zachowanie obiektu można modyfikować w trakcie działania programu (podmieniając wkomponowany obiekt na inny. Przy dziedziczeniu jest to niemożliwe – część odziedziczona jest „sztywna”

Wady kompozycji

Oczywiście należy także wspomnieć o wadach kompozycji:

  • Zwiększa się zużycie pamięci – używamy bowiem większej liczby małych obiektów
  • Lekko spada wydajność wywoływania metod – wywołując metodęnaobiekcie, tak naprawdę musimy wywołać metodę z delegata – jest to jedna ramka na stosie więcej
  • Klasy mające tę samą funkcjonalność i metody (w naszym przypadku Duck i ElectricDuck) stają się zupełnie rozłączne w hierarchii typów, co w przypadku niektórych języków uniemożliwia proste zastępowanie ich wzajemnie.
  • Powstaje sporo metod, których jedynym zadaniem jest wywołanie odpowiedniej metody delegata (tzw. metody przekazujące).

Dwie ostatnie wady są zależne od języka, z jakiego korzystamy, i w niektórych językach programowania nie występują. Niestety w przypadku Javy tak nie jest i trzeba włożyć więcej pracy, żeby się od nich uwolnić. Piszę o tym w dwóch kolejnych częściach artykułu.

Kaczki, a kacze typowanie

W poprzedniej części wspomnieliśmy, iż nasze klasy reprezentujące kaczki stały się zupełnie rozłączne w hierarchii typów. W związku z tym, mimo tego, iż współdzielą de facto te same metody (getVoice() i getSwimVoice()), dla kompilatora Javy są to zupełnie różne byty.

Dlaczego tak się dzieje? Otóż język Java jest językiem z silną kontrolą typów i o tym, jakie metody można wywołać na danym obiekcie, decyduje tylko i wyłącznie jego typ (klasa i implementowane interfejsy). Dlatego też nie ma w nim zastosowania tak zwane (nomen omen) kacze typowanie – w którym o możliwościach obiektu nie decyduje jego typ, lecz udostępnione przezeń metody [6]:

Jeśli coś chodzi jak kaczka i kwacze jak kaczka, to musi być kaczką.

Teoretycznie w Javie można wprowadzić kacze typowanie, posiłkując się mechanizmem refleksji. Powstały w ten sposób kod wygląda jednak bardzo nieczytelnie. Co gorsza, pozbawia nas silnej kontroli typów (co moim zdaniem jest jednak sporą i często niedocenianą zaletą języka Java). Na szczęście jest też do zastosowania o wiele prostszy sposób – wystarczy wprowadzić interfejs opisujący zachowanie kaczki. W przeciwieństwie do dziedziczenia (gdzie możemy mieć maksymalnie jednego przodka), każda klasa może implementować dowolną ilość interfejsów. Wprowadźmy zatem taki interfejs DuckBehaviour:

 class=
Listing 15: Interfejs opisujący zachowanie kaczki

A następnie zaimplementujmy go w klasach opisujących kaczki (nietrudno się domyślić, że nie wymaga to wielkich zmian)

 class=
Listing 16: Kaczka z interfejsem
 class=
Listing 17: Kaczka elektryczna z interfejsem

Jak widać, rozwiązanie z delegatem pozwoliło nam pozbyć się problemów z błędnym dziedziczeniem. Jednocześnie kod odpowiedzialny za bazowe zachowanie kaczki istnieje tylko w jednym miejscu (w klasie delegata). Czyżby sukces? Niestety jeszcze nie! Radość psują nam bowiem metody przekazujące w klasach Duck i ElectricDuck. Musimy je napisać, aby odbywało się wywołanie odpowiedniej metody w delegacie. Co gorsza – te metody będziemy musieli stworzyć w każdej klasie reprezentującej kaczkę, więc nie wygląda to za dobrze. Czyżby „zamienił stryjek siekierkę na kijek?” Aby zaradzić temu problemowi, będziemy musieli jeszcze nieco przerobić kod i wezwać na ratunek dobrze wszystkim znanego Lomboka

Lombok na ratunek

Problem z metodami przekazującymi tak naprawdę wynika z faktu, iż język Java sam w sobie nie wspiera mechanizmu delegacji (w przeciwieństwie do języków takich jak Groovy czy Kotlin, pozostając w świecie JVM). Wiemy też, że często takie braki języka są w całkiem rozsądny sposób „łatane” przez bibliotekę Lombok (żeby np. wspomnieć o setterach i getterach).

Tak jest i tym razem, ale jest pewien haczyk. Lombok oferuje adnotację @Delegate, która rozwiązuje nasz problem. Należy jednak mieć na uwadze, że jest ona traktowana jako eksperymentalna. Oznacza to, że może w przyszłości zniknąć. Nie jest to idealna sytuacja, ale dobre i to! Aby móc skorzystać z tej adnotacji, musimy doprowadzić do sytuacji, w której wszystkie metody przekazujące nie zawierają żadnej logiki poza wywołaniem odpowiadającej metody delegata. W klasie Duck nie jest to problemem, gdyż już tak się dzieje. Natomiast w klasie ElectricDuck metody przekazujące wykonują dodatkowe sprawdzenie (czy baterie są włożone).

Musimy zatem wyciągnąć te sprawdzenia na zewnątrz, korzystając ze wzorca strategii i takie strategie przekazać do delegata. W naszym przypadku będą to zwykłe dwa pola typu Runnable reprezentujące blok kodu do wykonania przed wydaniem głosu i próbą płynięcia:

 class=
Listing 18: Delegat ze strategiami

Dodatkowo dołożyliśmy stałą reprezentującą „pustą” strategię nierobiącą niczego. Ułatwi nam to za chwilę przerobienie klasy Duck. Samo użycie adnotacji @Delegate jest proste – wystarczy użyć jej na polu reprezentującym delegata. Następnie usuwamy metody przekazujące – i to wszystko!

Klasa Duck będzie prezentować się następująco:

 class=
Listing 19: Klasa Duck z adnotacją @Delegate

Nietrudno w takim razie przerobić i klasę ElectricDuck:

2020.09.30 code 20
Listing 20: Klasa ElectricDuck z adnotacją @Delegate

Możemy odetchnąć z ulgą, bo wynikowy kod wygląda już o niebo lepiej! Możemy teraz przyjrzeć się efektom pracy i spróbować przejść jeszcze raz wszystkie kroki.

Czas na podsumowanie

Jak pokazał nam przedstawiony przykład, tworząc hierarchię obiektów w Javie, można relatywnie łatwo popełnić błąd przy dziedziczeniu z istniejącej klasy. Co prawda powinna nas chronić przed tym zasada podstawienia Liskov, ale niestety łatwo ją nieświadomie złamać. Dlatego zamiast prostego dziedziczenia warto rozważyć inne podejścia, takie jak wprowadzenie klasy abstrakcyjnej (prostsze) lub zastąpienie dziedziczenia kompozycją (bardziej złożone, ale dające większą elastyczność).

W przypadku zastosowania kompozycji należy także pamiętać, iż z powodu silnej kontroli typów w Javie, należy stworzyć interfejs zawierający wspólne metody klas, tak aby w przyszłości nie zamykać sobie drogi do zamiany obiektu jednego typu na obiekt drugiego typu. Z kolei nadmiarowy kod spowodowany użyciem delegatów można skrócić z wykorzystaniem (eksperymentalnej co prawda) adnotacji @Delegate z Lomboka. Wymaga to czasami ekstrakcji dodatkowych interfejsów w delegacie, tak aby można było zastosować wzorzec strategii, ale w efekcie końcowym otrzymujemy maksymalnie prosty i elastyczny kod. Warto więc rozważyć i tę opcję.

Oczywiście kod zawarty w niniejszym artykule jest dostępny publicznie w moim repozytorium na GitHubie. Zapraszam zatem do klonowania i eksperymentowania na własną rękę. Przyjemnego tworzenia hierarchii i niech Wam obiekty lekkimi będą!

Literatura

[1] „Barbara Liskov [online]. Wikipedia: wolna encyklopedia”,, 2020-07-13 12:48Z. [dostęp: 2020-07-20 13:24Z]. Dostępny w Internecie.
[2] „Zasada podstawienia Liskov [online]. Wikipedia, wolna encyklopedia”, 2020-04-17 21:56Z. [dostęp: 2020-07-17 13:45Z]. Dostępny w Internecie.
[3] Wikipedia contributors, „Liskov substitution principle — Wikipedia, the free encyclopedia”, 2020. [Online; accessed 17July-2020].
[4] „Agregacja (programowanie obiektowe) [online]. Wikipedia, Wolna encyklopedia”, 2020-03-20 12:29Z. [dostęp: 2020-07-20 10:36Z]. Dostępny w Internecie.
[5] Wikipedia contributors, „Composition over inheritance — Wikipedia, the free encyclopedia” [Online; accessed 20-July-2020].
[6] „Duck typing [online]”. Wikipedia, wolna encyklopedia, 2020-04-17 21:54Z. [dostęp: 2020-07-20 12:06Z]. Dostępny w Internecie.

Java & Web Developer oraz Technical Leader z ponad 10-letnim stażem. Uczestniczył w wielu międzynarodowych projektach. Od czasu do czasu wspiera młodszych kolegów w rozwoju zawodowym. Lubi nowości w świecie IT, aczkolwiek podchodzi do nich z rozsądkiem i rezerwą wynikającą z doświadczenia. Najbardziej ceni sobie dobrze zgrane zespoły, które potrafią się same motywować i sobą zarządzać. Po pracy, fan wędrówek, zarówno po górach jak i płaskich terenach.

Zapisz się do newslettera, ekskluzywna zawartość czeka

Bądź na bieżąco z najnowszymi artykułami i wydarzeniami IT

Informacje dotyczące przetwarzania danych osobowych

Zapisz się do newslettera, ekskluzywna zawartość czeka

Bądź na bieżąco z najnowszymi artykułami i wydarzeniami IT

Informacje dotyczące przetwarzania danych osobowych

Zapisz się do newslettera, aby pobrać plik

Bądź na bieżąco z najnowszymi artykułami i wydarzeniami IT

Informacje dotyczące przetwarzania danych osobowych

Dziękujemy za zapis na newsletter — został ostatni krok do aktywacji

Potwierdź poprawność adresu e-mail klikając link wiadomości, która została do Ciebie wysłana w tej chwili.

 

Jeśli w czasie do 5 minut w Twojej skrzynce odbiorczej nie będzie wiadomości to sprawdź również folder *spam*.

Twój adres e-mail znajduje się już na liście odbiorców newslettera

Wystąpił nieoczekiwany błąd

Spróbuj ponownie za chwilę.

    Get notified about new articles

    Be a part of something more than just newsletter

    I hereby agree that Inetum Polska Sp. z o.o. shall process my personal data (hereinafter ‘personal data’), such as: my full name, e-mail address, telephone number and Skype ID/name for commercial purposes.

    I hereby agree that Inetum Polska Sp. z o.o. shall process my personal data (hereinafter ‘personal data’), such as: my full name, e-mail address and telephone number for marketing purposes.

    Read more

    Just one click away!

    We've sent you an email containing a confirmation link. Please open your inbox and finalize your subscription there to receive your e-book copy.

    Note: If you don't see that email in your inbox shortly, check your spam folder.