JavaRush /Blog Java /Random-PL /Polimorfizm i jego przyjaciele
Viacheslav
Poziom 3

Polimorfizm i jego przyjaciele

Opublikowano w grupie Random-PL
Polimorfizm jest jedną z podstawowych zasad programowania obiektowego. Pozwala wykorzystać moc silnego pisania w Javie i napisać użyteczny i łatwy w utrzymaniu kod. Wiele już o nim powiedziano, ale mam nadzieję, że każdy wyniesie z tej recenzji coś nowego.
Polimorfizm i jego przyjaciele - 1

Wstęp

Chyba wszyscy wiemy, że język programowania Java należy do Oracle. Dlatego nasza droga zaczyna się od strony: www.oracle.com . Na stronie głównej znajduje się „Menu”. W nim, w sekcji „Dokumentacja” znajduje się podsekcja „Java”. Wszystko, co dotyczy podstawowych funkcji języka, należy do „dokumentacji Java SE”, dlatego wybieramy tę sekcję. Otworzy się sekcja dokumentacji dotycząca najnowszej wersji, ale na razie wyświetla się komunikat „Szukasz innej wersji?” Wybierzmy opcję: JDK8. Na stronie zobaczymy wiele różnych opcji. Ale jesteśmy zainteresowani Naucz się języka: „ Ścieżki uczenia się samouczków Java ”. Na tej stronie znajdziemy kolejną sekcję: „ Nauka języka Java ”. To jest najświętsze ze świętych, tutorial na temat podstaw Java od Oracle. Java jest językiem programowania zorientowanym obiektowo (OOP), więc nauka tego języka nawet na stronie internetowej Oracle rozpoczyna się od omówienia podstawowych pojęć „ Koncepcji programowania obiektowego ”. Już sama nazwa jasno wskazuje, że Java nastawiona jest na pracę z obiektami. Z podsekcji „ Co to jest obiekt? ” jasno wynika, że ​​obiekty w Javie składają się ze stanu i zachowania. Wyobraź sobie, że mamy konto bankowe. Ilość pieniędzy na koncie to stan, a metodami pracy z tym stanem jest zachowanie. Obiekty trzeba w jakiś sposób opisać (powiedzieć, jaki mogą mieć stan i zachowanie) i tym opisem jest klasa . Kiedy tworzymy obiekt jakiejś klasy, określamy tę klasę i nazywa się to „ typem obiektu ”. Dlatego mówi się, że Java jest językiem silnie typowanym, jak podano w specyfikacji języka Java w sekcji „ Rozdział 4. Typy, wartości i zmienne ”. Język Java opiera się na koncepcjach OOP i obsługuje dziedziczenie za pomocą słowa kluczowego Extends. Dlaczego ekspansja? Ponieważ dzięki dziedziczeniu klasa potomna dziedziczy zachowanie i stan klasy nadrzędnej i może je uzupełniać, tj. rozszerzyć funkcjonalność klasy bazowej. Interfejs można również określić w opisie klasy za pomocą słowa kluczowego implements. Kiedy klasa implementuje interfejs, oznacza to, że klasa jest zgodna z jakąś umową - deklaracją programisty wobec reszty środowiska, że ​​klasa zachowuje się w określony sposób. Na przykład odtwarzacz ma różne przyciski. Przyciski te stanowią interfejs do kontrolowania zachowania odtwarzacza, a zachowanie będzie zmieniać stan wewnętrzny odtwarzacza (na przykład głośność). W tym przypadku stan i zachowanie jako opis dadzą klasę. Jeśli klasa implementuje interfejs, to obiekt tworzony przez tę klasę może być opisany typem nie tylko przez klasę, ale także przez interfejs. Spójrzmy na przykład:
public class MusicPlayer {

    public static interface Device {
        public void turnOn();
        public void turnOff();
    }

    public static class Mp3Player implements Device {
        public void turnOn() {
            System.out.println("On. Ready for mp3.");
        }
        public void turnOff() {
            System.out.println("Off");
        }
    }

