Java. Polimorfizm
Java. Polimorfizm
Polimorfizm w programowaniu obiektowym. Java
Java programowanie obiektowe - strona g��wna Programowanie sterowane zdarzeniami Hermetyzacja Dziedziczenie Polimorfizm Inne publikacje Analiza portfelowa Projektowanie magazynów Stylystycznie blog Moda męska

Polimorfizm

Polimorfizm to najważniejsza cecha, która umożliwia dostosowanie działania obiektów do własnych oczekiwań poprzez łączenie funkcjonalności zarówno dziedziczonej, jak i implementowanej samodzielnie. Idea polimorfizmu bazuje na tym, że użytkownik obiektu nie wie i nie musi wiedzieć, czy konkretne zachowanie wykorzystywanego obiektu zostało zrealizowane bezpośrednio w tym obiekcie czy też w tym, po którym dziedziczy on swoje właściwości. Ponadto może się okazać (i często tak się dzieje), że takie samo odwołanie do metody za każdym razem dotyczy innej akcji (inaczej zdefiniowanej). Może się też okazać, że w zależności od poziomu dziedziczenia pozornie ta sama metoda (nazywająca się tak samo) wykonuje inną akcję (tak jak pokazałem to we fragmencie swojej książki). Inny efekt polimorfizmu widać w przypadku kolejności inicjacji klas). Używanie pól klasy zawierającej może wprowadzić do konieczności rozszerzenia wiedzy na temat polimorfizmu w porównaniu do przypadku, gdy klasa wewnętrzna dziedziczy po tak zwanej normalnej klasie.

Książka: Polimorfizm.

Marek Wierzbicki

Rozdział 2

...

2.1.11. Kolejność inicjacji klas

Powróćmy do kwestii kolejności, w jakiej wykonuje się inicjacja klas. Załóżmy, że nasze dziedziczące klasy będą skonstruowane według schematu pokazanego na listingu 2.23:

Listing 2.23. Bloki inicjujące w klasach dziedziczących

class A {
  A() { System.err.println("konstruktor A"); }
  { System.err.println("inicjator obiektu A"); }
  static { System.err.println("inicjator klasy A"); }
}

class B extends A {
  B() {System.err.println("konstruktor B"); }
  { System.err.println("inicjator obiektu B"); }
  static { System.err.println("inicjator klasy B"); }
}

Pierwsze użycie klasy B, pokazanej na listingu 2.23, przy założeniu, że wcześniej nie używaliśmy klasy A, spowoduje wyświetlenie kolejnych napisów, pokazanych na rysunku 2.2:

Rysunek 2.2. Wydruk generowany przez program z listingu 2.23

inicjator klasy A
inicjator klasy B
inicjator obiektu A
konstruktor A
inicjator obiektu B
konstruktor B

Jak więc widać, najpierw - w kolejności dziedziczenia - inicjowane są klasy. Po nich następuje sekwencja charakterystyczna dla inicjacji obiektów typu klasy nadrzędnej. Obiekty te nazywam egzemplarzami wirtualnymi. W praktyce JVM rezerwuje od razu pamięć na cały rzeczywisty obiekt, jednak tworzenie go jest przeprowadzane sekwencyjnie. Najpierw inicjowany jest wirtualny obiekt bazowy, później uzupełniane są braki przez inicjacje kolejnych klas pochodnych. Inicjacja obiektów wirtualnych, zaznaczona na rysunku 2.2 kursywą, jest blokiem nie do rozłączenia. Jeśli klasa używająca B ma blok inicjujący klasę, to zostanie on wykonany przed inicjatorem klasy A. Sytuacja nie zmieni się, jeśli w pierwszej linii konstruktora klasy B dodamy jawne wywołanie konstruktora klasy nadrzędnej (z użyciem słowa super), czyli klasy A, tak jak pokazałem to na listingu 2.24. Dokładnie wyjaśnione kwestii odwołanie do klasy nadrzędnej zawiera ta książka w paragrafie 2.1.14. "Odwołanie do klas nadrzędnych".

Listing 2.24. Rozszerzenie inicjacji klas z listingu 2.23

class B extends A {
  B() {
    super();
    System.err.println("konstruktor B");
  }
  { System.err.println("inicjator obiektu B"); }
  static { System.err.println("inicjator klasy B"); }
}

Zgodnie z tym, co wcześniej napisałem, konstruktor klasy nadrzędnej wywoływany jest domyślnie jako pierwsze działanie konstruktora danej klasy.

