<

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:
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ę:

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.:
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 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:
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:


7. Podsumowanie

Zapoznaliśmy się z:

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).