    public static class Mp4Player extends Mp3Player {
        @Override
        public void turnOn() {
            System.out.println("On. Ready for mp3/mp4.");
        }
    }

    public static void main(String []args) throws Exception{
        // Какое-то устройство (Тип = Device)
        Device mp3Player = new Mp3Player();
        mp3Player.turnOn();
        // У нас есть mp4 проигрыватель, но нам от него нужно только mp3
        // Пользуемся им Jak mp3 проигрывателем (Тип = Mp3Player)
        Mp3Player mp4Player = new Mp4Player();
        mp4Player.turnOn();
    }
}
Typ to bardzo ważny opis. Mówi, w jaki sposób będziemy pracować z przedmiotem, tj. jakiego zachowania oczekujemy od obiektu. Zachowania są metodami. Dlatego zrozummy metody. W witrynie Oracle metody mają swoją własną sekcję w samouczku Oracle: „ Definiowanie metod ”. Pierwszą rzeczą, którą należy wyciągnąć z artykułu: Sygnatura metody to nazwa metody i typy parametrów :
Polimorfizm i jego przyjaciele - 2
Przykładowo, deklarując metodę public void method(Object o), podpisem będzie nazwa metody i typ parametru Object. Typ zwrotu NIE jest zawarty w podpisie. To jest ważne! Następnie skompilujmy nasz kod źródłowy. Jak wiemy, w tym celu kod należy zapisać w pliku z nazwą klasy i rozszerzeniem java. Kod Java jest kompilowany przy użyciu kompilatora „ javac ” do formatu pośredniego, który może zostać wykonany przez wirtualną maszynę Java (JVM). Ten format pośredni nazywany jest kodem bajtowym i jest zawarty w plikach z rozszerzeniem .class. Uruchommy polecenie kompilacji: javac MusicPlayer.java Po skompilowaniu kodu Java możemy go wykonać. Po uruchomieniu narzędzia „ Java ” zostanie uruchomiony proces wirtualnej maszyny Java w celu wykonania kodu bajtowego przekazanego w pliku klasy. Uruchommy polecenie uruchamiające aplikację: java MusicPlayer. Na ekranie zobaczymy tekst podany w parametrze wejściowym metody println. Co ciekawe, mając kod bajtowy w pliku z rozszerzeniem .class, możemy go obejrzeć za pomocą narzędzia „ javap ”. Uruchommy polecenie <ocde>javap -c MusicPlayer:
Polimorfizm i jego przyjaciele - 3
Z kodu bajtowego widać, że wywołanie metody poprzez obiekt, którego typ został określony w klasie, odbywa się za pomocą invokevirtual, a kompilator obliczył, jaką sygnaturę metody należy zastosować. Dlaczego invokevirtual? Ponieważ istnieje wywołanie (invoke jest tłumaczone jako wywołanie) metody wirtualnej. Co to jest metoda wirtualna? Jest to metoda, której treść można zastąpić podczas wykonywania programu. Po prostu wyobraź sobie, że masz listę powiązań pomiędzy określonym kluczem (sygnaturą metody) a treścią (kodem) metody. Ta zgodność pomiędzy kluczem a treścią metody może ulec zmianie podczas wykonywania programu. Dlatego metoda jest wirtualna. Domyślnie w Javie metody, które NIE są statyczne, NIE ostateczne i NIE prywatne, są wirtualne. Dzięki temu Java wspiera zasadę polimorfizmu w programowaniu obiektowym. Jak być może już zrozumiałeś, właśnie o tym jest nasza dzisiejsza recenzja.

Wielopostaciowość

Na stronie Oracle w ich oficjalnym tutorialu znajduje się osobna sekcja: „ Polimorfizm ”. Użyjmy kompilatora Java Online, aby zobaczyć, jak polimorfizm działa w Javie. Na przykład mamy klasę abstrakcyjną Number , która reprezentuje liczbę w Javie. Na co pozwala? Ma kilka podstawowych technik, które będą mieli wszyscy spadkobiercy. Każdy, kto dziedziczy po Numerze, dosłownie mówi: „Jestem numerem, możesz ze mną pracować jako numer”. Na przykład dla dowolnego następnika można użyć metody intValue(), aby uzyskać jego wartość całkowitą. Jeśli spojrzysz na interfejs API języka Java dla Number, zobaczysz, że metoda jest abstrakcyjna, co oznacza, że ​​każdy następnik Number musi samodzielnie implementować tę metodę. Ale co nam to daje? Spójrzmy na przykład:
public class HelloWorld {

