Kurs java dla początkujących - #7 Dziedziczenie, Polimorfizm, Interfejsy

Kurs Java dla początkujących – #7 Dziedziczenie, Polimorfizm, Interfejsy

W tej części zajmiemy się podstawowymi zagadnieniami programowani obiektowego, takimi jak: dziedziczenie, polimorfizm czy enkapsulacja i tym wszystkim, co się z nimi wiąże. Są to bardzo przydatne elementy, które stosowane z rozwagą mogą znacznie poprawić organizację twojego kodu. Opiszę tutaj także, do czego służą interfejsy i jak organizować swój kod w pakiety.

Z drugiej części kursu dowiedziałeś się co to są klasy i obiekty. Teraz zapoznam Cię z mechanizmami, które pomogą ci wykorzystać wszystkie możliwości jak dają. Zacznijmy od najprostszej rzeczy, czyli od dziedziczenia.

Dziedziczenie

Jest to mechanizm, który pozwala na rozszerzanie funkcjonalności obiektów poprzez hierarchiczne powiązanie ich ze sobą tzn. jeden obiekt dziedziczy zachowania i stan od drugiego.

Dziedziczenie definiujemy na poziomie deklaracji klasy poprzez użycie słowa kluczowego extends. Klasycznym przykładem dziedziczenia są klasy odwzorowujące zwierzęta. Mamy klasę Animal, która jest naszą klasą nadrzędną (nazywaną często również bazową). Klasa ta ma zaimplementowane podstawowe metody takie jak eat(), walk(), sleep():

public class Animal {
    public void eat() {
        System.out.println(this.getClass().getSimpleName() + " eat");
    }

    public void walk() {
        System.out.println(this.getClass().getSimpleName() + " walk");
    }

    public void sleep() {
        System.out.println(this.getClass().getSimpleName() + " sleep");
    }
}

Po klasie Animal dziedziczą takie klasy jak Dog i Cat:

public class Dog extends Animal {
}

public class Cat extends Animal {
}

Obie klasy mogą teraz korzystać z metod zawartych w klasie Animal:

public class Main {
    public static void main(String[] args) {
        Animal animal = new Animal();
        animal.eat();
        animal.walk();
        animal.sleep();
        Animal dog = new Dog();
        dog.eat();
        dog.walk();
        dog.sleep();
        Animal cat = new Cat();
        cat.eat();
        cat.walk();
        cat.sleep();
    }
}

Powyższy program wyświetli:

Animal eat
Animal walk
Animal sleep
Dog eat
Dog walk
Dog sleep
Cat eat
Cat walk
Cat sleep

Na tej zasadzie możesz rozszerzać każdą klasę w Javie, z wyjątkiem tych, które są oznaczone jako final np. String (gdy zajrzysz w źródła klasy String, to zobaczysz jej deklarację public final class String).

Wiele klas może dziedziczyć jedna po drugiej np. class SmallDog extends Dog, ale im bardziej rozbudowana hierarchia klas, tym trudniej jest się zorientować, która metoda jest z jakiej klas. Czasem może to doprowadzić do błędów.

Polimorfizm

Kolejnym zagadnieniem związanym także z dziedziczeniem jest polimorfizm, czyli w skrócie wielopostaciowość. Klasy pochodne mogą dziedziczyć zachowania i stan swoich klas nadrzędnych, ale także mogą zmieniać te zachowanie i stan. I to do tego właśnie w dużej mierze sprowadza się polimorfizm. 

Nadpisywanie metod klasy nadrzędnej 

W każdej klasie dziedziczącej z jakiejś klasy można nadpisać (Override) każdą publiczną (public) i chronioną (protected) metodę. Żeby odpowiednio oznaczyć taką metodę używamy adnotacji @Override.

Dla klasy Dog zmiana zachowania dla metody walk() wygląda w taki sposób:

public class Dog extends Animal {
    @Override
    public void walk() {
        System.out.println("Pies nie chodzi, pies biega");
    }
}

Metoda, która nadpisuje metodę z klasy nadrzędnej, może wywołać metodę, którą nadpisuje. Używamy do tego słowa kluczowego super:

public class Dog extends Animal {
    @Override
    public void walk() {
        super.walk();
        System.out.println("Pies nie chodzi, pies biega");
    }
}

Jako wynik takiego wywołania otrzymamy:

Dog walk
Pies nie chodzi, pies biega

Czasem takie rozszerzenie funkcjonalności metody nadrzędnej jest bardzo pożądane.

Przysłanianie metod klasy

