Wybierz region
pl
  • PL
  • EN
Wydrukuj

Cykl: Podstawy Javy. Epizod 1: Dziedziczenie

Dziedziczenie (obok abstrakcji, hermetyzacji i polimorfizmu) stanowi jeden z fundamentów Javy, jak i w ogóle programowania obiektowego. Mechanizm ten pozwala na tworzenie przejrzystej struktury klas i obiektów, w której poszczególne klasy nie powielają niepotrzebnie tych samych atrybutów lub zachowań.

Obiekty tworzone na podstawie klasy podrzędnej korzystać mogą nie tylko z cech klasy, na podstawie której zostały utworzone (klasy dziedziczącej), ale także z cech klasy bazowej (klasy, z której nastąpiło dziedziczenie). Mechanizm dziedziczenia zobrazować można przykładem rachunku bankowego. Utwórzmy zatem na początek klasę, która reprezentuje taki rachunek:

Rachunek bankowy posiada pole informujące o wysokości salda (balance) oraz metody dostępowe do tego pola. Dodatkowo Java dostarcza domyślnie konstruktora bezparametrowego, którego nie trzeba jawnie dodawać. Dysponując taką klasą bazową, stworzyć możemy następującą klasę reprezentującą rachunek oszczędnościowy:

W przeciwieństwie do zwykłego rachunku, wypłaty z rachunku oszczędnościowego obciążone są prowizją. Należy zatem dodać pole reprezentujące tę prowizję (commision) oraz metody dostępowe do tego pola. Aby obiekty rachunku oszczędnościowego mogły korzystać z pól i metod zwykłego rachunku należy wprowadzić między nimi relację dziedziczenia. W Javie służy do tego słowo extends, widoczne na listingu powyżej. Oznaczenie public class SavingAccount extends BankAccount mówi nam zatem, że obiekty klasy SavingAccount dysponują zarówno własnymi atrybutami i zachowaniami, jak i atrybutami i zachowaniami odziedziczonymi po klasie BankAccount. Dzięki temu możliwe jest utworzenie obiektu rachunku oszczędnościowego oraz ustawienie stanu konta w następujący sposób:

Jeżeli następnie, wykorzystamy metodę getBalance() i wypiszemy saldo rachunku oszczędnościowego na standardowym wyjściu, to przekonamy się, że istotnie wynosi ono 999,99. Łatwo zatem zauważyć, że dzięki dziedziczeniu mogliśmy skorzystać z pola balance oraz z metod dostępowych do tego pola, mimo że nie zostały one jawnie zdefiniowane w klasie SavingAccount

Patrząc na powyższe przykłady klas reprezentujących rachunki bankowe zauważyć można, że są one dalekie od doskonałości. Należałoby zastąpić gettery i settery bardziej przemyślanym interfejsem, odzwierciedlającym funkcjonalności, których oczekujemy od rachunku bankowego. Równocześnie dostosujemy w ten sposób nasze klasy do wymogu hermetyzacji (inaczej enkapsulacji), który również jest jednym z fundamentów programowania obiektowego. Mechanizm ten nakazuje ukrywać wewnętrzną strukturę klasy i wystawiać „dla świata” jedynie takie metody, które programista świadomie uzna za konieczne. Obecne w naszych przykładach metody dostępowe należy zatem zastąpić rozsądniejszym rozwiązaniem. Po zmodyfikowaniu klasa reprezentująca zwykły rachunek mogłaby wyglądać tak:

Getter i setter zastąpiono metodami reprezentującymi czynność wpłaty i wypłaty pieniędzy oraz metodą do wyświetlania stanu konta. Przy okazji uniemożliwiono dokonywanie wpłat i wypłat ujemnych kwot wypłat, które prowadziłyby do ujemnego salda. Domyślny, bezparametrowy konstruktor zastąpiono konstruktorem, który nakłada na nas konieczność określenia stanu konta przy jego tworzeniu. Spróbujmy zatem ponownie utworzyć konto oszczędnościowe i wyświetlić jego stan, korzystając z nowych rozwiązań klasy bazowej:

Niestety, tym razem kod się nawet nie kompiluje. Również jeżeli wrócimy do poprzedniego, bezparametrowego konstruktora:

to okaże się, że kod się nie kompiluje. Spróbujmy wyjaśnić, dlaczego tak się dzieje i jak to naprawić.

W pierwszej kolejności ustalmy, dlaczego nie udało się utworzyć konta oszczędnościowego za pomocą nowego konstruktora klasy nadrzędnej, który zdefiniowaliśmy samodzielnie. Odpowiedź w tym przypadku jest prosta: dzieje się tak, ponieważ konstruktory nie są dziedziczone. Skoro konstruktor klasy bazowej BankAccount nie został odziedziczony w klasie podrzędnej SavingAccount, to oczywistym jest, że nie możemy go użyć.

Dlaczego jednak straciliśmy możliwość wykorzystania domyślnego, bezparametrowego konstruktora tworzonego niejawnie przez Javę? Konstruktor taki nie został co prawda utworzony dla klasy bazowej BankAccount (gdyż stworzyliśmy tam własny konstruktor z jednym parametrem) jednak w klasie podrzędnej SavingAccount nie tworzyliśmy żadnego konstruktora, wskutek czego konstruktor bezparametrowy powinien być dostępny. I zasadniczo jest. W przypadku dziedziczenia domyślny konstruktor bezparametrowy w klasie podrzędnej tworzony jest niejawne w następującej formie:

