Elementarz Java #7 – Dziedziczenie

Wstęp

Dzisiaj mam dla Was mega dużą pigułę wiedzy związaną z tematyką „dziedziczenia”. W artykule poruszyłem wszystkie najważniejsze zagadnienia związane z tym tematem. Całość oparta jest o wiele praktycznych przykładów oraz diagramów UML. Nie przedłużając, życzę miłej lektury i owocnej nauki.

Implementacja dziedziczenia

Klasy mogą dziedziczyć właściwości i zachowania z innych klas. W Javie wykorzystuje się do tego słówko kluczowe extends. Klasy mogą również implementować wiele interfejsów, a te z kolei dziedziczyć po innych interfejsach. Jak wygląda implementacja dziedziczenia? Przenalizujmy to na podstawie krótkiego przykładu oraz pomocniczego diagramu UML.

Diagram klas dla powyższego kodu.

Mamy klasę Animals, która dziedziczy po Object (wszystkie obiekty w Java dziedziczą po Object) oraz implementuje interfejs canRun. Klasa Mammals, dziedziczy po Animals, klasa Horse, dziedziczy po Mammals, a klasy Foal i Pony dziedziczą po Horse. Dodatkowo klasa Foal implementuje statyczną publiczną metodę main(String[] args). W przykładzie stworzyłem kilka różnych obiektów wykorzystując mechanizm dziedziczenia. Na przykład na rzecz obiektu Mammals stworzyłem obiekt Foal. Mogłem tak zrobić dlatego, że Foal dziedziczy po Horse, a ta klasa z kolei dziedziczy po Mammals. Zasada tutaj jest bardzo prosta. Dany obiekt możemy przypisać do obiektu o typie klasy, do której możemy „dojść” za pomocą strzałek na przedstawionym wyżej wykresie. Pisząc innymi słowami, obiekt X może zostać przypisany na rzecz obiektu Y jeśli obiekt Y jest nadklasą (rodzicem) obiektu X. Nie jest więc możliwe, jak to zresztą zostało to pokazane, przypisanie obiektu HorseAnimals czy Pony na rzecz obiektu Foal.

Przysłanianie metod

Pobawiliśmy się trochę dziedziczeniem więc pora na nieco ciekawszy przykład. Czy taki kod zostanie skompilowany poprawnie?

Odpowiedź na to pytanie pytanie brzmi – nie. Problem jaki tutaj wynika dotyczy tak zwanych typów kowariantnych (ang. covariant type). W poprzednim przykładzie tłumaczyłem, że dany obiekt możemy przypisać na rzecz innego obiektu, jeśli jest on jego rodzicem. Identyczna zasada dotyczy typów zwracanym przysłanianych metod. Popatrzmy na schemat.

Mamy tutaj klasę Class, która dziedziczy po School. W obu klasach mamy metodę number. Jest jednak pewien błąd. Jeśli klasa Class dziedziczy po School to tak samo typ zwracany metody number z klasy Class powinien dziedziczyć po typie zwracanym metody number z klasy School. W naszym kodzie jest dokładnie na odwrót. 

Aby „naprawić” nasz przykład musimy więc zamienić Number z Integer.

Teraz wszystko będzie w porządku. Jak już poruszyłem temat przysłaniania metod no to lecimy dalej. Jaki będzie efekt działania takiego kodu?

Powyższy przykład to „klasyczne” przysłonięcie metody getNumber przez metodę o tej samej nazwie, ale zaimplementowaną w klasie Main. Tutaj trzeba zwrócić uwagę na jedną rzecz. Klasa Main implementuje interfejs Test. Jak dobrze wiemy implementacja interfejsu wiąże się również z implementacją metod jakie ten interfejs udostępnia. W tym przypadku jest jednak nieco inaczej. Metoda getNumber nie musi być implementowana w klasie Main i gdyby tak było to przy tworzeniu obiektu klasy Main została by wywołana implementacja zdefiniowana w interfejsie. Na ekranie pojawiła by się cyfra 0. My jednak zdecydowaliśmy się na przysłonięcie metody getNumber czyli zaimplementowanie nieco innego „zachowania” w klasie Main. Efektem uruchomienia powyższego przykładu będzie więc wypisanie 1. Przejdźmy do kolejnego zadania.