Nadpisanie (Override) to jeden ze sposobów osiągnięcia polimorfizmu w Javie. Kolejny to przeciążenie (Overload) i jest on związany z metodami. Ta sam metoda może mieć wiele postaci, a wyrażać się to może tym, że przeciążona metoda, może przyjmować inną liczbę parametrów lub tą samą liczbę parametrów, ale innego typu.

public class Dog extends Animal {
    @Override
    public void walk() {
        System.out.println("Pies nie chodzi, pies biega");
    }

    public void walk(String msg) {
        System.out.println(msg);
    }

    public void walk(String msg, String msg2) {
        System.out.println(msg + " " + msg2) ;
    }
}

Po uruchomieniu:

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        dog.walk("Pies biega");
        dog.walk("Pies biega", "i szczeka");
    }
}

Otrzymujemy następujący wynik:

Pies biega
Pies biega i szczeka

Oczywiście klasy pochodne mogą definiować swoje własne metody i pola niezależne od klas nadrzędnych.

Interfejsy

Kolejnym sposobem na osiągnięcie polimorfizmu jest użycie interfejsów. Interfejs można potraktować jak szablon dla klas (jednocześnie dla wielu klas). Mówimy że jakaś klasa implementuje interfejs (używamy słowa kluczowego implements). To zachowanie jest bardzo podobne do dziedziczenia z klasy nadrzędnej (tak jak w przypadku Animal). W interfejsach jednak nie ma możliwości implementacji metod publicznych (abstrakcyjne). Przez to każda klasa implementująca dany interfejs ma te same zachowania (ten sam zestaw metod), ale każda musi je implementować na własny sposób.

public interface Pet {
    void eat();
    void walk();
    void sleep();
}

W interfejsie Pet mamy zadeklarowane trzy metody. Każda klasa, która implementuje ten interfejs musi mieć zaimplementowane te trzy metody. Musza być one także oznaczone adnotacją @Override.

public class MyLittleDog implements Pet {
    @Override
    public void eat() {
        System.out.println("Mój mały pies je bardzo mało");
    }

    @Override
    public void walk() {
        System.out.println("Mój mały pies chodzi ze mną na spacer");
    }

    @Override
    public void sleep() {
        System.out.println("Mój mały pies idzie spać");
    }

    // metoda specyficzna tylko dla tej klasy
    public void thisIsVerySpecificDog() {
        System.out.println("Mój pies jest bardzo specyficzny");
    }
}

i kolejna klasa

public class MyLittleCat implements Pet {
    @Override
    public void eat() {
        System.out.println("Mój mały kot je bardzo mało");
    }

    @Override
    public void walk() {
        System.out.println("Mój mały kot chodzi ze mną na spacer");
    }

    @Override
    public void sleep() {
        System.out.println("Mój mały kot idzie spać");
    }
}

Po uruchomieniu programu

public class Main {
    public static void main(String[] args) {
        Pet myLittleDog = new MyLittleDog();
        myLittleDog.eat();
        myLittleDog.walk();
        myLittleDog.sleep();
        Pet myLittleCat = new MyLittleCat();
        myLittleCat.eat();
        myLittleCat.walk();
        myLittleCat.sleep();
    }
}

otrzymamy wynik jak poniżej:

Mój mały pies jest bardzo mało
Mój mały pies chodzi ze mną na spacer
Mój mały pies idzie spać
Mój mały kot je bardzo mało
Mój mały kot chodzi ze mną na spacer
Mój mały kot idzie spać

Użycie interfejsów jest bardzo podobne do użycia klas nadrzędnych. Jak praktyka pokazuje, w Javie częściej używane są interfejsy. Chociaż ich metody nie mają implementacji. Jednak właśnie to daje łatwiejsze zrozumienie kodu. Interfejs definiuje jakieś zachowanie np. walk(), a jego implementacja jest po stronie klasy. Dodatkowo interfejsy obsługują dziedziczenie. Podobnie jak w wypadku klas, jeden może rozszerzać drugi poprzez użycie słowa extends.

Natomiast dziedziczenie niewielkich fragmentów kodu bywa pomocne. Jednak często prowadzi do zbyt dużego przyrostu kodu w klasie nadrzędnej i często te klasy stają się tzw. „God class”. Wszystko trzeba robić z głową.

Używanie typów interfejsów i klas nadrzędnych

Jak pewnie zauważyłeś w powyższych przykładach, ale także w części poświęconej kolekcjom, do deklaracji typów zmiennych używam interfejsów lub klas nadrzędnych (Pet myLittleDog czy Animal dog). Ma to swoje zalety, ale także wady. Zaletą jest to, że każda taka zmienna może przyjmować jako wartość obiekt dowolnej klasy, która implementuje dany interfejs lub rozszerza klasę nadrzędną. Zarówno interfejs jak i klasa nadrzędna mają określony zestaw metod publicznych, więc operując na zmiennej np. typu Pet, bez względu na to, czy implementacją jest MyLittleDog czy MyLittleCat, poruszamy się w obrębie tego samego zestawu metod.