Z sekwencyjnym tworzeniem i inicjacją obiektów dziedziczących związany jest pewien ważny problem. Pokażę go na przykładzie klasy nadrzędnej o postaci zaprezentowanej na listingu 2.25:

Listing 2.25. Klasa, po której trudno dziedziczyć

public class A {
  private Object o;
  public A(Object o) {
    this.o = o;
  }
}

Klasa taka nie umożliwia utworzenia dziedziczenia w postaci zaprezentowanej na listingu 2.26:

Listing 2.26. Błędne dziedziczenie po klasie z listingu 2.25

public class B extends A {
  Object oo = new Object();
  public B() {
    super(oo);  // błąd
  }
}

W takim przypadku konstruktor klasy nadrzędnej, reprezentowany przez linię super(oo), jest wywoływany, zanim utworzony zostanie egzemplarz oo klasy Object. Przed konstruktorem klasy nadrzędnej nie może bowiem być wykonywana żadna inna akcja poza ewentualnym wywołaniem innego konstruktora przeciążonego, który wywoła konstruktor super. Podobnie niepoprawna będzie też konstrukcja pokazana na listingu 2.27.

Listing 2.27. Błędne dziedziczenie po klasie z listingu 2.25

public class B extends A {
  public B() {
    super(this);  // błąd
  }
}

Błąd wynika z tego, że egzemplarz obiektu tej klasy, reprezentowany przez this, będzie znany dopiero po jego utworzeniu, a więc najwcześniej po zakończeniu pracy konstruktora klasy nadrzędnej.

Mimo takiego podejścia, to znaczy kolejnego tworzenia egzemplarzy obiektów klas dziedziczących, metody tych klas są formalnie dostępne w obiektach nawet przed ich utworzeniem. Może to spowodować powstanie błędnego, przynajmniej w naszym pojęciu, działania niektórych konstruktorów. Na listingu 2.28 zaprezentowane zostały dwie klasy - A i B. W klasach tych metoda doSth została zadeklarowana i wykorzystana niepoprawnie.

Listing 2.28. Błędne deklaracje metody w klasach dziedziczących

class A {
  A() {
    doSth();
  }
  void doSth() {
    System.err.println("A.doSth");
  }
}

class B extends A {
  B(){
    super();
    doSth();
  }
  void doSth() {
    System.err.println("B.doSth");
  }
}

Jeśli zadeklarujemy użycie klasy B i utworzenie z niej obiektu

B b = new B();

otrzymamy niespodziewany dla większości osób wynik (wydruk na konsoli Javy):

B.doSth
B.doSth

Zaobserwowany efekt działania jest jednak poprawny. Jest on skutkiem działania polimorfizmu. W zaprezentowanym przykładzie konstruktor w klasie A wywołuje metodę doSth tworzonego obiektu (czyli klasy B). Tak więc to metodę tej klasy wywoła konstruktor klasy A, mimo iż twórca miał zapewne co innego na myśli. Aby wywołanie doSth zawsze dotyczyło własnej klasy, metoda ta musi być prywatna (modyfikator private). Warto na to zwrócić uwagę, gdyż może to być przyczyną wielu podobnych nieporozumień. Inicjacja klasy:

B b = new B(3);

której definicja pokazana jest na listingu 2.29 może przynieść nieoczekiwany efekt.

Listing 2.29. Użycie metod w konstruktorze

public class A {
  public A() {
    System.out.println("wewnątrz konstruktora A");
    doSth();
  }
  public void doSth() {
    System.out.println("nic nie robię");
  }
}

public class B extends A {
  private int p1;
  public B(int p) {
    p1 = p;
    System.out.println("wewnątrz konstruktora B");
  }
  public void doSth() {
    System.out.println("p1=" + p1);
    // obliczenia z użyciem p1
  }
}

Pozornie nieoczekiwany wynik działania klasy z listingu 2.29 zaprezentowany jest na rysunku 2.3.

Rysunek 2.3. Wydruk generowany przez program 2.29

wewnątrz konstruktora A
p1=0
wewnątrz konstruktora B

Czyli tak jak napisałem wcześniej, przed uruchomieniem konstruktora klasy B (czyli przed powstaniem egzemplarza tej klasy) system potrafi już użyć jego metody doSth. Oczywiście skoro dzieje się to przed uruchomieniem konstruktora B, prywatne pole p1 nie jest jeszcze zainicjowane, więc jest równe zero. Więcej na temat polimorfizmu zawiera ta książka w paragrafie 2.2.6. "Efekty polimorfizmu".

...

2.2.6. Efekty polimorfizmu

