Rozdział 10 - Dziedziczenie i polimorfizm - Czym jest polimorfizm?

Polimorfizm to bardzo potężny i często wykorzystywany w językach obiektowych mechanizm. Pozwala on na traktowanie obiektów pewnej klasy jako obiektów innej klasy, jeżeli jedna z tych klas dziedziczy po drugiej klasie (pośrednio bądź bezpośrednio).

Polimorfizm w akcji

Wracając do przykładu z poprzedniego rozdziału, obiekt klasy Pracownik mógłby zostać potraktowany jako reprezentant klasy Osoba, ponieważ klasa Pracownik rozszerza klasę Osoba – zachodzi więc tutaj zależność Pracownik jest Osobą.

Jak to wygląda w praktyce? W poniższej klasie metoda wejdzDoBudynku w klasie Budynek oczekuje jako argumentu obiektu typu Osoba:

Nazwa pliku: Budynek.java
package prostyprzyklad;

public class Budynek {
  public static void main(String[] args) {
    Osoba osoba = new Osoba();
    osoba.imie = "Jan";
    osoba.nazwisko = "Kowalski";

    Pracownik pracownik = new Pracownik();
    pracownik.imie = "Joanna";
    pracownik.nazwisko = "Sikorska";
    pracownik.numerIdentyfikatora = 1234;

    wejdzDoBudynku(osoba); // 1
    wejdzDoBudynku(pracownik); // 2
  }

  public static void wejdzDoBudynku(Osoba osoba) { // 3
    System.out.println("W budynku jest " + osoba);
  }
}

W wyniku uruchomienia tego programu zobaczymy na ekranie:

W budynku jest Osoba Jan Kowalski W budynku jest Osoba Joanna Sikorska

Zauważmy, że metoda wejdzDoBudynku oczekuje argumentu typu Osoba (3). W metodzie main najpierw wywołujemy ją z argumentem osoba typu Osoba (1), ale w kolejnej linii (2) wywołujemy tę samą metodę z argumentem innego typu – obiektem pracownik typu Pracownik!

Kompilator języka Java nie zgłasza problemów podczas kompilacji, a Maszyna Wirtualna Java nie rzuca wyjątku w trakcie działania programu, ponieważ powyższy kod jest całkowicie legalny i ilustruje polimorfizm w akcji. Kod działa, ponieważ każdy obiekt klasy Pracownik może być traktowany jako obiekt klasy Osoba ze względu na to, że jedna z tych klas dziedziczy (rozszerza) drugą klasę.

Czy moglibyśmy zmienić argument metody wejdzDoBudynku z Osoba na Pracownik? Tak, ale wtedy kod przestałby się kompilować – problemem byłaby linia (1). Wynika to z faktu, że o ile każdy Pracownik to Osoba, to nie każda Osoba to Pracownik. Kompilator wypisałby następujący błąd:

prostyprzyklad\Budynek.java:14: error: incompatible types: Osoba cannot be converted to Pracownik wejdzDoBudynku(osoba);

Przykład metody wejdzDoBudynku pokazuje, że argument tej metody raz wskazuje w pamięci na obiekt typu Osoba, a drugi raz – na obiekt typu Pracownik. Oznacza to, że zmienne typu bazowego możemy stosować do wskazywania na obiekty klas pochodnych – spójrzmy na poniższy przykład:

Osoba innaOsoba = new Pracownik();
innaOsoba.imie = "Artur";
innaOsoba.nazwisko = "Strzelecki";

Powyższy fragment kodu jest prawidłowy. Jak już wiemy, każdy Pracownik to Osoba. Korzystamy ze zmiennej innaOsoba, która może przechowywać referencję do obiektu typu Osoba, ale obiekt, jaki faktycznie tworzymy i którego adres przypisujemy do tej zmiennej, jest typu Pracownik. Nie jest to jednak problem, ponieważ klasa Pracownik dziedziczy po klasie Osoba.

