Pytania związane z programowaniem obiektowym (OOP) są integralną częścią wywiadu technicznego na stanowisko programisty Java w firmie IT. W tym artykule opiszemy jedną z zasad OOP – polimorfizm. Skoncentrujemy się na aspektach, o które często pyta się podczas wywiadów, a także podamy kilka przykładów.

Czym jest polimorfizm w Javie?

Polimorfizm to zdolność programu do traktowania obiektów o tym samym interfejsie w taki sam sposób, bez informacji o typie obiektu. Jeżeli odpowiesz na pytanie czym jest polimorfizm, najprawdopodobniej pojawi się prośba o wyjaśnienie co masz na myśli. Bez wywoływania mnóstwa dodatkowych pytań, należy opisać to prowadzącemu wywiad raz jeszcze. Polimorfizm w Javie - 1Możesz zacząć od tego, że podejście OOP polega na budowaniu programu w Javie w oparciu o interakcje pomiędzy obiektami, które oparte są na klasach. Klasy to wcześniej napisane schematy (szablony) używane do tworzenia obiektów w programie. Co więcej, klasa ma zawsze określony typ, który przy dobrym stylu programowania ma nazwę sugerującą jej przeznaczenie. Ponadto można zauważyć, że Java cechuje się silnym typowaniem, kod programu musi zawsze określać typ obiektu podczas deklaracji zmiennych. Dodaj do tego fakt, że silne typowanie poprawia bezpieczeństwo i niezawodność kodu oraz umożliwia, nawet podczas kompilacji, zapobiegając błędom wynikającym z niezgodności typów (na przykład próba dzielenia ciągu przez liczbę). Oczywiście kompilator musi "znać" deklarowany typ – może to być klasa z JDK lub taka, którą stworzyliśmy sami. Zwróć uwagę na to, że nasz kod może wykorzystywać nie tylko obiekty typu wskazanego w deklaracji, ale również potomne. To ważna kwestia: możemy pracować z wieloma różnymi typami jak z pojedynczym typem (pod warunkiem, że typy te są wyprowadzone z tego samego typu podstawowego). Oznacza to również, że jeśli zadeklarujemy zmienną, której typem jest nadklasa, to możemy przypisać jej instancję jednej z podklas. Prowadzącemu rozmowę spodoba się to, jeżeli podasz przykład. Wybierz klasę, która może być współdzielona (klasa bazowa) przez kilka klas i spraw, aby kilka z nich ją odziedziczyło. Klasa bazowa:

public class Dancer {
    private String name;
    private int age;

    public Dancer(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void dance() {
        System.out.println(toString() + " I dance like everyone else.");
    }

    @Override
    public String toString() {
        Return "I'm " + name + ". I'm " + age + " years old.";
    }
}
W podklasach przesłoń metodę klasy bazowej:

public class ElectricBoogieDancer extends Dancer {
    public ElectricBoogieDancer(String name, int age) {
        super(name, age);
    }
// Override the method of the base class
    @Override
    public void dance() {
        System.out.println(toString () + " I dance the electric boogie!");
    }
}

public class Breakdancer extends Dancer {

    public Breakdancer(String name, int age) {
        super(name, age);
    }
// Override the method of the base class
    @Override
    public void dance() {
        System.out.println(toString() + " I breakdance!");
    }
}
Przykład polimorfizmu i jak te obiekty mogą być wykorzystane w programie:

public class Main {

