powrót do spisu treści

Rozdział 3

Refaktoryzacja programów w języku Java

Nie jest możliwa analiza wpływu, jaki specyficzne cechy języka Java wywierają na wszystkie istniejące refaktoryzacje. Liczba wszystkich takich przekształceń programu, które nie zmieniają jego działania jest nieograniczona. W dodatku A przedstawiamy zwięzłą klasyfikację refaktoryzacji prostych, z których można składać refaktoryzacje dowolnie złożone. Istnieje jednakże znaczna liczba refaktoryzacji prostych nie mieszczących się w ramach przedstawionej tam klasyfikacji - przykładową listę ponad 80 można znaleźć w książce Fowlera [6]. Stopień możliwej automatyzacji większości tych przekształceń nie został jednak dotychczas zbadany.

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.

3.1 Wielokrotne dziedziczenie interfejsów oraz efekt fali

Zanim omówimy trudności związane z refaktoryzacją programów, w których używa się wielokrotnego dziedziczenia interfejsów, krótko przedstawimy najważniejsze właściwości mechanizmu interfejsów. Szczegółowy opis znajduje się w specyfikacji języka.

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.

Algorytm znajdowania metod znajdujących się wzdłuż fali

W tym punkcie podajemy algorytm znajdowania szukanego zbioru metod. Dokładniej, zbioru typów deklarujących szukane metody.
Dane: typ T, metoda m
Wynik: zmienna result zawiera listę typów, które deklarują metodę m (w typach z tej listy nazwa metody m zostanie zmieniona)
Założenie: Żaden nadtyp typu T nie deklaruje metody m

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)
}

Inne zastosowania podanego algorytmu

Podany w poprzednim punkcie algorytm może zostać wykorzystany także w innych refaktoryzacjach niż Zmiana Nazwy Metody. W istocie prawie każda refaktoryzacja modyfikująca metody musi się posługiwać tym algorytmem.
Na przykład, refaktoryzacja polegająca na zmianie kolejności parametrów metody (ang. Rearrange Parameters lub Exchange Parameters) musi dokonać modyfikacji deklaracji i odwołań do wszystkich metod wzdłuż fali. Należy wobec tego skorzystać z wyszukującego je algorytmu. Inne przykłady to, m.in. :

3.2 Przestrzenie nazw typów

W języku Java istnieją przestrzenie nazw typów. Może więc istnieć wiele typów o tej samej nazwie - jeżeli znajdują się w innych przestrzeniach nazw.
Każdy typ użyty w progamie ma nazwę prostą (np. Object) oraz unikatową nazwę kanoniczną (np. java.lang.Object). Nazwa kanoniczna składa się z (występujących w podanej niżej kolejności i oddzielonych znakami '.'):

    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.

Deklaracje importu

W celu umożliwienia odniesień do jakiegoś typu przez użycie jego nazwy prostej należy go ,,zaimportowac'' do jednostki kompilacji. Istnieją dwa sposoby importowania 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).
 

Typy zagnieżdżone

Typy można zagnieżdżać (ang. nest), tzn. definiować jedne wewnątrz innych. Szczegółowe omówienie reguł definiowania i semantyki typów zagnieżdżonych znajduje się w specyfikacji języka Java. Każdy typ może być zagnieżdżony co najwyżej w jednym innym typie; wszystkie typy w programie tworzą wobec tego, niezależnie od grafu dziedziczenia, zbiór hierarchii zawierania (typy niezagnieżdżone są na szczytach tych hierarchii).

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.
 

Typy lokalnie zdefiniowane

W języku Java można definiować typy wewnątrz metod. Jest do rzadko używana cecha języka, która jednak może przyczynić się do powstania trudnych do wykrycia błędów, jeżeli narzędzie do refaktoryzacji nie weźmie jej pod uwagę.
Typy można deklarować w dowolnym miejscu metody - tak, jak zmienne lokalne. Ich nazwy przesłaniają widoczność nazw innych typów począwszy od miejsca deklaracji.
Prosty przykład ilustruje sytuację, w której nazwa typu zdefiniowanego lokalnie przesłania widoczność nowej nazwy innego typu, uniemożliwiając tym samym refaktoryzację:

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.
 

Postępowanie w wypadku wystąpienia konfliktu nazw typów

W przypadku wystąpienia konfliktu nazw typów narzędzie do refaktoryzacji może posłużyć się jedną z dwóch strategii:
    a. Starać się naprawić kod - tzn. rozwiązać konflikt przez zamianę odniesień używających nazw prostych na odniesienia w postaci nazw w pełni kwalifikowanych.
    b. Uznać wystąpienie takiej sytuacji za niepożądane i zabronić refaktoryzacji.

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.