    public static int summ(Number first, Number second) {
        return first.intValue() + second.intValue();
    }

    public static void main(String []args){
        System.out.println(summ(1, 2));
        System.out.println(summ(1L, 4L));
        System.out.println(summ(1L, 5));
        System.out.println(summ(1.0, 3));
    }
}
Jak widać na przykładzie, dzięki polimorfizmowi możemy napisać metodę, która na wejściu przyjmie argumenty dowolnego typu, które będą potomkiem Number (nie możemy uzyskać Number, ponieważ jest to klasa abstrakcyjna). Podobnie jak w przypadku przykładu gracza, w tym przypadku mówimy, że chcemy pracować z czymś, na przykład z Numerem. Wiemy, że każdy, kto jest liczbą, musi być w stanie podać jej wartość całkowitą. I to nam wystarczy. Nie chcemy wdawać się w szczegóły implementacji konkretnego obiektu i chcemy pracować z tym obiektem metodami wspólnymi dla wszystkich potomków Number. Lista metod, które będą dla nas dostępne, zostanie określona według typu w czasie kompilacji (jak widzieliśmy wcześniej w kodzie bajtowym). W tym wypadku naszym typem będzie Number. Jak widać z przykładu, przekazujemy różne liczby różnych typów, co oznacza, że ​​metoda summ otrzyma na wejściu wartości Integer, Long i Double. Jednak łączy je to, że są potomkami abstrakcyjnej liczby i dlatego zastępują swoje zachowanie w metodzie intValue, ponieważ każdy konkretny typ wie, jak rzutować ten typ na liczbę całkowitą. Taki polimorfizm realizowany jest poprzez tzw. override, w języku angielskim Overriding.
Polimorfizm i jego przyjaciele - 4
Nadpisywanie lub dynamiczny polimorfizm. Zacznijmy więc od zapisania pliku HelloWorld.java z następującą zawartością:
public class HelloWorld {
    public static class Parent {
        public void method() {
            System.out.println("Parent");
        }
    }
    public static class Child extends Parent {
        public void method() {
            System.out.println("Child");
        }
    }

