1. Wprowadzenie
Polimorfizm – to jedna z trzech filarowych koncepcji programowania obiektowego (obok dziedziczenia i hermetyzacji). Dosłownie pochodzi z greki: „poly” (wiele) i „morph” (forma). W programowaniu oznacza: jeden interfejs – wiele implementacji.
Definicja
Polimorfizm to zdolność obiektów różnych klas do różnej reakcji na te same komunikaty (wywołania metod).
To znaczy: jeśli masz metodę makeSound(), możesz ją wywołać dla dowolnego zwierzęcia, ale kot zamiauczy, pies zaszczeka, a krowa zamuczy. Dla programisty to po prostu wywołanie animal.makeSound(), a to, co faktycznie się stanie, zależy od tego, jaki obiekt kryje się pod tą zmienną.
Analogia z życia
Wyobraź sobie, że masz w domu pilot do telewizora i tym samym pilotem możesz sterować głośnikami, projektorem, a nawet ekspresem do kawy. Naciskasz przycisk „Włącz” – turnOn(), a każde urządzenie reaguje po swojemu. Najważniejsze – wszystkie mają „przycisk” włączania, ale implementacja jest różna.
Przykład w Javie
class Animal {
void makeSound() {
System.out.println("Jakiś dźwięk...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Hau!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Miau!");
}
}
class Cow extends Animal {
@Override
void makeSound() {
System.out.println("Muuu!");
}
}
Teraz możemy zrobić tak:
Animal animal1 = new Dog();
Animal animal2 = new Cat();
Animal animal3 = new Cow();
animal1.makeSound(); // Hau!
animal2.makeSound(); // Miau!
animal3.makeSound(); // Muuu!
Zwróć uwagę: wszystkie zmienne mają typ Animal, ale wynik wywołania zależy od rzeczywistego typu obiektu.
2. Rodzaje polimorfizmu
W Javie (i w większości języków OOP) wyróżnia się dwa podstawowe rodzaje polimorfizmu:
Polimorfizm kompilacyjny (statyczny) – przeciążanie metod (overloading)
To sytuacja, gdy w jednej klasie istnieje kilka metod o tej samej nazwie, lecz z różnymi parametrami. Kompilator wybiera, którą metodę wywołać, na podstawie przekazanych argumentów.
Przykład (wyprzedzając – więcej w następnym wykładzie):
class Printer {
void print(int x) {
System.out.println("Liczba: " + x);
}
void print(String s) {
System.out.println("Napis: " + s);
}
}
Polimorfizm wykonaniowy (dynamiczny) – nadpisywanie metod (overriding)
To gdy metoda jest zdefiniowana w klasie bazowej, a następnie nadpisana w klasach pochodnych. Która metoda zostanie wywołana, rozstrzygane jest w czasie działania (runtime), w zależności od rzeczywistego typu obiektu.
Przykład – patrz wyżej ze zwierzętami.
3. Po co jest potrzebny polimorfizm?
Polimorfizm to nie tylko ładne słowo na rozmowę kwalifikacyjną. To narzędzie, które czyni twój kod elastycznym, rozszerzalnym i wygodnym w utrzymaniu.
Uniwersalność kodu
Możesz pisać kod, który pracuje z obiektami typu bazowego, nie martwiąc się o szczegóły ich implementacji. Na przykład, jeśli masz listę zwierząt, możesz po niej przejść i wywołać u każdego makeSound(), nie zastanawiając się, czy to kot, czy pies.
Animal[] animals = { new Dog(), new Cat(), new Cow() };
for (Animal animal : animals) {
animal.makeSound(); // Za każdym razem wywoła się właściwa metoda
}
Łatwość rozszerzania
Jeśli jutro szef powie: „Dodajmy papugę!”, po prostu piszesz nową klasę Parrot extends Animal i dodajesz ją do tablicy. Cała reszta kodu pozostaje bez zmian. To – otwartość na rozszerzenia i zamknięcie na modyfikacje (zasada OCP z SOLID).
Uproszczenie architektury
Możesz budować złożone systemy, w których poszczególne części współdziałają przez abstrakcje (klasy bazowe lub interfejsy), nie martwiąc się o konkretne implementacje. To oszczędza czas, nerwy i kawę.
4. Kluczowe pojęcia: typ referencyjny i rzeczywisty
Typ referencyjny zmiennej
Kiedy piszesz Animal animal = new Dog();, zmienna animal ma typ referencyjny Animal, czyli kompilator „myśli”, że to zwierzę, i pozwala tylko na te metody, które są zadeklarowane w klasie Animal.
Rzeczywisty (faktyczny) typ obiektu
W pamięci faktycznie znajduje się obiekt typu Dog. To on decyduje, która metoda zostanie wywołana przy odwołaniu do makeSound().
Ilustracja
Animal animal = new Dog();
animal.makeSound(); // Wywoła Dog.makeSound(), a nie Animal.makeSound()
Ważne! Przez referencję typu bazowego (Animal) nie wywołasz metod, które istnieją tylko w Dog, jeśli nie są zadeklarowane w klasie bazowej.
Późne (dynamiczne) wiązanie
To „magia”, która dzieje się w czasie wykonywania: kiedy wywołujesz metodę przez referencję typu bazowego, JVM patrzy na rzeczywisty typ obiektu i wywołuje „właściwą” implementację. To właśnie polimorfizm w działaniu.
5. Przykład praktyczny: polimorfizm w aplikacji
Kontynuujmy rozwój naszej aplikacji szkoleniowej. Załóżmy, że piszemy prostą symulację zoo. Mamy klasę bazową Animal i kilku jej potomków. Chcemy, aby wszystkie zwierzęta potrafiły „wydawać dźwięk”, ale nie chcemy za każdym razem pisać osobnego kodu dla każdego typu zwierzęcia.
Krok 1: Klasa bazowa i potomkowie
class Animal {
void makeSound() {
System.out.println("Jakiś dźwięk...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Hau!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Miau!");
}
}
Krok 2: Tablica zwierząt
Animal[] zoo = { new Dog(), new Cat(), new Animal() };
Krok 3: Iteracja i wywołanie metody
for (Animal animal : zoo) {
animal.makeSound();
}
Wynik działania:
Hau!
Miau!
Jakiś dźwięk...
Zauważ: nie wiemy z góry, kto dokładnie znajduje się w tablicy – ale program sam rozstrzyga i wywołuje właściwą metodę.
6. Schematyczne przedstawienie polimorfizmu
. Animal (makeSound)
/ \
Dog Cat
(makeSound) (makeSound)
Animal animal = new Dog();
animal.makeSound(); // --> Dog.makeSound()
Animal animal = new Cat();
animal.makeSound(); // --> Cat.makeSound()
7. Jeszcze jeden przykład: polimorfizm w realnym zadaniu
Załóżmy, że piszesz program do zarządzania pracownikami firmy. Masz klasę bazową Employee i dwie klasy pochodne: Manager i Developer. Wszyscy pracownicy potrafią pracować – work() – ale robią to na różne sposoby.
class Employee {
void work() {
System.out.println("Pracownik pracuje.");
}
}
class Manager extends Employee {
@Override
void work() {
System.out.println("Menedżer prowadzi spotkanie.");
}
}
class Developer extends Employee {
@Override
void work() {
System.out.println("Programista pisze kod.");
}
}
Teraz możesz zrobić tak:
Employee[] staff = { new Manager(), new Developer(), new Employee() };
for (Employee emp : staff) {
emp.work();
}
Wynik:
Menedżer prowadzi spotkanie.
Programista pisze kod.
Pracownik pracuje.
8. Kiedy polimorfizm NIE działa
Polimorfizm działa tylko dla metod zadeklarowanych w klasie bazowej. Jeśli w podklasie jest jej unikalna metoda, przez referencję typu bazowego jej nie zobaczysz.
class Dog extends Animal {
void fetchStick() {
System.out.println("Pies przynosi kij!");
}
}
Animal animal = new Dog();
// animal.fetchStick(); // Błąd kompilacji! Taka metoda nie jest widoczna przez Animal
Aby wywołać metodę specyficzną dla klasy, trzeba rzutować zmienną na odpowiedni typ:
if (animal instanceof Dog) {
((Dog) animal).fetchStick();
}
Ale to już inna historia – najważniejsze: przez polimorfizm dostępne są tylko metody zadeklarowane w klasie bazowej.
9. Typowe błędy przy pracy z polimorfizmem
Błąd nr 1: Oczekiwanie, że przez referencję typu bazowego będą dostępne wszystkie metody podklasy. W rzeczywistości dostępne są tylko te, które zadeklarowano w klasie bazowej.
Błąd nr 2: Nieużywanie adnotacji @Override przy nadpisywaniu metody. Bez niej można przypadkowo napisać metodę z niepoprawną sygnaturą i wtedy polimorfizm nie zadziała (metoda z klasy bazowej nie zostanie nadpisana).
Błąd nr 3: Próba wywołania metody specyficznej dla podklasy bez rzutowania typu. Kompilator na to nie pozwoli, bo nie wie, co dokładnie kryje się pod referencją typu bazowego.
Błąd nr 4: Pomylenie przeciążania (overloading) z nadpisywaniem (overriding). Przeciążanie to kilka metod o tej samej nazwie i różnych parametrach w jednej klasie. Nadpisywanie to zmiana zachowania metody w podklasie.
GO TO FULL VERSION