30 kwietnia 2008

SCJP - Implementacja interfejsu

Tym razem to właściwie tylko małe uzupełnienie w kwestii implementacji interfejsów. Uzupełnienie tego, z czym od dłuższego czasu się zmagam a co rozpocząłem artykułem "Przygotowania do SCJP czas zacząć".

Było już o tym, że jeśli klasa implementująca interfejs nie jest abstrakcyjna to musi implementować wszystkie zadeklarowane metody nie zaimplementowane jeszcze przez abstrakcyjne nadklasy. Wreszcie było też o tym, że klasa może implementować wiele interfejsów, choć rozszerzać może tylko jedną klasę, ale nie było o jednym – co to właściwie znaczy zaimplementować metody zadeklarowane w interfejsie, inaczej: jak dokładnie musi wyglądać metoda implementująca tą zadeklarowaną w interfejsie? Zacznijmy od przykładu.

interface SomeInf {
Number doSomething() throws Exception;
}

class SomeClass implements SomeInf {
public Integer doSomething() {
return 1;
}
}

Czy powyższy kod jest poprawny? Tzn. czy metoda doSomething() z klasy SomeClass rzeczywiście implementuje tą zadeklarowaną w interfejsie? Tak, kod ten jest poprawny, albowiem sygnatury nie muszą być identyczne, wystarczy, że są zgodne. Jakby się nad tym dłużej zastanowić, to można by dojść do wniosku, że implementacja metody to jakby jej nadpisanie (ang. overriding), tzn. nadpisanie metody nie posiadającej implementacji, metodą z implementacją. I ma to jakiś sens, jako że w obu tych przypadkach obowiązują dokładnie te same zasady. Metody implementujące interfejsy obowiązują dokładnie te same zasady, co metody nadpisujące metody z nadklasy i są one szczegółowo opisane w artykule "SCJP - Redefiniowanie metod".

SCJP - Rzutowanie referencji

Rzutowanie referencji, czyli streszczenie sekcji "Reference Variable Casting" – kontynuacja działań zapowiedzianych w artykule "Przygotowania do SCJP czas zacząć".

Wiemy już, że typ zmiennej referencyjnej i typ obiektu wskazywanego przez tą zmienną nie koniecznie są tym samym typem – typ obiektu może być także podtypem zmiennej, albo dowolnym typem implementującym, jeśli zmienna ma typ pewnego interfejsu. Zerknijmy na poniższy przykład.

class Parent {
void doSomething() { }
}

class Child extends Parent {
void doMore() { }
}

public class Test {
public static void main(String[] args) {
Parent obj = new Child();

obj.doSomething();
((Child)obj).doMore();
}
}
Zmienna obj jest typu Parent, ale wskazuje na obiekt typu Child. Jak pisałem w artykule "SCJP – Dziedziczenie i wielodziedziczenie" o tym, jakie metody możemy wywołać na danym obiekcie decyduje typ referencji, a nie typ obiektu. Aby więc wywołać metodę doMore() zdefiniowaną w klasie Child trzeba wykonać rzutowanie (ang. casting), tak jak to pokazano w powyższym przykładzie. Jeśli byśmy tego rzutowania nie wykonali, to kod by się nie skompilował, byłby to więc błąd czasu kompilacji. Ale z rzutowaniem związane są także błędy czasu wykonania. Rozważmy następującą modyfikację klasy Test.

public class Test {
public static void main(String[] args) {
Parent obj = new Parent();

((Child)obj).doMore(); // Błąd czasu wykonania!
}
}
W czasie kompilacji wszystko jest w porządku, problem pojawia się po uruchomieniu programu – dostajemy wyjątek java.lang.ClassCastException. Rzeczywiście, obiekt wskazywany przez zmienną obj jest klasy Parent a nie Child. Kompilator zakłada, że wiemy co robimy wykonując rzutowanie i ślepo ufa, że w czasie wykonania zmienna będzie wskazywała na obiekt odpowiedniego typu. Nie ma zresztą innego wyjścia, wykonanie takiego sprawdzenia w czasie kompilacji było by często zbyt skomplikowane, a nawet nie możliwe. Aczkolwiek możliwa jest statyczna weryfikacja w pewnym zakresie. Kompilator zaprotestuje, jeśli wskazane rzutowanie nie ma szans na powodzenie, tj. jeśli typ rzutowania nie jest podtypem albo nadtypem typu zmiennej. Ilustruje to poniższy przykład.

