25 kwietnia 2008

SCJP - Redefiniowanie metod

Jeszcze trochę i zakończymy przerabianie drugiego rozdziału książki "SCJP Sun Certified Programmer for Java 5 Study Guide (Exam 310-055)" – patrz artykuł "Przygotowania do SCJP czas zacząć". Dziś będzie o przedefiniowywaniu metod (ang. overriding).

Redefiniowanie metody to implementacja na nowo metody, którą odziedziczyliśmy z klasy nadrzędnej. Jeśli implementujemy interfejs czy dziedziczymy z klasy abstrakcyjnej to nie ma wyjścia, metody trzeba zaimplementować, jeśli natomiast dziedziczymy metody wraz z implementacją to mamy wybór: możemy zaakceptować tą implementację, lub dostarczyć nową, odpowiednią dla danej podklasy. Metoda redefiniująca metodę odziedziczoną musi być z nią zgodna co do listy argumentów, modyfikatorów dostępu, deklarowanych wyjątków i zwracanego typu, jednak zgodna to nie oznacza identyczna. Reguły są następujące:

• Modyfikator dostępu dla metody redefiniującej nie może być bardziej restrykcyjny niż metody redefiniowanej. Musi być taki sam albo mniej restrykcyjny.
• Argumenty metody redefiniującej muszą być dokładnie takie same jak argumenty metody redefiniowanej.
• Zwracany typ metody redefiniującej musi być albo taki sam, albo być podtypem typu zwracanego przez metodę redefiniowaną – musi być z nim kompatybilny pod względem przypisania.
• Metoda redefiniująca nie może deklarować wyjątków, które nie były zadeklarowane w metodzie redefiniowanej jednak może deklarować ich mniej – zbiór wyjątków deklarowanych przez metodę redefiniującą musi być podzbiorem wyjątków deklarowanych przez metodę redefiniowaną. Ale uwaga, tutaj znowu nie chodzi o to żeby były to dokładnie te same wyjątki. Mogą być także wyjątki będące podtypami tych zadeklarowanych w metodzie przedefiniowywanej. Dla przykładu, poniższy kod jest poprawny, ponieważ zarówno IOException jak i SQLException są podklasami klasy Exception.

class Parent {
void test() throws Exception { }
}

class Child extends Parent {
@Override // dzięki tej adnotacji mamy pewność, że redefiniujemy
void test() throws IOException, SQLException { }
}

• Naturalnie, nie można przedefiniować metody, która została oznaczona jako finalna, tj. została oznaczona słówkiem kluczowym final – właśnie do tego, by redefiniowania zabronić to słówko kluczowe służy.
• Nie można przedefiniować metody statycznej, tj. oznaczonej słówkiem kluczowym static. Metody takie nie podlegają polimorfizmowi, więc zwyczajnie nie miałoby to sensu.

Ale co to właściwie oznacza, że metoda redefiniująca musi spełniać określone wymagania? Pod jakim rygorem? Jaki jest skutek nie przestrzegania tych zasad? Rozważmy następujące klasy.

class Parent {
static String getDescr() {
return "descr for Parent class";
}
}

class Child extends Parent {
static String getDescr() {
return "descr for Child class";
}
}

Czy metoda getDescr() została prawidłowo przedefiniowana? Nie! Czy powyższy kod się skompiluje, tj. czy jest poprawny? Tak! O co więc chodzi? Ano o to, że metoda getDescr() w klasie Child jest zupełnie inną metodą niż metoda getDescr() w klasie Parent. Obie metody są zdefiniowane poprawnie tyle, że jedna nie przedefiniowuje drugiej, po prostu, są to zupełnie dwie różne metody. Metody statyczne nie podlegają przedefiniowaniu. Zerknijmy na kolejny przykład.

class Parent {
String getDescr(String str) {
return str;
}
}

class Child extends Parent {
String getDescr(Object obj) {
return obj.toString();
}
}

Tym razem metoda getDescr() z klasy Parent nie jest statyczna, więc można ją przedefiniować, ale czy powyższy kod jest poprawnym przedefiniowaniem? Nie! Rzeczywiście, nie zgadza się lista argumentów, nie jest ona identyczna. Czy kod się kompiluje? Tak! W jakim sensie zatem przedefiniowanie to jest niepoprawne? Otóż kod jest poprawny, ale nie jest to przedefiniowanie (ang. overriding) tylko przeciążenie (ang. overloading). Skutek jest więc taki, że w klasie Child zdefiniowane są dwie metody o tej samej nazwie, ale różniące się argumentami. Więcej o przeciążaniu w kolejnym artykule. I jeszcze ostatni przykład.

class Parent {
String getDescr() {
return "descr for Parent class";
}
}

class Child extends Parent {
String getDescr() throws Exception {
return "descr for Child class";
}
}

