Polimorfizm część 1 – upcasting, downcasting, binding


Polimorfizm, czyli wielopostaciowość, oznacza występowanie czegoś pod wieloma postaciami. Nie ma chyba co do tego wątpliwości, że określenie nie wywodzi się z informatyki, a pierwotnie  odnosiło się raczej do zjawisk obserwowanych w przyrodzie. Używając więc super trafnego porównania, tak jak w przyrodzie woda występuje pod postacią pary, czy lodu, tak i obiekt jakiegoś typu, może wystąpić pod postacią innego.

W Javie wszystkie obiekty (z wyjątkiem tych klasy Object) mają możliwość wystąpień polimorficznych. Mianowicie, każdy obiekt może zostać przypisany do co najmniej dwóch typów danych, tj. swojego i typu Object. Wynika to z tego, że wszystkie klasy w Java dziedziczą po Object.

Upcasting – rzutowanie w górę

Wzór:
Parent p = new Child();

Przykład:

public class App {
    public static void main(String[] args) {
        String s1 = "ABC";
        Object s2 = s1;
        System.out.println(s1 + "\n" + s2);
    }
}

Zmienna referencyjna s1 typu String posiadająca wskaźnik na obiekt – literał, bez problemu daje się przypisać do zmiennej s2 – automatyczne/niejawne rzutowanie w górę. W efekcie zmienna referencyjna klasy Object, wskazuje na instancję klasy String, a obiekt klasy String, występuje pod postacią klasy Object. Dodam jeszcze tylko, że gdyby pozamieniać String z Object, to wyjdzie błąd.

Zasada:
Gdziekolwiek oczekiwany jest obiekt klasy rodzica, można użyć obiektu klasy dziecka – rzutowanie w górę. 

Nazewnictwo “rzutowanie w górę” i “rzutowanie w dół” ma związek ze sposobem rysowania diagramów przedstawiających dziedziczenie, gdzie klasa bazowa występuje u góry. Przyznam, że dla mnie określenie “w górę” było mylące bo myślałem sobie, że to typ występujący wyżej w hierarchii rzutuje na obiekt podklasy – w dół. Teraz staram się myśleć bardziej obiektem, że jest on przypisywany do wyższego lub niższego typu w hierarchii.

Istotne jest, że nie może zachodzić sprzeczność pomiędzy typem a obiektem. Sam obiekt nie może też ulec zmianie, może jedynie przyjąć postać innego typu. Z tego punktu widzenia rzutowanie w górę jest zawsze bezpieczne, gdyż obiekt podklasy będzie zawsze pasował do typu nad klasy.

Przykład:

class Driver {
}
class TruckDriver extends Driver {
}

Prawdą jest, że każdy kierowca ciężarówki jest również kierowcą, więc mogę go przypisać do ogólniejszego typu “kierowca”:

Driver driver = new TruckDriver();

Nie prawdą jest, że każdy kierowca jest również kierowcą ciężarówki, więc nie mogę przypisać obiektu ogólniejszego typu “kierowca”, do typu “kierowca ciężarówki”:

TruckDriver truckDriver = new Driver(); // Źle - błąd składni

No chyba, że…

Downcasting – rzutowanie w dół

Wzór:
Parent p = new Child();
Child c = (Child) p;

Błąd składni w linijce kodu wyżej oznacza, że to co napisałem się nie skompiluje. Gdybym chciał uniknąć tego błędu, musiałbym jawnie rzutować:

TruckDriver truckDriver = (TruckDriver) new Driver();

Taka składnia jest poprawna dla kompilatora, ale on nie wie tego co JVM na etapie wykonywania programu – czy obiekt jest zgodny z klasą, do której został przypisany. Pomimo więc poprawnej składni, w trakcie wykonania programu prawdopodobne będzie zgłoszenie wyjątku ClassCastException.

“Maszyna wirtualna w trakcie wykonania programu dokonuje sprawdzenia zgodności klas. Wykonywanie operacji na obiekcie okazuje się możliwe tylko wtedy, kiedy zmienna wskazująca na ten obiekt jest klasy tego obiektu lub klasy nadrzędnej do klasy tego obiektu — nigdy odwrotnie.”
“Java. Praktyczny kurs” śp. Marcin Lis

Rzutowanie w dół niesie ze sobą ryzyko problemu sprzeczności, oraz niepoprawnego odwzorowania rzeczywistości, w kodzie programu. Nie mniej, można je w sposób udany i bezpieczny przeprowadzić. 

Zgodnie z powyższym wzorem i trzymając się przykładu z kierowcami, jeżeli rzutowałbym wcześniej referencję typu Driver na obiekt klasy TruckDriver, mógł bym w następnej kolejności bez błędu przypisać tę referencję do nowo utworzonej referencji klasy TruckDriver, powiedzmy o nazwie truckDriver. Mógłbym tak zrobić tylko dlatego, że referencja klasy Driver przez wcześniejszy upcasting, wskazywałaby już na obiekt klasy TruckDriver – zachowana zgodność klas.

Przykład:

class Driver {
    void drive() {
        System.out.println("Driver starts to drive.");
    }
    void stop() {
        System.out.println("Driver stops.");
    }
}

class TruckDriver extends Driver {
    @Override
    void drive() {
        System.out.println("Truck driver starts to drive.");
    }
    @Override
    void stop() {
        System.out.println("Truck driver stops.");
    }
    void unload() {
        System.out.println("Truck driver unloads the load.");
    }
}

