Java class design #2
Override methods.
W życiu wiele czynności możemy wykonywać na różne sposoby. Niektórzy myjąc naczynia korzystają z miednicy z wodą, inni całość wykonują pod bieżącą wodą z kranu, a jeszcze inni po prostu wrzucają wszystko do zmywarki i zapominają o temacie.
Java również oferuje nam możliwość zdefiniowania tej samej metody na kilka sposobów. Mechanizm ten znany jest jako nadpisywanie metod (ang. methods override).
Na skróty:
Jeśli idea nadpisywania metod jest Ci znana i interesują Cię tylko zasady tego mechanizmu w Javie, przejdź od razu do trzeciego akapitu:
Zasady nadpisywanie metod w Javie [Opis]
Repozytorium z przykładami [GitHub]
Idea nadpisywania metod
Zasady programowania obiektowego zachęcają nas do maksymalnego ograniczania powielania tego samego kodu. Dlatego, gdybyśmy przykładowo pisali symulator ZOO, nie było by najlepszym rozwiązaniem napisanie szeregu klas Lion, Zebra, Bear… i tak dalej dla każdego z setek zwierząt, gdzie w każdej klasie byłyby powtarzające się metody eat(), sleep(), walk(). Przecież każde zwierze potrafi, a nawet musi jeść, spać i chodzić. Tylko, że każde robi to „po swojemu”. Aby ograniczyć powtarzalność kodu i ułatwić przyszłe zmiany powinniśmy utworzyć nadrzędną klasę Animal. Taka klasa zawierała by domyślne implementacje metod eat(), sleep(), walk(). Później każda klasa reprezentująca konkretne zwierze, dziedziczyła by po Animal, od razu mając dostęp do standardowych zachowań, które w razie potrzeby moglibyśmy modyfikować pod konkretne zwierze – ta modyfikacja to właśnie nadpisywanie metod.
Zoologiczny przykład
Skoro już zacząłem „pokazywać na zwierzątkach”, to rozwińmy ten wątek.
Mamy ogólną klasę Animal, która w konstruktorze przyjmuje nazwę zwierzęcia. Oraz dodatkowo zadeklarowaliśmy publiczną metodę eat().
package com.javathehat.classdesign2.zoo;
public class Animal {
protected String name = "Animal";
public Animal(String name){
this.name = name;
}
public void eat(){
System.out.println(name + " is eating");
}
}
Teraz, dziedzicząc po Animal, tworzymy klasę Monkey. Klasa ta obecnie nie ma żadnej swojej metody, jedynie w konstruktorze nadaje sobie imię „Monkey” .
package com.javathehat.classdesign2.zoo;
public class Monkey extends Animal{
public Monkey() {
super("Monkey");
}
}
Następnie powołamy do życia nasze małe Zoo, w którym żyje sobie jedna małpa.
package com.javathehat.classdesign2.zoo;
public class ZOO {
public static void main(String[] args) {
Monkey monkey = new Monkey();
monkey.eat();
}
}
Małpa odziedziczyła po nadrzędnej klasie domyślną implementację metody eat(). Dlatego po uruchomieniu kodu otrzymamy taki wynik:
Monkey is eating
Jeśli dodamy teraz jeszcze jedno zwierze:
package com.javathehat.classdesign2.zoo;
public class Lion extends Animal{
public Lion() {
super("Lion");
}
}
package com.javathehat.classdesign2.zoo;
public class ZOO {
public static void main(String[] args) {
Monkey monkey = new Monkey();
monkey.eat();
Lion lion = new Lion();
lion.eat();
}
}
Otrzymamy przewidywalny wynik:
Monkey is eating Lion is eating
Wiemy jednak, że małpy i lwy mają delikatnie odmienne gusta i chcielibyśmy to odzwierciedlić w kodzie. Tu do akcji wchodzi nadpisywanie metod.
Do klas Monkey oraz Lion dodajemy ich własną wersję metody eat():
public class Monkey extends Animal{
public Monkey() {
super("Monkey");
}
public void eat(){
System.out.println(name + " is eating banana");
}
}
package com.javathehat.classdesign2.zoo;
public class Lion extends Animal{
public Lion() {
super("Lion");
}
public void eat(){
System.out.println(name + " is eating monkey");
}
}
Teraz wynik uruchomienia kodu wygląda tak:
Monkey is eating banana Lion is eating monkey
No dobra, fajnie, działa. Nic nie zmieniliśmy w klasie Zoo, a jednak wypisuje coś innego… Tylko po co ten cyrk z klasą Animal i dziedziczeniem po niej, skoro i tak dla każdego obiektu reprezentującego zwierze piszemy własną metodę eat()? Faktycznie może to wyglądać bezsensownie przy naszym mini zoo, z dwoma zwierzakami. Ale co gdybyśmy mieli w Zoo tysiąc zwierzaków i co jakiś czas ich liczba mogła by się zmieniać? Wypisanie tysiąca razy monkey.eat() definitywnie nie wchodzi w grę…
Dlatego przerobimy sobie nasz przykład tak, aby do wszystkich zwierząt odnosić się po klasie Animal, a nie bezpośrednio po tym typie zwierzęcia jakie faktycznie reprezentują.
package com.javathehat.classdesign2.zoo;
public class ZOO {
public static void main(String[] args) {
Animal[] animals = new Animal[10];
animals[0] = new Monkey();
animals[1] = new Lion();
animals[2] = new Lion();
animals[3] = new Lion();
animals[4] = new Lion();
animals[5] = new Monkey();
animals[6] = new Monkey();
animals[7] = new Monkey();
animals[8] = new Monkey();
animals[9] = new Monkey();
for(Animal animal : animals){
animal.eat();
}
}
}
Tak wygląda klasa Zoo po lekkim tuningu. Deklarujemy tablicę typu Animal. Wiemy więc, że w naszym zoo jest dziesięć zwierząt. Tak na prawdę z punktu widzenia ich życia nie jest istotne jakie to są konkretnie zwierzęta i ile ich jest. Na dzień dzisiejszy deklarujemy sobie, że jest to sześć małp i cztery lwy. Następnie w pętli iterującej się po wszystkich naszych zwierzętach – znów, nie ma znaczenia ile ich jest i jakiego typu, pętla „obsłuży” wszystko co jest pochodną klasy Animal; wywołujemy na każdym zwierzęciu metodę eat(). Dopiero tutaj zaczyna mieć znaczenie konkretny typ zwierzęcia. Tam gdzie metoda eat() została nadpisana, użyta będzie właśnie ta wersja kodu, a nie ogólna, z klasy Animal.
Po uruchomieniu otrzymujemy:
Monkey is eating banana Lion is eating monkey Lion is eating monkey Lion is eating monkey Lion is eating monkey Monkey is eating banana Monkey is eating banana Monkey is eating banana Monkey is eating banana Monkey is eating banana
Zahaczyłem tutaj mocno o inne ważne, ale i bardziej zaawansowane zagadnienia programowania obiektowego, którym zostaną poświęcone oddzielne artykuły, ale jednak chciałem od razu pokazać jaki jest sens nadpisywania metod i, że nie jest to po prostu „bajer”, tylko mechanizm wręcz niezbędny w dużych systemach.
Zasady nadpisywania metod w Javie
Zasady omówimy sobie na prostym przykładzie:
Klasa nadrzędna:
public class Animal {
public void eat(){
System.out.println("eating");
}
}
Klasa pochodna:
public class Monkey extends Animal{
@Override
public void eat(){
System.out.println( "eating banana");
}
}
Po pierwsze, adnotacja @Override, którą została oznaczona metoda eat w klasie pochodnej jest OPCJONALNA. To znaczy, że jej zastosowanie lub nie, nie ma wpływu na pozytywny wynik kompilacji, ani na działanie kodu.
Ale! Oficjalnie zaleca się stosowanie tej adnotacji, ponieważ przy kompilacji, lub we współczesnych IDE – w trakcie pisania kodu, zostaniemy ostrzeżeni, jeśli złamiemy zasady prawidłowego nadpisywania metod, co w praktyce przekłada się na to, że z punktu widzenia Javy będzie to albo metoda przeciążona (o tym w oddzielnym artykule), albo będzie to zupełnie nowa metoda dostępna tylko w tym obiekcie.
Po drugie, metoda nadpisująca musi mieć taki sam, lub mniej restrykcyjny modyfikator dostępu (access modifier) jak metoda nadpisywana.
Czyli, jeżeli nadpisujemy metodę public, to nasza metoda również musi być public. Jeśli nadpisujemy metodę protected to nasza metoda musi być protected lub public itd..
public eat(); może być rozszerzone tylko przez public eat();
protected eat(); przez public eat() lub protected eat();
eat(); przez public eat(), protected eat() lub eat();
private eat(); przez przez public eat(), protected eat(), eat() lub private eat();
Po trzecie, tak zwany „non access modifier” w większości przypadków nie ma znaczenia, ale jak to zazwyczaj bywa… są wyjątki.
- Metoda oznaczona słowem kluczowym final nie może być nadpisana.
- Metoda oznaczona słowem kluczowym static również musi być nadpisana przez metodę static i vice versa, czyli nadpisująca metoda nie może być static, jeśli nadpisywana nie jest.
- Natomiast metoda nadpisująca może być opatrzona modyfikatorem abstract, nawet jeśli nadpisywana metoda nie jest.
Po czwarte, typ zwracany przez metodę nadpisującą musi być identyczny lub pochodny od typu zwracanego przez metodę nadpisywaną.
Na przykład jeśli klasa Monkey dziedziczy po klasie Animal to dla metody:
public Animal getAnimal(){
return new Animal();
}
Prawidłowym nadpisaniem będzie:
public Monkey getAnimal(){
return new Monkey();
}
Po piąte, choć to oczywiste to jednak trzeba zaznaczyć, że nazwa metody nadpisywanej i nadpisującej musi być identyczna.
Po szóste, już może mnie oczywiste – obie metody muszą mieć identyczną listę parametrów wejściowych.
Po siódme, jeśli metoda nadpisywana deklaruje możliwość wyrzucenia wyjątku, to metoda nadpisująca musi deklarować rzucanie tego samego wyjątku lub jego pochodnej lub w ogóle nie deklarować wyrzucania żadnego wyjątku.
W praktyce przekłada się to na fakt, że metoda nadpisująca nie może wyrzucić innego typu wyjątku lub typu bardziej ogólnego niż w metodzie nadpisywanej.
Zatem dla metody:
public void test() throws SQLException{}
Dozwolone są nadpisania:
public void test() throws SQLException{} //Taki sam wyjątek jak w metodzie nadpisywanej
public void test() throws BatchUpdateException{} //BatchUpdateException rozszerza SQLException
public void test(){} //Brak deklaracji wyjątku
Natomiast nie skompilują się:
public void test() throws Exception{} //Exception jest rodzicem SQLException
public void test() throws FileNotFoundException{} //To nie jest wyjatek pochodny od SQLException
Zasady te tyczą się tylko tak zwanych „checked exception” , czyli tych, które musimy obsłużyć blokiem „try catch„, lub spropagować je „wyżej” za pomocą słowa kluczowego throws. W kwestii „unchecked exception” , takich jakimi zostaniemy potraktowani na przykład, gdy skończy się nam pamięć i nie mamy praktycznie żadnej możliwości wyjścia z tego programistycznie- nie ma znaczenia, czy zadeklarujemy je w metodzie nadpisywanej i/lub nadpisującej – kompilator je ignoruje.
Nadpisywanie metod statycznych
Właściwie nie ma czegoś takiego. W przypadku metod statycznych mamy do czynienia z „przysłanianiem”, a nie nadpisywaniem…
Omówię to na przykładzie:
public class Animal {
public static void sleep(){
System.out.println("Animal is sleeping");
}
}
public class Monkey extends Animal{
public static void sleep(){
System.out.println("Monkey is sleeping");
}
}
Mamy klasę Animal i jej pochodną Monkey, która teoretycznie nadpisuje statyczną metodę sleep().
Powołajmy zatem do życia dwa obiekty typu Monkey i wywołajmy na nich metodę sleep():
Animal monkey1 = new Monkey();
Monkey monkey2 = new Monkey();
monkey1.sleep();
monkey2.sleep();
Co zobaczymy na konsoli po uruchomieniu programu?
Animal is sleeping
Monkey is sleeping
Co się stało? Dlaczego „nadpisanie” metody zadziałało tylko połowicznie? To nie było nadpisanie. Statycznych metod nie możemy nadpisać. Możemy w klasie pochodnej utworzyć statyczną metodę o takich samych parametrach.
W takim przypadku o tym, która metoda zostanie wykonana decyduje referencja.
Nasza pierwsza małpka (monkey1) została przypisana do referencji typu Animal, dlatego wykonała się metoda sleep() z klasy Animal. Natomiast obiekt monkey2 został przypisany do referencji o typie Monkey, więc z tej klasy została wzięta metoda sleep().
To wszystko. W następnych artykule będziemy kontynuować temat metod. Tym razem będzie to ich przeciążanie.