4. Zaawansowane programowanie obiektowe
Zapoznamy się tu z niektórymi zagadnieniami
zaawansowanego programowania obiektowego w Javie. Niebanalnym problemem
okazuje się sposób przedefiniowania niektórych metod.
Zobaczymy to na przykładzie metody equals(). Rozważymy także zalety
kompozycji w porównaniu z dziedziczeniem, a także sposoby
uzyskiwania polimorficznych właściwości odwołań przy kompozycji. Na
uwagę zasługują także kwestie związane z kopiowaniem obiektów i
ich niezmiennością.
Wysłuchaj omówienia treści wykładu.
1. Kłopoty z przedefiniowaniem metody equals()
Przedefiniowanie metody equals() ma sens wtedy, gdy można porównywać "treści"
obiektów "na równość". Tworząc uniwersalne klasy (klasy ponownego użytku)
powinniśmy zawsze rozważyć przedefiniowanie metody equals() i dokonać tego,
jeśli tylko porównywanie obiektów "na równość" ma sens, nawet gdy aktualnie
nie jest to nam potrzebne. Nasza klasa może przecież być użyta w innych programach,
w których stwierdzanie czy jakieś jej obiekty są sobie znaczeniowo równe
będzie istotne.
Z przedefiniowania metody equals możemy jednak zrezygnować
wtedy, gdy ew. ją definicję w klasie dziedziczonej przez naszą klasę
uznamy za całkowicie wystarczające, Zazwyczaj jednak będziemy metodę equals() w jakiś sposób w naszych klasach definiować
Częstym błędem jest definiowanie metody equals z parametrem konkretnej klasy
np. Car czy Pies. Taka definicja jest w stosunku do definicji z klasy Object
przeciążona, a nie przedefiniowana, co - w sytuacji konwersji do Object (częstej
w przypadku tablic obiektowych lub kolekcji) - uniemożliwia polimorficzne
odwołania.
Innym błędem jest nieprzestrzeganie ogólnych reguł definiowania metody equals.
Reguły te są następujące:
- metoda zwraca wynik true, jeśli obiekty są takie same lub false w przeciwnym razie,
- obiekty różnych klas nie są takie same,
- jeżeli referencja przekazana jako argument jest równa null, to obiekty nie są takie same,
- metoda equals nie powinna sygnalizować żadnego wyjątku, chyba, że porównanie
obiektów danej klasy "na równość" jest niedopuszczalne, co sygnalizujemy
za pomocą wyjątku UnsupportedOperationException.
Dla spełnienia tych ogólnych wymagań wystarczy zdefiniować metodę equals w następujący sposób:
class Klasa {
public boolean equals(Object o) {
if ( !(o instanceof Klasa) ) return false;
Klasa obiektPorównywany = (Klasa) o;
// kod stwierdzający czy ten obiekt (this) jest taki sam jak przekazany
// jeśli tak - zwracamy true
// jeśli nie - zwracamy false
}
}
Zwróćmy uwagę:
- jeśli o nie jest obiektem tej klasy w której zdefiniowano
metodę equals albo jest to null - wynikiem operatora instanceof jest false
i obiekty nie są równe,
- aby porównać obiekty (zazwyczaj) trzeba będzie użyć własnych metod
klasy Klasa lub odwołać się do jej pól - stąd konieczność konwersji zawężającej
z formalnego typu Object do typu Klasa, przy czym możliwość przeprowadzenia
tej konwersji jest zagwarantowana, gdyż wcześniej stwierdziliśmy już, że o jest typu Klasa.
Pokazany szablon jest bardzo ogólny i nie mówi nic o tym jak należy pisać
kod stwierdzający czy obiekty są równe. Wbrew pozorom okazuje się często,
że nie jest to zadanie trywialne, szczególnie w sytuacji gdy nasza klasa
dziedziczy faktyczną klasę parametru metody equals.
Idzie głównie o to, że equals wprowadza relacją równoważności, która - oczywiście - musi być m.in.:
- symetryczna: x.equals(y) == y.equals(x)
- przechodnia: x.equals(y) && y.equals(z) => x.equals(z)
Rozważmy przykład: niech klasa Worker (robotnik) dziedziczy klasę Person (osoba).
Klasa Person ma jeden atrybut - powiedzmy nazwisko/imię (name), klasa Worker dodaje swój atrybut - pensję (salary).
class Person {
private String name;
public Person(String s) {
name = s;
}
// ...
}
class Worker extends Person {
private int salary;
public Worker(String name, int sal) {
super(name);
salary = sal;
}
// ...
}
W klasie Person łatwo jest zdefiniować metodę equals:
public boolean equals(Object o) {
if (!(o instanceof Person)) return false;
Person p1 = (Person) o;
return name.equals(p1.name);
}
W klasie Worker możemy zrobić to na kilka sposobów. Wydawałoby się, że np.
taki sposób jest całkiem naturalny i spełnia kontrakt dla metody equals:
public boolean equals(Object o) {
if (!(o instanceof Worker)) return false;
Worker w = (Worker) o;
return super.equals(o) && salary == w.salary;
}
Przetestujmy to. Poniższy fragment kodu:
Person p = new Person("Jan");
Person p1 = new Person("Jan");
Worker w = new Worker("Jan", 1000);
Worker w1 = new Worker("Jan", 1000);
System.out.println("p i p1 oper ==: " + (p == p1) + ", equals: " + p.equals(p1));
System.out.println("w i w1 oper ==: " + (w == w1) + ", equals: " + w.equals(w1));
System.out.println("p i w equals: " + p.equals(w));
System.out.println("w i p equals: " + w.equals(p));
wyprowadzi na konsolę:
p i p1 oper ==: false, equals: true
w i w1 oper ==: false, equals: true
p i w equals: true
w i p equals: false
Okazuje się, że przy porównywaniu osób między sobą oraz osób-robotników
między sobą wszystko jest w porządku. Ale gdy porównujemy osobę-Jana i robotnika-Jana
(co możemy spokojnie robić, bo sygnatura metody na to nam pozwala) metoda
equals nie spelnia warunku symetryczności: p.equals(w) daje inny wynik niż
w.equals(p). Dzieje się tak dlatego, że w pierwszym przypadku nie brana jest
pod uwagę pensja, zatem Jan bez pensji (nie będący robotnikiem) jest tym
samym co Jan-robotnik (z pensją 1000). W drugim przypadku uzyskujemy false,
bowiem obiekt-argument nie należy do klasy Worker,
Spróbujmy więc inaczej zdefiniować metodę equals w klasie Worker. Pomysł
na zachowanie symetryczności relacji równoważności może być następujący:
jeżeli przekazany argument nie jest typu Worker - to pomijamy pensję i porównujemy
obiekty tak jak by oba były "tylko" osobami, w przeciwnym razie porównujemy
również pensję.
public boolean equals(Object o) {
if (!(o instanceof Person)) return false;
if (!(o instanceof Worker)) return super.equals(o);
Worker w = (Worker) o;
return super.equals(o) && salary == w.salary;
}
Poprzedni program wyprowadzi teraz dobre wyniki dla jednolitych porównań
(dwóch osób lub dwóch osób-robotników) oraz wynik symetryczny dla porównań
"krzyżowych" (osoba - robotnik, robotnik-osoba):
p i p1 oper ==: false, equals: true
w i w1 oper ==: false, equals: true
p i w equals: true
w i p equals: true
Czy jednak są to dobre wyniki? Czy osoba-Jan to taki sam (logicznie) obiekt
co robotnik-Jan z pensją 1000? Wydawałoby się, że to raczej wątpliwe rozumowanie,
ale przecież taki jest sens dziedziczenia: kiedy robotnika-Jana porównujemy
z osobą-Janem musimy brać pod uwagę tylko wspólne cechy - to, że oba obiekty
reprezentują osoby (w szczególności tę samą osobę).
Niestety, łatwo się przekonać, że teraz nowe equals nie spełnia warunku przechodniości.
Potwierdza to następujący fragment:
Person p = new Person("Jan");
Worker w = new Worker("Jan", 1000);
Worker w1 = new Worker("Jan", 2000);
System.out.println("p i w equals: " + p.equals(w));
System.out.println("p i w1 equals: " + p.equals(w1));
System.out.println("w i w1 equals: " + w.equals(w1));
który wyprowadzi wyniki jawnie naruszające regułę przechodniości:
p i w equals: true
p i w1 equals: true
w i w1 equals: false
Czy możemy coś z tym zrobić? Niestety, nie.
Okazuje się, że w sytuacji, gdy dziedziczymy jakąś klasę i klasa pochodna
dodaje swoje własne, nowe atrybuty (pola), które są ważne dla wyniku porównania - nie ma żadnego sposobu, aby
zachować przechodniość metody equals.
Czy musimy się tym martwić? Na pewno tak, bo to nie jest tylko kwestia jakichś
wyszukanych wymagań teoretycznych. Być może w obecnej Javie główne znaczenie
metody equals polega na tym, że stanowi ona kryterium umieszczania elementów
w pewnych strukturach danych (kolekcjach), które nie dopuszczają powtarzających
się elementów . W tym kontekście naruszeni\e
symetryczności i/lub przechodniości metody equals może prowadzić do zależnego
od kolejności dodawania elementów stanu kolekcji, co oczywiście jest niepożądane.
Istnieją trzy wyjścia z tej sytuacji (ale każde ma swoje wady).
Pierwsze - na pewno niedobre, ale za to dość pragmatyczne - nie martwić
się brakiem przechodniości operacji equals. Wymaga ono jednak pełnej świadomości
tego jak działa nasze "nieprzechodnie" equals i praktycznie nadaje się do
zastosowania tylko pod warunkiem, że tworzone przez nas klasy będą stosowane
wyłącznie do budowy jakiejś konkretnej aplikacji, i nie będą stanowić części
ogólniejszego, szerzej dostępnego API. Niestety nigdy nie mamy gwarancji,
że oba te warunki będą spełnione w jakimś choć trochę dłuższym okresie.
Drugim rozwiązaniem jest zapewnienie, by metoda equals mogła porównywać wyłącznie obiekty
ściśle tych samych klas, inaczej mówiąc, by wymaganie, że "obiekty różnych
klas są różne" było spełnione w bardzo ścisłym tego słowa znaczeniu. W omawianym,
standardowym szablonie metody equals() do sprawdzenia przynależności do klasy
stosowany jest operator instanceof. Zwraca on wartość true, jeśli jego lewy
operand reprezentuje obiekt, który należy do klasy podanej jako operand prawy
lub dowolnej jej nadklasy.
Zatem w metodzie equals klasy Person argumentem może być obiekt dowolnej
podklasy klasy Person i obiekt ten zostanie uznany za obiekt klasy Person
(stąd wynikała niesymetryczność operacji equals).
Można jednak naprawdę sprawdzić do jakiej konkretnie klasy należy obiekt.
Służy do tego metoda getClass(), zwracająca klasę obiektu ma rzecz którego
została wywołana. Dla obiektu p klasy Person getClass() zwróci obiekt oznaczający
klasę Person, a dla obiektu w klasy Worker - obiekt oznaczający klasę Worker,
Te dwa obiekty nie będą tożsame (w przeciwieństwie: p instanceof Person zwróci true i w instanceof Person zwróci true)
Moglibyśmy zatem zdefiniować metody equals w następujący sposób:
// W klasie Person
public String getName() {
return name;
}
public boolean equals(Object o) {
if (o == null) return false;
if (getClass() != o.getClass()) return false;
Person p1 = (Person) o;
return name.equals(p1.name);
}
// W klasie Worker
public boolean equals(Object o) {
if (o == null) return false;
if (getClass() != o.getClass()) return false;
Worker w = (Worker) o;
return getName().equals(w.getName()) && salary == w.salary;
}
Te definicje spełniają kontrakt metody equals (m.in. zapewniają symetryczność
i przechodniość), ale bardzo utrudniają stwierdzenie czy np. dwa obiekty odzwierciedlające
dwóch robotników (powiedzmy elektronika i malarza - z różnymi pensjami) nie
reprezentują przypadkiem tej samej osoby ("Jana"), występującej w dwóch rolach
(zatrudnionej na dwa etaty). A także stwierdzenie - czy "Jan" jako obiekt
klasy Person żyjący w jakimś programie symulacyjnym, powiedzmy przed tygodniem
czasu programu, nie jest dziś tym samym co "Jan" - nowy obiekt klasy Worker
- czyli tym samym "Janem", tyle, że teraz, od dziś, zatrudnionym jako
robotnik. Obie metody equals() wołane dla obiektu "Jan" klasy Person i "Jan"
klasy Worker stwierdzą, że obiekty nie są takie same, bo należą do różnych
klas. W pewnym sensie pozbyliśmy się więc tu zalet polimorfizmu ("treściowo"
prezentowane metody equals nie korzystają z różnorodności swoich argumentów,
raczej tę różnorodność "ucinają").
A z drugiej strony - polimorficzne odwołania do equals - przeszkadzają! Przychodzi
bowiem do głowy następujący pomysł: jeśli chcemy porównać Jana-robotnika
z Janem-osobą, dostarczmy w klasie Worker metody zwracającej referencję do
obiektu klasy Worker jako referencję do obiektu klasy Person:
Person asPerson() { return (Person) this; }
i porównujmy tak:
Person p = new Person("Jan");
Worker w = new Worker("Jan", 1000);
p.equals(w.asPerson())
w.asPerson().equals(p);
Nie uzyskamy pożądanego efektu (wartości true w porównaniach), bowiem metoda
getClass() wołana jest polimorficznie i dla obiektu klasy Worker (nawet jeśli
odwołujemy się do niego za pomocą referencji typu Person) zawsze zwróci klasę
Worker.
Pozostaje nam więc tylko w klasie Person dostarczyć nowej metody, porównującej
"tylko" osoby, np. equalsAsPerson(Person). Klasy pochodne odziedziczą tę
metodę i będą mogły ją spokojnie stosować:
public boolean equalsAsPerson(Object o) {
if (!(o instanceof Person)) return false;
Person p1 = (Person) o;
return name.equals(p1.name);
}
Teraz porównanie "Janów" jako osób będziemy mogli przeprowadzć w naszych programach za pomocą tej metody.
To jednak jest znowu tylko "lokalne" rozwiązanie: jakieś ogólne algorytmy,
zewnętrzne wobec naszego programu (np. działające na kolekcjach), będą niekiedy
wykorzystywać metodę equals i nie będą umiały stwierdzić, że "Jan" z klasy
Person jest tym samym co "Jan" z klasy Worker.
Trzecim możliwym rozwiązaniem problemu jest zastosowanie kompozycji zamiast dziedziczenia.
Klasa Worker będzie wykorzystywać klasę Person przez kompozycję. W obu klasach
wrócimy do standardowej wersji metody equals, a w klasie Worker - dodatkowo
- dostarczymy metody zwracającej widok na obiekt tej klasy jako na Person,
class Person {
private String name;
public Person(String s) {
name = s;
}
public boolean equals(Object o) {
if (!(o instanceof Person)) return false;
Person p1 = (Person) o;
return name.equals(p1.name);
}
}
class Worker {
private Person person; // kompozycja
private int salary;
public Worker(Person p, int sal) {
person = p;
salary = sal;
}
public Person asPerson() { // zwraca widok na obiekt jako Person
return person;
}
public boolean equals(Object o) {
if (!(o instanceof Worker)) return false;
Worker w = (Worker) o;
return person.equals(w.person) && salary == w.salary;
}
}
Teraz equals spełnia swój kontrakt i zachowuje wszystkie właściwości relacji
rónoważności, a jednocześnie - stosując metodę asPerson() z klasy Worker możemy
porównywać jej obiekty traktowane jako obiekty klasy Person z obiektami klasy Person.
Jest to niewątpliwie rozwiązanie najlepsze, jednak - ogólnie - powoduje utratę
zalet dziedziczenia (w tym przykładzie tego nie widać, ale gdyby klasy były
bardziej rozbudowane, albo inne, to niewątpliwie moglibyśmy trochę żałować
polimorfizmu).
Zastanówmy się też czy opisane kłopoty nie wynikają z mnożenia bytów
ponad miarę? Może nie powinniśmy mieć w programie jednocześnie "Jana" jako
obiektu klasy Person i "Jana" jako obiektu klasy Worker. "Jan" - jako osoba
- jest przecież jeden. Pokazany sposób kompozycji zaprzecza tej obserwacji.
Dziedziczenie byłoby więc tu bardziej naturalne: klasę Person należałoby
uczynić abstrakcyjną i operować tylko na obiektach konkretnych jej podklas,
takich jak Worker czy Teacher. Ale to nie usunęłoby problemu: w szczególności
musielibyśmy umieć porównywać czy robotnik i nauczyciel nie jest przypadkiem
tym samym Janem w dwóch różnych rolach. Jak widzieliśmy takie porównanie
jest sensownie możliwe tylko przy kompozycji, a ta z kolei łamie naturalny
porządek, że i Jan-robotnik i Jan-nauczyciel jest osobą (jest typu Person).
Jednak nawet jeśli uznać, że dziedziczenie klasy Person w klasach Worker i Teacher nie jest najszczęśliwszym
pomysłem i że kompozycja dobrze opisuje sytuację (bycie osobą jest aspektem
robotnika, nauczyciela, pacjenta, ucznia etc), to powinniśmy zabronić możliwości
niezależnego od ról jakie spełniają osoby tworzenia obiektów klasy Person.
A to w Javie nie będzie łatwe. Klasa Person nie może być abstrakcyjna (bo
musimy utworzyć jej obiekt wkomponowany w klasy-role). Z kolei dostarczenie
- zamiast publicznego - chronionego (protected) konstruktora w klasie Person
nie ogranicza możliwości jego użycia do podklas, bowiem składowe chronione
są dostępne również z innych klas pakietu, niekoniecznie dziedziczących
daną klasę. Dotyczy to zresztą wszelkich innych możliwych sposobów inicjacji
pól klasy Person z poziomu podklas. Zabronienie istnienia abstrakcyjnej "osoby-w-ogóle"
w przypadku kompozycji nie jest możliwe.
Kompozycja ma więc także swoje wady.
Zastanawiając się nad wyborem sposobu ponownego wykorzystania klas musimy jednak pamiętać, że problem z nieprzechodniością
relacji wyznaczanej przez equals w sytuacji dziedziczenia, rozszerzającego
zakres pól klasy o atrybuty ważne dla porównań jest ogólny i może wystąpić nawet wtedy, gdy
zastosowanie dziedziczenia jest nie budzącym
żadnych wątpliwości,i ze wszech miar uzasadnionym i pożądanym, rozwiązaniem .
Kończąc omawianie metody equals należy jeszcze przypomnieć, że
równolegle
z przedefiniowaniem metody equals należy również - w
sposób zgodny z metodą
equals - przedefiniować metodę hashCode, a także metodę compareTo(), o
ile klasy będą implementować interfejs Comparable.
2. Kompozycja a dziedziczenie
Można odnieść wrażenie, że dziedziczenie
jest znacznie potężniejszym (dającym większe możliwości) sposobem ponownego
wykorzystania klas niż kompozycja. I że - choć bardziej skomplikowane od kompozycji - daje
programiście większą elastyczność i łatwość tworzenia i wykorzystania kodów
"ponownego użytku".
Niewątpliwie wrażenie to nie jest mylne.
Ale z dziedziczeniem związane są pewne problemy.
Na jeden z nich natknęliśmy się przed chwilą przy próbie właściwego zdefiniowania metody equals().
Innym jest tzw. "słaba hermetyzacja" kodu klasy bazowej.
Rozpatrzmy przykład. Mamy oto ogólną klasę kontenerów na butelki. I chcemy
mieć różne specyficzne klasy takich kontenerów (np. skrzynki piwa lub Coca-Coli,
albo może lodówki sklepowe etc).
Oczywiście, skrzynka piwa jest kontenerem na butelki, zatem naturalne
jest tu dziedziczenie (klasa BeerBox odziedziczy BottleContainer) i wykorzystanie
w innej klasie (nazwanej dalej Inhe1):
class BottleContainer {
private int bottlesCount;
public BottleContainer(int n) {
bottlesCount = n;
}
public int getCount() {
return bottlesCount;
}
public String toString() {
return "BottleContainer, bottles = " + bottlesCount;
}
}
class BeerBox extends BottleContainer {
public BeerBox(int n) { super(n); }
public String toString() {
return "BeerBox, bottles = " + getCount();
}
}
class Inhe1 {
public static void main(String[] args) {
BeerBox bb = new BeerBox(10);
int n = bb.getCount();
//...
}
}
Tu założyliśmy, że ogólnie kontenery mogą zawierać tylko pełne butelki (stąd typ int w klasie BottleContainer).
Może kiedyś zmienimy interfejs klasy bazowej w taki sposób, że metoda
getCount() będzie miała inny typ wyniku - double (niepełne butelki).
class BottleContainer {
private double bottlesCount;
public BottleContainer(double n) {
bottlesCount = n;
}
public double getCount() {
return bottlesCount;
}
}
W tym przykładzie klasa BeerBox nie będzie wymagała żadnych zmian, ale w
klasie Inhe1 wykorzystującej BeerBox wystąpią błędy w kompilacji (bo spodziewanym
typem wyniku jest int, a nie double i trzeba by użyć konwersji zawężającej, aby to się skompilowało).
Mówi się, że klasa BeerBox "słabo hermetyzuje" kod klasy BottleContainer,
gdyż zmiana interfejsu tej klasy powoduje, iż ponowne wykorzystanie hierarchii
dziedziczenia w innych klasach staje się niemożliwe. Co gorsza, po takiej ew. zmianie, wadliwy staje
się kod klas już skompilowanych i być może dzialających od długiego czasu.
Okazuje się, że kompozycja pozwala uniknąć takich problemów. W naszym "butelkowym" przykładzie
zastosowanie kompozycji może wyglądać tak.
class BottleContainer {
private int bottlesCount;
public BottleContainer(int n) {
bottlesCount = n;
}
public double getCount() {
return bottlesCount;
}
}
class BeerBox {
// wykorzystanie klasy BottleContainer przez kompozycję
private BottleContainer cont;
public BeerBox(int n) {
cont = new BottleContainer(n);
}
public int getCount() {
return cont.getCount();
}
}
class Compos1 {
public static void main(String[] args) {
BeerBox bb = new BeerBox(10);
int n = bb.getCount();
// ...
}
}
Mamy tu dwie klasy, które tworzą "zestaw ponownego użycia":
klasę BottleContainer i BeerBox. Przez inne klasy (np. Compos1) bezpośrednio
używana jest klasa BeerBox. Klasa BottleContainer używana jest pośrednio.
I teraz, jeśli - podobnie jak w przykładzie z dziedziczeniem - w klasie
BottleContainer zmienimy interfejs (czyli np. zmienimy typ zwracanego przez
metodę getCount() wyniku na double), to - będziemy musieli zmienić coś w
implementacji klas bezpośrednio wykorzystywanych przez inne klasy (tu: BeerBox),
ale ich interfejsy pozostaną bez zmian i w związku z tym nie trzeba będzie
nic zmieniać w klasach wykorzystujących nasz zestaw klas pojemników na butelki:
class BottleContainer {
private double bottlesCount;
// ...
public double getCount() {
return bottlesCount;
}
}
class BeerBox {
private BottleContainer cont;
// ...
public int getCount() {
return (int) cont.getCount(); // drobna zmiana implementacji
}
}
// nie wymaga zmian po zmianie interfejsu klasy BottleContainer
class Compos1 {
public static void main(String[] args) {
BeerBox bb = new BeerBox(10);
int n = bb.getCount();
}
Jest to sytuacja korzystniejsza w stosunku do "słabej hermetyzacji" kodu
przy dziedziczeniu, bowiem - w końcu - zwykle klasy takie jak BottleContainer
i BeerBox są kontrolowane przez twórców jakiegoś API, zaś użytkownicy API
zwykle korzystają z klas bezpośredniego ponownego użycia (w naszym przykładzie BeerBox).
Oczywiście, przy dziedziczeniu łatwiejsze niż przy kompozycji jest dodawanie
nowych klas jakiegoś API (w naszym przykładzie np. lodówek, półek z wodą
mineralną, pojemników z Colą itp.). Łatwiejsze i bardziej naturalne jest
też zastosowanie polimorfizmu.
No, właśnie, czy kompozycja w ogóle umożliwia wykorzystanie odwołań polimorficznych?
Na pierwszy rzut oka - nie bardzo.
3. Polimorfizm kompozycji - zastosowanie interfejsów.
Umiejętne zastosowanie interfejsów
pozwala korzystać z dobrodziejstw polimorfizmu nawet przy kompozycji!
Dla przykładu: wprowadzając interfejs Stringable z metodą String asString()
i implementując go w klasach skrzynek piwa (BeerBox) i lodówek (Frige) uczynimy
referencje do obiektów tych klas typu Stringable i będziemy mogli używać
polimorficznego odwołania do metody asString np. w poniższej metodzie getInfo(Stringable).
interface Stringable {
public String asString();
}
class BottleContainer {
// ...
}
class BeerBox implements Stringable {
private BottleContainer cont;
public BeerBox(int n) {
cont = new BottleContainer(n);
}
public int getCount() {
return cont.getCount();
}
public String asString() {
return "BeerBox, bottles = " + getCount();
}
}
class Frige implements Stringable {
private BottleContainer cont;
public Frige(int n) {
cont = new BottleContainer(n);
}
public int getCount() {
return cont.getCount();
}
public String asString() {
return "Frige, bottles = " + getCount();
}
}
class Compos1 {
static String getInfo(Stringable s) {
return s.asString();
}
public static void main(String[] args) {
BeerBox bb = new BeerBox(10);
Frige fr = new Frige(20);
System.out.println(getInfo(bb));
System.out.println(getInfo(fr));
}
}
4. Reguły ponownego wykorzystania klas
Po pierwsze, należy starać się dostosowywać sposób
ponownego wykorzystania
do dziedziny problemu. Dziedziczenia używamy wtedy, kiedy spełniona
jest
relacja "B jest A", ale czasem okazuje się, że taka relacja jest
pozorna lub może ulegać zmianom w cyklu życiowym obiektów.
Dobrym testem jest postawienie pytania czy zawsze, w każdych
okolicznościach
działania naszego programu można sensownie myśleć o rozszerzającej
konwersji
referencyjnej typu podklasy do typu nadklasy.
Po drugie, z wyborem sposobu wykorzystania klas związane są kwestie efektywnościowe:
kompozycja okazuje się niekiedy bardziej pracochłonna i wolniejsza przy wykonaniu
programu, ale z kolei w niektórych okolicznościach pozwala oszczędzać pamięć
(tworzenie obiektu, definiowanego w ramach kompozycji jako pole klasy może
być odroczone w czasie do momentu kiedy będzie on potrzebny, co w danym przebiegu
programu może nie nastąpić nigdy; przy dziedziczeniu zawsze wydzielana jest
pamięć dla pól nadklasy).
Po trzecie, wreszcie nie należy nadużywać ani dziedziczenia ani kompozycji.
Jak widzieliśmy dziedziczenie ma swoje wady, związane przede wszystkim ze
"słabą hermetyzacją" (a także "kruchością" klasy bazowej - niekiedy zmiana
jej interfejsu powoduje konieczność zmian w interfejsach wielu klas dziedziczących,
np. jeśli zmienimy typ zwracanego wyniku w metodzie, która w podklasach jest
przedefiniowywana). Z kolei kompozycja - która pozwala unikać takich problemów
- jest często mniej naturalna i w związku z tym kody klas są trudniejsze
do "prowadzenia" (uzupełnień, modyfikacji), a rozbudowa API o nowe klasy dużo trudniejsza niż przy zastosowaniu dziedziczenia.
5. Kopie obiektów
Jak wiemy, w Javie przy pracy z obiektami posługujemy się referencjami. Zatem, jeśli mamy obiekt klasy A:
A a = new A();
to deklaracja:
A a1 = a;
nie stworzy nowego obiektu, a jedynie nową referencję, za pomocą której będziemy
się mogli odwoływać do tego samego obiektu, co oznaczany przez referencję
a.
Czasami jednak będziemy potrzebować kopii, innego, nowego obiektu (powiedzmy
klasy A), który ma te same elementy, o takich samych wartościach co oryginał.
U początków Javy wprowadzono mechanizm klonowania obiektów.
Polega on na tym, że w klasie Object zdefiniowano zabezpieczoną metodę clone(),
która zwraca nowy obiekt ze skopiowanymi polami oryginału.
To kopiowanie jest płytkie: pola oryginału, które oznaczają referencje
do obiektów, kopiowane są jako referencje (nie ma przy tym tworzenia kopii
obiektów na które te referencje wskazują).
Często takie zachowanie nie będzie
odpowiednie (bo kopia nie będzie niezależna, będzie miała wspólne części danych z
oryginałem). Dlatego metodę clone() "przeznaczono" do przedefiniowania.
Jednocześnie
jednak, ponieważ nie wiadomo co tak naprawdę ma oznaczać klonowanie w konkretnych
przypadkach i czy w ogóle klonowanie (takie czy inne) dla obiektów jakiejś
klasy jest dopuszczalne, wprowadzono interfejs Cloneable, którego implementacja
w klasie daje sygnał, że klonowanie jest dopuszczalne.
Interfejs nie ma żadnych
metod, jest tylko znacznikiem, który mówi - "obiekty tej klasy mogą być klonowane".
Jeśli klasa nie implementuje tego interfejsu, to użycie metody clone() (zdefiniowanej
w klasie Object, albo też przedefiniowanej w danej klasie) powoduje powstanie
wyjątku CloneNotSupportedException. Wyjątek ten jest zgłaszany przez clone()
z klasy Object i wymaga obsługi, niezależnie od tego czy implementujemy interfejs
Cloneable czy też nie.
Zobaczmy przykłady.
class A implements Cloneable {
private int a = 3;
int get() { return a; }
public Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException exc) {
throw new Error("Dziękujemy za klonowanie!");
}
}
}
class Cloning1 {
public static void main(String[] args) {
A a = new A();
A a1 = a;
A a2= (A) a.clone();
System.out.println(a.get() + " " + a1.get() + " " + a2.get());
System.out.println("a==a1: " + (a==a1) + " a==a2: " + (a==a2));
}
}
W programie tym zapewniamy klonowanie obiektów klasy A zgodnie z wymaganiami
specyfikacji metody clone(), Całkiem wystarczające przy tym jest wywołanie
metody clone z nadklasy (w tym przypadku Object). Widzimy, że po klonowaniu
mamy dwa różne obiekty o tej samej zawartości.
3 3 3
a==a1: true a==a2: false
Już w tym przykładzie jednak natykamy się na trochę nonsensowną konieczność
obsługi wyjątku CloneNotSupportedException, który - ponieważ nasza klasa
A implementuje interfejs Cloneable - nie może wystąpić w żadnej sytuacji!
Takie proste, zapewniane przez domyślną implementację metody clone(0 w klasie
Object, kopiowanie obiektów, jest często niewystarczające, bowiem jest płytkie,
Np. jeżeli mamy klasę B, której polem jest tablica liczb całkowitych, to
płytkie kopiowanie przepisze do nowego obiektu referencję do tej tablicy,
ale tablica pozostanie ta sama (w obu obiektach: oryginale i kopii) i jeżeli
będziemy zmieniać elementy tablicy za pośrednictwem oryginału, to będą
się one również zmieniać w kopii i odwrotnie. Np. poniższy program:
class B implements Cloneable {
private int[] arr = {1, 2, 3};
public int[] get() { return arr; }
public void set(int i, int val) { arr[i] = val; }
public Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException exc) {
throw new Error("Dziękujemy za klonowanie!");
}
}
}
class Cloning2 {
static void show(String nam, int[] arr) {
System.out.println(nam);
for (int i=0; i<arr.length; i++) System.out.print(" " + arr[i]);
System.out.println("");
}
public static void main(String[] args) {
B x = new B();
B x1 = (B) x.clone();
show("x", x.get());
show("x1 - nowy obiekt kopia x" , x1.get());
x.set(1, 10);
show("x1 - płytka kopia, nie jest niezależnym obiektem!", x1.get());
}
wyprowadzi następujace wyniki:
x
1 2 3
x1 - nowy obiekt kopia x
1 2 3
x1 - płytka kopia, nie jest niezależnym obiektem!
1 10 3
Musimy zatem inaczej zdefiniować metodę clone, tak by uzyskać kopię głęboką,
odtwarzającą stan wszystkich obiektów, oznaczanych przez pola klasy,
Może to wyglądać tak:
class B implements Cloneable {
private int[] arr = {1, 2, 3};
public int[] get() { return arr; }
public void set(int i, int val) { arr[i] = val; }
public Object clone() {
try {
B copy = (B) super.clone();
copy.arr = (int[]) arr.clone();
return copy;
} catch (CloneNotSupportedException exc) {
throw new Error("Dziękujemy za klonowanie!");
}
}
Używamy tu metody clone wobec tablicy arr. Metoda ta zapewnia utworzenie
nowej tablicy i skopiowanie do niej wszystkich elementów tablicy oryginalnej
(klonowanej). W rezultacie otrzymamy (w tym przypadku) głęboką kopię
obiektu klasy B (będzie on miał teraz jako element referencję do nowej tablicy
liczb całkowitych ze skopiowanymi z oryginału elementami).
Co by się jadnak stało, gdyby w klasie B, tablica arr była zadeklarowana jako final:
private final int[] arr = { 1, 2, 3 };
Oczywiście, wystąpiłby błąd w kompilacji, bowiem pole arr nie może być modyfikowane
po inicjacji (bo jest final) i przypisanie copy.arr = (int[]) arr.clone()
jest syntaktycznie błędne.
Zauważmy: nie chodzi tylko o przekopiowanie elementów tablicy, ale stworzenie
tablicy nowej i przypisanie referencji arr (w klonie) odniesienia do tej
nowej tablicy. Zatem nawet gdybyśmy zrezygnowali z użycia clone wobect
tablicy arr i zapisywali kod "bezpośrednio", to:
B copy = (B) super.clone();
for (int i=0; i<arr.length; i++) copy.arr[i] = arr[i];
nic nam nie daje (bo arr i copy.arr oznacza ten sam obiekt-tablicę), a z kolei takie rozwiązanie:
C copy = (C) super.clone();
int[] newArr = new int[arr.length]
for (int i=0; i<arr.length; i++) newArr[i] = arr[i];
copy.arr = newArr;
jest niedopuszczalne, bo modyfikujemu zainicjowane finalne pole arr.
Zatem nawet w prostych przypadkach mechanizm klonowania w Javie jest bardzo
problematyczny. Jeśli uwzględnimy do tego fakt, że w złożonych hierarchiach
dziedziczenia musimy opierać się na klonowaniu obiektów nadklas (które np.
może być zabronione) i na rekursywnym wołaniu clone po polach obiektowych
(co też może być w ich klasach zabronione) oraz dodamy uciążliwą i nonsensowną
koniecznośc obsługi wyjątku CloneNotSupportedException, to łatwo dojdziemy
do wniosku, że:
mechanizm klonowania w Javie jest skomplikowany, źle zdefiniowany i niespójny.
Nie należy zatem go używać (być może za wyjątkiem zastosowania dobrze zdefiniowanych,
już istniejących w niektórych klasach, metod clone, np. dla tablic lub dla
obiektów takich klas jak Date, Dimension, Rectangle, Vector).
Alternatywą jest budowanie własnych konstruktorów kopiujących lub statycznych metod fabrycznych.
Konstruktor kopiujący ma jako parametr referencję do obiektu swojej klasy i inicjuje nowotworzony obiekt wartościami obiektu-argumentu.
Czasami metody fabryczne są nazywane krótko fabrykami (factory), ale najczęściej pojęcie factory
stosowane jest wobec klas, które dostarczają zestawu metod, z których każda
fabrykuje (tworzy i inicjuje) obiekt innej klasy, ukrywając przed użytkownikiem
niektóre zawiłości użycia konstruktorów tych klas (te klasy są zwykle logicznie
powiązane, np. poprzez implementację tego samego interfejsu).
Statyczna
metoda fabryczna (ang. factory method) tworzy i inicjuje nowy obiekt klasy oraz zwraca referencję do niego. Zwykle metody fabryczne mają nazwę getInstance
, najczęsciej pozwalają zwracać - zależne od parametru - obiekty różnych klas
(w szczególności podklas jakiejś klasy abstrakcyjnej lub klas implementujacych
jakiś interfejs). W naszym przypadku statyczna metoda fabryczna będzie miała
jako parametr referencję do obiektu oryginału i będzie zwracać referencję
do obiektu-kopii.
Metody fabryczne oraz fabryki - to wzorce projektowe z zestawu 23 wzorców projektiowych GOF.
Zobacz opis wzorca Factory w dalszych wykładach.
Przykład konstruktora kopiujacego i statycznej metody fabrycznej pokazano na wydruku.
class E {
private final int[] arr = {1, 2, 3};
public E() { }
// statyczna metoda fabryczna
public static E getInstance(E orgObj) {
// nowy obiekt
E newInst = new E();
// skopiowanie zawartości tablicy
// - wugodna metoda arraycopy (zob. dokumentacje)
System.arraycopy(orgObj.arr, 0, newInst.arr, 0, orgObj.arr.length);
// zwrot wyniku
return newInst;
}
// konstruktor kopiujący
public E( E orgObj ) {
for (int i=0; i<orgObj.arr.length; i++) arr[i] = orgObj.arr[i];
}
public int[] get() { return arr; }
public void set(int i, int val) { arr[i] = val; }
}
class Cloning5 {
static void show(String nam, int[] arr) {
System.out.println(nam);
for (int i=0; i<arr.length; i++) System.out.print(" " + arr[i]);
System.out.println("");
}
public static void main(String[] args) {
E x = new E();
E x1 = new E(x);
show("x", x.get());
show("x1 - nowy obiekt kopia x" , x1.get());
x.set(1, 10);
show("x1 - głęboka kopia, jest niezależnym obiektem!", x1.get());
show("x jest teraz", x.get());
E x2 = E.getInstance(x);
x.set(2, 11);
show("x2 - głęboka kopia, jest niezależnym obiektem!", x2.get());
show("a x jest teraz", x.get());
}
}
Program wyprowadzi właściwe (spodziewane) wyniki:
x
1 2 3
x1 - nowy obiekt kopia x
1 2 3
x1 - głęboka kopia, jest niezależnym obiektem!
1 2 3
x jest teraz
1 10 3
x2 - głęboka kopia, jest niezależnym obiektem!
1 10 3
a x jest teraz
1 10 11
Warto też podkreślić, że argumenty konstruktorów kopiujących lub kopiujących
statycznych metod fabrycznych możemy formułować w kategoriach interfejsów,
umożliwiając w ten sposób tworzenie obiektów naszej klasy, zawierających
dane z obiektów innej klasy w inny sposób implementującej podany interfejs.
Bardzo dobitnym przykładem tego jest JCF przykładowo konstruktory klas implementujących
kolekcje (ArrayList, HashSet, TreeSet itp.), w których typem parametru jest
Collection.
No dobrze, ale co zrobić gdy chcemy uzyskać kopię obiektu klasy, która ani
nie implementuje metody klonowania, ani nie dostarcza konstruktora kopiującego
czy fabrycznych metod kopiujących? Jest jeszcze jedno rozwiązanie:
- jeśli
klasa jest serializowalna, to można stworzyć kopię jej obiektu za pomocą
serializacji i deserializacji (zapisu stanu obiektu do strumienia - zwykle
pamięciowego - z następującym odczytaniem tego obiektu).
- jeśli klasa spełnia protokół Java Beans - to można użyć XMLEncodera i XMLDecodera na strumieniach pamięciowych.
Jeśli przyjrzymy się jeszcze raz uważnie ostatniemu kodowi programu, to zauważymy
w klasie E metodę int[] get(), która zwraca referencję do pola deklarującego
tablicę liczb całkowitych. Jak wiemy, te tablice dla oryginału i kopii będą
zawierały te same dane, ale będą różnymi obiektami, dzięki czemu zmiany
danych w kopii nie będą dotyczyć zmian w oryginale i odwrotnie.
Metoda get()
eksponuje te tablice na zewnątrz klasy i przez to umożliwia dokonywanie zmian
danych-elementów tablic spoza klasy E. Powstaje pytanie czy to dobrze? A
przynajmniej - czy zawsze będzie to pożądane, a jeśli nie - to jak można
wykluczyć taką możliwość?
6. Niezmienność obiektów
W Javie (ale również w innych środowiskach
programowania obiektowego) bardzo ważną rolę spełnia koncepcja niezmienności
obiektów.
Obiekt jest niezmienny (
ang. immutable), jeśli po utworzeniu
obiektu jego stan, określany przez elementy stanowiące o treści obiektu w
kontekstach jego zewnętrznego wykorzystania, nie może być zmieniony
Oczywiście, nie zawsze będziemy chcieli, by obiekty naszych klas były niezmienne.
Ale często okazuje się to użyteczne, a nawet niezbędne.
Wyobraźmy sobie np. że mamy jakieś pudełka opisywane przez wymiary oraz zawartość.
Wymiary (szerokość, wysokość) będą obiektami klasy Dimension:
class Dimension {
public int width;
public int height;
public Dimension(int w, int h) {
width = w;
height = h;
}
}
Pudełka będą opisywane przez wymiary i opis zawartości:
class Box {
Dimension dim;
String cont;
public Box(Dimension d, String c) {
dim = d;
cont = c;
}
public void show() {
System.out.println("Pudełko: " + dim.width + "x" + dim.height +
" Zawartość: " + cont);
}
}
Możemy teraz utworzyć jakiś standardowy wymiar:
Dimension d = new Dimension(50,50);
i tworzyć pudełka na płyty:
Box b1 = new Box(d, "Płyty 1");
Box b2 = new Box(d, "Płyty 2");
...
Box b10 = new Box(d, "Płyty 10");
Następnie przychodzi pora na pudełko na książki. Szerokość pudełka na książki
jest o 20 cm większa. Niewątpliwie odczujemy nieprzepartą chęć, by zapisac
to w ten sposób:
d.width += 20;
Box bk = new Box(d, "Książki");
i - niestety - popełnimy błąd, co pokazuje poniższy fragment programu i wydruk jego działania:
public static void main(String[] args) {
// Wymiary pudełka na płyty
Dimension d = new Dimension(50, 50);
// ... tworzymy pudełka na płyty
// ...
Box b10 = new Box(d, "Płyty 10");
b10.show();
// pudełko na książki będzie szersze o 20 cm
d.width += 20;
Box bk = new Box(d, "Ksiązki");
bk.show();
System.out.println("Co się stało z pudełkiem na płyty?");
b10.show();
}
Pudełko: 50x50 Zawartość: Płyty 10
Pudełko: 70x50 Zawartość: Ksiązki
Co się stało z pudełkiem na płyty?
Pudełko: 70x50 Zawartość: Płyty 10
Pudełka na płyty (już po ich utworzeniu) zmieniły swoje rozmiary! Stało się
tak dlatego, iż obiekt klasy Dimension nie jest niezmienny, a ponieważ stanowi
pole (element) obiektu klasy Box, to zmiana wymiaru d ( zwiększenie szerokości
pudełka) powoduje zmiany we wszystkich już utworzonych i wykorzystujących
ten obiekt pudełkach.
Nasza klasa Dimension - przynajmniej w tym przykładzie, po to by uniemożliwić
popełnianie takich błędów - powinna być zatem niezmienna (immutable).
Ogólnie, aby klasa była niezmienna (co oznacza niezmienność jej obiektów) muszą być spełnione następujące warunki:
- klasa nie może być dziedziczona (gdyż poprzez przedefiniowanie lub
dostarczenie nowych metod w podklasie moglibyśmy uczynić jej obiekty zmiennymi); specyfikator final użyty w deklaracji klasy (np. final public class Dimension) zabrania jej dziedziczenia,
- nieprywatne pola klasy (jeśli są) powinny być finalne,
- obiektowe pola klasy powinny być prywatne i niezmienne po ich pierwszej
efektywnej inicjacji (to ostatnie niekoniecznie oznacza, że pola muszą być
finalne: np. w niezmiennej klasie String pola nie są finalne, ale po inicjacji,
która - jak np w przypadku pola hashcode jest efektywnie odłożona do momentu
użycia metody hashCode() - nie są zmieniane w klasie String i nie mogą być
zmienione z zewnątrz),
- klasa nie powinna udostępniać metod uzyskiwania referencji do pól,
zawierających odniesienia do obiektów, które nie są niezmienne (np. do tablic).
Warto podkreślić, że ostatnie wymaganie dotyczy również kodowania
konstruktora, a także niekoniecznie oznacza, że klasa nie może udostępniać
danych dostępnych poprzez pola obiektowe (np. całej tablicy), tyle tylko,
że takie udostępnienie nie może umożliwiać zmian wartości tych danych w obiekcie
klasy.
Zobaczmy to na schemacie.
Niezmienna tablica - jak tworzyć klasy niezmienne?
class Tablica { // powinno być: final class Tablica
private final int[] tab; // dobrze: pole prywatne i finalne
// Nieprawidłowy sposób kodowania konstruktora
// Po stworzeniu obiektu zewnątrzne zmiany elementów przekazanej tabArg
// będą zmieniać elementy tab (czyli stan tego obiektu)
public Tablica(int[] tabArg) {
tab = tabArg;
}
// Prawidłowy sposób kodowania konstruktora
// Tworzymy kopie (nowy obiekt) przekazanej tablicy
// Późniejsze zmiany w tabArg nie będą miały wpływu na tab
public Tablica(int[] tabArg) {
tablica = (int[]) tabArg.clone();
}
// Nieprawidłowy sposób kodowania - udostępnia na zewnątrz referencję do tablicy
// Jej elementy mogą być zewnętrznie zmieniane
public int[] getTab() { return tab; }
// Prawidłowy sposób kodowania
// --- nie udostępniamy referencji do tablicy
public int getTab(int n) { return tab[n]; }
// Jeśli chcemy udostępnić całą tablicę
// --- skopiujmy ją, by nikt nie mógl zmienić elementów oryginału
public int[] getTab() { return (int[]) tab.clone(); }
}
Uwaga: bardzo często w sytuacjach takich jak przedstawiona wyżej klasa Tablica
będziemy chcieli zachować zewnętrzną niezmienność obiektów, dając jednocześnie
użytkownikowi możliwość dokonywania zmian wyłącznie za pomocą metod naszej
klasy. W tym przypadku - wszystkie przedstawione na schemacie reguły dalej
mają zastosowanie, ale dodatkowo udostępniamy metodę zmiany elementów tablicy
np. void set(int indeks, int wartość)).
|
Niezmienność obiektów pozwala na:
-
unikanie błędów przy przekazywaniu argumentów (np. jeśli przekazujemy argument
typu String, to mamy pewność, że metoda nie zmieni przekazanego napisu, bo
klasa String jest niezmienna),
-
bezproblemowe współdzielenie obiektów w programach wielowątkowych (ponieważ
niezmienność obiektów wyklucza możliwości powstawania niespójnych stanów,
gdy na obiekcie operuje kilka wątków jednocześnie, zatem unikamy narzutu synchronizacji ),
-
spójne przechowywanie referencji w strukturach danych (np. klucze w tablicach
asocjacyjnych - mapach (takich jak np. Hashtable) powinny być obiektami niezmiennymi,
bowiem zmiana klucza po zapisaniu go do tablicy prowadzi do niespójności danych,
7. Podsumowanie
Zapoznaliśmy się z:
- subtelnościami przedefiniowania metody equals(),
- problemem "słabej hermetyzacji" przy dziedziczeniu i zaletami kompozycji,
- porównaniem sposobów ponownego wykorzystania klas (dziedziczenie a kompozycja),
- problemem kopiowania obiektów (słabości klonowania, potrzeba tworzenia konstruktorów kopiujących lub fabryk),
- pojęciem niezmienności klas i obiektów oraz jego zaletami.
8. Zadania
Zad. 1
Rozwinąć hierarchię dziedziczenia z pkt. 1 dodając obok klasy Worker,
klasy Teacher i Student. Zdefiniować poprawne metody equals(), a także
compareTo().
Zad. 2
Wymyślić inny od podanego w materiale przykład niwelowania "słabej
hermetyzacji" przez zastąpienie dziedziczenia - kompozycją. Zapewnić
polimorfizm odwołań przy kompozycji poprzez dpstarczenie odpowiednich
interfejsów.
Zad. 3
Stworzyć przykłady klas z konstruktorami kopiującymi i kopiującymi metodami fabtrycznymi.
Zad. 4
Zapisać przykłady klas niezmiennych i pokazać ich zalety w
aplikacjachach współbieżnych (porównanie sytuacji, gdy
klasy są modyfikowalne i gdy są niezmienne).