Co tu się dzieje? Mamy obiekt typu Magazine i wywołujemy dwukrotnie metodę name, raz z liczbą 1 czyli intem, a drugi raz z liczbą 1.0 czyli doublem. Problem jaki może się tutaj pojawić związany jest z wywołaniem metody name dla double. Zwróć jednak uwagę na jedną istotną rzecz. Metoda name znajdująca się w klasie Magazine nie przysłania metody name z klasy Book. Mamy tutaj zaimplementowany mechanizm, który nazywa się przeładowaniem metod. Posługując się obiektem typu Magazine, możemy wywoływać dowolną wersję metody name w zależności od tego jaki typ danych przekażemy w argumencie. Nasz kod wypisze więc „Magazine Book”. 

W materiale na temat zmiennych pisałem o słówku kluczowym final. Jak już wspomniałem, ma ono zastosowanie również przy okazji metod. Przenalizujmy taki program.

Jeśli przy deklaracji zmiennej zastosujemy słówko kluczowe final, wtedy taka zmienna staje się stałą. To samo dzieje się przy metodach. Powyższy kod nie zostanie skompilowany dlatego, że metoda name oznaczona słówkiem kluczowym final nie może być przysłaniana, ale może być przeciążana. W naszym przykładzie jest przysłaniana co powoduje błąd kompilacji.

Wyczerpując tematykę implementacji dziedziczenia zastanówmy się jeszcze nad dziedziczeniem klas abstrakcyjnych. 

Powyższy kod nie zostanie skompilowany. Dziedzicząc po klasie abstrakcyjnej należy przysłonić wszystkie metody abstrakcyjne jakie ta klasa implementuje. Pamiętaj również, że przy okazji metod abstrakcyjnych nie definiuje się ich ciał. W naszym przykładzie metoda canRun w klasie Cheetah nie przysłania metody canRun z klasy Animal lecz ją przeładowuje. Powoduje to finalnie błąd kompilacji.

Do tej pory wszystkie metody, które były przysłaniane miały modyfikator dostępu publiczny lub chroniony. To trochę upraszczało sprawę, no bo jak wiadomo metody publiczne widoczne są „wszędzie”, a chronione w tym samym pakiecie oraz klasach dziedziczących. Pytanie jakie może się pojawić to co z metodami prywatnymi, które przecież w klasie dziedziczącej nie będą dostępne?

Rezultatem wywołania powyższego kodu będzie wyświetlenie napisu „Type is undefined„. Dzieje się tak dlatego, że metoda type z klasy Document oznaczona jest jako prywatna. Co za tym idzie nie jest widoczna dla klasy Passport. Jeśli na rzecz obiektu o typie Document tworzymy obiekt typu Passport to w „normalnej” sytuacji metoda type z klasy Document powinna zostać przysłonięta przez metodę type klasy Passport. W tym przypadku tak się jednak nie dzieje. Metoda type z klasy Document nie jest widoczna dla klasy Passport. Nie może więc ona zostać nadpisana ani przysłonięta. Finalnie wywoływana jest metoda type z klasy Document gdyż nie jest ona przysłonięta.

Rola i użycie polimorfizmu

W poprzednich przykładach metody z nadklasy oraz podklas miały te same nazwy. Nie był to przypadek, ale zamierzone działanie, które ma zresztą swoją nazwę. Mowa oczywiście o polimorfizmie. „Polimorfizm” w dosłownym tłumaczeniu to „wiele form”. W Javie o polimorfizmie mówimy wtedy, kiedy między klasami zachodzi relacja dziedziczenia, a zarówno klasa nadrzędna jak i podklasa mają zdefiniowane metody o tej samej nazwie. Metody polimorficzne nazywane są również metodami przysłoniętymi (ang. overridden method). Metody przysłonięte powinny mieć tę samą nazwę i tą samą listę argumentów. Typ zwracany powinien być taki sam lub powinna to być podklasa typu zwracanego w metodzie bazowej (to właśnie ten przykład z typami kowariantnymi, który opisałem nieco wyżej). Modyfikator widoczności musi być również identyczny lub mniej restrykcyjny. Pamiętaj jednak, że w przypadku przeładowania tych samych metod, nie ma mowy o polimorfizmie. Na zakończenie tego tematu przenalizujmy jeszcze taki kod. 

Jak pewnie się domyślasz, ten kod nie zostanie skompilowany poprawnie. W metodzie main, mamy tworzony obiekt typu City na rzecz obiektu typu State. Następnie wywołujemy metodę getName. Wywołanie tej metody jest polimorficzne (ewidentnie widać, że autor miał tutaj na myśli zastosowanie mechanizmu przysłaniania metod). Problem polega jednak na tym, że w obiekcie State ani Country nie zdefiniowaliśmy publicznej lub też chronionej metody getName (w przypadku modyfikatora private, dalej nie było by możliwe przysłonięcie, a dodatkowo wywołanie getName z poziomu klasy City nie wchodziło by w grę, bo modyfikator private umożliwia korzystanie z takiej metody tylko w klasie w której została ona zdefiniowana). Mechanizm przysłaniania nie będzie miał jak zadziałać. Dostaniemy więc błąd kompilacji.

Na marginesie dodam jeszcze, że metoda getName mogła by zostać zdefiniowana w klasie Country, gdyż jest ona rozszerzana przez klasę State. Oznacza to, że klasa State dziedziczy wszystkie własności oraz zachowania z klasy Country. Dziedziczyła by również metodę getName, która była by poprawnie przysłaniana przez tą samą metodę z klasy City.

Różnica między typem referencji i obiektu

Dostęp do obiektów w Java odbywa się przez referencję. Odwołując się do klasy pochodnej możemy zrobić to przy pomocy zmiennej (referencji) o typie klasy bazowej lub interfejsu. Nie jest możliwe odwoływanie się do klasy bazowej przy użyciu zmiennej o typie klasy pochodnej. Jeśli do obiektu odwołujemy się poprzez zmienną referencyjną klasy bazowej to możemy uzyskać dostęp tylko do zmiennych i metod zdefiniowanych w klasie bazowej. Dostęp do metod z klasy pochodnej jest możliwy przez polimorfizm. Jeśli do obiektu odwołujemy się przez zmienną o typie zaimplementowanego przez klasę bazową interfejsu, możemy uzyskać dostęp tylko do zmiennych i metod zdefiniowanych przez ten interfejs. Typ obiektu określa jakie jego właściwości zapisywane są w pamięci. Do wszystkich obiektów w pamięci możemy „dostać się” za pomocą zmiennej typu java.lang.Object. Na zakończenie warto jeszcze dodać, że wszystkie zmienne tablicowe zdefiniowane przez programistę mają typ referencji (nie obiektowy). 

To są wszystko typy referencyjne (nie prymitywy).

Rzutowanie

Java w pewnych przypadkach umożliwia niejawne rzutowanie typów danych. Zasada jest tutaj prosta. Jeśli przykładowo mamy liczbę typu int to możemy ją bez jawnego rzutowania przypisać do zmiennych o typie, który nie powoduje utraty precyzji, dla int będzie to: longfloatdouble czy też Object. Strukturę dziedziczenia typów danych przedstawiłem na poniższej grafice.

Popatrzmy na parę praktycznych przykładów.

Mamy tutaj dwie liczby typu byte, które dodajemy do siebie i przypisujemy do nowej zmiennej. Chodź są to typy byte to po dodaniu będą traktowane jak int. Zmienna result może więc być typu intlongfloatdoubleObject lub po jawnym rzutowaniu byte albo short.

W tym wypadku mnożymy wartość zmiennej typu long przez 2. Wynik takiego działania będzie longiem (bo mnożyliśmy longa). Zmienna result może więc być typu: longfloatdoubleObject lub po jawnym rzutowaniu byteshortint. Teraz czas na coś ciekawszego.