Ustawiliśmy powyżej imie i nazwisko – te pola są w klasie Osoba. A gdybyśmy spróbowali ustawić pole numerIdentyfikatora?

// blad!
innaOsoba.numerIdentyfikatora = 4321;

Ta linia spowodowałaby następujący błąd kompilatora:

prostyprzyklad\Budynek.java:21: error: cannot find symbol innaOsoba.numerIdentyfikatora = 4321; ^ symbol: variable numerIdentyfikatora location: variable innaOsoba of type Osoba

Co prawda tworzymy obiekt typu Pracownik, w której to klasie zdefiniowane jest pole numerIdentyfikatora, ale do odnoszenia się do tego obiektu używamy referencji typu Osoba – a obiekty klasy Osoba pola numerIdentyfikatora nie posiadają.

Jak zobaczymy w kolejnych rozdziałach, jest sposób na ustawienie pola numerIdentyfikatora za pomocą referencji typu Osoba. W tym celu należy skorzystać z mechanizmu rzutowania, który poznaliśmy już w poprzednich rozdziałach – rzutowaliśmy na przykład liczby typu całkowitego na liczby rzeczywiste. Rzutowanie odbywa się poprzez napisanie typu docelowego w nawiasach przed rzutowaną wartością. Zobaczmy, jak moglibyśmy poprawić powyższy przykład, by zadziałał:

((Pracownik) innaOsoba).numerIdentyfikatora = 4321;

Dzięki takiemu zapisowi, kod jest poprawny i jesteśmy w stanie ustawić wartość pola numerIdentyfikatora. Poprzez użycie rzutowania powiedzieliśmy kompilatorowi:

„Wiem, że zmienna innaOsoba jest typu Osoba, ale tak naprawdę wskazuje ona na obiekt klasy pochodnej o nazwie Pracownik, proszę więc o pozwolenie na kompilację i ustawienie pola numerIdentyfikatora na moją odpowiedzialność”.

Dlaczego „na naszą odpowiedzialność”? Ponieważ zmienna innaOsoba mogłaby wskazywać na obiekt klasy Osoba, a nie Pracownik – wtedy rzutowanie byłoby niemożliwe i działanie programu zakończyłoby się błędem. Działanie programu, a nie kompilacja – zauważmy tutaj, że kompilator nie jest w stanie nas uchronić przed próbą nieprawidłowego rzutowania – o potencjalnym problemie dowiemy się dopiero po uruchomieniu programu:

fragment pliku Budynek.java
Osoba kolejnaOsoba = new Osoba();
// kompilacja ok, ale blad wykonania!
((Pracownik) kolejnaOsoba).numerIdentyfikatora = 5555;

Kompilacja tego fragmentu kodu zakończy się bez błędów – kompilator w tym przypadku nie uchroni nas przed potencjalnym błędem, który, w tym przypadku, ewidentnie popełniliśmy, co widać na ekranie po uruchomieniu programu:

Exception in thread "main" java.lang.ClassCastException: class prostyprzyklad.Osoba cannot be cast to class prostyprzyklad.Pracownik at prostyprzyklad.Budynek.main(Budynek.java:26)

Problem występuje, ponieważ próbujemy ustawić pole numerIdentyfikator rzutując obiekt kolejnaOsobana na typ Pracownik, jednak jest to niemożliwe – zmienna kolejnaOsoba wskazuje na obiekt typu Osoba, a nie Pracownik.

Przykład method overriding

Z polimorfizmem i dziedziczeniem wiąże się także bardzo ważny mechanizm zwany method overriding (nadpisywanie metod), o którym opowiemy sobie więcej w kolejnych rozdziałach, a na razie zobaczymy jeden przykład, który wyjaśni, na czym ten mechanizm polega.

Method overriding to tworzenie w klasie pochodnej takiej samej metody, jaka już znajduje się w klasie nadrzędnej (z możliwością pewnych zmian, które poznamy wkrótce). Gdy metoda ta zostanie wywołana na zmiennej typu bazowego, to wykonana zostanie nie metoda z typu bazowego, lecz typu obiektu, na który ta zmienna faktycznie wskazuje w pamięci.

Dla przykładu, załóżmy, że dodamy do klasy Pracownik metodę toString – ta metoda będzie wypisywała nie tylko imię i nazwisko (jak już to robi metoda toString w klasie Osoba), ale także identyfikator Pracownika:

Nazwa pliku: Pracownik.java
package prostyprzyklad;

public class Pracownik extends Osoba {
  public int numerIdentyfikatora;

  public String toString() {
    return "Pracownik " + imie + " " + nazwisko +
        ", identyfikator: " + numerIdentyfikatora; 
  }

  // metoda main zostala pominieta
}

Do klasy Pracownik dodaliśmy dedykowaną dla tej klasy metodę toString. Przypomnijmy jeszcze, jak wygląda metoda toString z klasy bazowej Osoba:

Nazwa pliku: Osoba.java
package prostyprzyklad;

public class Osoba {
  public String imie;
  public String nazwisko;

  public String toString() {
    return "Osoba " + imie + " " + nazwisko;
  }
}

Gdy teraz utworzymy obiekt klasy Osoba i obiekt klasy Pracownik i wypiszemy ich tekstową reprezentację na ekran, to zobaczymy następujący komunikat:

fragment metody main z pliku Pracownik.java
Osoba pewnaOsoba = new Osoba();
pewnaOsoba.imie = "Jan";
pewnaOsoba.nazwisko = "Kowalski";

System.out.println(pewnaOsoba.toString());

Pracownik pewienPracownik = new Pracownik();
pewienPracownik.imie = "Joanna";
pewienPracownik.nazwisko = "Sikorska";
pewienPracownik.numerIdentyfikatora = 1234;

System.out.println(pewienPracownik.toString());
Osoba Jan Kowalski Pracownik Joanna Sikorska, identyfikator: 1234

Na razie nie jest to nic nowego – obiekt pewnaOsoba został zamieniony na tekst za pomocą metody toString z klasy Osoba, a obiekt pewienPracownik – nową metodą toString z klasy Pracownik.

Spójrzmy jednak co się stanie, jeżeli do referencji do typu Osoba przypiszemy obiekt typu Pracownik i wtedy użyjemy metody toString:

fragment metody main z pliku Pracownik.java
Osoba innaOsoba = new Pracownik();
innaOsoba.imie = "Adrian";
innaOsoba.nazwisko = "Sochacki";

System.out.println(innaOsoba.toString());

Ten fragment kodu spowoduje wypisanie na ekran komunikatu:

Pracownik Adrian Sochacki, identyfikator: 0

Tutaj zachodzi magia mechanizmu method overriding – pomimo, że typ zmiennej innaOsoba to Osoba, a nie Pracownik, została użyta metoda toString zaimplementowana w klasie Pracownik, ponieważ faktyczny obiekt wskazywany przez zmienną innaOsoba jest właśnie typu Pracownik.

Mechanizm ten działa automatycznie i daje ogromne możliwości. Jest on jedną z podstaw programowania zorientowanego obiektowo. Jest kilka istotnych zasad dotyczących nadpisywania metod (method overriding), które trzeba mieć na uwadze – opowiemy sobie o nich dokładnie w jednym z kolejnych rozdziałów.

Nie należy mylić nadpisywania metod z przeciążaniem metod (method overriding vs. method overloading). Przeciążanie metod poznaliśmy w rozdziale o metodach – polegało ono na tworzeniu metod o tej samej nazwie, ale różniących się argumentach. Z kolei w poznanym w tym rozdziale nadpisywaniu metod, lista parametrów jest taka sama (z dokładnością do pewnych wyjątków, które poznamy wkrótce).

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Nie musisz podawać swojego imienia, e-mailu, ani strony www, aby opublikować komentarz. Komentarze muszą zostać zatwierdzone, aby były widoczne na stronie.