Problemy zaczynają się wtedy gdy konkretna implementacja np. MyLittleDog implementuje dodatkowe metody specyficzne tylko dla tego typu. Wtedy musimy posłużyć się rzutowaniem do typu MyLittleDog. Warto także sprawdzić czy dany obiekt jest dokładnie tego typu. Służy do tego instrukcja instanceof:

public static void main(String[] args) {
        Pet pet = giveMeMyPet();
        if (pet instanceof MyLitleDog) {
            MyLitleDog dog = (MyLitleDog) pet; // używamy rzutowania
            dog.thisIsVerySpecificDog();
        }
    }

    private static Pet giveMeMyPet() {
        return new MyLitleDog();
    }

Rzutowanie – jest to zmiana jednego typu na inny. Jeśli jest poprawne, to stary typ obiektu jest zamieniany na nowy. Jeśli rzutowanie nie jest poprawne, wyrzucany jest wyjątek ClassCastException. Dzieje się tak, ponieważ nie da się zamienić typu Pies na Kot – są to dwa różne typy. Natomiast da się zamienić typ Animal na Pies (jeśli dany obiekt jest faktycznie typu Pies).

W tym wypadku możemy zmienić implementację metody giveMeMyPet() tak, by zwracała obiekt klasy MyLittleCat i cały kod będzie nadal wykonywał się poprawnie.

Enkapsulacja

Inaczej hermetyzacja. Jest to ukrywanie pewnych wartości bądź zachowań przed światem zewnętrznym. Budując klasę nadrzędną nie zawsze chcesz, żeby wszystkie jej szczegóły (metody i pola) były widoczne dla klas, które dziedziczą po nich lub na zewnątrz tych klas. Dlatego w klasach mamy dostępne modyfikatory dostępu takie jak: public, protected i private.

Jeśli chcesz żeby dana metoda była dostępna tylko w klasie Animal to możesz nadać jej modyfikator private. Jeśli jakaś metoda ma być dostępna dla klas potomnych, ale nie powinna być widoczna na zewnątrz tych klas, to możesz nadać jej modyfikator protected. wszystkim metodom, które mają być widoczne w klasach potomnych i na zewnątrz powinieneś nadać modyfikator public. Istotne jest to, że można nadpisywać metody public i protected.

Pakiety

Pakiety służą do grupowania klas. Jako wyznacznik grupowania może nam posłużyć typ, z którego dziedziczą dane klasy lub typ interfejsu, który implementuje dana grupa klas. Pakiety w języku Java są bardzo powszechnie używane. Kryterium, którym się posłużymy do zebrania klas w jeden pakiet może być w zasadzie dowolne. Może to być jakaś funkcjonalność w danej aplikacji np. Zamówienia, Katalog produktów lub bardziej technicznie, mogą to być warstwy aplikacji np. Frontend, Backend lub bardziej klasycznie Widok, Model Kontroler. Poszczególne części nazwy pakietu oddzielone są kropką i każda z tych części odpowiada katalogowi na dysku komputera.

Każda klasa, która znajduje się w danym pakiecie musi mieć w pierwszej linijce zadeklarowaną nazwę pakietu:

package my.pet;

public class MyLittleDog implements Pet {
    //...
}

Jeśli chcesz teraz użyć tej klasy w innym pakiecie (w innej klasie), musisz ją zaimportować:

import my.pet.MyLittleDog ;

public class Main {
    Pet pet = new MyLittleDog();
}

Często spotykaną konwencją w nazewnictwie pakietów jest używanie nazwy domeny internetowej pisanej od końca z dopisaną nazwą funkcjonalną pakietu np. pl.nullpointerexception.pet

Podsumowanie

Poznanie technik programowania obiektowego pomaga w lepszej organizacji kodu i uelastycznieniu powiązań pomiędzy różnymi elementami aplikacji. Także w lepszym ustrukturyzowaniu ich. Warto jest ćwiczy różne rozwiązania, tak by się nimi swobodnie posługiwać. Mimo, że niektóre z tych mechanizmów są używane raczej rzadko, to znajomość ich pozwala w bardzo krótkim czasie rozwiązywać napotkane problemy.

 

Co powinien wiedzieć każdy początkujący programista?

 

Kurs Java dla początkujących

Spis Treści:

  1. Wprowadzenie
  2. Klasy i Obiekty
  3. Tablice
  4. Kolekcje
  5. Instrukcje warunkowe i pętle
  6. Operacje wejścia i wyjścia
  7. Dziedziczenie, Polimorfizm, Interfejsy
  8. Stream’y i lambdy

 

Źródła:

https://pl.wikipedia.org/wiki/Polimorfizm_%28informatyka%29
https://docs.oracle.com/javase/tutorial/java/concepts/index.html
https://stackoverflow.com/questions/6308178/what-is-the-main-difference-between-inheritance-and-polymorphism/6308416

 

Mateusz Dąbrowski

Cześć jestem Mateusz, zajmuję się programowaniem już ponad 12 lat z czego ponad 8 programuję w Javie. Zapraszam Cię do lektury mojego bloga. Możesz przeczytać więcej o mnie >>TUTAJ<<

7 thoughts to “Kurs Java dla początkujących – #7 Dziedziczenie, Polimorfizm, Interfejsy”

  1. Bardzo dobrze się to czyta , krótko zwięźle i na temat , 🙂 Pozdrawiam serdecznie i zachęcam do wrzucania dalszych materiałów.

  2. Mateuszu, nie wiem, czy się zgodzisz, ale myślę, że warto tutaj podkreślić jedną rzecz. Mam wrażenie, że użyte w tekście przykłady dziedziczenia klas i implementacji interfejsów nie do końca obrazują naturę problemu. W obu podanych przykładach pokazujesz przekazywanie funkcjonalności (metody walk(), eat()…). Warto tu chyba podkreślić, że jednak dziedziczenie jest istotne dla odzwierciedlenia struktury obiektowości, wyszczególnienia cech wspólnych – niekoniecznie zachowań (relacja pies jest zwierzęciem), a interfejsy propagują funkcjonalności niezależnie od struktury dziedziczenia.

    1. Cześć Kamil dzięki za komentarz. Trochę zgadzam się z tobą 😉 Ale weź pod uwagę to, że ten kurs jest dla początkujących i starałem się to przedstawić w taki sposób, żeby było zrozumiałe właśnie dla takich osób. Starałem się to przedstawić w miarę prostej formie, żeby nie przytłaczać wiedzą.

      Trudno jest znaleźć dobry przykład, w którym wytłumaczysz osobie początkującej, co to jest dziedziczenie i jednocześnie, żeby ten przykład był sensowny dla osoby z większym doświadczeniem.

      W wielu kursach jest bardzo szczegółowo pokazane, co to jest dziedziczenie (tak mniej więcej jak ty to opisałeś), ale też w tych kursach nie jest opisane, z jakimi problemami się to wiąże. Ludzie czytają te wszystkie kursy i rozumieją je dosłownie. Później stosują tę wiedzę w praktyce: „skoro mogę współdzielić wszystko, to jest to dobre”. No nie jest. Dlatego skupiłem się na dziedziczeniu zachować i wybrałem takie przykłady. Uważam, że takie podejście do dziedziczenia jest w miarę bezpieczne. To, co ja myślę po kilkunastu latach o dziedziczeniu, jest bardzo odległe od tego, jak uczą się tego początkujący programiści. Ale to nie jest wiedza na kurs dla początkujących 😉

      1. Oceniam trochę ten materiał z perspektywy osoby, która ma już jakieś doświadczenie, ale jeszcze dość dobrze pamięta swoje trudne początki i nie jestem w 100% przekonany, czy gdybym od tego wpisu zaczął swoją przygodę, to czy widziałbym wyraźną różnicę pomiędzy interfejsem, a klasą bazową na podstawie tych przykładów, a jak jeszcze dorzucimy do interfejsu metodę defaultową, to już w ogóle :).

        Szukanie dobrych przykładów to największa tajemnica edukowania ludzi, dlatego ja tego nie robię ;).

        Komentarz poczyniłem z premedytacją. Może jakieś nowego adepta dodatkowo zaswędzi pod kopułką i doczyta trochę, poszuka innych przykładów.

        1. „… i nie jestem w 100% przekonany, czy gdybym od tego wpisu zaczął swoją przygodę, to czy widziałbym wyraźną różnicę pomiędzy interfejsem, a klasą bazową na podstawie tych przykładów, a jak jeszcze dorzucimy do interfejsu metodę defaultową, to już w ogóle :).” dzięki Kamil za ten komentarz teraz lepiej rozumiem twój punkt widzenia. Masz rację, można nie zauważyć różnicy. Postaram się zredagować ten artykuł, tak żeby bardziej było to widoczne. Dzięki 😉

  3. Nie zapomniałeś czasem o domyślnym modyfikatorze dostępu do metod?
    Tj gdy nie podaje się przy metodzie żadnego ze słów kluczowych public/protected/private.

Komentarze są zamknięte.