Czy jest to poprawne przedefiniowanie? Nie! Czy kod się kompiluje? Niespodzianka – też nie! Metoda w klasie Child deklaruje wyjątek nie zadeklarowany w metodzie z klasy Parent, więc nie jest to poprawne przedefiniowanie. Nie jest to też przeciążenie, jako że metody nie różnią się listą argumentów, zatem nie pozostaje nic innego jak tylko zgłosić błąd kompilacji. W ostatnim przykładzie nieudana próba przedefiniowania metody skutkuje brakiem możliwości kompilacji kodu, wydawałoby się więc, że jest to przypadek najgorszy, ale czy na pewno? To chyba dobrze, że wiemy, że nasz kod nie robi tego, co się nam wydaje, że robi. Jeśli piszemy jakąś metodę z tym zamiarem, aby była ona przedefiniowaniem metody z nadklasy to chcielibyśmy, żeby kompilator powiedział nam, że popełniliśmy błąd i w istocie jest inaczej. Właśnie po to jest adnotacja @Override. Jeśli umieścimy ją nad jakąś metodą to kompilator sprawdzi, czy rzeczywiście przedefiniowuje ona jakąś metodę z nadklasy i zgłosi błąd kompilacji, jeśli jest inaczej. Nie jest to coś, o co mogą nas zapytać na egzaminie SCJP 5 – bo nie pytają na nim o adnotacje – ale warto wiedzieć.

Implementując metodę redefiniującą możemy wywołać metodę redefiniowaną, tj. z nadklasy, wywołując ją ze słówkiem kluczowym super. Przykład poniżej.

class Parent {
String getDescr() {
return "descr for Parent class";
}
}

class Child extends Parent {
String getDescr() {
return "descr for Child class and " + super.getDescr();
}
}

Na zakończenie odnotujmy jeszcze jeden fakt dotyczący zadeklarowanej listy wyjątków. Spójrzmy na poniższy kod.

class Parent {
String getDescr() throws Exception {
return "descr for Parent class";
}
}

class Child extends Parent {
String getDescr() {
return "descr for Child class";
}
}

class Other {
void test() {
Parent obj = new Child();

obj.getDescr(); // Nie obsługiwany wyjątek!
}
}

Kod ten nie kopiluje się. Powodem jest nie obsługiwany wyjątek. Zmienna obj wskazuje na obiekt klasy Child którego metoda getDescr() nie deklaruje wyjątków, ale nie ma to znaczenia. Istotny jest typ zmiennej, a jest to Parent. Metoda getDescr() w klasie Parent deklaruje wyjątek i musi on być obsługiwany, jeśli posługujemy się zmienną tego typu.

6 komentarzy:

RMK pisze...

Może lepiej trzymać się oficjalnej nomenklatury, wtedy override nie będzie zredefiniowaniem, a nadpisaniem metody.

Mariusz Lipiński pisze...

Zgadzam się, że dobrze jest się trzymać ustalonej nomenklatury, ale czy rzeczywiście taką dysponujemy? Mógłbyś podać jakieś źródło, które można by potraktować jako autorytatywne i w którym używane jest podane przez ciebie tłumaczenie?

Mariusz Pratnicki pisze...

Hmmm, mnie też uczyli że to "nadpisywanie" metody :] ,a o redefiniowaniu pierwsze słysze.

Paweł Jankowski pisze...

Dla mnie to zawsze było przesłonięcie metody ;-)

Anonimowy pisze...

Redefinicja, odwrotnie niż powiedziałeś, zwiazana jest właśnie z metodami statycznymi (override - przeładowanie [polimorfizm], overload - przeciążanie nazwy funkcji/metody).

W przypadku redefinicji metod statycznych kompilator stosuje trik zamieniając nazwe zmiennej na rzeczywistą klase obiektu tzn.

Parent p = new Child();
p.getDescr();
// dostajemy Parent.getDescr(), bez wzgledu na typ do jakiego referencja sie odwoluje

Jeszcze jedno. Co do zasad przeładowywania ("override") i wyjątków. W javie rozróżniamy 2 rodzaje wyjątków "unchecked" (wszystkie, które dziedziczą z klasy Error lub RuntimeException) i "checked" (pozostałe). Przeładowując metodę możemy zadeklarować za pomocą throws dowolną liczbę nowych wyjątków "unchecked" bez względu na sygnaturę klasy super.

Anonimowy pisze...

Odnosząc się do stwierdzenia:

"W przypadku redefinicji metod statycznych kompilator stosuje trik zamieniając nazwe zmiennej na rzeczywistą klase obiektu tzn."rozumiem, iż masz na myśli klasę której typu jest zmienna. Ponieważ to typ zmiennej decyduje o metodach i polach statyczych.