3.3 Przesłanianie, ukrywanie i zaciemnianie nazw

Trzy zjawiska dotyczące nazw leżą w kręgu zainteresowania projektanta narzędzia do refaktoryzacji programów w jezyku Java - przesłanianie, ukrywanie oraz zaciemnianie. Przedstawimy je teraz po kolei, a następnie omówimy możliwe sposoby postępowania w przypadku ich wystąpienia.
 

Przesłanianie

Przesłanianie (ang. shadowing) opisane jest w punkcie 6.3.1 specyfikacji języka Java. Deklaracje mogą być przesłaniane w częściach swych zasięgów przez inne deklaracje o tej samej nazwie. Nazwa prosta nie może być wówczas użyta w celu odniesienia do zadeklarowanego elementu. Szczegółowa lista warunków, w jakich jedne deklaracje przesłaniają inne znajduje się w specyfikacji języka.
Przesłanianie jest szeroko znaną i stosowaną cechą jezyków programowania. Wobec tego, w niniejszym punkcie podany jedynie krótki przykład pokazujący w jaki sposób może ono wpłynąć na refaktoryzację - konkretnie na Zmianę Nazwy Pola.

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

Pojęcie ukrywania (ang. hiding) odnosi się do elementów, które byłyby odziedziczone z nadtypów - jednak nie są, z powodu istnienia deklaracji w podtypie. Jest ono opisane w specyfikacji języka Java (punkty 8.3, 8.4.6.2, 8.5, 9.3, 9.5).

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*/
 

Zaciemnianie

W punkcie 6.3.2 specyfikacji języka stwierdza się, że nazwy proste mogą występować w kontekstach, w których mogą zostać interpretowane jako nazwy zmiennej, typu lub pakietu. W takich sytuacjach zmienna ma pierwszeństwo przed typem, a typ przed pakietem (punkt 6.5 specyfikacji języka). To zjawisko nazywa się zaciemnianiem nazw (ang. obscuring). Może się więc zdarzyć, że nie możemy odwołać się do widocznego typu bądź pakietu przez jego nazwę prostą. Zaciemnianie zdarza się najcześciej wtedy, gdy nie przestrzega się konwencji nazw (opisanych w punkcie 6.8 specyfikacji języka).

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.
 

Postępowanie w przypadku wystąpienia przesłaniania, ukrywania lub zaciemniania

Podobnie jak przy konflikcie nazw typów, również w przypadkach wystąpienia przesłaniania, ukrywania lub zaciemniania możliwe jest przyjęcie jednej z dwu strategii postępowania:
    a. Możemy (jako twórcy narzędzia do refaktoryzacji) starać się odpowiednio zmodyfikować kod, by uniknąć niepożądanego przesłaniania, ukrywania lub zaciemniania. Użycie konkretnej metody postępowania jest uzależnione od sytuacji - w niektórych przypadkach niezbędne jest wprowadzenie rzutowania (ang. downcast), w innych użycie w pełni kwalifikowanych nazw typów itd.
    b. Możemy wykrywać sytuacje prowadzące do powstania przesłaniania, ukrywania lub zaciemniania i zabraniać refaktoryzacji w takich przypadkach.

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.

3.4 Przeciążanie nazw metod

,,Jeżeli dwie metody w klasie (zadeklarowane w tej samej klasie lub obie odziedziczone z nadklasy lub jedna zadeklarowana, a druga odziedziczona) mają tę samą nazwę lecz różne sygnatury, to mówimy, że nazwa metody jest przeciążona (ang. overloaded).'' [JLS2, p. 8.4.7].

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.

3.5 Metody natywne

Metody natywne (ang. native) są napisane w innych niż Java językach programowania i dołączane (ang. link) podczas działania programu. Zwykle jest to język C bądź C++ (i takie założenie przyjmiemy w tej pracy). Używając metod natywnych nie podaje się ich treści - jedynie deklaracje, podobnie jak metod abstrakcyjnych. ,,Natywny'' oznacza więc w tym kontekście dwie rzeczy - po pierwsze metody w języku Java zadeklarowane jako natywne, po drugie ich deklaracje i kod po stronie C/C++. Określenie ,,kod natywny'' oznacza kod w C/C++ implementujący metody natywne.

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.
 

3.6 Przypadki specjalne

W języku Java obowiązuje kilka reguł specjalnych - nie mieszczących się w żadnej z podanych dotąd kategorii. Najważniejsze z nich to te, które dotyczą nierównouprawnienia nazw.
 

Metoda toString()

Metoda toString(), zadeklarowana w klasie java.lang.Object jest wywoływana domyślnie przez użycie operatora + wtedy, gdy co najmniej jeden z argumentów jest obiektem klasy java.lang.String. Ta reguła sprawia, że następujące wyrażenie jest dozwolone:

    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.
 