Polimorfizm to zamierzone działanie w idei programowania obiektowego. Zapewnia ono wielopostaciowość działania zależną od wykorzystywanego obiektu. Dokładnie opisałem to w poprzednim rozdziale. Ponadto wspominałem już o tym zarówno przy opisywaniu dziedziczenia, jak i przykrywania pól i metod. Ominę więc rozważania teoretyczne, a przedstawię tylko efekt działania polimorfizmu. Do prezentacji wykorzystam dwie klasy o definicji podanej na listingu 2.42.

Listing 2.42. Klasy dziedziczące z pokrytymi metodami

class A {
  public void info1() {
    System.err.println("klasa A(1)");
  }
  public void info2() {
    System.err.println("klasa A(2)");
    info3();
  }
  public void info3() {
    System.err.println("klasa A(3)");
  }
}

class B extends A {
  public void info1() {
    System.err.println("klasa B(1)");
  }
  public void info3() {
    System.err.println("klasa B(3)");
  }
}

Efekty działania polimorfizmu będę mógł zaobserwować dzięki apletowi przedstawionemu na listingu 2.43.

Listing 2.43. Użycie klas z pokrytymi metodami

public class Applet2 extends Applet {
  public void init() {
    A a = new A();
    a.info1();
    a.info2();
    a = new B();  // a staje się klasy B
    a.info1();
    a.info2();
  }
}

Na konsoli Javy przeglądarki internetowej pojawi się ciąg napisów zaprezentowany na rysunku 2.5:

Rysunek 2.5. Wydruk generowany przez listing 2.43 z użyciem klas z listingu 2.42

klasa A(1)
klasa A(2)
klasa A(3)
klasa B(1)
klasa A(2)
klasa B(3)

Należy zwrócić uwagę na następujące sprawy:

Przypominam, że inny przykład działania polimorfizmu zawiera ta książka wcześniej, w paragrafie 2.1.11. "Kolejność inicjacji klas". Problemy związane z polimorfizmem i klasami wewnętrznymi omówione są też w paragrafie 2.3.2. "Polimorfizm i zmienne klasy zawierającej".

Omawiając efekt polimorfizmu, warto też wspomnieć, że niektórzy autorzy pod pojęcie polimorfizmu podciągają możliwość przeciążania metod. Wydaje mi się to zbyt rozszerzoną interpretacją. Metody przeciążone to zazwyczaj takie, które działają w bardzo podobny sposób, tylko przekazywanie do nich parametry są innego rodzaju bądź wykorzystuje się domyślne. Ponadto istnieje wiele języków programowania, które nie udostępniają przeciążenia metod, i nikt nie zwraca uwagi na to, że ich polimorfizm jest zubożony. Według mnie przeciążanie nie należy do zakresu polimorfizmu, ale zwracam uwagę na to, że możesz zetknąć się z taką tezą.

...

2.3.2. Polimorfizm i zmienne klasy zawierającej

Używanie pól klasy zawierającej powinno odbywać się z dużą ostrożnością w przypadku, gdy klasa wewnętrzna dziedziczy po tak zwanej normalnej klasie. Poniżej przedstawiony jest przykład, który mimo tego, że bezbłędnie przechodzi kompilację (nawet z użyciem kompilatora jikes i jego opcji pedantycznej kompilacji +P), nie pracuje poprawnie. Najważniejsze fragmenty, na które należy zwrócić uwagę, wytłuściłem na listingu 2.47.

Listing 2.47. Błędne użycie pól klasy zawierającej

import java.applet.*;

public class Aaa extends Applet {
  public Integer p1 = new Integer(2);
  public void init() {
    System.out.println("wewnatrz konstruktora apletu");
    B b = new B();
  }
  private class B extends A {
    public void doSth() {
      System.out.println("p1=" + p1);

    }
  }
}

abstract class A {
  public A() {
    System.out.println("wewnatrz konstruktora A");
    doSth();
  }
  abstract public void doSth();
}

Wewnętrzna klasa B korzysta z pola p1 pozornie w sposób poprawny. Pozornie, gdyż jeśli cofniesz się do paragrafu (który zawiera ta książka) 2.1.11. "Kolejność inicjacji klas" , przypomnisz sobie zapewne, że zanim zostanie zainicjowana klasa B, wcześniej inicjowana jest A. W czasie jej inicjacji w jej konstruktorze wywoływana jest metoda doSth. A metoda ta, zgodnie z zasadami polimorfizmu, zostanie zaczerpnięta już z klasy B. Jednak nie stanie się to w obszarze wewnętrznym apletu Aaa, lecz poza nim. I efektem działania będzie błąd (konsola Java z Internet Explorera) pokazany na rysunku 2.6.