public class App {
    public static void main(String[] args) {
     
        // Upcasting
        Driver driver = new TruckDriver();
        driver.drive();
        driver.stop();
     
        // Downcasting
        if(driver instanceof TruckDriver) {
            TruckDriver truckDriver = (TruckDriver) driver;
            truckDriver.unload();
        }
    }
}

Zasada:
Nawet jeżeli zmienna klasy bazowej wskazuje na obiekt podklasy, to i tak nie umożliwia dostępu do metod podklasy, a jedynie do metod klasy bazowej, które mogą być przesłonięte w podklasie.

W efekcie nie musiałem tworzyć nowego obiektu klasy TruckDriver, a jedynie nową zmienną tej klasy. Następnie wykorzystując już istniejący obiekt, oraz rzutowanie w dół, otrzymałem dostęp do metody unload()

Słowem instanceof sprawdziłem, czy zmienna driver odwołuje się do obiektu klasy TruckDriver,  gdyby nie, rzutowanie by się nie powiodło, uniknął bym jednak błędu.

Late binding – późne wiązanie

Nazywa się go również dynamicznym wiązaniem i w zasadzie wszystko o czym wyżej pisałem jest warunkowane tym właśnie mechanizmem. W Java napisany kod wpierw jest kompilowany przez javac, a następnie uruchamiany w postaci kodu bajtowego przez JVM. To na etapie uruchamiania programu wiązane są metody z ich ciałami – toż to przecież polimorfizm jest tatku!

W programiku z kierowcami przesłaniam dwie metody występujące w klasie bazowej – drive() i stop().  Na etapie kompilacji nie jest rozstrzygane, która wersja metody się uruchomi. Jeżeli typ zmiennej jest klasy rodzica, to i obiekt na który wskazuje jest dla kompilatora klasy rodzica. Dopiero na etapie wykonania programu, przez maszynę wirtualną, sprawdzany jest rzeczywisty typ obiektu, a następnie wiązane jest odpowiednie ciało metody z jej nazwą.

“W różnych językach odbywa się to w różny sposób, można sobie jednak wyobrazić, że pewna informacja o typie musi być wbudowana w obiekty”
“Thinking in Java” – Bruce Eckel

“(…) maszyna wirtualna tworzy na początku tabelę metod zawierającą sygnatury i ciała wszystkich metod, które mogą być wywołane. Kiedy dana metoda jest wywoływana, maszyna wirtualna odszukuje ją w swojej tabeli.”
“Java Podstawy” – Cay S. Horstmann, Gary Cornell

W klasie TruckDriver, powyżej nadpisanych metod używam również adnotacji @Override, która chociaż nie jest konieczna do przesłonięcia metody, stanowi dobrą praktykę, informując jawnie o zamiarach. No a dodatkowo, gdy już użyję adnotacji, to metoda musi nadpisywać, bo wyjdzie błąd.

Early binding – wczesne wiązanie

Nazywa się go również statycznym wiązaniem. Dotyczy sytuacji kiedy połączenie metody z jej ciałem jest oczywiste już na etapie kompilacji. Domyślnie wszystkie metody w Java są dynamicznie wiązane, chyba że są zadeklarowane jako privatefinalstatic lub są konstruktorami.

  • Metody prywatne należą tylko do swojej klasy i nie można ich nadpisywać w klasach pochodnych.
  • Metody finalne mają pozostać niezmienne, więc nieco z innego powodu ale również nie można ich nadpisywać.
  • Metody statyczne są na zawsze przypisane do swojej klasy i wywołuje się je poza obiektami, więc nie ma tu w ogóle mowy o przesłanianiu i wywoływaniu na obiektach.
  • Konstruktory są wywoływane wraz z użyciem słowa new, jako pierwsze metody na nowo powstałym obiekcie, następnie inicjują wartości w jego wnętrzu, po czym ich misja się kończy.  Konstruktorów się nie dziedziczy, ani się ich nie przesłania, ale można je przeciążać i wywoływać wewnątrz konstruktorów klas pochodnych, za pomocą słowa superpolecam artykuł.

Późne wiązanie dla tego typu metod jest po prostu zbędne, a skoro tak, to pewnie sprawniej jeżeli JVM nie musi go przeprowadzać – do you agree? 😉 .

Ten typ wiązania daje również możliwości do zachowań polimorficznych, polegających na przeciążaniu metod lub konwersji typów – ad hoc polymophizm.

Przykład:

class Overload {
    void doSome(String str) {
        System.out.println(str);
    }
    void doSome(String str, String str1) {
        System.out.println(str + str1);
    }
}

public class App {
    public static void main(String[] args) {
        Overload overload = new Overload();
        overload.doSome("ABC");
        overload.doSome("DEF","GHI");
    }
}

Dwie metody o takiej samej nazwie, lecz przyjmujące różne parametry w obrębie klasy. Decyzja o wywołaniu właściwej zapadnie na etapie kompilacji, w oparciu o podstawione dane, które jednoznacznie wskażą na odpowiednią metodę. Użycie dwóch lub więcej metod o takiej samej nazwie, oraz przyjmujących te same parametry, nie jest możliwe.

“I don’t believe coercion and overloading are true polymorphism; they’re more like type conversions and syntactic sugar.”
Java 101: Polymorphism in Java” By Jeff FriesenJavaWorld.com

Jak widać krąży jednak opinia, że nie jest to prawdziwy polimorfizm 🙂 .

Chciałbym jeszcze przy okazji zwrócić uwagę na pewne hasełko, którym być może dev-wannabe mógł by się poczuć zaskoczony – overriding vs overloading? Powinno już być jasne na czym polega różnica.

Warning! Tego nie pisał programista, ale dev-wannabe. Jak coś poknociłem, chętnie się o tym dowiem.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *