Cała niniejsza praca jak i wspomniane narzędzie, którego opis stanowi
jej istotną część, powstały podczas stażu autora w biurze firmy Object
Technology International w Zurychu (1) pod kierownictwem
dra Ericha Gammy. Projekt i implementacja narzędzia zostały wykonane przez
Adama Kieżuna (ogólny projekt, wszystkie refaktoryzacje poza Wydzieleniem
Metody, operacje na plikach, operacje na pakietach, infrastruktura zestawu
testów, interfejs graficzny) oraz dra Dirka Baeumera (refaktoryzacja Wydzielenie
Metody, operacje tekstowe, interfejs graficzny).
Integracja wsparcia refaktoryzacji ze środowiskiem programistycznym
IBM WebSphere Studio Workbench została prez nas opisana w artykule [1].
Do tej pory (wersja R0.9 produktu) zaimplementowano część planowanych refaktoryzacji wraz z dużym zestawem testów towarzyszącym każdej z nich. Znaczną część pracy pochłonęło stworzenie elastycznej architektury, dzięki której, jak się spodziewamy, z czasem stosunkowo łatwo będzie dodawać kolejne refaktoryzacje. Nasze narzędzie było z powodzeniem stosowane do refaktoryzacji dużego projektu - IBM WebSphere Studio Workbench (ponad 500000 wierszy kodu źródłowego) - jest też na codzień używane przez samych autorów.
Program został w całości napisany w języku Java. Dało nam to możliwość użycia go do refaktoryzacji jego własnego kodu źródłowego - co z powodzeniem robimy podczas pracy nad kolejnymi udoskonaleniami.
IBM WebSphere Studio Workbench jest pierwszym dużym środowiskiem programistycznym zawierającym wsparcie dla refaktoryzacji. Począwszy od jednej z kolejnych wersji będzie dostępne bezpłatnie wraz z kodem źródłowym. Mamy nadzieję, że dzięki temu damy wielu programistom możliwość zapoznania się z nowym sposobem pracy, jaki umożliwia refaktoryzacja. Ponadto przez stworzenie elastycznej infrastruktury chcemy dać innym twórcom narzędzi możliwość łatwej implementacji nowych refaktoryzacji. Sądzimy, że otwartość środowiska umożliwi stworzenie lepszej jakości narzędzi do refaktoryzacji (i nie tylko).
System zarządzania zasobami kontroluje dostęp do zasobów (w naszym przypadku plików i katalogów) i sposób ich modyfikacji. Innym bardzo istotnym jego zadaniem jest zarządzanie znacznikami (ang. markers). Znacznik to obiekt związany z zasobem. Każdy znacznik związany z plikiem ma atrybut określający jego pozycję w danym pliku. Przykładowe rodzaje znaczników to: punkty kontrolne (ang. breakpoints), zakładki (ang. bookmarks), wyniki wyszukiwań (ang. search results) oraz znaczniki oznaczające błędy kompilacji.
Najważniejszą cześcią kompilatora Javy, z której korzystamy w naszym programie, są drzewa składni (ang. abstract syntax trees, w skrócie AST). Używamy ich do analizy warunków wstępnych, a także, w kilku przypadkach, do wyszukiwania odniesień do elementów programu.
Wyszukiwarka jest, obok kompilatora i kilku innych komponentów, elementem modelu Javy (czyli tej części modułu wspomagającego programowanie w Javie, która jest pozbawiona interfejsu graficznego). Służy do efektywnego i precyzyjnego znajdowania deklaracji i odniesień do elementów programu, np. klas, metod itp.
Najpierw przedstawimy warunki podane przez Robertsa i Tokudę.
Integracja ze środowiskiem programistycznym Wspomaganie refaktoryzacji musi być ściśle zintegrowane ze środowiskiem programistycznym. Przeprowadzenie refaktoryzacji powinno odbywać się tak jak każda inna czynność wykonywana przez użytkownika (najczęściej przez wybranie opcji z menu).
Możliwość wycofania zmian Musi istnieć możliwość sprawnego wycofania zmian wprowadzonych do programu przez refaktoryzację. Dopuszczalne jest przy tym, by dokonane zmiany można było wycofać tylko do pewnego momentu. Przykładowo, jeśli po przeprowadzeniu refaktoryzacji wewnątrz pliku z kodem źrodłowym usuniemy ten plik, to wycofanie refaktoryzacji może okazać się niemożliwe.
Niezawodność Zachowywanie działania programu powinno być tak pełne, jak to możliwe (biorąc pod uwagę zawiłą konstrukcję języka bardzo trudne byłoby zagwarantowanie pełnej niezmienności działania - nawet pod nieobecność metod natywnych i mechanizmu odbicia). Przyjęta przez nas definicja niezmienności działania programu znajduje się w punkcie 2.1. Dopuszczalne są jednak sytuacje, gdy z powodów wydajności pominięta zostanie część analizy warunków wstępnych. Musi to być jednak traktowane jako sytuacja wyjątkowa, a pominięte warunki wstępne muszą być spełnione przez typowe programy.
Formatowanie kodu Formatowanie kodu powinno zostać niezmienione, tzn. takie, jakie było przed refaktoryzacją. Jedynym wyjątkiem są sytuacje, gdy narzędzie generuje nowy kod. Także wtedy należy, w miarę możliwości, stosować poprzednie formatowanie kodu.
Komentarze Komentarze muszą pozostać nienaruszone tzn. takie, jakie były przed refaktoryzacją. W przyszłości nasze narzędzie będzie wspierać także modyfikacje tzw. komentarzy dokumentujących - w języku Java są to tzw. komentarze JavaDoc. Komentarze te traktuje się wówczas jako część kodu. Jednak w żadnym wypadku narzędzie nie może usuwać bądź zmieniać położenia istniejących komentarzy w kodzie programu.
Umieszczenie nowotworzonego kodu Należy dać użytkownikowi możność wyboru miejsca, w którym zostanie umieszczony kod wygenerowany podczas refaktoryzacji.
Kontrola dostępności modyfikowanych plików Narzędzie służące do refaktoryzacji musi wykrywać sytuacje, w których pewne objęte refaktoryzacją pliki są niedostępne do zapisu. Należy wówczas poinformować o tym fakcie użytkownika. Wykrycie takiej sytuacji musi być częścią sprawdzania warunków wstępnych, więc odbywa się przed dokonaniem jakichkolwiek zmian w programie.
Nazwy dla nowotworzonych bytów Każdorazowo gdy narzędzie do refaktoryzacji tworzy nowe elementy programu - klasy, metody itp., użytkownik musi mieć możliwość ustalenia dla nich nazw. Nazwa wybrana przez użytkownika może jednak powodować błędy (np. powtarzające się nazwy zmiennych) bądź wpływać na zmianę zachowania programu. Należy wówczas poinformować użytkownika o tym fakcie.
Następujące warunki zostały przez nas dodane do listy podanej przez Robertsa i Tokudę:
Pełna lista niespełnionych warunków wstępnych W miarę możliwości należy zaprezentować użytkownikowi wszystkie niespełnione warunki wstępne - w podobny sposób, jak robią to kompilatory informując o błędach kompilacji. Użytkownik może wówczas naprawić wiele błędów na raz bez konieczności ponownego uruchamiania narzędzia po wprowadzeniu każdej poprawki z osobna.
Pełna lista wprowadzanych zmian w programie Należy pokazać użytkownikowi wszystkie zmiany w programie, jakie są potrzebne do wykonania refaktoryzacji. Ponadto użytkownik musi mieć możliwość niezgodzenia się na dowolną z tych zmian (choć wtedy prawie na pewno program nie będzie działał tak, jak poprzednio). Również w tym przypadku opcja wycofania zmian musi działać bez zarzutu.
Konwencje nazw Nie możemy niczego zakładać o przestrzeganiu bądź nieprzestrzeganiu konwencji nazw (konwencje nazw są opisane w specyfikacji języka [JLS 6.8]).
Analiza warunków wstępnych Wszelka analiza wykonalności refaktoryzacji musi zostać przeprowadzona bez dokonywania zmian w programie. Wewnętrzne struktury kompilatora (jak drzewa składni) także muszą bezwzględnie pozostać nietknięte podczas tej fazy refaktoryzacji.
Transakcyjność operacji Modyfikacja kodu źródłowego programu powinna odbywać się w sposób transakcyjny. Niepożądane są sytuacje, w których narzędzie ma zmodyfikować wiele plików i, z powodu błędu w programie lub błędu zewnętrznego, modyfikuje tylko część i przerywa pracę. Ręczne przywrócenie systemu do pierwotnego stanu może okazać się wówczas bardzo pracochłonne (2).
Zaprezentowana lista wymagań jest niezależna od środowiska programistycznego w jakim powstaje rozważane narzędzie. Ostatnie założenie projektowe dotyczy środowiska, w którym zaimplementowany jest nasz program:
Nietykalność znaczników Znaczniki (opisane w punkcie 4.1.) powinny pozostać nienaruszone. Stanowią one bardzo istotną część środowiska programistycznego, w którym zaimplementowane jest nasze narzędzie. Jeśli to możliwe, powinny zostać uaktualnione (ich atrybuty), by odzwierciedlać nowy stan systemu.
1. Użytkownik wywołuje refaktoryzację przez wybranie
opcji z menu i podanie niezbędnych informacji (np. nazwy dla nowej metody).
2. Część refaktoryzacji uruchamia w tym momencie
wyszukiwarkę w celu efektywnego odszukania odniesień do modyfikowanych
elementów programu.
3. Budowane są drzewa składni dla tych jednostek
kompilacji, które będą modyfikowane (3).
4. Sprawdzane są warunki wstępne refaktoryzacji.
5. Jeżeli analiza wykryje niespełnione warunki wstępne,
to użytkownik ma możliwość kontynuacji - mimo prawdopodobnych błędów, jakie
pojawią się w programie. Uznaliśmy, że jest istotne, by nasze narzędzie
nie przeszkadzało użytkownikowi w pracy. Jeżeli chce on wprowadzić zmiany
do swego programu mimo ostrzeżeń, to ma taką możliwość.
6. Obliczane są wszystkie zmiany, jakie należy wprowadzić
do programu, by przeprowadzić refaktoryzację.
7. Jeżeli użytkownik chce zobaczyć zmiany przez
ich wprowadzeniem, to pełna ich lista jest prezentowana w interfejsie użytkownika.
8. Narzędzie wprowadza zmiany do refaktoryzowanego
programu.
9. Na stos operacji do wycofania jest wkładana informacja
o tym, w jaki sposób wycofać ostatnią przeprowadzoną refaktoryzację.
Informacje (ang. infos) nie oznaczają problemów. Użytkownicy mogą bezpiecznie ignorować komunikaty z tej kategorii. Służą one jedynie jako forma komunikacji programu z użytkownikiem.
Ostrzeżenia (ang. warnings) zgłaszane są np. wtedy, gdy przeprowadzenie refaktoryzacji spowoduje pojawienie się ostrzeżeń kompilatora lub w inny negatywny lecz nie destruktywny sposób wpłynie na modyfikowany program.
Przykładowy komunikat z tej grupy pojawia się wówczas, gdy użytkownik chce zmienić nazwę metody main lub klasy zawierającej taką metodę. Programu, który modyfikujemy nie będzie już można wywoływać z wiersza poleceń. Przestaną też działać skrypty go uruchamiające. Nie prowadzi to do błędów kompilacji. Należy jednak poinformować użytkownika o możliwości wystąpienia problemów i przypomnieć o konieczności aktualizacji skryptów itp.
Błędy (ang. errors) oznaczają, że można przeprowadzić modyfikację programu, ale prawie na pewno spowoduje to wystąpienie błędów kompilacji bądź, co gorsza, zmianę działania programu bez wystąpienia błędu kompilacji.
Przykładowo zmiana nazwy metody natywnej na pewno spowoduje przerwanie wykonania programu z błędem UnsatisfiedLinkError przy pierwszym wywołaniu tej metody - jeśli nie zostaną dokonane zmiany w kodzie natywnym. Nasz interfejs użytkownika zezwala na przeprowadzenie modyfikacji, gdy występują jedynie ostrzeżenia. Są one prezentowane użytkownikowi w postaci listy, z opisem i miejscem wystąpienia. Użytkownik może zrezygnować z przeprowadzenia modyfikacji programu bądź mimo wszystko jej dokonać. W takim przypadku, jeśli po przeprowadzeniu modyfikacji (i zapoznaniu się z błędami kompilacji, które ona wówczas zwykle powoduje) zdecyduje sie wycofać zmiany, to może tego dokonać.
Poważne Błędy (ang. stop errors) są zgłaszane w sytuacjach,
gdy nie można przeprowadzić refaktoryzacji.
Przykładowo, użytkownik wybrał dla publicznej, niezagnieżdżonej klasy
nazwę, która nie może być nazwą klasy i pliku bądź plik o tej nazwie już
istnieje. Zgodnie ze specyfikacją języka takie klasy muszą znajdować się
w pliku o nazwie identycznej z nazwą klasy, a system operacyjny nie zezwoli
na stworzenie drugiego pliku o tej samej nazwie. Podobnie błąd zgłaszany
jest wówczas, gdy podczas wykonywania refaktoryzacji musiałby zostać zmodyfikowany
plik, do którego nie ma dostępu (np. jest otwarty tylko do odczytu).
Interfejs użytkownika prezentuje wszystkie te komunikaty (jeśli występują) i zezwala na wykonanie modyfikacji tylko wówczas, gdy nie ma wśrod nich Poważnych Błędów. Jednak, jak wspomniano poprzednio, w przypadku występowania Błędów oraz Ostrzeżeń program, po wykonaniu modyfikacji, najprawdopodobniej przestanie działać (wystąpią błędy kompilacji bądź błędy wykonania) lub jego zachowanie będzie zmienione. Metodę createChange wywołuje się po sprawdzeniu warunków wstępnych. Buduje ona obiekt klasy Change (opisanej w punkcie 4.5.2.), który następnie będzie odpowiedzialny za modyfikację kodu źródłowego programu (samo wywołanie metody createChange nie dokonuje żadnych zmian w programie).
Do łączenia wielu zmian razem używamy wzorca Composite [7]. Podobnie jak w programie Robertsa, również nasze zmiany są odwracalne, zarówno pojedynczo jak i razem, jako złożenie (odwrócenie sekwencji zmian polega na odwróceniu zmian składowych w odwrotnej kolejności). To czyni je bardzo efektywnym i elastycznym narzędziem. Każda, dowolnie skomplikowana, modyfikacja kodu źródłowego jest wykonywana jako ciąg zmian, z których każda potrafi odwrócić efekt swego działania.
Dodatkowo, każda zmiana może być aktywna lub nieaktywna. Zazwyczaj jest to konsekwencją tego, że użytkownik zdecydował, iż pewne zmiany są niepożądane i nie zgodził się na ich przeprowadzenie (jak wspomnieliśmy, programista ma możliwość zobaczenia wszystkich zmian, jakie narzędzie ma zamiar dokonać i niezgodzenia się na dowolną z nich). Taka zmiana staje się wówczas nieaktywna, co oznacza, że wywołanie jej metody perform nie ma żadnego skutku, a przekazywany jest obiekt klasy NullChange (przykład wzorca NullObject [6]). Dezaktywacja zmian prawie zawsze prowadzi do błędów kompilacji (ponieważ narzędzie sugeruje wprowadzenie zmiany, która jest potrzebna do przeprowadzenia refaktoryzacji, a na którą użytkownik się nie zgadza), musi zatem zawsze zostać dokonana na polecenie użytkownika - wszystkie zmiany są zawsze domyślnie aktywne. Mechanizm wycofywania radzi sobie z wycofaniem dowolnej kombinacji aktywnych i nieaktywnych zmian.
Wszystkie refaktoryzacje zostały zaimplementowane przy użyciu niewielkiej liczby podklas klasy Change. Poniżej podajemy ich spis:
Ze względu na wydajność nie cały program znajduje się jednocześnie w pamięci, a drzewa składni tworzone są jedynie dla tych jednostek kompilacji, dla których tego jawnie zażądamy. Jest to kosztowna operacja (trzeba wczytać jednostkę kompilacji i dokonać jej analizy składniowej; sprawdza się także typy wszystkich odwołań i wyrażeń). Wskazane jest wobec tego, by analiza oparta na drzewach składni ograniczała się do możliwie małej liczby jednostek kompilacji.
Podczas pracy i testowania nie wykryliśmy problemów ze skalowalnością. Jest to, po części, zasługa bardzo wydajnej infrastruktury, jaką dysponujemy (szybki kompilator oraz wyszukiwarka). W pamięci przechowywane są jedynie niezbędne informacje. Nasze obserwacje (niepoparte szczegółową analizą) wskazują, że czas działania refaktoryzacji nie zależy w zauważalny sposób od wielkości programu, lecz jedynie od liczby plików, których dotyczy refaktoryzacja.
Wydajność narzędzia pokażemy na przykładach refaktoryzacji Zmiana Nazwy Pakietu oraz Zmiana Nazwy Typu. Wykonanie refaktoryzacji Zmiana Nazwy Pakietu dla pakietu, do którego istnieje ok. 100 odniesień (5) w całym programie trwa ok. 15-17 sekund (komputer 500MHz CPU, 190MB RAM). Z tego ok. 70\% zajmuje wyszukanie odniesień, analiza warunków wstępnych oraz obliczenie modyfikacji programu niezbędnych do przeprowadzenia refaktoryzacji. Pozostały czas zabiera przeprowadzenie obliczonych zmian w programie i aktualizacja stanu środowiska programistycznego. Na wycofanie tej refaktoryzacji potrzeba tylko ok. 5 sekund - ponieważ nie wymaga to wyszukiwania odniesień ani analizy programu.
Na przeprowadzenie refaktoryzacji Zmiana Nazwy Typu dla często używanej klasy, do której istnieje ok. 500 odniesień potrzeba ok. 40 sekund (30 sekund wyszukiwanie odniesień, analiza warunków wstępnych oraz obliczenie koniecznych modyfikacji programu, 10 sekund wprowadzenie zmian i aktualizacja stanu środowiska). Wycofanie tej refaktoryzacji trwa ok. 7-8 sekund.
Z podanych liczb wynika, że możliwa jest efektywna refaktoryzacja nawet dużych programów, obejmująca całość języka programowania i szczegółową analizę warunków wstępnych. Pokazują one także jak wiele czasu (potrzebnego na identyfikację niezbędnych zmian w programie, ich wprowadzenie i przetestowanie) można zaoszczędzić przeprowadzając refaktoryzację z użyciem wydajnych narzędzi.
Wysoką niezawodność narzędzia zapewniono dzięki używaniu programu na codzień przez wielu programistów (także autorów) oraz zestawowi zautomatyzowanych testów, obejmującemu ok. 100 szczegółowych przypadków wykonania każdej z refaktoryzacji. Podczas pracy nad narzędziem wspomniany zestaw testów przeprowadzano po wprowadzeniu każdej istotnej zmiany do programu - co najmniej kilka razy w tygodniu. Dla każdego wykrytego w programie błędu tworzono przypadek użycia pokazujący jego wystąpienie. Błąd uznawano za naprawiony dopiero wtedy, gdy pokazujący go przypadek użycia oraz wszystkie pozostałe przypadki zawarte w zestawie testów dawały wyniki zgodne z oczekiwaniami. Metoda ta pozwoliła na wyeliminowanie w znacznym stopniu ponownego pojawiania się naprawionych wcześniej błędów. Celem części testów było sprawdzenie zachowania narzędzia w sytuacjach, gdy jeden lub więcej warunków wstępnych refaktoryzacji nie był spełniony. Inne testy służyły do sprawdzenia, czy zmiany wprowadzane w refaktoryzowanym programie były zgodne z przewidywaniami (porównywano w nich, znak po znaku, oczekiwaną zawartość plików z kodem źródłowym z tym, co faktycznie było efektem refaktoryzacji programu.).