Pakiet java.lang

Wszystkie publiczne typy z pakietu java.lang są importowane niejawnie (import na żądanie) przez wszystkie jednostki kompilacji. Nie trzeba umieszczać jawnej deklaracji importu tego pakietu.
Program do refaktoryzacji musi traktować wszystkie jednostki kompilacji jako zawierające deklaracje import java.lang.*;Warunki wstępne refaktoryzacji, jakie się z tym wiążą, wynikają bezpośrednio z uwag podanych w punkcie 3.2.
Ponadto nazwy wielu typów zadeklarowanych w tym pakiecie są na stałe zakodowane w maszynie wirtualnej Javy. Możemy więc założyć, że nie wolno:
    a. zmienić nazwy ani położenia żadnego typu zadeklarowanego w tym pakiecie,
    b. zmienić nazwy tego pakietu.
 

Metoda main

Maszyna wirtualna Javy, podczas uruchomienia, próbuje zlokalizować metodę o sygnaturze main(java.lang.String[]), zadeklarowaną jako public static void w klasie, której w pełni kwalifikowaną nazwę podano jako argument w wierszu poleceń. Z tego powodu nie jest możliwa, bez zmiany zachowania programu, zmiana nazwy, położenia, typu wyniku, typu i liczby argumentów tej metody, jak również jej modyfikatorów. Nie można także zmienić nazwy ani położenia typu, w którym jest zadeklarowana ani żadnego z zawierających go typów. Również zmiana nazwy pakietu, w którym znajduje się typ deklarujący taką metodę spowoduje błąd. Niemożliwa jest także (bez spowodowania niemożności uruchomienia programu) zmiana nazwy lub położenia typu java.lang.String.
    Narzędzie wspierające refaktoryzację powinno ostrzegać użytkownika o wystąpieniu tego typu sytuacji, by pozwolić, kiedy to możliwe (np. przy zmianie nazwy typu deklarującego metodę main) na aktualizację zewnętrznych programów uruchamiających refaktoryzowany program w Javie (skryptów, programów wsadowych i innych).
 

Niejawne dziedziczenie z klasy java.lang.Object

W specyfikacji języka napisano ([JLS2 9.2]), że : ,,jeżeli interfejs nie ma żadnych bezpośrednich nadinterfejsów, wówczas niejawnie deklaruje publiczną, abstrakcyjną metodę m o sygnaturze s, typie wyniku r oraz klauzuli throws t odpowiadającą każdej z publicznych metod instancyjnych m o sygnaturze s, typie wyniku r oraz klauzuli throws t zadeklarowanych w klasie java.lang.Object, chyba, że metoda o tej samej sygnaturze, typie wyniku i odpowiadającej klauzuli throws jest jawnie zadeklarowana w tym interfejsie. Wynika z tego, że zgłaszany jest błąd kompilacji, jeżeli interfejs deklaruje metodę o tej samej sygnaturze i odmiennym typie wyniku bądź nieodpowiadającej klauzuli throws.''
Z tej reguły wynika znaczna liczba warunków wstępnych, które muszą zostać sprawdzone w celu upewnienia się, że podczas refaktoryzacji programu nigdy nie powstanie interfejs łamiący tę zasadę. Lista tych warunków jest łatwa do skonstruowania i zostanie tu pominięta.
 

Mechanizm odbicia

Mechanizm odbicia pozwala na dynamiczny dostęp do elementów programu używając ich nazw, które mogą być określone dopiero podczas działania systemu. Przykładowo, poniższy fragment kodu może być użyty do wywołania metody m obiektu o:

        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.

3.7 Podsumowanie

Java jest językiem programowania o skomplikowanej składni i semantyce. W tym rozdziale pokazaliśmy, w jaki sposób pewne cechy języka Java (charakterystczne, lecz nie specyficzne dla niego) sprawiają, że refaktoryzacja programów w tym języku jest trudna i musi być przeprowadzana uważnie, by nie prowadzić do błędów.

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.



(1) Jest tak nie tylko ze względu na wielokrotne dziedziczenie, lecz także ze względu na metody niewirtualne oraz typy zagnieżdżone.
(2) Odmiennie niż w przypadku metod wirtualnych, które, jeżeli mają tę samą sygnaturę, są niejako odmianami tej samej metody.
(3) Refaktoryzacja polegająca na stworzeniu nowej klasy jest uznawana za jedną z najprostszych i nie wymaga sprawdzenia prawie żadnych warunków wstępnych w systemach, w których nie występują przestrzenie nazw. Podany przykład pokazuje, że nie jest to tak proste w systemach używających przestrzeni nazw.