    public static void main(String[] args) {
        Dancer dancer = new Dancer("Fred", 18);

        Dancer breakdancer = new Breakdancer("Jay", 19); // Widening conversion to the base type 
        Dancer electricBoogieDancer = new ElectricBoogieDancer("Marcia", 20); // Widening conversion to the base type

        List<dancer> disco = Arrays.asList(dancer, breakdancer, electricBoogieDancer);
        for (Dancer d : disco) {
            d.dance(); // Call the polymorphic method
        }
    }
}
W metodzie main wskaż, że linie

Dancer breakdancer = new Breakdancer("Jay", 19);
Dancer electricBoogieDancer = new ElectricBoogieDancer("Marcia", 20);
deklarują zmienną nadklasy i przypisują ją do obiektu będącego instancją jednej z klas potomnych. Najpewniej pojawi się pytanie, dlaczego kompilator nie zgłasza problemu niespójności typów zadeklarowanych po lewej i prawej stronie operatora przypisania — w końcu Java jest silnie typowana. Wyjaśnij, że odbywa się tutaj konwersja typu rozszerzającego — odwołanie do obiektu jest traktowane jak odwołanie do jego klasy bazowej. Po napotkaniu takiej konstrukcji w kodzie, kompilator dokonuje automatycznej, niejawnej konwersji. Przykładowy kod pokazuje, że typ zadeklarowany po lewej stronie operatora przypisania (Dancer) ma wiele form (typów), które są zadeklarowane po prawej stronie (Breakdancer, ElectricBoogieDancer). Każda z form może mieć swoje unikalne zachowanie w odniesieniu do ogólnej funkcjonalności zdefiniowanej w nadklasie (metoda dance). Oznacza to, że metoda zadeklarowana w nadklasie może być inaczej zaimplementowana w jej potomkach. W takim przypadku mamy do czynienia z przesłanianiem metod i właśnie to tworzy wiele form (zachowań). Można to zobaczyć uruchamiając kod w metodzie main: Dane wyjściowe programu:
Nazywam się Janek. Mam 18 lat. Tańczę tak jak wszyscy. Nazywam się Mati. Mam 19 lat. Tańczę breakdance! Nazywam się Zuzia. Mam 20 lat. Tańczę elektryczne boogie!
Jeśli nie przesłonimy metody w podklasach, nie uzyskamy innego zachowania. Na przykład, jeśli zakomentujemy metodę dance w klasach Breakdancer i ElectricBoogieDancer, wynik działania programu będzie następujący:
Nazywam się Janek. Mam 18 lat. Tańczę tak jak wszyscy. Nazywam się Mati. Mam 19 lat. Tańczę tak jak wszyscy. Nazywam się Zuzia. Mam 20 lat. Tańczę tak jak wszyscy.
A to oznacza, że tworzenie klas Breakdancer i ElectricBoogieDancer zwyczajnie nie ma sensu. Gdzie konkretnie przejawia się zasada polimorfizmu? Gdzie w programie obiekt zostaje użyty bez znajomości jego dokładnego typu? W naszym przykładzie dzieje się to, gdy metoda dance() jest wywoływana dla obiektu Dancer d. W Javie polimorfizm oznacza, że program nie musi wiedzieć, czy obiekt jest typu Breakdancer lub ElectricBoogieDancer. Ważne jest to, że jest on potomkiem klasy Dancer. A jeśli wspominasz o potomkach, zauważ że dziedziczenie w Javie to nie tylko extends, ale również implements. Można teraz wspomnieć, że Java nie obsługuje dziedziczenia wielokrotnego — każdy typ może mieć tylko jednego rodzica (nadklasę) i nieograniczoną liczbę potomków (podklasy). W związku z tym interfejsy służą do dodawania do klas wielu zestawów funkcji. W porównaniu z podklasami (dziedziczenie), interfejsy są w mniejszym stopniu sprzężone z klasą nadrzędną. Są bardzo szeroko stosowane. W Javie interfejs jest typem referencyjnym, zatem program może deklarować zmienną typu interfejsu. Pora na przykład. Utwórz interfejs:

public interface CanSwim {
    void swim();
}
Dla jasności weźmiemy różne niepowiązane klasy i zaimplementujemy w nich interfejs:

public class Human implements CanSwim {
    private String name;
    private int age;

    public Human(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public void swim() {
        System.out.println(toString()+" I swim with an inflated tube.");
    }

    @Override
    public String toString() {
        return "I'm " + name + ". I'm " + age + " years old.";
    }

}
 
public class Fish implements CanSwim {
    private String name;

    public Fish(String name) {
        this.name = name;
    }

    @Override
    public void swim() {
        System.out.println("I'm a fish. My name is " + name + ". I swim by moving my fins.");

    }

public class UBoat implements CanSwim {

    private int speed;

    public UBoat(int speed) {
        this.speed = speed;
    }

    @Override
    public void swim() {
        System.out.println("I'm a submarine that swims through the water by rotating screw propellers. My speed is " + speed + " knots.");
    }
}
Metoda main:

public class Main {