Rysunek 2.6. Efekt działania programu 2.47 w Internet Explorerze

wewnatrz konstruktora apletu
wewnatrz konstruktora A

java.lang.NullPointerException
  at aaa$B.doSth (aaa.java:11)
  at A. (aaa.java:20)
  at aaa$B. (aaa.java:9)
  at aaa.init (aaa.java:8)
  at com/ms/applet/AppletPanel.securedCall0 (AppletPanel.java)
  at com/ms/applet/AppletPanel.securedCall (AppletPanel.java)
  at com/ms/applet/AppletPanel.processSentEvent (AppletPanel.java)
  at com/ms/applet/AppletPanel.processSentEvent (AppletPanel.java)
  at com/ms/applet/AppletPanel.run (AppletPanel.java)
  at java/lang/Thread.run (Thread.java)

Opera (a w zasadzie jej konsola Javy) pokaże ten błąd nieco inaczej, co zaprezentowane jest na rysunku 2.7.

Rysunek 2.7. Efekt działania programu 2.47 w Operze

wewnatrz konstruktora apletu
wewnatrz konstruktora A
java.lang.NullPointerException

  at aaa$B.doSth(aaa.java:11)
  at A.(aaa.java:19)
  at aaa$B.(aaa.java:9)
  at aaa.init(aaa.java:7)
  at opera.PluginPanel.run(opera/PluginPanel.java:308)
  at java.lang.Thread.run(Unknown Source)

Przyczyny błędu lepiej widoczne są po dekompilacji przedstawionego programu z użyciem dekompilatora jad (należy użyć opcji -noinner). Przedstawiony wcześniej kod został przetłumaczony nieco inaczej niż wygląd źródła (pominąłem kod klasy A, który jest dokładnie taki sam jak w oryginale). Wynika to z jawnego wyniesienia klasy wewnętrznej na zewnątrz. Na listingu 2.48 pokazany jest sposób, w jaki widzi ten kod maszyna Javy.

Listing 2.48. Kod 2.47 po kompilacji i dekompilacji

import java.applet.Applet;
import java.io.PrintStream;

public class Aaa extends Applet {
  public void init() {
    System.out.println("wewnątrz konstruktora apletu");
    Aaa$B aaa$b = new Aaa$B(this);
  }
  public Aaa() {
    p1 = new Integer(2);
  }
  public Integer p1;
}

class Aaa$B extends A {
  public void doSth() {
    System.out.println("p1=" + this$0.p1);
  }
  Aaa$B(Aaa aaa1){
    this$0 = aaa1;
  }
  private final Aaa this$0; /* synthetic field */
}

Jak widać, wewnątrz klasy B dodane jest pole this$0, które przechowuje wskazanie do używającego jej obiektu. Wskazanie to jest inicjowane w konstruktorze. Jak widać, zarówno konstruktor, jak i inicjacja klasy odbywa się inaczej, niż jest to jawnie zapisane. Jeśli przypomnimy sobie paragraf 2.1.11. "Kolejność inicjacji klas", jasne się stanie, dlaczego pojawia się błąd. W czasie tworzenia obiektu b klasy B, czyli w czasie wykonania wiersza:

B b = new B();

JVM rozpoczyna wykonywanie konstruktora B (w kodzie dekompilowanym Aaa$B). W ramach wykonania tego konstruktora przed wykonaniem jego pierwszego wiersza, czyli przed wykonaniem kodu:

this$0 = aaa1;

uruchamiany jest konstruktor klasy A. W konstruktorze tym uruchamiana jest metoda doSth, w ramach której (w związku z polimorfizmem metod) wykonywany jest wiersz:

System.out.println("p1=" + this$0.p1);

Oczywiście pole p1 apletu Aaa jest już zainicjowane. Ale pole this$0 obiektu b klasy B jeszcze nie. W związku z tym naturalne jest pojawienie się wyjątku NullPointerException. Oczywiście problemowi temu można zapobiec, przekazując kaskadowo do klasy nadrzędnej wartość tego parametru. W praktyce jednak widać, że w takim przypadku nie powinno się używać klasy wewnętrznej. W końcu została ona wprowadzona generalnie w celu rozwiązywania problemów, które wymagają tworzenia małych klas na potrzeby jednorazowego użycia. Przypadek, kiedy używamy zagnieżdżonego dziedziczenia, raczej powinno się zrealizować z użyciem klasycznego rozwiązania, czyli klasycznego tworzenia klas prywatnych.