Mamy tutaj przeładowaną metodę test, która przyjmuje różne typy argumentów. Cała zabawa polega na tym, że metoda ta jest również wywoływana z danymi o typie dla których nie została zdefiniowana. Jak więc zadziała program? Po jego uruchomieniu otrzymamy następujące wyjście:

Dla zmiennej typu byte, została uruchomiona metoda przyjmująca argument typu byte. Dla zmiennej typu short została uruchomiona metoda przyjmująca argument typu int. W tym przypadku Java automatycznie zrzutowała dane do najbliższego możliwego typu i uruchomiła stosowną metodę. Dalej postępujemy analogicznie (zgodnie ze schematem opublikowanym wyżej). Dla utrwalenia jeszcze jeden przykład związany z prymitywami.

Efektem działania takiego programu będzie wypisanie wartości “int” (dla metody uruchamianej z intem) oraz „Object” (dla metody uruchamianej z longiem). Jeśli jesteśmy w tematyce rzutowania to nie można również zapomnieć, że ten sam mechanizm dotyczy obiektów, które sami stworzyliśmy. Popatrzmy na taki kod.

Czy zostanie on skompilowany poprawnie? To pytanie jest trochę podchwytliwe bowiem – tak. Przenalizujmy co się tutaj dzieje. Na początku na rzecz obiektu typu Object przypisujemy obiekt typu Parent. Nie ma w tym najmniejszego problemu bowiem wszystkie obiekty w Javie dziedziczą po Object. Pewien kłopot jest w linijce niżej. Tutaj znowu utworzony chwilę wcześniej Object jest rzutowany do obiektu typu Child. Czy jest to możliwe? Popatrzmy na schemat.

Najpierw klasę Parent rzutujemy na Object, a następnie na tym samym obiekcie chcemy wykonać rzutowanie do Child. To nie jest jednak możliwe. Szkopuł polega na tym, że kompilator w momencie kompilacji o tym nie wie. Kod ten zostanie więc skompilowany i uruchomiony. Dopiero w trakcie działania programu zostanie rzucony wyjątek ClassCastException. Na marginesie dodam, że gdyby zmienna object została by zrzutowana do obiektu typu Parent to wszystko by zadziałało.

Użycie super oraz this

Słówka kluczowe super oraz this są referencją do obiektu. Inicjalizowane są one przez maszynę wirtualną Javy dla każdego obiektu znajdującego się w pamięci. Słówko kluczowe this zawsze wskazuje na własną instancję obiektu, natomiast super odnosi się do pól oraz metod klasy bazowej. Popatrzmy na krótki przykład.

Powyższy fragment wykorzystuje słówko kluczowe this, do inicjalizacji pola name klasy Test.

W tym przykładzie wykorzystaliśmy słówko kluczowe super to inicjalizacji pola name z klasy bazowej.

Instrukcje this oraz super mogą zostać wykorzystywane również do uruchomienia innych konstruktorów. Na początku muszę tutaj napisać o jednej istotnej zasadzie: this oraz super musi być pierwszą instrukcją w konstruktorze i nie może być wywoływane z poziomu innych metod. W przypadku this, uruchamiamy innych konstruktor obiektu, na który wskazujemy, a w przypadku super uruchamiany jest konstruktor klasy nadrzędnej. 

Instrukcja this(“Hello”) umieszczona w konstruktorze bezargumentowym uruchomiła drugi konstruktor, znajdujący się w tej samej klasie ale przyjmujący jako argument ciąg znaków. Efektem działania powyższego programu będzie wyświetlenie napisu „Hello world!”. 

Tutaj mała niespodzianka. Kod, który zamieściłem wyżej nie zostanie skompilowany. W konstruktorze bezargumentowym klasy Chair mamy niejawnie wywołaną instrukcję super(), a klasa Furiniture nie ma zaimplementowanego konstruktora bezargumentowego. Java tworząc instancję obiektu podklasy wywołuje niejawnie z poziomu dowolnego konstruktora podklasy, instrukcję super(). Wyjątkiem od tej reguły jest zdefiniowanie własnego wywołania instrukcji super. Przypominam również, że jeżeli w klasie bazowej nie zostanie zdefiniowany żaden konstruktor, to przy wywoływaniu jawnym bądź nie, instrukcji super() nie będziemy mieli błędu. Zostanie wtedy uruchomiony konstruktor domyślny.

W tym przykładzie, mamy konstruktor w podklasie, który jak argument przyjmuje zmienną typu String. Tutaj również niejawnie będzie wywołana instrukcja super(). Zostanie więc wywołany bezargumentowy konstruktor z nadklasy.

Jak już wspominałem, brak zdefiniowanego konstruktora bezargumentowego w nadklasie nie powoduje błędu przy niejawnym wywołaniu super(). Zadziała konstruktor domyślny. 

Ten przykład zostanie uruchomiony poprawnie. Przenalizujmy krok po kroku co tu się dzieje. W konstruktorze Chair() mamy instrukcję this(1). Z racji tego, że super() musi być pierwszy (tak samo jak this()) nie ma tutaj wywołania niejawnego super() – gdyby było, otrzymalibyśmy błąd kompilacji (tak stało by się po usunięciu this(1)). Niejawne wywołanie super() mogło by pojawić się dopiero w konstruktorze Chair(String val) ale tak nie jest, ponieważ jawnie wywołaliśmy super(""). Nie powoduje to błędu kompilacji, bo taki konstruktor w klasie nadrzędnej występuje. Dodam jeszcze, że w konstruktorze Chair(String val) nie mogliśmy wstawić przykładowo this() – nie możemy zapętlać wywołań konstruktorów.

Klasy abstrakcyjne oraz interfejsy

Interfejsy

Interfejsy mogą używać słowa kluczowego extends do dziedziczenia po innych interfejsach. Może to być wiele interfejsów jednocześnie. Przykładowo dopuszczalna jest taka konstrukcja.

Oczywiście klasa, która implementuje interfejs rozszerzający inny interfejs musi implementować metody ze wszystkich tych interfejsów.

Jak widać musieliśmy zaimplementować metody z obydwu interfejsów. Co ciekawe klasa może implementować wiele interfejsów, które mają te same nazwy pól lub metod. Dochodzi wtedy do ich przysłaniania przez interfejs znajdujący się „niżej w hierarchii”.

Powyższy przykład wypisze: 1 oraz method2. Jeśli interfejsy nie dziedziczyły by po sobie to wtedy wymagana była by implementacja takiej metody bądź metod (nawet jeśli była by to klasa abstrakcyjna lub metody były by statyczne albo domyślne – zawierały ciało).

Wszystkie pola w interfejsie domyślne są typu public static final. Metody w interfejsie domyślnie są typu public abstract chyba, że zostały zdefiniowane jako statyczne lub domyślne (wtedy nie są abstrakcyjne). Jeśli w konkretnej klasie przy metodzie zdefiniowanej w interfejsie, a którą chcemy zaimplementować, nie mamy modyfikatora dostępu, metoda ta jest typu „package-private” czyli nie jest implementacją metody z interfejsu (nie zgadzają się modyfikatory dostępu).

Ten kod nie zostanie skompilowany. W klasie PersonalComputer nie implementujemy metody getName, zdefiniowanej w interfejsie. Nie zgadza się modyfikator dostępu.

Klasa abstrakcyjna może, ale nie musi implementować metod z interfejsu.

Ten kod będzie skompilowany poprawnie. Pierwszą konkretną podklasą (ang. concrete subclass) jest klasa MacBook. Klasa abstrakcyjna implementująca interfejs nie musi implementować jego metod. Musi to zrobić pierwsza konkretna podklasa, czyli klasa dziedzicząca nie będąca abstrakcyjną. Jest jedna uwaga. Jeśli już decydujemy się na implementację metod zdefiniowanych w interfejsie, to musimy pamiętać o modyfikatorach dostępu. Nie jest dopuszczone takie rozwiązanie.

Nie zgadza się tutaj modyfikator dostępu. Jest to nieoprawne nadpisanie metody z interfejsu. Dostaniemy błąd kompilacji.

Powyższy kod jest błędny. Metody statyczne (oraz domyślne) w interfejsie muszą mieć ciało. Domyślnie metody w interfejsie nie mają ciała (są abstrakcyjne). Jeśli chcemy, aby miały ciało muszą być oznaczone jako static lub default

Pewnie zastanawiasz się teraz czym się różni metoda statyczna zdefiniowana w interfejsie od metody domyślnej zdefiniowanej w interfejsie. Różnica polega na sposobie ich wywołania. Metoda statyczna interfejsu nie może być wywoływana przy użyciu zmiennej referencyjnej. Możemy ją uruchomić poprzez bezpośrednie użycie interfejsu. Metoda domyślna, chociaż nie jest implementowana w klasie (jej implementacja nie jest zabroniona) może być wywołana tak jak by w tej klasie była zdefiniowana.

Po usunięciu dwóch błędnych linijek, kod ten zostanie skompilowany poprawnie. Obie metody mogą zostać również przysłonięte (o czym pisałem wyżej).

Interfejsy nie definiują żadnych konstruktorów.

Klasa abstrakcyjna           

Dziedzicząc po klasie abstrakcyjnej należy zaimplementować jej abstrakcyjne metody (dotyczy to również metod abstrakcyjnych z klas, po których dana klasa abstrakcyjna dziedziczy). Musisz tutaj pamiętać o modyfikatorach dostępu, które w konkretnej klasie (nie będącej abstrakcyjną) nie mogą być bardziej restrykcyjne od modyfikatora tej samej metody z klasy bazowej.

Metoda type w powyższym przykładzie, może mieć modyfikator dostępu protected lub public. Nie możemy mieć modyfikatora dostępu bardziej restrykcyjnego niż protected (np. package-private albo private). Reguła ta nie dotyczy oczywiście metod przeładowanych. Klasa Table może więc zawierać metodę private void type() { }, jeśli dodatkowo zostanie zaimplementowana metoda abstrakcyjna (przyjmująca argument typu String).

Ten kod nie zadziała. Mamy tutaj dwa błędy, metoda abstrakcyjna nie może mieć ciała oraz metod abstrakcyjnych nie możemy umieszczać w klasie, która nie jest abstrakcyjna. W klasach abstrakcyjnych możemy za to umieszczać również metody, które nie są abstrakcyjne. Mogą one być normalnie dziedziczone przez inne klasy.

Nie możemy stworzyć instancji klasy abstrakcyjnej.

Klasa abstrakcyjna vs interfejs

Najczęstsze pytanie jakie pada podczas rozmów rekrutacyjnych: Czym różni się klasa abstrakcyjna od interfejsu? W Java zasady są następujące:

  1. Obie konstrukcje mogą zawierać pola typu public static final,
  2. Mogą być rozszerzane przez słowo kluczowe EXTENDS,
  3. Wymagają konkretnej podklasy do utworzenia instancji (interfejs musi być implementowany, a klasa abstrakcyjna musi być dziedziczona),
  4. W interfejsie możemy zdefiniować metody „domyślne”, w klasie abstrakcyjnej nie,
  5. W interfejsie domyślnie metody są publiczne i abstrakcyjne (w tym mogą mieć tylko modyfikator public),
  6. W interfejsie pola są publiczne, statyczne i finalne. W klasie abstrakcyjnej mamy pod tym względem dowolność,
  7. Klasa abstrakcyjna może dziedziczyć po jednej klasie, a implementować wiele interfejsów.

Podsumowanie

Uff.. To by było na tyle. Zachęcam do lektury innych artykułów z serii #ElementarzJava oraz dyskusji w sekcji komentarzy. Tymczasem zapraszam za kolejne dwa tygodnie gdzie omówię tematykę wyjątków.

Przeczytaj również

, , , , , , , , , , , , , , , , ,

Dodaj komentarz

guest
0 komentarzy
Inline Feedbacks
View all comments