    public static void main(String[] args) {
        Parent parent = new Parent();
        Parent child = new Child();
        parent.method();
        child.method();
    }
}
Zróbmy javac HelloWorld.javai javap -c HelloWorld:
Polimorfizm i jego przyjaciele - 5
Jak widać, w kodzie bajtowym linii z wywołaniem metody wskazane jest to samo odniesienie do metody wywołania invokevirtual (#6). Zróbmy to java HelloWorld. Jak widać zmienne parent i child deklarowane są z typem Parent, ale sama implementacja wywoływana jest w zależności od tego, jaki obiekt został do zmiennej przypisany (czyli jaki typ obiektu). Podczas wykonywania programu (mówią też w czasie wykonywania) JVM, w zależności od obiektu, wywołując metody używające tej samej sygnatury, wykonywała różne metody. Oznacza to, że używając klucza odpowiedniego podpisu, najpierw otrzymaliśmy jedną treść metody, a następnie otrzymaliśmy drugą. W zależności od tego, jaki obiekt znajduje się w zmiennej. To określenie w czasie wykonywania programu, która metoda zostanie wywołana, nazywane jest także późnym wiązaniem lub wiązaniem dynamicznym. Oznacza to, że dopasowanie sygnatury do treści metody odbywa się dynamicznie, w zależności od obiektu, na którym wywoływana jest metoda. Oczywiście nie można zastąpić statycznych członków klasy (Class Member), a także członków klasy z typem dostępu private lub final. Adnotacje @Override są również pomocne dla programistów. Pomaga to kompilatorowi zrozumieć, że w tym momencie nadpiszemy zachowanie metody nadrzędnej. Jeśli popełniliśmy błąd w sygnaturze metody, kompilator natychmiast nas o tym poinformuje. Na przykład:
public static class Parent {
        public void method() {
            System.out.println("parent");
        }
}
public static class Child extends Parent {
        @Override
        public void method(String text) {
            System.out.println("child");
        }
}
Nie kompiluje się z błędem: błąd: metoda nie przesłania ani nie implementuje metody z nadtypu
Polimorfizm i jego przyjaciele - 6
Redefinicja wiąże się także z pojęciem „ kowariancji ”. Spójrzmy na przykład:
public class HelloWorld {
    public static class Parent {
        public Number method() {
            return 1;
        }
    }
    public static class Child extends Parent {
        @Override
        public Integer method() {
            return 2;
        }
    }

    public static void main(String[] args) {
        System.out.println(new Child().method());
    }
}
Pomimo pozornej zawiłości sens sprowadza się do tego, że przy nadpisywaniu możemy zwrócić nie tylko typ, który został określony w przodku, ale także typ bardziej konkretny. Na przykład przodek zwrócił liczbę, a my możemy zwrócić liczbę całkowitą - potomka liczby. To samo dotyczy wyjątków zadeklarowanych w rzutach metody. Spadkobiercy mogą zastąpić metodę i udoskonalić zgłoszony wyjątek. Ale nie mogą się rozwijać. Oznacza to, że jeśli rodzic zgłosi wyjątek IOException, wówczas możemy zgłosić bardziej precyzyjny wyjątek EOFException, ale nie możemy zgłosić wyjątku. Podobnie nie można zawęzić zakresu i nałożyć dodatkowych ograniczeń. Na przykład nie można dodać statycznego.
Polimorfizm i jego przyjaciele - 7

Ukrywanie

Istnieje również coś takiego jak „ ukrycie ”. Przykład:
public class HelloWorld {
    public static class Parent {
        public static void method() {
            System.out.println("Parent");
        }
    }
    public static class Child extends Parent {
        public static void method() {
            System.out.println("Child");
        }
    }

    public static void main(String[] args) {
        Parent parent = new Parent();
        Parent child = new Child();
        parent.method();
        child.method();
    }
}
Jest to dość oczywista rzecz, jeśli się nad tym zastanowić. Statyczne elementy klasy należą do klasy, tj. do typu zmiennej. Dlatego logiczne jest, że jeśli dziecko jest typu Parent, to metoda zostanie wywołana na Parent, a nie na dziecku. Jeśli spojrzymy na kod bajtowy, tak jak to zrobiliśmy wcześniej, zobaczymy, że metoda statyczna jest wywoływana za pomocą invokestatic. To wyjaśnia maszynie JVM, że musi patrzeć na typ, a nie na tabelę metod, jak to miało miejsce w przypadku invokevirtual lub invokeinterface.
Polimorfizm i jego przyjaciele - 8

Metody przeciążania

Co jeszcze widzimy w samouczku Java Oracle? W poprzednio studiowanej sekcji „ Definiowanie metod ” jest coś o przeciążeniu. Co to jest? W języku rosyjskim jest to „przeciążenie metody”, a takie metody nazywane są „przeciążeniem”. A więc przeciążenie metody. Na pierwszy rzut oka wszystko jest proste. Otwórzmy internetowy kompilator Java, na przykład tutorialspoint online kompilator Java .
public class HelloWorld {

	public static void main(String []args){
		HelloWorld hw = new HelloWorld();
		hw.say(1);
		hw.say("1");
	}

	public static void say(Integer number) {
		System.out.println("Integer " + number);
	}
	public static void say(String number) {
		System.out.println("String " + number);
	}
}
Zatem wszystko tutaj wydaje się proste. Jak stwierdzono w samouczku Oracle, przeciążone metody (w tym przypadku metoda say) różnią się liczbą i typem argumentów przekazywanych do metody. Nie można zadeklarować tej samej nazwy i tej samej liczby identycznych typów argumentów, ponieważ kompilator nie będzie w stanie ich od siebie rozróżnić. Warto od razu zwrócić uwagę na bardzo ważną rzecz:
Polimorfizm i jego przyjaciele - 9
Oznacza to, że podczas przeciążenia kompilator sprawdza poprawność. To jest ważne. Ale w jaki sposób kompilator faktycznie ustala, że ​​należy wywołać określoną metodę? Wykorzystuje zasadę „Najbardziej szczegółowej metody” opisaną w specyfikacji języka Java: „ 15.12.2.5. Wybór najbardziej szczegółowej metody ”. Aby zademonstrować, jak to działa, weźmy przykład z Oracle Certified Professional Java Programmer:
public class Overload{
  public void method(Object o) {
    System.out.println("Object");
  }
  public void method(java.io.FileNotFoundException f) {
    System.out.println("FileNotFoundException");
  }
  public void method(java.io.IOException i) {
    System.out.println("IOException");
  }
  public static void main(String args[]) {
    Overload test = new Overload();
    test.method(null);
  }
}
Weź przykład stąd: https://github.com/stokito/OCPJP/blob/master/src/ru/habrahabr/blogs/java/OCPJP1/question1/Overload.j... Jak widać, mijamy null do metody. Kompilator próbuje określić najbardziej konkretny typ. Obiekt nie nadaje się ponieważ wszystko jest po nim odziedziczone. Zacząć robić. Istnieją 2 klasy wyjątków. Przyjrzyjmy się wyjątkowi java.io.IOException i zobaczmy, że istnieje wyjątek FileNotFoundException w „Bezpośrednio znanych podklasach”. Oznacza to, że okazuje się, że FileNotFoundException jest najbardziej specyficznym typem. Dlatego wynikiem będzie wynik ciągu „FileNotFoundException”. Jeśli jednak zamienimy IOException na EOFException, okaże się, że mamy dwie metody na tym samym poziomie hierarchii w drzewie typów, czyli dla obu IOException jest rodzicem. Kompilator nie będzie mógł wybrać metody wywołania i zgłosi błąd kompilacji: reference to method is ambiguous. Jeszcze jeden przykład:
public class Overload{
    public static void method(int... array) {
        System.out.println("1");
    }

    public static void main(String args[]) {
        method(1, 2);
    }
}
Otrzyma wynik 1. Nie ma tutaj żadnych pytań. Typ int... to vararg https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html i tak naprawdę jest niczym więcej niż „cukrem składniowym” i w rzeczywistości jest int. .. tablicę można odczytać jako tablicę int[]. Jeśli teraz dodamy metodę:
public static void method(long a, long b) {
	System.out.println("2");
}
Wtedy wyświetli się nie 1, ale 2, ponieważ przekazujemy 2 liczby, a 2 argumenty są lepszym dopasowaniem niż jedna tablica. Jeśli dodamy metodę:
public static void method(Integer a, Integer b) {
	System.out.println("3");
}
Wtedy nadal będziemy widzieć 2. Ponieważ w tym przypadku prymitywy są dokładniej dopasowane niż boks w liczbie całkowitej. Jeśli jednak wykonamy, method(new Integer(1), new Integer(2));wyświetli się 3. Konstruktory w Javie są podobne do metod, a ponieważ można ich również użyć do uzyskania podpisu, obowiązują ich te same zasady „rozstrzygania przeciążenia”, co metody przeciążone. Specyfikacja języka Java mówi nam o tym w „ 8.8.8. Przeciążanie konstruktora ”. Przeciążenie metody = Wczesne wiązanie (inaczej wiązanie statyczne) Często można usłyszeć o wczesnym i późnym wiązaniu, znanym również jako wiązanie statyczne lub wiązanie dynamiczne. Różnica między nimi jest bardzo prosta. Wczesna kompilacja, późna moment wykonania programu. Dlatego wczesne wiązanie (wiązanie statyczne) polega na określeniu, która metoda zostanie wywołana dla kogo w czasie kompilacji. Cóż, późne wiązanie (wiązanie dynamiczne) polega na określeniu, którą metodę wywołać bezpośrednio w momencie wykonywania programu. Jak widzieliśmy wcześniej (kiedy zmieniliśmy wyjątek IOException na EOFException), jeśli przeciążymy metody tak, że kompilator nie będzie wiedział, gdzie wykonać które wywołanie, otrzymamy błąd w czasie kompilacji: odniesienie do metody jest niejednoznaczne. Słowo ambiguous przetłumaczone z języka angielskiego oznacza niejednoznaczne lub niepewne, nieprecyzyjne. Okazuje się, że przeciążenie jest wcześnie wiążące, ponieważ sprawdzenie jest wykonywane w czasie kompilacji. Aby potwierdzić nasze wnioski, otwórzmy specyfikację języka Java w rozdziale „ 8.4.9. Przeciążanie ”:
Polimorfizm i jego przyjaciele - 10
Okazuje się, że podczas kompilacji informacja o typie i liczbie argumentów (która jest dostępna w momencie kompilacji) zostanie wykorzystana do określenia sygnatury metody. Jeśli metoda jest jedną z metod obiektu (tj. metodą instancji), rzeczywiste wywołanie metody zostanie określone w czasie wykonywania przy użyciu dynamicznego wyszukiwania metod (tj. dynamicznego wiązania). Aby było to jaśniejsze, weźmy przykład podobny do tego omówionego wcześniej:
public class HelloWorld {
    public void method(int intNumber) {
        System.out.println("intNumber");
    }
    public void method(Integer intNumber) {
        System.out.println("Integer");
    }
    public void method(String intNumber) {
        System.out.println("Number is: " + intNumber);
    }

    public static void main(String args[]) {
        HelloWorld test = new HelloWorld();
        test.method(2);
    }
}
Zapiszmy ten kod w pliku HelloWorld.java i skompilujmy go za pomocą javac HelloWorld.java Teraz zobaczmy, co nasz kompilator napisał w kodzie bajtowym, uruchamiając polecenie: javap -verbose HelloWorld.
Polimorfizm i jego przyjaciele - 11
Jak stwierdzono, kompilator ustalił, że w przyszłości zostanie wywołana jakaś metoda wirtualna. Oznacza to, że treść metody zostanie zdefiniowana w czasie wykonywania. Ale w momencie kompilacji ze wszystkich trzech metod kompilator wybrał najodpowiedniejszą, więc wskazał liczbę:"invokevirtual #13"
Polimorfizm i jego przyjaciele - 12
Co to za metoda? To jest link do metody. Z grubsza rzecz biorąc, jest to wskazówka, dzięki której w czasie wykonywania wirtualna maszyna Java może faktycznie określić, której metody szukać do wykonania. Więcej szczegółów można znaleźć w super artykule: „ Jak JVM obsługuje wewnętrzne przeciążanie i zastępowanie metod ”.

Zreasumowanie

Dowiedzieliśmy się więc, że Java, jako język obiektowy, obsługuje polimorfizm. Polimorfizm może być statyczny (wiązanie statyczne) lub dynamiczny (wiązanie dynamiczne). W przypadku polimorfizmu statycznego, znanego również jako wczesne wiązanie, kompilator określa, która metoda powinna zostać wywołana i gdzie. Pozwala to na zastosowanie mechanizmu np. przeciążenia. W przypadku dynamicznego polimorfizmu, znanego również jako późne wiązanie, w oparciu o wcześniej obliczoną sygnaturę metody, metoda zostanie obliczona w czasie wykonywania na podstawie tego, który obiekt jest używany (tj. jaka metoda obiektu jest wywoływana). Sposób działania tych mechanizmów można zobaczyć za pomocą kodu bajtowego. Przeciążenie sprawdza sygnatury metod i podczas rozwiązywania przeciążenia wybierana jest najbardziej konkretna (najdokładniejsza) opcja. Przesłanianie sprawdza typ, aby określić, jakie metody są dostępne, a same metody są wywoływane w oparciu o obiekt. A także materiały na ten temat: #Wiaczesław
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION