Ein Überblick über Refactoring und dessen Einsatz bei der Java-Entwicklung

Runderneuerung

Thomas Schoen

Am Ende ist alles Chaos! Dies ist die Schlussfolgerung aus dem 2. Hauptsatz der Thermodynamik über die zunehmende Entropie: Die Entropie eines Systems wächst über die Zeit, es sei denn, es wird dauerhaft Energie (Arbeit) investiert, um das System instand zuhalten. Dies lässt sich unmittelbar auf Softwaresysteme übertragen. Je mehr Entropie (das Maß für die Unordnung), um so weniger Struktur ist im Code eines Softwaresystems vorhanden.

Umkehr des Alterungsprozesses

Refactoring (siehe Abschnitt Refactoring) befasst sich mit genau dieser Problemstellung und bietet Lösungsansätze. In seinem Buch beschreibt Martin Fowler Refactoring auch so: In essence when you refactor you are improving the design of the code after it has been written. Damit kehrt Refactoring die Entwicklung um, die Software normalerweise durchläuft: Von einem (mehr oder weniger) guten Design über viele Iterationen von Fehlerkorrekturen, Erweiterungen und Wiederverwendungen, hin zu einem schlecht strukturierten und kaum verständlichen Code – im Extremfall bis hin zu einem nicht mehr wartbaren Code-Gemenge.

Ursprünglich entstand die Methodik des Refactoring in Smalltalk-Kreisen und basiert daher stark auf den Prinzipien der objektorientierten Softwareentwicklung. Kein Wunder, dass Refactoring für die Pflege und Weiterentwicklung von Java-Applikationen zunehmend an Bedeutung gewonnen hat. Der eigentliche Durchbruch kam mit der Verfügbarkeit von geeigneten Refactoring-Tools.

Was ist Refactoring?

Refactoring ist eine (noch relativ junge) Software Engineering Disziplin, die ein methodisches Vorgehen bei der Bearbeitung von existierendem Programmcode definiert. Bearbeitung bedeutet in diesem Kontext nicht Fehlerkorrektur oder Erweiterung der Applikation. Im Gegenteil ist das Grundprinzip von Refactoring, die Struktur (das Design) des Codes zu verbessern ohne dabei das funktionale Verhalten der Applikation zu verändern.

Martin Fowler definiert Refactoring in seinem Standardwerk `Refactoring: Improving the Design of Existing Code‘ wie folgt: Refactoring is the process of changing software in such a way that it does not alter the external behavior of the code yet improves its internal structure.

Was heißt nun Verbesserung des Codes?

Ziel ist es, die Überschaubarkeit und Verständlichkeit des Codes zu erhöhen und die Wartbarkeit zu vereinfachen. Wichtige Standardoperationen dabei sind zum Beispiel die Umbenennung von Feldern und Methoden mit selbst erklärenden Namen sowie die Entfernung von Code-Duplikaten, die durch Copy/Paste entstanden sind. Natürlich beinhaltet Refactoring wesentlich komplexere Maßnahmen zur Codeverbesserung.

Mittlerweile gibt es etwa hundert benannte Refactoring-Operationen (auch Refactoring-Patterns oder einfach Refactorings genannt), die ein Codefragment ausgehend von einem definierten Anfangszustand mittels einer Folge von wohl formulierten Transformationen in einen definierten Endzustand überführen. Viele dieser Refactorings sind in diesem Online Katalog gelistet: www.refactoring.com/catalog

Trotzdem ist der Einsatz von Refactoring in vielen IT-Organisationen nicht unumstritten. Es herrscht zwar weitgehend Übereinstimmung, dass ein wohl strukturierter und verständlicher Quellcode den Einarbeitungsaufwand für einen neuen Softwareentwickler erheblich reduziert. Auch die Fehlersuche wird dadurch beschleunigt und die Erweiterbarkeit vereinfacht. Über die gesamte Lebenszeit dieser Applikation betrachtet ist dies viel wert. Aber wie viel Geld möchte man für ein Refactoring-Projekt ausgeben, das als unmittelbares Ergebnis nur die inneren Qualität eines schon lauffähigen Systems verbessert? Zudem birgt natürlich jeder Eingriff in ein funktionierendes System die Gefahr des Einschleppens neuer Fehler.

Wann Refactoring einsetzen?

In der Praxis hat sich der Einsatz von Refactoring daher für die Situationen etabliert, bei denen der Code aus folgenden anderen Gründen sowieso angefasst werden muss:

  • Bugs sollen gefixt werden,
  • Erweiterungen bzw. funktionale Änderungen sollen vorgenommen werden,
  • Applikationsteile sollen wiederverwendet werden oder
  • ein Code-Review soll durchgeführt werden.

Vor jeder Codeänderung steht immer der Prozess des Verstehens und Analysierens des vorliegenden Programmstücks. Bei einer Kombination von Refactoring und funktionaler Änderung kann das gewonnene Wissen über ein Codefragment unmittelbar für beides genutzt werden. Eine schlechte Struktur dieses Fragments wird durch Refactoring verbessert. Unmittelbar danach werden für dieses Teilstück die notwendigen Erweiterungen vorgenommen.

Ein Beispiel

Das Beispiel in Listing 1 erläutert diese Vorgehensweise. Ausgangssituation ist eine Klasse Canvas. Diese definiert Bilder, die aus einer Menge von Quadraten (Squares) aufgebaut sind (abgelegt in einer Collection).

Listing 1

public class Square {
int width;

}

public class Canvas {
/** Squares to draw. */
Collection squares = new ArrayList();

public void paint() {
Iterator i = squares.iterator();
while (i.hasNext()) {
// all items are squares according to the spec
Square square = (Square) i.next(); // potential class cast exception !!

// draw the square: println substitutional for real drawing algorithm
System.out.println("Square width:" + square.getWidth());
}
}

}

In einer Erweiterungsphase für diese Applikation soll das Bild nicht nur aus Quadraten (Squares) sondern zusätzlich auch aus Kreisen (Circles) bestehen können. Wenn das Datenfeld squares (vom Typ Collection) im vorliegenden Programm zur Laufzeit auch ein Objekt der Klasse Circle enthält, dann wird die Cast-Operation hin zum Typ Square illegal. Eine Class Cast Exception wird ausgelöst. Sicherlich könnte man diesen Fall in der Methode paint abfangen und eine spezielle Behandlung für Objekte vom Typ Circle einführen. Wenn das Bild aber später um weitere Formen ergänzt wird, steht man wieder vor dem Problem einer potenziellen Class Cast Exception. Im Sinne der Übersichtlichkeit und Erweiterbarkeit ist folgende Lösung besser (Listing 2). Zunächst wird mit Hilfe der Refactoring-Operation Extract Method die Codesequenz, die das Zeichnen des Quadrats realisiert, in eine eigene Methode draw überführt.

Listing 2

void draw(Square square) {
// draw the square: println substitutional for real drawing algorithm
System.out.println("Square width:" + square.getWidth());
}

public void paint() {
Iterator i = squares.iterator();
while (i.hasNext()) {
// all items are squares according to the spec
Square square = (Square) i.next(); // potential class cast exception !!
draw(square);
}
}

Die neue Methode draw ist in der Klasse Canvas definiert. Da in dieser Methode auf das Objekt square zugegriffen wird, muss es mit Hilfe eines Aufrufparameters an den Methodenrumpf übergeben werden. An die Stelle der ursprünglichen Codesequenz in der Methode paint wird ein Methodenaufruf von draw gesetzt. Bei Betrachtung der Methode draw wird dann schnell klar, dass diese eigentlich in die Klasse Square gehört. Das Verschieben einer Methode in eine andere Klasse wird durch die Refactoring-Operation Move Method realisiert. In unserem Fall wird die Methode draw aus der Klasse Canvas in die Klasse Square überführt. Dabei kann auf den Parameter square verzichtet werden, da die Methode ja nun selbst in der Klasse Square liegt. Die Aufruf-Referenzen der Methode werden bei dieser Refactoring-Operation auch entsprechend angepasst. Im nächsten Schritt wird eine abstrakte Superklasse Shapes für die Klasse Squares angelegt. Die Klasse Shapes erhält dabei die abstrakte Methode draw. Erledigt wird dies durch die Refactoring-Operation Extract Superclass.
Schließlich wird noch die Collection-Variable squares durch Einsatz der Refactoring-Operation Rename umbenannt in den jetzt passenderen Namen shapes. Der bearbeitete Code hat nun eine andere innere Struktur – funktional ist das Verhalten aber unverändert (Listing 3).

Listing 3

public class Square extends Shape {
int width;
...
public void draw() {
// draw the square: println substitutional for real drawing algorithm
System.out.println("Square width:" + this.getWidth());
}
}

public class Canvas {
/** Shapes to draw. */
Collection shapes = new ArrayList();

public void paint() {
Iterator i = shapes.iterator();

while (i.hasNext()) {
Shape shape = (Shape) i.next();
shape.draw();
}
}
...
}

Bei Verwendung eines Refactoring-Tools können die oben beschriebenen Operationen mit wenigen Mausklicks erledigt werden. Nachdem der Code durch das Refactoring vorbereitet wurde, ist die funktionale Erweiterung um die Klasse Circle nun schnell erledigt. Sie erfordert nun auch keine Änderungen in der Klasse Canvas (Listing 4).

Listing 4

public class Circle extends Shape {
int radius;
...

public void draw() {
// draw the circle: println substitutional for real drawing algorithm
System.out.println("Circle radius:" + this.getRadius());
}
}

Durch einen solchen verschränkten Einsatz von Refactoring-Operationen im Kontext von anstehenden Programmänderungen geschieht nach und nach das Refactoring der Applikation im Zuge der regulären Wartungsarbeiten. Trotzdem bleibt jeder Refactoring-Prozess ein in sich abgeschlossener Vorgang. An seinem Ende steht das Testen des umstrukturierten Codes durch Unit-Tests auf Klassenbasis. Üblicherweise greift man hier auf den Defacto-Standard JUnit zurück. Die Tests stellen sicher, dass sich das funktionale Verhalten gegenüber der Ausgangssituation nicht geändert hat.

Der im Beispiel gezeigte Umbau des Codes lässt sich relativ einfach und schnell durchführen. In der Regel liegt der schwierigere Teil liegt im Vorfeld: Zu erkennen wo Refactoring eingesetzt werden soll und welche Operationen dabei geeignet sind. Ziel ist es ja, das Code Design zu verbessern – insbesondere im Hinblick auf geplante Codeerweiterungen.

Wann liegt im Code eine Situation vor, die Refactoring sinnvoll macht?

Diese Frage ist nicht alleine durch einen Satz von objektiven Kriterien oder Metriken zu beantworten. Die Antwort hängt ebenso von Art und Kontext des Projekts ab sowie den geplanten funktionalen Veränderungen. Hier kommt auch die Erfahrung und Intuition des Softwareentwicklers ins Spiel. Gleichwohl gibt es typische Situationen (auch Bad Smells genannt), die auf den Bedarf von Refactoring hinweisen. Hier sind einige Beispiele angeführt:

  • Identische Codefragmente, die an verschiedenen Stellen im Quellcode auftreten, machen die Wartung aufwändig und fehleranfällig.
  • Durch schlecht gewählte Namen für Methoden und Variablen wird der Code unverständlicher.
  • Eine häufig Verwendung von Switch-Statements deutet darauf hin, dass hier Potenzial für die Einführung von Subklassen und Polymorphismus ist.
  • Sehr lange Methoden erschweren es die Programmlogik nachzuvollziehen und diese Methoden häufig zu verwenden.
  • Sehr große Klassen deuten entweder auf Redundanz hin oder verwalten zu viele Daten und wollen zu viel Funktionalität bereitstellen. Dies widerspricht dem Prinzip der Objektorientierung.
    Wenn eine Menge von Datenfeldern häufiger gemeinsam verwendet wird ist dies ein Hinweis, dass daraus eine eigene Klasse gebildet werden könnte.

Oben angeführtes Beispiel zeigt auch, dass die Umstrukturierung des Codes durch das Zerlegen in einzelne Teilschritte deutlich einfacher ausführbar und auch gut automatisierbar wird. Hier ist ein gutes Einsatzfeld für Werkzeuge.

Was ist ein Refactoring-Tool?

Ein Refactoring-Tool wendet die Refactoring-Operationen auf den Code automatisch an und wird dabei von den Eingaben des Benutzers gesteuert. Diese Benutzereingaben werden in der Regel durch Dialogboxen abgefragt. Damit lassen sich Refactoring-Operationen schnell und einfach durchführen. Ein Tool kann manuelle Fehler beim Ändern von Code vermeiden. Es reduziert damit deutlich die Wahrscheinlichkeit, dass sich beim Refactoring Fehler im Code einschleichen. Zusätzlich leistet ein Tool wichtige Hilfestellung bei der vorausgehenden Analyse des Codes. Es kann entscheidungsrelevante Informationen liefern: Wo im Code sollen und können Refactorings durchgeführt werden und was sind die Auswirkungen?

Grundvoraussetzung für ein Java Refactoring-Tool ist, dass es die Syntax von Java versteht. Dies wird schon bei der einfachsten Refactoring-Operation deutlich, dem Renaming. Wenn zum Beispiel die Methode foo() umbenannt werden soll, dann könnte dies sicherlich auch das Search/Replace-Kommando des Texteditors leisten. Dabei müsste der Benutzer bei jeder Fundstelle des Worts foo entscheiden, ob es sich um die Methode der richtigen Klasse handelt. Nur dann darf eine Namensersetzung durchgeführt werden. Ein Java Refactoring-Tool hingegen analysiert den Javacode und erkennt automatisch, welche Fundstellen im Code für die richtige Methode stehen. Dabei kann es auch zwischen überladenen Methoden in derselben Klasse unterscheiden. Darüber hinaus weist ein gutes Refactoring-Tool auch auf mögliche Problem hin. In unserem Beispiel könnten dies polymorphe Funktionen foo() sein, die in Subklassen definiert sind. Ein isoliertes Umbenennen der Methode foo() in der Superklasse zerstört den Polymorphismus zwischen Superklasse und Subklasse. Das führt dann vermutlich zu einem Compilerfehler.

Da beim Refactoring direkt mit dem Code gearbeitet wird, liegt es nahe, ein Refactoring-Tool eng mit dem Java Editor bzw. der Java IDE zu integrieren. So bietet Eclipse mit Version 2 einige Tool-gestützte Refactoring-Operationen. Den größten Umfang an automatisierten Refactoring-Operationen habe ich in der Java IDE IntelliJ IDEA gefunden. Hier sind auch komplexere Refactoring-Operationen implementiert wie z.B. Replace Inheritance with Delegation oder Convert Anonymous Class to Inner.

Abb. 1: Das Tool RefactorIT bei der Refactoring-Operation Extract Method

Ein anderer, nützlicher Ansatz sind Refactoring-Tools, die als Ergänzung zu bestehenden Java-IDEs angeboten werden. Hier ist mir das Tool RefactorIT besonders aufgefallen (Abb. 1). Es arbeitet als integriertes Zusatzwerkzeug mit den Java-Entwicklungsumgebungen JBuilder, Netbeans, Sun One Studio und JDeveloper zusammen und kann auch parallel zu anderen IDEs betrieben werden. RefactorIT bietet Unterstützung für alle wichtigen Refactoring-Operationen und kann dabei nicht nur Java, sondern auch JSP-Code bearbeiten. In Tabelle 1 werden die unterstützten Refactoring-Operationen von den IDEs Eclipse und IntelliJ IDEA sowie dem Refactoring-Tool RefactorIT gegenübergestellt. Dabei wird deutlich, dass IntelliJ IDEA hier zurzeit einen Vorsprung hat. Wer sich aber auf eine Java-IDE wie JBuilder oder Sun One Studio/Netbeans festgelegt hat, muss das Thema Refactoring nicht abschreiben. Er kann seine IDE mit Hilfe des integrierbaren RefactorIT klar auf Kurs Refactoring bringen.

Refactoring-Operation Erläuterung RefactorIT 1.3 Eclipse 2.1 IntelliJ IDEA 3.0
Rename Class, Method, Field, Package, Parameter, Local Variable Umbenennung von Klassen/Interfaces, Methoden, Feldern, Packages, Methodenparameter und lokalen Variabeln. Bei allen Renaming-Operationen werden auch alle zugehörigen Referenzen umbenannt (optional auch in Javadocs). + + +
Move Class Verschiebt eine Klasse bzw. Interface in ein anderes Package. Alle Referenzen im Sourcecode werden entsprechend aktualisiert. + + 1 +
Move Method/Field Verschiebt eine Methode bzw. ein Feld in eine andere Klasse. Alle Referenzen im Sourcecode werden entsprechend aktualisiert. + + 2 + 2
Extract Method Analysiert ein selektiertes Codefragment und generiert eine neue Methode mit passender Signatur für die im Teilstück verwendeten Variablen. Das Codefragment wird in den Rumpf der neuen Methode übertragen. An die Stelle des ursprünglichen Codefragments wird der Methodenaufruf gesetzt. + + +
Inline Method Umkehr-Operation zu Extract Method: Anstelle eines Methodenaufrufs wird der Methodenrumpf im Code eingesetzt. Parameter werden entsprechend aufgelöst. +
Encapsulate Field Generiert Getter- und Setter-Methoden für ein ausgewähltes Feld. Im Sourcecode wird an Stelle des verwendeten Feldes die passende Getter/Setter-Methode eingesetzt. + + +
Create Factory Method Erzeugt zu einem Konstruktor eine Factory-Methode. Dabei wird die Signatur des Konstruktors auf die Factory-Methode übertragen. In ihrem Rumpf wird der Konstruktor aufgerufen. Die im Sourcecode existierenden Aufrufe des Konstruktors werden wahlweise durch den Aufruf der Factory-Methode ersetzt. + +
Extract Superclass Extrahiert in einer Klasse ausgewählte Methoden und Felder in eine neue Superklasse. Die ursprüngliche Klasse wird als Subklasse deklariert. + +
Extract Interface Legt für eine Klasse eine neue Interface-Klasse an. Dabei werden ausgewählte Methoden und Felder in der Interface-Klasse spezifiziert. Die ursprüngliche Klasse wird als Implementierungs-Klasse deklariert. + +
Change Method Signature Ändert die Signatur einer Methode: Reihenfolge, Typ und Anzahl der Parameter. Die zugehörigen Referenzen werden entsprechend geändert. Achtung: Hier sind evtl. manuelle Nacharbeiten erforderlich. + 3 +
Minimize Access Rights Die minimal möglichen Zugriffsrechte (in Bezug auf Sichtbarkeit) von Methoden und Felder innerhalb einer Klasse werden ermittelt und angezeigt. Diese Zugriffsrechte können dann vom Tool gemäss den Vorgaben des Benutzers geändert werden. + + 4
Create Constructor Erzeugt einen Klassenkonstruktor für ausgewählte Felder dieser Klasse. Dabei wird für jedes dieser Felder ein Parameter in die Signatur des Konstruktors eingetragen. + +
Pull Up in Hierarchy Verschiebt Methoden und Felder in der Vererbungshierarchie der Klassen nach oben. Mögliche Konflikte werden angezeigt und Lösungsvorschläge angeboten. + + +
Push Down in Hierarchy Verschiebt Methoden und Felder in der Vererbungshierarchie der Klassen nach unten. Mögliche Konflikte werden angezeigt und Lösungsvorschläge angeboten. + +
Introduce Variable Für einen ausgewählten Ausdruck im Code wird eine Variable von passendem Typ eingeführt. Die Variable wird mit dem Ergebnis dieses Ausdrucks initialisiert. Anstelle des ursprünglichen Ausdrucks wird eine Referenz dieser Variable gesetzt. + + 5
Inline Variable Umkehr-Operation zu Introduce Variable. Für eine ausgewählten Variable (die bei der Deklaration initialisiert wird) wird Ihr initialisierender Ausdruck an die Stelle der Referenz dieser Variable gesetzt. Wenn möglich wird die Variablendeklaration gelöscht. + +
Replace Temp with Query Für eine ausgewählten Variable wird eine Methode erzeugt, die den initialisierende Ausdruck der Variable als Ergebnis liefert. Alle Referenzen der Variable werden durch die Methode ersetzt. Die Variablendeklaration wird gelöscht. +
Convert Anonymous to Inner Wandelt eine anonyme Klasse in eine benannte, innere Klasse um. +
Replace Inheritance With Delegation Eine Vererbungsbeziehung zu einer Superklasse wird ersetzt durch eine Assoziation hin zu der ehemaligen Superklasse. Die zuvor geerbten Methoden werden modelliert durch gleichnamige Methoden, die in ihrem Rumpf die entsprechende Methode der assoziierten Klasse aufrufen. +
Anmerkungen:
1) Nicht für lokale Klassen möglich.
2) Nur für static Methoden/Felder möglich.
3) Nur Änderung der Parameterreihenfolge möglich.
4) Die Änderung ist nur hin zum minimalen Zugriffsrecht möglich. Eine Änderung mit frei einstellbarem Zugriffsrecht ist nicht möglich.
5) Anstatt einer Variablen kann auch ein Feld oder eine Konstante erzeugt werden.

Wie schon angesprochen sind die Analysen im Vorfeld wesentlicher Bestandteil des Refactoring-Prozesses. Diese Analysen adressieren Fragen wie beispielsweise Wo wird diese Methode verwendet?, Welches Klassen-Member werden nirgendwo verwendet?, Wie ist die Vererbungshierarchie für die vorliegende Klasse?, Wo wird diese Exception behandelt? oder Wie sehen die möglichen Aufrufbäume für eine Methode aus?. Solche Fragestellungen werden sicherlich am besten durch Einsatz eines Tools behandelt. Leistungsfähige IDEs können die entsprechenden Antworten liefern – so auch die hier betrachteten Eclipse und IntelliJ IDEA. Das Refactoring Werkzeug RefactorIT bringt diese Analysemöglichkeiten ebenfalls mit.

Geschrieben von
Thomas Schoen
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.