Widoczna wyżej instrukcja super() oznacza wywołanie konstruktora klasy bazowej. Tworząc obiekt konta oszczędnościowego w ten sposób:

próbujemy więc wywołać konstruktor bezparametrowy klasy nadrzędnej, w której już go nie ma, ponieważ zastąpiliśmy go własnym konstruktorem przyjmującym jeden parametr typu double. Wywołujemy konstruktor nadrzędny, który oczekuje od nas przekazania wartości, nie przekazując mu tej wartości – to nie może zadziałać.

Aby umożliwić utworzenie obiektu klasy SavingAccount musimy zatem zgrać konstruktor tej klasy z konstruktorem klasy nadrzędnej. Możemy uczynić to np. dodając do SavingAccount następujący konstruktor:

W konstruktorze tym najpierw wywołujemy konstruktor klasy nadrzędnej. Tym razem w sposób prawidłowy przekazujemy mu parametr typu double. Poniżej zawrzeć możemy jeszcze inne, własne instrukcje właściwe dla klasy dziedziczącej. W tym przypadku zainicjalizowaliśmy wartość zmiennej reprezentującej wysokość prowizji.

Klasy dziedziczące mogą nie tylko korzystać z metod klas bazowych, ale także modyfikować ich zachowanie. Mówimy w takim przypadku o „przesłanianiu” metod. Posłużmy się przykładem. W klasie bazowej zdefiniowaliśmy metodę withdrawMoney(), za pomocą której dokonujemy wypłaty pieniędzy z rachunku. Wyobraźmy sobie teraz, że w klasie dziedziczącej chcielibyśmy dodatkowo poinformować użytkownika, że operacja wypłaty będzie wiązała się z obciążeniem go prowizją oraz doliczyć tę prowizję. Efekt taki możemy osiągnąć np. dodając do klasy SavingAccount następujące linie kodu:

Powyższą metodą przesłaniamy metodę withdrawMoney() z klasy bazowej. W ciele metody najpierw doliczamy prowizję, następnie wyświetlamy informację o tej prowizji, a w ostatniej linii za pomocą słowa super wywołujemy metodę klasy nadrzędnej. W ten sposób po wywołaniu tej metody:

najpierw najpierw obliczona zostanie kwota, która ma być odjęta z salda (wypłata + prowizja), następnie wyświetlone zostanie ostrzeżenie, a w końcu wywołana zostanie metoda klasy nadrzędnej, czyli przeprowadzona zostanie operacja wypłaty.

W przeciwieństwie do konstruktorów, słowo super w metodzie przesłaniającej nie musi pojawić się jako pierwsza instrukcja. Co więcej, nie musi się ono w ogóle pojawić. Równie dobrze moglibyśmy ustalić, że wypłaty z konta oszczędnościowego są niemożliwe i zawsze zwracać false. Taki kod zatem również jest prawidłowy:

Widoczna wyżej adnotacja @Override jest informacją dla kompilatora, że metoda jest przesłaniana.

Opisane wyżej przesłanianie metod możliwe jest jedynie w przypadku metod publicznych. Dzieje się tak z tej przyczyny, że metody prywatne w ogóle nie są dziedziczone. Posłużmy się przykładem. Do wyświetlania informacji o stanie konta korzystamy z metody showBalance(), która została zdefiniowana w klasie bazowej jako publiczna, dzięki czemu wykorzystać możemy ją również w klasie dziedziczącej. Zmieńmy tę metodę w następujący sposób:

oraz dodajmy prywatne metody getInfo() do klasy bazowej (BankAccount):

oraz do klasy dziedziczącej (SavingAccount):

Założenie jest takie, że chcielibyśmy, aby dla każdego rachunku wyświetlany był stan konta poprzedzony właściwym dla niego komunikatem dostarczanym przez metodę getInfo(). Jeżeli jednak stworzymy konto oszczędnościowe i wyświetlimy jego stan:

to na wyjściu otrzymamy informację: „Stan rachunku rozliczeniowego: 9.1”. Jak widać, prywatna metoda getInfo() nie została nadpisana i kompilator korzysta z metody sformułowanej w klasie nadrzędnej. Mogliśmy co prawda stworzyć metodę z taką samą nazwą w klasie dziedziczącej, jednak już próba dodania do niej adnotacji @Override zakończyłaby się błędem kompilacji, ponieważ nie nastąpiło tu przesłonięcie, a jedynie stworzenie innej metody o takiej samej nazwie.

Na koniec dodać należy jeszcze, że dziedziczenie w Javie (w przeciwieństwie do niektórych innych języków) jest zawsze jednokrotne. Innymi słowy – dana klasa dziedziczyć może po maksymalnie jednej klasie nadrzędnej. Jeżeli chcielibyśmy, żeby klasa SavingAccount dziedziczyła po BankAccount i równocześnie np. po Investment, to niestety nie będzie to w Javie możliwe.

Jak widać na powyższych przykładach, dziedziczenie w Javie, choć nie jest mechanizmem trudnym do implementacji, to przy odrobinie nieuwagi może stać się źródłem problemów. Warto zatem zawsze pamiętać o jego podstawowych zasadach.


Michał Karmelita

Z wykształcenia prawnik i informatyk. Zawodowo i z zamiłowania programista Java. W wolnym czasie stara się oddawać przyjemnościom i unikać przykrości. Zapalony podróżnik.


Wydrukuj