W poszczególnych punktach tego rozdziału będziemy przedstawiać jakie warunki wstępne powstają jako konsekwencja opisywanej cechy języka. Zazwyczaj ograniczymy się do podania warunków wstępnych dla kilku refaktoryzacji prostych. Omawiane przez nas cechy języka mają jednak wpływ na wszystkie bądź prawie wszystkie znane z literatury refaktoryzacje, których tu, z braku miejsca, nie omawiamy.
Celem niniejszego rozdziału jest rozwinięcie istniejących prac w dziedzinie refaktoryzacji o analizę wpływu specyficznych cech języka Java na proces refaktoryzacji. Przedstawiamy i omawiamy ,,strefy problemowe'' - czyli takie obszary języka, gdzie nawet pozornie łatwe i bezpieczne przekształcenia programu mogą zmienić jego działanie. Wskazane jest, by przyszli projektanci narzędzi do refaktoryzacji zdawali sobie sprawę z wielu pułapek języka opisanych szczegółowo w tej pracy. Jest to niezbędne do powstania narzędzi na tyle wszechstronnych i niezawodnych, by mogły być używane na codzień przez zawodowych programistów.
Częścią tej pracy jest opis projektu i implementacji narzędzia wspomagającego refaktoryzację (zawarty w rozdziale 4), przy konstrukcji którego staraliśmy się wziąć pod uwagę wszystkie opisane tu obszary problemowe. W przypadku wielu z nich nasze narzędzie jest jedynym istniejącym (według naszych danych), które je uwzględnia.
Większość przykładów w tym rozdziale została celowo skonstruowana w taki sposób, by zilustrować sytuacje, w których, po wykonaniu przekształcenia, program nadal kompiluje się bez błędów, jednak jego działanie ulega zmianie. Występowanie takich przypadków jest bowiem przyczyną najtrudniejszych do wykrycia problemów.
Rzecz jasna bardzo znaczna liczba cech języka ma wpływ na refaktoryzację, a w szczególności na analizę warunków wstępnych. Przy tworzeniu spisu cech zawartego w tym rozdziale pominęliśmy lub wspomnieliśmy jedynie krótko o tych, które są już wystarczająco szczegółowo omówione w istniejących publikacjach. Staraliśmy się wskazać takie, które są, naszym zdaniem, istotne i nieuwzględniane przez znane nam narzędzia.
Niniejszy rozdział, rozdział 4 oraz dodatek B przedstawiają główny wkład merytoryczny tej pracy.
Interfejs w języku Java może rozszerzać (ang. extend) dowolną liczbę innych interfejsów (cykle są niedozwolone). Analogicznie do terminologii używanej przy hierarchii klas mówimy wtedy, że interejs rozszerzany jest nadinterfejsem interfejsu rozszerzającego (który wobec tego jest jego podinterfejsem). Wszystkie interfejsy w programie tworzą wiec zbiór parami rozłącznych skierowanych grafów acyklicznych (kierunek strzałek jest zgodny z rozszerzaniem). Jeżeli klasa implementuje interfejs, wówczas każda jej nieabstrakcyjna podklasa musi deklarować (bądź dziedziczyć z nadklas) wszystkie metody zadeklarowane w tym interfejsie oraz jego nadinterfejsach (bezpośrednich i pośrednich). Specyfikacja języka określa ponadto, że metody te muszą być instancyjne i publiczne. Podobnie, implementowanie wielu interfejsów oznacza, że każda nieabstrakcyjna podklasa danej klasy musi deklarować bądź dziedziczyć z nadklas wszystkie metody (w sensie sumy teoriomnogościowej) zadeklarowane we wszystkich implementowanych interfejsach i ich nadinterfejsach.
Okazuje się, że wiele zagadnień refaktoryzacji jest znacznie prostszych w językach, w których nie występuje wielokrotne dziedziczenie.
W tym punkcie ograniczymy się do rozważenia tylko jednej refaktoryzacji,
mianowicie zmiany nazwy metody. W języku Smalltalk oraz w podzbiorach języka
Java, w których istnieje tylko pojedyncze dziedziczenie, stosuje się następujący
algorytm (dana jest metoda, typ, w którym jest zadeklarowana i nowa nazwa
metody):
1.Sprawdź, czy metoda o nowej nazwie (sygnaturze)
nie jest zadeklarowana w hierarchii danego typu - jeśli tak, to zmiana
nazwy nie jest możliwa.
2. Znajdź najbardziej abstrakcyjny typ deklarujący
daną metodę.
3. Zmień nazwę metody we wszystkich podtypach znalezionego
typu.
Algorytm ten jest niewystarczający w pełnym języku Java (1), co ilustruje poniższy przykład:
interface I1{
public void m();
}
interface I2{
public void m();
}
class A implements I1, I2{
public void m(){}
}
Zmiana nazwy metody I1::m() musi pociągać za sobą zmianę nazwy
A::m()
- w przeciwnym razie powstanie błąd kompilacji. Zauważmy także, że również
nazwa I2::m() musi ulec zmianie.
Jak widać zmiana nazwy metody jest przenoszona po grafie typów. Nazwaliśmy
to zjawisko efektem fali (ang. ripple effect). Taka fala może się
rozchodzić po wielu klasach i interfejsach. Algorytm wyszukiwania metod,
których nazwy należy zmienić, powinien przeglądać graf typów w górę i w
dół począwszy od maksymalnie abstrakcyjnego typu deklarującego daną metodę.
Zanim naszkicujemy algorytm znajdujący interesujący nas zbiór metod należy zauważyć, że:
1. Nie wszystkie typy wzdłuż fali muszą deklarować
metodę o sygnaturze takiej, jak sygnatura metody, której nazwę chcemy zmienić.
Rozważmy następujący przykład pokazujący, że wystarczy, by metoda taka
była odziedziczona z nadklasy:
interface I{
public void m();
}
class A{
public void m(){}
}
class B extends A implements I{
}
Po zmianie nazwy metody I::m() również nazwa metody A::m() musi zostać zmieniona.
2.W pewnych sytuacjach fala zatrzymuje się. Metody
niewirtualne nie zastępują (ang. override) metod wirtualnych i wobec
tego zatrzymują falę. Taką sytuację ilustruje następujący przykład:
interface I{
public void m();
}
class A{
private void m(){}
}
class B extends A implements I{
public void m(){}
}
Zmianie nazwy metody I::m() musi towarzyszyć zmiana nazwy metody B::m(),
natomiast metoda A::m() może pozostać nieprzemianowana.
Punkt 2 wymaga wyjaśnienia. Przy wywołaniu metody wirtualnej, konkretna metoda jest ustalana podczas działania programu, na podstawie faktycznego typu (ang. runtime type) obiektu, na rzecz którego jest wywoływana. W przypadku metod niewirtualnych jest to ustalane zawczasu, podczas kompilacji. Metody niewirtualne (w języku Java wszystkie metody statyczne, a także prywatne są niewirtualne) stosuje się bardzo często (ze względu na przejrzystość, a także wydajność, powstającego dzięki temu kodu) - jeśli chcemy zbudować narzędzie do refaktoryzacji dużych programów, to musimy wziąć je pod uwagę.
W tym punkcie rozważymy jedynie metody prywatne. Zbiór metod statycznych stanowi, do pewnego stopnia, przestrzeń rozłączną ze zbiorem metod instancyjnych - w tym sensie, że metoda statyczna nie może ukrywać (ang. hide) metody instancyjnej z nadklasy. Podobnie, metoda instancyjna nie może zastąpić metody statycznej z nadklasy. Ponieważ w przypadku metod niewirtualnych kompilator wie, którą dokładnie metodę należy wywołać, wobec tego zmiana nazwy takiej metody nie musi pociągać za sobą zmiany nazwy żadnej innej metody. Spójrzmy na przykład:
class A{
private void m(){}
}
class B extends A{
public void m(){}
Metody A::m() oraz B::m() są jednoznacznie odróżniane podczas kompilacji dla każdego wywołania. Oznacza to, że są to, w pewnym sensie, dwie niezależne metody (2). Wobec tego zmiana nazwy jednej z nich nie powoduje konieczności zmiany nazwy drugiej.
W jaki sposób wobec tego ustalić zbiór metod, których nazwy muszą zostać zmienione razem z nazwą danej metody? Jedno z możliwych podejść jest następujące: należy zmienić nazwy wszystkich metod o tej samej sygnaturze, co dana metoda. O ile jest to możliwe, tzn. jeśli metoda o nowej sygnaturze nie istnieje w żadnym z deklarujących typów (ani, dodatkowo, w ich grafach dziedziczenia i hierarchiach zawierania), to taka zmiana będzie istotnie refaktoryzacją, czyli nie będzie wpływać na działanie systemu. To podejście wydaje się jednak nie do przyjęcia - jako jaskrawo sprzeczne z intuicją i oczekiwaniami użytkowników. Powinniśmy wobec tego ustalić możliwie mały zbiór takich metod. Nieco dokładniej, szukamy takiego zbioru metod (o tej samej sygnaturze i typie wyniku), że:
a. Zmiana nazw wszystkich metod z tego zbioru nie
wpływa na działanie programu.
b. Znaleziony zbiór jest minimalny.
W następnym punkcie prezentujemy szukany algorytm po czym omawiamy możliwe jego użycie w przypadku innych refaktoryzacji.
result:= empty set // zbior typow deklarujacych metode m
visited:= empty set //zbior typow juz odwiedzonych
q:= empty queue //kolejka typow do odwiedzenia
q.insert(T)
while (!q.isEmpty()){
t:= q.remove();
//assert(t jest interfejsem lub deklaruje m
jako metode wirtualna)
//assert(!visited.contains(t))
visited.add(t);
result.add(t);
forall: i in: t.subTypes do:
if (i deklaruje m)
result.add(i);
forall: i in: t.subTypes do:
q.insert(x) gdzie x
jest dowolnym typem spelniajacym warunki:
a. x jest nadtypem typu i
b. x jest interfejsem deklarujacym m lub x deklaruje m jako metode wirtualna
c. zaden nadtyp typu x nie jest interfejsem deklarujacym m oraz zaden nadtyp
typu x nie jest klasa deklarujaca m jako metode wirtualna
d. ! visited.contains(x)
e. ! q.contains(x)
}
1. nazwy pakietu, w którym dany typ się znajduje,
2. nazw (kolejno ,,w dół'' - od typu niezagnieżdżonego)
wszystkich typów, w których dany typ jest zagnieżdżony,
3.nazwy danego typu.
Przykładowo, kanoniczna nazwa typu o nazwie prostej A zagnieżdżonego
w typie o nazwie prostej B i zdefiniowanego w pakiecie o nazwie p1.p2 to
p1.p2.B.A. W przypadku typów niezagnieżdżonych część 2 nie występuje.
W pełni kwalifikowane nazwy typów (ang. fully qualified type names)
są odmienne od nazw kanonicznych (ang. canonical names) i, w ogólnym
przypadku, nie są unikatowe w systemie. W niejakim skrócie można powiedzieć,
że nazwa kanoniczna jest jedną z w pełni kwalifikowanych nazw typu. Różnice
są szczegółowo opisane w specyfikacji języka [JLS2 6.7]. Tam gdzie to rozróżnienie
nie wpływa na tok rozumowania, będziemy używali pojęcia w pełni kwalifikowanej
nazwy typu.
Ze względu na zwięzłość i wygodę rzadko używa się w pełni kwalifikowanych nazw typów. Konieczność ich użycia wystepuje jedynie wówczas, gdy w jednej jednostce kompilacji znajdują się odniesienia do co najmniej dwóch typów o tej samej nazwie prostej.
Kilka konstrukcji języka Java powoduje, że podczas refaktoryzacji może
zdarzyć się tak, że w refaktoryzowanym fragmencie kodu może być widocznych
wiele typów o tej samej nazwie prostej. Możliwe są wówczas dwa wyjścia
z takiej sytuacji:
a. nazwa jednego z tych typów przesłania (lub ukrywa)
pozostałe,
b. dwie lub więcej nazw pozostaje w konflikcie i
powstaje błąd kompilacji.
Przedstawimy teraz te konstrukcje języka, po czym przejdziemy do omówienia możliwych sposobów postępowania w przypadku wystąpienia niepożądanego przesłaniania lub konfliktu nazw typów.
Import pojedynczego typu (ang. single-type-import) Używa
się do niego w pełni kwalifikowanej nazwy importowanego typu.
Import na żądanie (ang. import-on-demand) w tym przypadku
używa się nazwy pakietu deklarującego dany typ z następującymi po niej
znakami ".*" (import dotyczy wówczas wszystkich publicznych, niezagnieżdżonych
typów zdefiniowanych w tym pakiecie).
Typy zagnieżdżone również można importować na żądanie - należy wówczas
użyć w pełni kwalifikowanej nazwy typu, w którym zdefiniowany jest dany
typ (np. w celu takiego zaimportowania typu o nazwie p1.p2.B.A należy użyc
wyrażenia import p1.p2.B.*;).
Wielu programistów używa wyłącznie importu pojedynczego typu za względu na jego jednoznaczność. Inni preferują import na żądanie ze względu na zwięzłość. Istnieją jednak poważniejsze niż estetyczne róznice między tymi dwoma sposobami importowania typów. Zgodnie ze specyfikacją języka ([JSL2 7.5.1] i [JLS2 7.5.2)), import na żądanie nigdy nie przesłania żadnych deklaracji, natomiast import pojedynczego typu przesłania deklaracje typów importowanych na żądanie oraz typów importowanych domyślnie (tzn. typów zdefiniowanych w pakiecie, w którym znajduje się importująca jednostka kompilacji oraz typów zdefiniowanych w wyróżnionym pakiecie o nazwie java.lang).
Kilka reguł języka Java dotyczących importowania typów leży w obszarze
zainteresowania projektanta narzędzia do refaktoryzacji:
a. Żadna jednostka kompilacji nie może importować
(import pojedynczego typu) dwóch lub wiecej różnych typów o tej samej nazwie
prostej.
b. Żadna jednostka kompilacji nie może deklarować
niezagnieżdżonego typu i jednocześnie importować (import pojedynczego typu)
innego typu o tej samej nazwie prostej.
c. Jeśli jednostka kompilacji importuje dwa
lub wiecej typów (import na żądanie) o tej samej nazwie prostej i występują
wewnątrz niej odniesienia do jednego z tych typów używające jego nazwy
prostej, to jest zgłaszany błąd kompilacji (odniesienia nie są jednoznaczne).
Spójrzmy na przykłady:
"A.java"
package p;
public class A{}
"B.java"
package p1;
public class B{}
"Test.java"
package test;
import p1.B; /*1*/
import p.A;
class Test{
B b; /*2*/
}
Zmiana nazwy typu A na B (wraz z aktualizacją odniesień) daje w rezultacie błąd kompilacji w ostatniej jednostce kompilacji - która importuje wówczas (import pojedynczego typu) dwa typy o tej samej nazwie prostej (tj. B).
Podobnie, rozważmy następujący przykład:
"A.java"
package p;
public class A{}
"Test.java"
package test;
import java.util.*; //zawiera typ List
import p.*;
class Test{
A a;
List l; /*1*/
}
Jeśli zmienimy nazwę typu A na List, to odniesienie w wierszu oznaczonym przez /*1*/ stanie się niejednoznaczne, co spowoduje błąd kompilacji.
Kolejny przykład ilustruje, jak działanie programu może ulec zmianie (bez wystąpienia błędów kompilacji) przez przesłonięcie widoczności typu zdefiniowanego w tym samym pakiecie, ale innej jednostce kompilacji.
"B.java"
package p1;
public class B{
public static int x= 42;
}
"A.java"
package p;
public class A{
public static int x= 0;
}
"Test.java"
package p;
import p1.B;
class Test{
static int i;
static {
i= A.x;
}
}
Jeśli jedynie zmienimy nazwę typu p1.B na A (wraz z aktualizacją wszystkich odniesień do tego typu), to pole i w klasie Test zostanie zainicjalizowane wartością 42, a nie 0, jak poprzednio. Pomimo zmiany w działaniu programu, pozostaje on poprawny, tzn. nie powstają błędy kompilacji. Zauważmy, że działanie programu się zmienia, mimo, że w klasie Test nie ma odniesień do typu p1.B, którego nazwę zmieniamy. Importowanie typu (i związane z tym przesłanianie) wystarcza, by w istotny sposób zmienić funkcjonowanie programu.
Ostatni przykład pokazuje, jak samo stworzenie nowej klasy (3) może zmodyfikować działanie programu.
package p;
import java.util.*; // zawiera klase ArrayList
class A{
public Object m(){
return new ArrayList();
}
}
Stworzenie w pakiecie p klasy o nazwie ArrayList spowoduje, że metoda
A::m() przekaże obiekt klasy innej niż dotychczas, zmieniając w ten sposób
działanie programu (bez powodowania błędów kompilacji).
Nazwy typów zagnieżdżonych przesłaniają widoczność nazw innych typów oraz zaciemniają (patrz punkt 3.3) widoczność nazw pakietów. Spójrzmy na przykład:
import java.util.*; //zawiera typ Stack
class A{
class B{
Object m(){
return new Stack{};
}
}
}
Zmiana nazwy klasy dowolnej z klas A lub B na Stack spowoduje zmianę działania programu - bez powstania błędów kompilacji.
Mniej oczywistą, a równie ważną, konsekwencją używania typów zagnieżdżonych
jest niejednoznaczność nazw typów, jaka się wówczas pojawia. Omówimy teraz
to zjawisko.
Do każdego niezagnieżdżonego typu możemy odnosić się na dwa sposoby:
za pomocą nazwy prostej lub nazwy kanonicznej (która jest wtedy tożsama
z nazwą w pełni kwalifikowaną). Do typów zagnieżdżonych możemy odnosić
się na wiele sposobów - w zależności od miejsca odniesienia. W istocie,
w pewnych warunkach, liczba sposobów, na jakie możemy się odnosić do jednego,
zagnieżdżonego typu, jest nieograniczona.
W podanym przykładzie wszystkie zmienne w wierszach oznaczonych przez
/**/ są tego samego typu (o kanonicznej nazwie p.O.I.J).
Przykład jest niestety, z konieczności, dość zawiły:
package p;
public class O{
public class I{
public class J{}
J j; /**/
}
I.J ij; /**/
}
class O1 extends O{
class O2 extends O.I{
O2(O o){ o.super();
}
}
O2.J o2j; /**/
}
class Test{
O.I.J oij; /**/
p.O.I.J poij; /**/
O1.I.J o1ij; /**/
p.O1.I.J po1ij; /**/
O1.O2.J o1o2j; /**/
p.O1.O2.J po1o2j; /**/
}
Zauważmy, że p.O.I.J, p.O1.I.J i p.O1.O2.J to w pełni kwalifikowane
nazwy dla wskazanego typu - co ilustruje wspomnianą wcześniej nieunikatowość
takich nazw.
Dodanie nowego pakietu powoduje dalsze zwiększenie liczby możliwych
sposobów odniesień do tego typu. Przykład ilustrujący tę sytuację zostanie
tu pominięty.
Łatwo zdać sobie sprawę z tego, jak ta cecha języka wpływa na znaczne
zwiększenie możliwych przesłonięć nazw typów. Każda z podanych nazw może
być przesłaniana (lub zaciemniana) niezależnie od innych. Oznacza to, że
podczas refaktoryzacji musimy, po pierwsze, odszukać je wszystkie jako
odniesienia to danego typu, a po drugie analizować każdą z nich oddzielnie.
Zmiana nazwy lub położenia typu, dodanie nowego typu do systemu oraz wszelkie
zmiany w hierarchiach dziedziczenia, czy zawierania) to kilka przykładów
refaktoryzacji, które mogą być niepoprawne, jeśli nie zostaną poprzedzone
analizą przypadków przedstawionych w tym punkcie. Jak pokazano w punkcie
3.3 również przekształcenia operujące na zmiennych i pakietach (dodające
je do systemu, zmieniające ich nazwy lub położenie itp.) muszą analizować
te przypadki.
package p;
class X{}
class Test{
String f;
Object m(){
class A{};
return new X(); /*1*/
}
}
Zmiana nazwy typu o kanonicznej nazwie p.X na A spowoduje, że wywołanie
metody Test::m() przekaże obiekt innego typu.
Opisana w punkcie a. próba naprawy kodu może (choć nie musi - patrz
punkt 3.3) okazać się skuteczna (konieczne mogą okazać się dodatkowe zmiany).
Jednakże wprowadzenie do programu odniesień używających nazw w pełni kwalifikowanych
wydaje się sprzeczne z oczekiwaniami użytkowników co do narzędzia wspomagającego
refaktoryzację. Jak wspomnieliśmy, nazwy w pełni kwalifikowane są stosunkowo
rzadko wykorzystywane, a ich nadużywanie jest uznawane za element złego
stylu. Uważamy także, że wystąpienie w programie konfliktu nazw typów wskazuje
na nieprawidłowości w używanym schemacie nadawania nazw elementom programu.
Z tego powodu uznajemy, że zabronienie refaktoryzacji jest w takich
przypadkach lepszym rozwiązaniem i zdecydowaliśmy się je zaimplementować
w naszym programie.
class S{
protected int g;
}
class A extends S{
public int m(int p){
return g + p;
}
}
Zmiana nazwy pola g na p powoduje, że jego deklaracja zostaje przesłonięta
przez nazwę parametru w treści metody A::m().
Ukrywanie, mimo, że jest odmienne od przesłaniania, w procesie refaktoryzacji
może być traktowane podobnie.
Spójrzmy na następujący przykład:
class A{
protected int f;
}
class B extends A{
void m(){
f= 42; /*1*/
}
}
Rozważmy modyfikację polegającą na dodaniu do klasy B nowego pola typu
int, o nazwie f. W celu zachowania dotychczasowego działania programu,
należy wiersz oznaczony przez /*1*/ zmienić na:
((A)this).f= 42; /*1*/
class X{
static int length(){
return 42;
};
}
class Test extends TestSuperclass{
String s= "hello";
int m(){
return X.length(); /*1*/
}
}
Zmiana nazwy typu X na s nie powoduje błędów kompilacji. Działanie programu
ulega jednak zmianie (wywołanie metody Test::m() przekazuje wartość 5,
a nie 42, jak poprzednio). Przykłady ilustrujące inne rodzaje zaciemniania
(t.j. nazwa zmiennej zaciemniająca nazwę pakietu, nazwa typu zaciemniająca
nazwę pakietu) są łatwe do skonstruowania i nie zostaną tu podane.
W implementacji naszego narzędzia zdecydowaliśmy się na realizację podejścia b. Argumenty przemawiające za taką decyzją są podobne do przedstawionych w punkcie 3.2.
Refaktoryzacje mogą prowadzić zarówno do eliminowania, jak i do powstawania przeciążania. W tej pracy omówimy tylko przypadek powstawania przeciążania.
Rozważmy następujący przykład:
class A{
void a(String s){}
void b(Object s){}
}
Zmiana nazwy metody A::b() na a powoduje, że wywołanie b("hello") musi zostać zastąpione przez a((Object)"hello").
Podobnie jak w przypadku przestrzeni nazw typów istnieją dwie strategie postępowania przy zmianie nazwy metody:
a. Możemy użyć rzutowania parametrów dla każdego
wywołania metody.
b. Możemy wykrywać przypadki powstawania przeciążania
metod i zabraniać refaktoryzacji.
Podejście opierające się na rzutowaniu jest, naszym zdaniem, sprzeczne z intuicją i oczekiwaniami użytkowników. Narzędzie dokonywałoby wówczas w programie zmian, jakich użytkownicy się nie spodziewają. Co więcej, nadmierne użycie rzutowania jest uznawane za niepożądaną praktykę programistyczną. Z tych powodów zdecydowaliśmy się na wykrywanie przypadków przeciążania nazw metod i informowanie o tym użytkownika.
Refaktoryzacja programów w Javie korzystających z kodu natywnego musi być przeprowadzana uważnie, gdyż większość możliwych błędów nie może zostać wykrytych przez kompilator. Kod natywny jest ładowany i dołączany podczas działania programu i wszelkie błędy ujawniają się dopiero wtedy, tzn. przy pierwszym błędnym odwołaniu do metody natywnej. Sposób, w jaki używa się metod natywnych i jak są one dołączane do działającego programu jest określony przez specyfikację JNI (Java Native Interface). Podaje ona dokładny opis deklaracji metod natywnych w kodzie C/C++. Wiązanie natywnych metod Javy z odpowiadającym im kodem natywnym odbywa się za pomocą dopasowania nazw. Nazwy metod natywnych po stronie C/C++ są tworzone od nazw odpowiadających im metod natywnych po stronie Javy.
Nazwa metody natywnej w C/C++ składa się z:
a. przedrostka "Java_",
b. kanonicznej nazwy klasy deklarującej odpowiadającą
metodę natywną Javy,
c. znaku '_',
d. nazwy odpowiadającej metody natywnej Javy,
e. oraz (jeśli nazwa metody jest przeciążona) ciągu
"__" wraz z następującą po nim sygnaturą metody (wraz z w pełni kwalifikowanymi
nazwami typów argumentów). Jeżeli nazwa nie jest przeciążona, to ostatni
fragment jest opcjonalny.
Przykładowo, dla podanej niżej definicji klasy
class A{
native void m(B b);
native void m(C b);
}
gdzie A znajduje się w pakiecie p1.p2.p3, natomiast B oraz C w pakiecie
p4.p5.p6, odpowiedni fragment pliku nagłówkowego (wygenerowanego przez
program javah) deklarującego metodę m w kodzie C/C++ wygląda następująco:
/*
* Class: p1_p2_p3_A
* Method: m
* Signature: (Lp4/p5/p6/B;)V
*/
JNIEXPORT void JNICALL Java_p1_p2_p3_A_m__Lp4_p5_p6_B_2
(JNIEnv *, jobject,
jobject);
/*
* Class: p1_p2_p3_A
* Method: m
* Signature: (Lp4/p5/p6/C;)V
*/
JNIEXPORT void JNICALL Java_p1_p2_p3_A_m__Lp4_p5_p6_C_2
(JNIEnv *, jobject,
jobject);
Przyjmujemy założenie, że nie mamy dostępu do kodu natywnego, w związku z czym nie możemy go analizować ani modyfikować. Znając reguły wiązania nazw natywnych metod Javy i odpowiadających im metod C/C++ możemy jednak ostrzegać użytkownika przed przeprowadzeniem modyfikacji programu, w następstwie której powstałyby błędy podczas wykonywania programu korzystającego z metod natywnych.
Z podanych reguł tworzenia nazw wynika, że bez zmiany działania programu nie można zmienić (w programie napisanym w Javie):
a. nazwy ani położenia metody natywnej;
b. nazwy ani położenia klasy, która deklaruje metodę
natywną bądź której jakakolwiek klasa zagnieżdżona (na dowolnym poziomie)
deklaruje taką metodę (odnosi sie to również do klas zdefiniowanych lokalnie
w rozpatrywanej klasie oraz wszystkich klasach zagnieżdżonych i lokalnie
w niej zdefiniowanych);
c.nazwy pakietu, w którego skład wchodzi klasa (na
dowolnym poziomie zagnieżdżenia - także klasy lokalnie zdefiniowane), która
deklaruje metodę natywną;
d. nazwy ani położenia typu, który występuje jako
typ argumentu jakiejkolwiek metody natywnej (dowolnie zlokalizowanej).
Ponieważ nie mamy dostępu do kodu natywnego, więc musimy założyć, że nawet
przy braku przeciążania w nazwie metody natywnej użyto w pełni kwalifikowanych
nazw typów parametrów;
e. nazwy pakietu, w którego skład wchodzi klasa
(na dowolnym poziomie zagnieżdżenia - także klasy lokalnie zdefiniowane),
która występuje jako typ argumentu jakiejkolwiek metody natywnej (dowolnie
zlokalizowanej).
Sprawdzenie ostatnich dwóch warunków wymaga analizy całego programu.
Jeśli nie jest wywoływana żadna z metod natywnych, które spełniają
co najmniej jeden z podanych warunków, to działanie programu pozostanie
niezmienione. Kompilator Javy nie ma możliwości orzec, czy kod natywny
jest prawidłowo napisany. Jeśli tak nie jest, to przy pierwszym wywołaniu
metody wystąpi błąd (UnsatisfiedLinkError). Problem sprawdzenia, czy dana
metoda jest wołana podczas działania programu jest jednak, w ogólnym przypadku,
nierozstrzygalny.
Z mechanizmu JNI korzysta się raczej rzadko, więc powyższe ograniczenia nie powinny być zbyt uciążliwe w praktyce. Są jednak konieczne do tego, by można było uzyskać niezmienność działania programu.
Niestety kod natywny stanowi większe zagrożenie dla poprawności refaktoryzacji.
Kod natywny może odwoływać się do dowolnego elementu programu w Javie (typu,
metody, pola obiektu) co powoduje, że, podobnie jak przy użyciu mechanizmu
odbicia (ang. reflection), narzędzie nie jest w stanie zagwarantować
poprawności przekształceń w obecności metod natywnych (bez względu na to,
czy dana refaktoryzacja ich dotyczy, czy nie). Jest to jedno z tych ograniczeń,
którym nie sposób łatwo zapobiec. W tym celu należałoby mieć dostęp do
kodu natywnego i móc go analizować. Kod natywny może być jednak napisany
w dowolnym języku programowania, co całkowicie uniemożliwia jego analizę
w ogólnym przypadku.
String f= "a" + new Exception();
Pewne (hipotetyczne) refaktoryzacje muszą brać ten przypadek pod uwagę.
W szczególności, jest to zmiana nazwy tej metody lub jej położenia.
o.getClass().getMethod("m", null).invoke(o)
Zmiana nazwy metody m spowoduje, że efekt wykonania podanego fragmentu
kodu nie będzie taki, jak poprzednio - zostanie zgłoszony wyjątek.
Ponieważ nie mamy możliwości przeprowadzenia statycznej analizy użycia
mechanizmu odbicia (w szczególności tego, które elementy programu będą
używane), wobec tego musimy zgodzić się na powstanie ewentualnych błędów
podczas refaktoryzacji używającego go kodu.
Opisaliśmy w jaki sposób użycie wielokrotnego dziedziczenia interfejsów wpływa na proces refaktoryzacji. Na przykładzie refaktoryzacji Zmiana Nazwy Metody pokazaliśmy jak występowanie tej cechy języka wpływa na zbiór metod, które muszą być refaktoryzowane razem (w celu zachowania działania programu - w szczególności uniknięcia błędów kompilacji). Omówiliśmy wpływ metod niewirtualnych na to zjawisko i przedstawiliśmy algorytm znajdowania wszystkich metod, które muszą być refaktoryzowane wspólnie. Następnie krótko opisaliśmy zastosowania podanego algorytmu do przeprowadzania również innych refaktoryzacji.
W kolejnym punkcie pokazaliśmy możliwe problemy występujące przy refaktoryzacji programów w języku Java, wynikające z istnienia przestrzeni nazw typów. Przedstawiliśmy niektóre z przyczyn i następstw konfliktu nazw typów. Omówiliśmy możliwe strategie postępowania w przypadku wystąpienia konfliktu nazw typów, a następnie podaliśmy i uzasadniliśmy decyzję podjętą przez nas przy implementacji narzędzia do refaktoryzacji.
W punkcie 3.3 omówiliśmy trzy zbliżone do siebie zjawiska związane z nazwami elementów programu - przesłanianie, ukrywanie i zaciemnianie. Pokazaliśmy wpływ, jaki wywierają one na refaktoryzację. Następnie, podobnie jak poprzednio, omówiliśmy możliwe strategie postępowania w przypadku wystąpienia problemów związanych z występowaniem omawianych zjawisk, przedstawiliśmy i uzasadniliśmy dokonany przez siebie wybór.
Wpływ mechanizmu przeciążania nazw metod na refaktoryzację omówiony został w punkcie 3.4 - wraz z dyskują możliwych sposobów rozwiązywania związanych z tym problemów oraz przedstawieniem i uzasadnieniem podjętej decyzji.
Ostatnie dwa punkty rozdziału zawierają omówienie kilku przypadków specjalnych - zwłaszcza istnienia wyróżnionych nazw elementów programu oraz problemów związanych z refaktoryzacją programów korzystających z metod natywnych. W punkcie 3.5, omawiając metody natywne, przedstawiliśmy listę warunków wstępnych związanych z refaktoryzacją programów korzystających z tego mechanizmu.