PO Dziedziczenie i interfejsy
Wprowadzenie
Wiemy już, że przy pomocy klas możemy modelować pojęcia z dziedziny obliczeń naszego programu. Bardzo często podczas takiego modelowania zauważamy, że pewne pojęcia są mocno ze sobą związane. Podejście obiektowe dostarcza mechanizmu umożliwiającego bardzo łatwe wyrażanie związków pojęć polegających na tym, że jedno pojęcie jest uszczegółowieniem (lub uogólnieniem) drugiego. Ten mechanizm nazywa się dziedziczeniem. Stosujemy go zawsze tam, gdzie chcemy wskazać, że dwa pojęcia są do siebie podobne.
Zwróćmy uwagę, że zastosowanie dziedziczenia jest związane ze znaczeniem klas powiązanych tych związkiem, a nie z ich implementacją. To że dwie klasy mają podobną implementację w żadnym stopniu nie upoważnia do wyrażenia tej zależności za pomocą dziedziczenia. Implementacja ma być ukryta w klasach (więc jej podobieństwa między klasami nie powinny być eksponowane za pomocą dziedziczenia). Implementacja poszczególnych pojęć może się zmieniać, co nie powinno mieć wpływu na relację dziedziczenia pomiędzy klasami w programie.
Dziedzieczenie jest jedną z fundamentalnych własności podejścia obiektowego. Pozwala kojarzyć klasy obiektów w hierarchie klas. Te hierarchie, w zależności od użytego języka programowania, mogą przyjmować postać drzew, lasów drzew, bądź skierowanych grafów acyklicznych. W Javie hierarchia dziedziczenia dla klas ma postać drzewa. Jej korzeniem jest klasa Object. Jak wkrótce się przekonamy jest niesłychanie wygodnie mieć taką jedną wspólną nadklasę dla wszystkich klas występujących w programie.
Realizacja dziedziczenia polega na tym, że klasa dziedzicząca dziedziczy po swojej nadklasie wszystkie jej atrybuty i metody (i nie ma znaczenia, czy te atrybuty i metody były zadeklarowane bezpośrednio w tej nadklasie, czy ona też odziedziczyła je po swojej z kolei nadklasie).
W różnych językach programowania można natknąć się na różną terminologię, klasę po której inna klasa dziedziczy nazywa się nadklasą lub klasą bazową, zaś klasę dziedziczącą podklasą lub klasą pochodną.
Dziedziczenie odzwierciedla relację is-a (jest czymś). Oznacza to, że każdy obiekt podklasy jest także obiektem nadklasy. Na przykład hierarchia klas zbudowana z nadklasy Owoc i dwu podklas Jabłko i Gruszka jest prawidłowo zbudowana, bo każde jabłko i każda gruszka jest owocem. Niestety często relacja is-a jest mylona z relacją has-a (ma coś) dotyczącą składania obiektów.
Zasada podstawialności: ponieważ obiekty podklas są też obiektami nadklas, to oczywiście zawsze można podstawiać obiekty podklas w miejsce obiektów nadklas. Na przykład metoda, która ma parametr typu Owoc prawidłowo zadziała z argumentem będącym jabłkiem albo gruszką.
Oto typowe zastosowania dziedziczenia:
- opisanie specjalizacji jakiegoś pojęcia (np. klasa Prostokąt dziedzicząca po klasie Czworokąt),
- specyfikowanie pożądanego interfejsu (klasy abstrakcyjne, interfejsy),
Nie należy natomiast stosować dziedziczenia do wyrażania ograniczania (np. klasa Stos dziedzicząca po uniwersalnej implementacji sekwencji wartości z operacjami dostępu do dowolnego elementu sekwencji - tu należy zastosować składanie).
Dziedziczenie pozwala pogodzić ze sobą dwie sprzeczne tendencje w
tworzeniu oprogramowania:
- chcemy żeby stworzone programy były zamknięte: gdy program już kompiluje się, działa i przeszedł przez wszystkie testy, chcielibyśmy go zapieczętować tak, by nikt już go nie modyfikował, bo modyfikacje często sprawiają, że programy przestają działać.
- chcemy żeby stworzone programy były otwarte: jeśli napisaliśmy dobry program, taki który jest używany przez użytkowników, to na pewno trzeba go będzie modyfikować. Nie dlatego że jest zły i zawiera błędy, tylko właśnie dlatego, że jest dobry i użytkownicy chcą go używać w ciągle zmieniającym się świecie. Skoro zmienia się sprzęt, na którym jest uruchamiany program, system operacyjny, upodobania użytkownika, przepisy prawne, to oczywiście i sam program musi być zmieniany. Losem dobrych programów jest częste ich modyfikowanie.
Dziedziczenie pozwala wtedy gdy potrzebujemy zmian nie modyfikować klas już istniejących, lecz na ich podstawie (przez dziedziczenie właśnie) stworzyć nowe dostosowane do zmienionych wymagań. Czyli dotychczasowy kod pozostaje bez zmian, a jednocześnie możemy rozwijać nasz program.
Realizacja w Javie
W Javie można dziedziczyć zarówno po klasach jak i po interfejsach (o tych drugich będziemy jeszcze mówić dalej). W ramach tego wykładu będziemy obie te formy dziedziczenia nazywali po prostu dziedziczeniem (lub będziemy wyraźnie zaznaczać, ze w danych miejscu chodzi nam o dziedziczenie po klasach lub o dziedziczenie po interfejsach). Co więcej, żeby co chwila nie pisać o "nadklasie lub nadinterfejsie" pozwolimy sobie mówić tylko o nadklasach i traktować interfejsy jako szczególny, bardzo uproszczony, rodzaj klas.
Składniowo oba te sposoby dziedziczenia są odmiennie wyrażane (za pomocą innych słów kluczowych). Ponadto dziedziczenie po klasach jest ograniczone do tylko jednej bezpośredniej nadklasy (nie ma w Javie wielodziedziczenia takiego jak w C++, gdzie z kolei nie ma interfejsów), można natomiast dziedziczyć (bezpośrednio) po dowolnej liczbie interfejsów.
Składnia dziedziczenia jest bardzo prosta, po nazwie klasy można dopisać słowo kluczowe extends a po nim podać nazwę klasy, po której tworzona klasa ma dziedziczyć. Dalej można podać słowo kluczowe implements z następującymi po nim nazwami interfejsów, po których tworzona klasa ma dziedziczyć (używając terminologii Javy powiedzielibyśmy, że jest to lista interfejsów, które tworzona klasa ma implementować). Poniżej podajemy kilka przykładów deklaracji dziedziczenia:
class Pracownik extends Osoba // dziedziczenie po klasie class Samochód implements Pojazd, Towar // dziedziczenie po kilku interfesjach class Chomik extends Ssak implements Puchate, DoGłaskania // dziedziczenie po klasie i kilku interfejsach
Jeśli klauzula extends jest pominięta, to domyślnie przyjmuje się, że klasa dziedziczy po klasie Object<ref>Nie dotyczy to samej klasy Object, bo ona nie ma żadnej nadklasy, ale akurat tej klasy sami nie możemy zadeklarować (mówiąc precyzyjniej, nie możemy zadeklarować tej klasy Object, możemy zadeklarować własną klasę, która będzie się nazywała Object, ale nie będzie ona miała oczywiście żadnych specjalnych własności).</ref>
Już powiedzieliśmy wcześniej, że klasa dziedzicząca otrzymuje ze swojej nadklasy komplet jej atrybutów i metod. O ile nazwy atrybutów i metod z klasy dziedziczącej i tej po której następuje dziedziczenie są różne sytuacja jest dość jasna, poniższy fragment programu:
class A{ int iA=1; void infoA(){ System.out.println( "Jestem infoA() z klasy A\n"+ " wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" + " iA="+iA); } } class B extends A{ int iB=2; void infoB(){ infoA(); System.out.println( "Jestem infoB() z klasy B\n"+ " wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" + " iA="+iA + ", iB=" + iB); } } class C extends B{ int iC=2; void infoC(){ infoA(); infoB(); System.out.println( "Jestem infoC() z klasy C\n"+ " wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName()+ "\n" + " iA="+iA + ", iB=" + iB + ", iC=" + iC); } } C c = new C(); c.infoC();
spowoduje wypisanie:
Jestem infoA() z klasy A wywołano mnie w obiekcie klasy C iA=1 Jestem infoA() z klasy A wywołano mnie w obiekcie klasy C iA=1 Jestem infoB() z klasy B wywołano mnie w obiekcie klasy C iA=1, iB=2 Jestem infoC() z klasy C wywołano mnie w obiekcie klasy C iA=1, iB=2, iC=2
Wyrażenie this.getClass().getSimpleName() powoduje wypisanie nazwy klasy obiektu, w którym to wyrażenie wyliczamy.
Jak widać z tego przykładu, klasa C odziedziczyła po swoich przodkach wszystkie atrybuty i metody (podobnie klasa B). Możemy więc myśleć o obiektach klasy C jak o kawałkach tortu składających się z wielu warstw, gdzie każda warstwa odpowiada kolejnej nadklasie.
Na razie nic szczególnego się nie wydarzyło, spróbujmy nieco skomplikowac nasz przykład.
Polimorfizm
Klasy abstrakcyjne i interfejsy
Podsumowanie
Dziedziczenie jest bardzo silnym narzędziem pozwalającym lepiej strukturalizować programy. Jest charakterystyczne dla podejścia obiektowego. Tak jak każde silne narzędzie ma zarówno zalety jak i wady. Poniżej krótko je charakteryzujemy.
Zalety:
- Możliwość jawnego zapisywania w programie związków ogólniania/uszczegóławiania pomiędzy opisywanymi pojęciami.
- Możliwość ponownego wykorzystywania (nie trzeba od nowa pisać odziedziczonych metod i deklaracji odziedziczonych zmiennych). Ponowne wykorzystywanie zwiększa niezawodność (szybciej wykrywa się błędy w częściej używanych fragmentach programów) i pozwala szybciej tworzyć nowe systemy (budować je z gotowych klocków).
- Zgodność interfejsów (osiągana przez tworzenie hierarchii klas dziedziczących po wspólnej nadklasie lub interfejsie).
Problemy:
- Problem jo-jo (nadużywanie dziedziczenia może uczynić czytanie programu bardzo żmudnym procesem).
- Modyfikacje kodu w nadklasach mają wpływ na podklasy i na odwrót (wirtualność metod, która jednocześnie stanowi o sile dziedziczenia).
Należy na koniec podkreślić, że dziedziczenie nie jest jedyną techniką wyrażania związków pomiędzy klasami. Innym ważnym mechanizmem wyrażania związków jest składanie. Oba te mechanizmy wzajemnie się uzupełniają i nigdy nie należy dążyć na siłę do zastąpienia jednego z nich drugim.
Przypisy
<references/>