    public static void main(String[] args) {
        CanSwim human = new Human("John", 6);
        CanSwim fish = new Fish("Whale");
        CanSwim boat = new UBoat(25);

        List<swim> swimmers = Arrays.asList(human, fish, boat);
        for (Swim s : swimmers) {
            s.swim();
        }
    }
}
Wyniki wywołania metody polimorficznej zdefiniowanej w interfejsie pokazują nam różnice w zachowaniu typów, które implementują ten interfejs. W naszym przypadku są to różne ciągi wyświetlane przez metodę swim. Po zapoznaniu się z przykładem prowadzący spotkanie może zapytać, dlaczego uruchomienie tego kodu w metodzie main

for (Swim s : swimmers) {
            s.swim();        
}
powoduje wywołanie przesłaniających metod zdefiniowanych w naszych podklasach? Jak podczas działania programu wybierana jest odpowiednia implementacja metody? Aby odpowiedzieć na te pytania, należy wyjaśnić ideę późnego wiązania (dynamicznego). Wiązanie oznacza utworzenie mapowania pomiędzy wywołaniem metody, a konkretną implementacją klasy. Zasadniczo kod decyduje, która z trzech metod zdefiniowanych w klasach zostanie wykonana. Java domyślnie używa późnego wiązania, tzn. wiązanie ma miejsca w czasie wykonywania, a nie w czasie kompilacji, jak dzieje się w przypadku wczesnego wiązania. Oznacza to, że gdy kompilator kompiluje ten kod

for (Swim s : swimmers) {
            s.swim();        
}
nie wie, która klasa (Human, Fish lub Uboat) zawiera kod, który zostanie wykonany po wywołaniu metody swim. Jest to określane dopiero podczas wykonywania programu, dzięki mechanizmowi dynamicznego wiązania (sprawdzanie typu obiektu w czasie wykonywania programu i wybór implementacji odpowiedniej dla tego typu). Jeśli pojawi się pytanie, jak jest to zaimplementowane, możesz odpowiedzieć, że podczas wczytywania i inicjalizacji obiektów maszyna wirtualna maszyna Javy (JVM) buduje w pamięci tabele i łączy zmienne z ich wartościami, a obiekty z ich metodami. W ten sposób, jeśli klasa jest dziedziczona lub implementuje interfejs, w pierwszej kolejności sprawdzane jest istnienie przesłoniętych metod. Jeżeli istnieją, są wiązane z ich typem. Jeżeli nie, wyszukiwanie pasującej metody przechodzi do klasy o jeden krok wyżej (rodzica) i tak dalej, aż do korzenia w wielopoziomowej hierarchii. Jeśli chodzi o polimorfizm w OOP i jego implementację w kodzie, zauważamy, że dobrą praktyką jest używanie klas abstrakcyjnych i interfejsów do dostarczania abstrakcyjnych definicji klas bazowych. Ta praktyka wynika z zasady abstrakcji — identyfikowania wspólnych zachowań i właściwości oraz umieszczania ich w abstrakcyjnych klasach lub identyfikowania tylko wspólnych zachowań i umieszczania ich interfejsie. Do implementacji polimorfizmu wymagane jest projektowanie i tworzenie hierarchii obiektów w oparciu o interfejsy i dziedziczenie klas. Jeśli chodzi o polimorfizm i innowacje w Javie, zauważamy, że począwszy od Javy 8 podczas tworzenia klas abstrakcyjnych i interfejsów można użyć słowa kluczowego default, aby napisać domyślną implementację metod abstrakcyjnych w klasach bazowych. Na przykład:

public interface CanSwim {
    default void swim() {
        System.out.println("I just swim");
    }
}
Czasami prowadzący pytają jak należy deklarować metody w klasach bazowych, aby nie naruszać zasady polimorfizmu. Odpowiedź jest prosta: metody nie mogą być static, private ani final. Private sprawia, że metoda jest dostępna tylko w obrębie klasy, zatem nie można przesłonić jej w podklasie. Static kojarzy metodę z klasą, a nie z dowolnym obiektem, więc wywoływana będzie zawsze metoda nadklasy. Natomiast final sprawia, że metoda jest niemutowalna i ukryta z poziomu podklas.

Co daje nam polimorfizm?

Prawdopodobnie pojawi się również pytanie, jakie korzyści daje nam polimorfizm. Możesz odpowiedzieć krótko, nie zagłębiając się w szczegóły:
  1. Umożliwia zastępowanie implementacji klas. Na tym opiera się testowanie.
  2. Ułatwia rozszerzalność, co upraszcza tworzenie fundamentu, na którym można będzie budować w przyszłości. Dodawanie nowych typów na podstawie istniejących to podstawowy sposób na rozszerzanie funkcjonalności programów OOP.
  3. Pozwala łączyć obiekty, które mają wspólny typ lub zachowanie w kolekcję lub tablicę i obsługiwać je w jednolity sposób (jak w naszych przykładach, gdy zmuszaliśmy wszystkich do wykonywania dance() lub swim() :)
  4. Elastyczność w tworzeniu nowych typów: możesz wybrać implementację metody z nadklasy lub przesłonić ją w podklasie.
Polimorfizm w Javie - 2

Kilka słów na pożegnanie

Polimorfizm to bardzo ważny i obszerny temat. Zajmuje prawie połowę tego artykułu na temat OOP w Javie i stanowi solidną część fundamentu języka. Nie można uniknąć wyjaśnienia tej zasady podczas rozmowy kwalifikacyjnej. Jeżeli nie znasz tego tematu lub go nie rozumiesz, rozmowa prawdopodobnie dobiegnie końca. Więc nie bądź leniwy — sprawdź swoją wiedzę przed rozmową kwalifikacyjną i odśwież ją, jeżeli to konieczne.