public class Test {
public static void main(String[] args) {
Number num = new Integer(1);

String str = ((String)num); // Błąd kompilacji!
}
}
Obiekt typu Number oraz żaden jego podtyp nigdy nie może być potraktowany jako String, tak więc powyższy kod skutkuje błędem czasu kompilacji.

Myśląc o rzutowaniu zwykle mamy na myśli rzutowanie uszczegóławiające (ang. downcasting), tj. rzutowanie zmiennej do pewnego typu pochodnego, ale możliwe jest też rzutowanie uogólniające (ang. upcasting) do typu nadrzędnego, tak jak to pokazano poniżej, choć nie ma ono większego sensu.

public class Test {
public static void main(String[] args) {
Number num = new Integer(1);

((Object)num).toString();
}
}

29 kwietnia 2008

SCJP - Przeciążanie metod

Kolejny zastrzyk wiedzy przed egzaminem SCJP – kontynuacja dzieła zapowiedzianego w artykule "Przygotowania do SCJP czas zacząć". Dziś będzie o przeciążaniu metod (ang. overloading).

Przeciążanie metod to definiowanie wielu metod o tej samej nazwie a różniących się listą argumentów. W zasadzie o metodzie przeciążającej – tj. używającej tej samej nazwy, co już istniejąca metoda – można myśleć jak o metodzie zupełnie nowej. Nie ma więc żadnych zasad ograniczających zwracany typ, deklarowane wyjątki czy modyfikatory dostępu, jako to ma miejsce w przypadku redefiniowania (ang. overriding). W zasadzie można by to sformułować następująco: jeśli chcemy zdefiniować metodę która będzie się nazywała tak samo jak inna, już istniejąca metoda to musimy określić inną listę argumentów. To tyle.

Mamy więc w naszej klasie zdefiniowanych kilka metod o tej samej nazwie, skąd więc wiadomo, którą z nich wywołujemy w danej chwili? Naturalnie, na podstawie listy parametrów, tyle, że nie zawsze sprawa jest bardzo prosta. Weźmy dla przykładu następujący program.

class Test {
public static void main(String[] args) {
Test obj = new Test();

System.out.println(obj.getString(1));
}

String getString(float f) {
return "float value: " + f;
}

String getString(long l) {
return "long value: " + l;
}
}
Jaki jest efekt jego działania? Która z metod getString() zostanie wywołana? W tym wypadku efektem działania programu będzie napis "long value: 1". Autorzy książki nie podali w rozdziale drugim precyzyjnych reguł, ale intuicyjnie, wywoływana jest ta metoda, której argumenty "bardziej odpowiadają" przekazywanym parametrom. Szczegóły mają się pojawić w dalszych rozdziałach i póki co nie będę wybiegał przed szereg – poczekam. Popatrzmy jednak na jeszcze jeden przykład.

class Parent { }

class Child extends Parent { }

class Test {
public static void main(String[] args) {
Parent obj = new Child();

test(obj);
}

static void test(Parent p) {
System.out.println("method for Parent");
}

static void test(Child c) {
System.out.println("method for Child");
}
}
Efektem działania będzie napis "method for Parent", mimo, że obiekt, dla którego wywołano metodę test() jest klasy Child. Powód jest prosty – to, która z przeciążonych metod zostanie wywołana jest określane w czasie kompilacji, kiedy to nie wiadomo jeszcze, jaki będzie rzeczywisty obiekt, a więc o wywoływanej metodzie decyduje typ referencji nie zaś typ obiektu.

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.