2. Obiektowość Javy II.
Dziedziczenie i polimorfizm
2.1. Dziedziczenie
Podejście obiektowe umożliwia ponowne wykorzystanie już gotowych klas przy
tworzeniu klas nowych, co znacznie oszczędza pracę przy kodowaniu, a także
chroni przed błędami.
Istnieją dwa sposoby ponownego wykorzystania klas:
- kompozycja,
- dziedziczenie
Z koncepcyjnego punktu widzenia kompozycja oznacza, że "obiekt jest
zawarty w innym obiekcie" . Jest to relacja "całość – część" ( B "zawiera"
A). Np. obiekty typu Lampa zawierają obiekty typu Żarówka.
Kompozycję uzyskujemy w prosty sposób - poprzez definiowanie pól obiektowych w klasie.
Na przykład, klasa RegisteredUser (użytkownik np. jakiejś listy dyskusyjnej
lub serwisu WEB - dla uproszczenia pomijamy tu kwestie hasla i identyfikatora
użytkownika) może określać informacje o osobie (obiekt klasy Person),
jej adresie (obiekt klasy Address) i mailu (String).
public class RegisteredUser {
private Person person;
private Address address;
private String email;
public RegisteredUser (Person person, Address address, String email) {
this.person = person;
this.address = address;
this.email = email;
}
public Person getPerson() {
return person;
}
public Address getAddress() {
return address;
}
public String getEmail() {
return email;
}
// ...
}
Albo klasa opisująca półki na książki:
public class BookShelf {
private Book[] books;
// ....
}
Dziedziczenie polega na przejęciu własności i funkcjonalności innego
obiektu i ewentualnej ich modyfikacji w taki sposób, by były one bardziej
wyspecjalizowane.
Jest to relacja, nazywana generalizacją-specjalizacją: B "jest typu" A,
"B jest A", a jednocześnie B specjalizuje A. A jest generalizacją B.
Przede wszystkim jednak dziedziczenie umożliwia posługiwanie się bardzo ważną
koncepcją programowania obiektowego, jaką jest polimorfizm (o czym za chwilę).
Jest to również odzwierciedlenie rzeczywistych sytuacji.
Np.obiekty klasy samochodów przejmują wszystkie właściwości obiektów klas
pojazdów, dodatkowo dostarczając jakichś własnych specyficznych cech.
Projektując klasę samochodów (klasę Car) możemy skorzystać z gotowej klasy
Vehicle (nie musimy na nowo pisać metod, definiować pól etc). Skupiamy się
na specyficznych cechach samochodów, ich cechy jako pojazdów "w ogóle" przejmując
z klasy Vehicle.
Przyjmijmy, że wyróżniającymi cechą samochodów są:
- numer rejestracyjny
- użycie paliwa (przy braku paliwa samochód nie może ruszyć)
i
- dodajmy odpowiednie pola do klasy Car oraz odpowiedni konstruktor,
- dodajmy metodę fill() pozwalającą tankować paliwo,
- przedefiniujmy metodę start(), tak, by bez paliwa samochód nie mógł ruszyć
Przedefiniowanie metody nadklasy w klasie pochodnej oznacza dostarczenie
w klasie pochodnej definicji niestatycznej i nieprywatnej metody z taką samą sygnaturą i typem wyniku jak sygnatura i typ wyniku metody
nadklasy, ale inną definicją ciala metody (innym kodem, który jest wykonywany
przy wywołaniu metody)
W klasie Car przedefiniowano metody start() i toString() z klasy Vehicle.
Obiekt klasy Car składa się z elementów zdefiniowanych przez pola klasy Vehicle
oraz elementów zdefiniowanych przez pola klasy Car.
Wobec obiektów klasy Car możemy używać:
- wszystkich nieprywatnych (i nieprzedefiniowanych) metod klasy Vehicle,
- przedefiniowanych w klasie Car metod klasy Vehicle,,
- własnych metod klasy Car.
Pokazuje to poniższy program:
class Cars21 {
static void say(Car c) { System.out.println(c.toString()); }
public static void main(String[] args) {
Car c = new Car("WA1090",
new Person("Janek", "0909090"),
100, 100, 100, 100, 50),
d = new Car("WB7777", new Person("Zbyszek", "0909090"),
100, 100, 100, 100, 50);
c.start();
say(c);
c.fill(30);
c.start();
say(c);
d.fill(40);
d.start();
say(d);
c.stop();
say(c);
d.crash(c);
say(c);
say(d);
}
}
który wyprowadzi:
Brak benzyny
Samochód nr rej WA1090 - STOI
Samochód nr rej WA1090 - JEDZIE
Samochód nr rej WB7777 - JEDZIE
Samochód nr rej WA1090 - STOI
Samochód nr rej WA1090 - ZEPSUTY
Samochód nr rej WB7777 - ZEPSUTY
Odwołania do przesłoniętych składowych nadklasy realizowane są za pomocą konstrukcji:
super.odwołanie_do_składowej
czyli np.:
super.x // odwołanie do pola z nadklasy,
// które ma taki sam identyfikator
// jak pole w klasie
super.show() // wywołanie metody z nadklasy,
// której nazwa w danej klasie
// jest przesłonięta
// (metoda jest przedefiniowana)
|
W konstruktorze
użycie wyrażenia:
super(argumenty)
oznacza wywołanie konstruktora klasy bazowej z argumentami "argumenty".
Jeśli występuje - MUSI być pierwszą instrukcją konstruktora klasy pochodnej.
Jeśli nie występuje - przed utworzeniem obiektu klasy pochodnej zostanie wywołany konstruktor bezparametrowy klasy bazowej.
|
Przy budowaniu obiektów klas pochodnych podstawową regułą jest, iż najpierw muszą być zainicjowane pola klasy bazowej.
Sekwencja inicjowania obiektu klasy pochodnej jest następująca:
- Wywoływany jest konstruktor klasy pochodnej.
- Jeśli pierwszą instrukcją jest super(args) wykonywany jest konstruktor klasy bazowej z argumentami args.
- Jeśli nie ma super(...) wykonywany jest konstruktor bezparametrowy klasy bazowej.
- Wykonywane są instrukcje wywołanego konstruktora klasy pochodnej.
Jaki będzie wynik następującego kodu:
class A {
int a = 1;
A(int x) { a = x; }
}
class B extends A {
B() {}
}
class Test {
public static void main(String[] args) {
B b = new B();
}
}
Java ma pewne szczególne cechy w porównaniu z innymi językami obiektowymi:
- nie ma w niej wielodziedziczenia (nie można dziedziczyć wielu klas),
- a hierarchia dziedziczenia wszyskich klas zaczyna się w jednym miejscu.
W Javie każda klasa może bezpośrednio odziedziczyć tylko jedną klasę.
Ale pośrednio może mieć dowolnie wiele nadklas, co wynika z hierarchii dziedziczenia.
Ta hierarchia zawsze zaczyna się na klasie Object z pakietu java.lang.
Zatem w Javie wszystkie klasy pochodzą pośrednio od klasy Object.
Jeśli definiując klasę nie użyjemy słowa extends (nie zażądamy jawnie dziedziczenia),
to i tak nasza klasa domyślnie będzie dziedziczyć klasę Object (tak jakbyśmy
napisali class A extends Object).
2.2. Obiektowe konwersje rozszerzające
Referencje do każdego obiektu możemy przekształcić do typu referencja do jego (dowolnej) nadklasy.
Nazywa się to referencyjną konwersją rozszerzającą, albo obiektową konwersją
rozszerzającą, "upcasting" (up – bo w górę hiererachii dziedziczenia).
Obiektowe konwersje rozszerzające dokonywane są automatycznie (nie musimy stosować operatora konwersji) przy:
- przypisywaniu zmiennej-referencji odniesienia do obiektu klasy pochodnej,
- przekazywaniu argumentów metodom, gdy parametr jest typu refrencja do nadklasy argumentu ,
- zwrocie wyniku, gdy wynik podstawiamy na zmienną będącą referencją do obiektu nadklasy zwracanego wyniku.
Przykład:
W powyższym przykładzie metoda reportState() może być wywołana z dowolną
liczbą argumentów, które są referencjami do dowolnych podklas klasy Vehicle.
Dzięki obiektowym konwersjom rozszerzającym możemy pisać uniwersalne metody.
Gdyby nie było obiektowych konwersji rozszerzających musielibyśmy dostarczyć
odrębnej metody reportState() dla każdego typu pochodnego od Vehicle.
2.3. Polimorfizm. Metody wirtualne
W klasie Car przedefiniowaliśmy metodę start() z klasy Vehicle (dla samochodów
sprawdza ona czy jest paliwo by ruszyć, nie robi tego dla pojazdów "w ogóle").
Przedefiniowaliśmy też metodę toString() (dla obiektów klasy Car zwraca ona
inne napisy niż dla ogólniejszych obiektów klasy Vehicle).
Jeżeli teraz:
Car c = new Car(...); // utworzymy nowy obiekt klasy Car
Vehicle v = c; // dokonamy obiektowej konwersji rozszerzającej
to jaki będzie wynik użycia metod start() i toString() wobec obiektu oznaczanego v:
v.start();
System.out.println(v.toString());
Czy zostaną wywołane metody z klasy Vehicle (formalnie metody te są wywoływane
na rzecz obiektu klasy Vehicle) czy z klasy Car (referencja v formalnego
typu "referencja na obiekt Vehicle" faktycznie wskazuje na obiekt klasy Car)
?
Rozważmy przykład "z życia" zapisany w programie, a mianowicie schematyczną symulację wyścigu pojazdów.
Uczestnicy: rowery (obiekty klasy Rower), samochody (obiekty klasy Car), rydwany (obiekty klasy Rydwan).
Wszystkie klasy są pochodne od Vehicle.
Każda z tych klas inaczej przedefiniowuje metodę start() z klasy Vehicle
(np. Rower może w ogóle jej nie przedefiniowywać, Car – tak jak w poprzednich
przykładach, Rydwan – w jakiś inny sposób).
Sygnał do startu wszystkich pojazdów daje starter.
W programie moglibyśmy to symulować poprzez:
- uzyskanie tablicy wszystkich startujących w wyścigu pojazdów (np. getAllVehiclesToStart()),
- przebiegnięcie przez wszystkie elementy tablicy i posłanie do każdego
z obiektów, przez nie reprezentowanych, komunikatu start()
przykładowo:
Vehicle[] v = getAllVehiclesToStart();
for (int i = 0; i <v.length; i++) v[i].start();
Jeżeli nasz program ma odwzorowywać rzeczywistą sytuację wyścigu (sygnał
startera, po którym wszystkie pojazdy – jeśli mogą – ruszają), to oczywiste
jest, że – mimo, iż v[i] są formalnego typu Vehicle – powinny być wywołane
metody start() z każdej z odpowiednich podklas klasy Vehicle.
I tak jest rzeczywiście w Javie.
Ale jak to jest możliwe?
Z punktu widzenia.łączenia przez kompilator odwołań do metody (np. start())
oraz jej definicji (wykonywalnego kodu) sytuacja jest następująca:
- kompilator wie tylko, że start() jest komunikatem do obiektu typu Vehicle,
- powinien więc związać odwołanie v.start() z definicją start() z klasy Vehicle
Jakże inaczej? Przecież wartość v może zależeć od jakichś warunków występujących
w trakcie wykonania programu (nieznanych kompilatorowi).
Np. mając dwie klasy dziedziczące klasę Vehicle, Car i Rydwan możemy napisać:
public static void main(String args[]) {
Car c = new Car(...);
Rydwan r = new Rydwan(...);
Vehicle v;
if (args[0].equals("Rydwan")) v = r;
else v = c;
v.start();
}
Kompilator nie może wiedzieć jaki konkretnie jest typ obiektu wskazywanego przez v (czy Car czy Rydwan). I nie wie!
W jaki sposób zatem uzyskujemy opisany wcześniej (zgodny z życiowym doświadczeniem)
efekt, czyli np. wywołanie metody start() z klasy Car, jeśli v wskazuje
na obiekt klasy Car i wywołanie metody start() z klasy Rydwan, jeśli v wskazuje
na obiekt klasy Rydwan ?
Otóż metoda start() z klasy Vehicle jest metodą wirtualną, a dla takich metod wiązanie odwołań z kodem następuje w fazie wykonania, a nie w fazie kompilacji.
Nazywa się to "dynamic binding" lub "late binding" i technicznie realizowane jest poprzez wykorzystanie ukrytego w obiekcie pola, wskazującego na jego prawdziwy typ.
Mówi się, że odwołania do metod wirtualnych są polimorficzne, a słowo
"polimorficzne" używane jest w tym sensie, iż konkretny efekt odwołania może
przybierać różne kształty, w zależności od tego jaki jest faktyczny typ obiektu
na rzecz którego wywołano metodę wirtualną.
Istotnie, jak widzieliśmy:
v,start() raz może oznaczać start samochodu, a innym razem start rydwanu, czy roweru.
Wszystkie metody w Javie są wirtualne, za wyjątkiem:
- metod statycznych (bo przecież nie dotyczą obiektów),
- metod deklarowanych ze specyfikatorem final (co oznacza, że postać
metody jest ostateczna i nie może być ona przedefiniowana w klasie pochodnej,
a jak nie ma przedefiniowania, to niepotrzebna jest wirtualność),
- metod prywatnych (do których odwołania w innych metodach danej klasy nie są polimorficzne).
2.4. Znaczenie polimorfizmu
Rozważmy pewną hierarchię dziedziczenia, opisującą takie właściwości różnych
zwierząt jak nazwa rodzaju, sposób komunikowania się ze światem oraz imię.
Dzięki odpowiedniemu określeniu bazowej klasy Zwierz przy definiowaniu klas
pochodnych (takich jak Pies czy Kot) mamy całkiem niewiele roboty.
(uwaga:
dla ustalenia uwagi w dalszych przykładach pomijamy specyfikatory dostępu,
bowiem nie mają one znaczenia dla omaiwanych tu treści).
class Zwierz {
String name = "nieznany";
Zwierz() { }
Zwierz(String s) { name = s; }
String getTyp() { return "Jakis zwierz"; }
String getName() { return name; }
String getVoice() { return "?"; }
void speak() {
System.out.println(getTyp()+" "+getName()+" mówi "+getVoice());
}
}
class Pies extends Zwierz {
Pies() { }
Pies(String s) { super(s); }
String getTyp() { return "Pies"; }
String getVoice() { return "HAU, HAU!"; }
}
class Kot extends Zwierz {
Kot() { }
Kot(String s) { super(s); }
String getTyp() { return "Kot"; }
String getVoice() { return "Miauuuu..."; }
}
W klasie Main wypróbujemy naszą hierarchię klas zwierząt przy symulowaniu
rozmów pomiędzy poszczególnymi osobnikami. Rozmowę symuluje statyczna funkcja
animalDialog, która ma dwa argumenty – obiekty typu Zwierz, oznaczające aktualnych
"dyskutantów".
class Main {
public static void main(String[] arg) {
Zwierz z1 = new Zwierz(),
z2 = new Zwierz();
Pies pies = new Pies(),
kuba = new Pies("Kuba"),
reksio = new Pies("Reksio");
Kot kot = new Kot();
animalDialog(z1, z2);
animalDialog(kuba, reksio);
animalDialog(kuba, kot);
animalDialog(reksio, pies);
}
static void animalDialog(Zwierz z1, Zwierz z2) {
z1.speak();
z2.speak();
System.out.println("----------------------------------------");
}
}
Uruchomienie tej aplikacji da następujący wynik:
Jakis zwierz nieznany mówi ?
Jakis zwierz nieznany mówi ?
----------------------------------------
Pies Kuba mówi HAU, HAU!
Pies Reksio mówi HAU, HAU!
----------------------------------------
Pies Kuba mówi HAU, HAU!
Kot nieznany mówi Miauuuu...
----------------------------------------
Pies Reksio mówi HAU, HAU!
Pies nieznany mówi HAU, HAU!
----------------------------------------
Cóż jest ciekawego w tym przykładzie? Otóż dzięki wirtualności metod getTyp()
i getVoice() metoda speak(), określona w klasie Zwierz prawidłowo działa
dla różnych zwierząt (obiektów podklas klasy Zwierz).
Jest to nie tylko ciekawe, ale i wygodne: jedna definicja metody speak()
załatwiła nam wszystkie potrzeby (dotyczące dialogów różnych zwerząt). Co
więcej – będzie ona tak samo użyteczna dla każdej nowej podklasy Zwierza,
którą kiedykolwiek w przyszłości wprowadzimy!
2.5. Metody i klasy abstrakcyjne
Metoda abstrakcyjna nie ma implementacji (ciała) i winna być zadeklarowana ze specyfikatorem abstract.
abstract int getSomething(); // nie ma ciała - tylko średnik
Klasa w której zadeklarowano jakąkolwiek metodę abstrakcyjną jest klasą abstrakcyjną i musi być opatrzona specyfikatora abstract.
Np.
abstract class SomeClass {
int n;
abstract int getSomething();
void say() { System.out.println("Coś tam");
}
Po co są metody abstrakcyjne?
Metody abstrakcyjne to takie, co do których nie wiemy jeszcze jaka może być
ich konkretna implementacja (lub nie chcemy tego przesądzać), ale wiemy,
że powinny wystąpić w zestawie metod każdej konkretnej klasy dziedziczącej klasę abstrakcyjną.
Konkretna implementacja może być bardzo różna, w zależności od konkretnego rodzaju obiektów, które opisuje dana klaas.
Klasa abstrakcyjna nie musi mieć metod abstrakcyjnych.
Wystarczy zadeklarować ją ze specyfikatorem abstract.
Abstrakcyjność klasy oznacza, iż nie można tworzy jej egzemplarzy (obiektów).
Moglibyśmy więc zadeklarować klasę Zwierz ze specyfikatorem abstract:
abstract class Zwierz {
String name = "nieznany";
Zwierz() { }
Zwierz(String s) { name = s; }
String getTyp() { return "Jakis zwierz"; }
String getName() { return name; }
String getVoice() { return "?"; }
void speak() {
System.out.println(getTyp()+" "+getName()+" mówi "+getVoice());
}
}
powiadając w ten sposób:
nie chcemy bezpośrednio tworzyć obiektów klasy Zwierz.
Cóż to jest Zwierz?
To dla nas jest - być może - czysta abstrakcja.
Abstrakcyjna klasa Zwierz może być natomiast dziedziczona przez klasy konkretne
np. Pies czy Kot. albo może Tygrys, co daje im już pewne zagwarantowane cechy
i funkcjonalność.
Dopiero z tymi konkretnymi typami zwierząt możemy się jakoś obchodzić, a
zestaw metod wprowadzonych w klasie Zwierza daje nam po temu ustalone środki.
Skoro Zwierz jest abstrakcyjny, to zestaw jego metod (tu: do jakiegoś stopnia) może być też abstrakcyjny:
abstract class Zwierz {
String name;
Zwierz() { name = "nieznany" }
Zwierz(String s) { name = s; }
abstract String getTyp();
abstract String getVoice();
String getName() { return name; }
void speak() {
System.out.println(getTyp()+" "+getName()+" mówi "+getVoice());
}
}
Metody getTyp() i getVoice() są abstrakcyjne (nie dostarczyliśmy ich implementacji, bowiem zależy ona od konkretnego Zwierza).
Więcej są - jak domyślnie wszystkie metody w Javie - wirtualne.
Wirtualne - znaczy o możliwych różnych definicjach przy konkretyzacji.
Wirtualne - o nieznanym (jeszcze) dokładnie sposobie działania.
Wirtualne - niekoniecznie już istniejące.
W tym kontekście metoda speak() staje się jeszcze ciekawsza/.
Oto używamy w niej nieistniejących jeszcze metod!
Możemy się odwoływać do czegoś co być może powstanie dopiero w przyszłości.
Co może mieć wiele różnorodnych konkretnych kształtów, teraz nam jeszcze nie znanych.
Konkretyzacje następują w klasach pochodnych, gdzie implementujemy (definiujemy) abstrakcyjne metody getTyp i getVoice.
Klasa dziedzicząca klasę abstrakcyjną musi zdefiniować wszystkie abstrakcyjne
metody tej klasy, albo sama będzie klasą abstrakcyjną i wtedy jej definicja
musi być opatrzona specyfikatorem abstract.
Zatem po to, byc móc tworzyć i posługiwać się obiektami klas Pies i Kot musimy
zdefiniować w tych klasach abstrakcyjne metody klasy Zwierz.
class Pies extends Zwierz {
Pies() { }
Pies(String s) { super(s); }
String getTyp() { return "Pies"; }
String getVoice() { return "HAU, HAU!"; }
}
class Kot extends Zwierz {
Kot() { }
Kot(String s) { super(s); }
String getTyp() { return "Kot"; }
String getVoice() { return "Miauuuu..."; }
}
Możliwość deklarowania metod abstrakcyjnych można też traktować jako pewne
pragmatyczne ulatwienie. Nie musimy oto wymyślać (i zapisywać) sztucznej
funkcjanalności, sztucznego działania na zbyt abstrakcyjnym poziomie (jak
np. return "jakiś zwierz" czy return "?".).