Heute wollen wir uns mal mit dem Thema Refaktorisieren/Refactoring beschäftigen. Wir schauen uns mal an, was es ist, und wie und ob es bei Softwareprojekten sinnvoll einzusetzen ist. Kurze Antwort: Refaktorisierungen sind ein extrem nützliches Werkzeug zur Verbesserung der Codequalität.
Definition
Die Wikipedia schreibt dazu: Refaktorisierung (auch Refactoring, Refaktorierung oder Restrukturierung) bezeichnet in der Software-Entwicklung die manuelle oder automatisierte Strukturverbesserung von Quelltexten unter Beibehaltung des beobachtbaren Programmverhaltens. Dabei sollen Lesbarkeit, Verständlichkeit, Wartbarkeit und Erweiterbarkeit verbessert werden, mit dem Ziel, den jeweiligen Aufwand für Fehleranalyse und funktionale Erweiterungen deutlich zu senken.
Martin Fowler, Autor des Buchs Refactoring, hat das Thema populär gemacht. Sehen wir uns hier seine Definition an: Refactoring is a controlled technique for improving the design of an existing code base. Its essence is applying a series of small behavior-preserving transformations, each of which „too small to be worth doing“. However the cumulative effect of each of these transformations is quite significant. By doing them in small steps you reduce the risk of introducing errors. You also avoid having the system broken while you are carrying out the restructuring – which allows you to gradually refactor a system over an extended period of time.
Hintergrund
Hinter dem Refaktorisieren steht eine mathematische Theorie. Es handelt sich um Gruppenopertionen und man kann die Beweise führen, dass gewisse Refaktorisierungsoperationen den Programmkode bedeutungsidentisch transformieren. So, als ob funktionell nichts geschehen sei. Per Induktion kann man dann daraus ableiten, dass eine Folge von Refaktorisierungsoperationen auch Bedeutungserhaltend sind. Somit ist also der Gesamte Vorgang nicht Bedeutungsändernd.
Praxis
In der Praxis jedoch wird man aber genau deshalb Refaktorisieren, weil man eine Änderung oder Erweiterung mit den bisherigen Strukturen nicht mehr leisten kann. Man benötigt also eine Abstraktion und sobald man diese eingezogen hat, wird man wechselweise Kodeänderungen und Refaktorisierungen durchführen. Somit ist die Gesamtoperation natürlich nicht Bedeutungserhaltend. Allerdings war ja auch das glatte Gegenteil das Ziel: Ein Entwickler sollte ja die Kodebasis irgendwie erweitern. Und damit das Verhalten Ändern.
Das Gute an der Sache ist eben, dass das Refaktorisieren zunächst ein Schritt ist, der in der Theorie keine Bedeutungsänderung bringt.
Beispiele
An dieser Stelle sollten wir den Blick mal auf einige Beispiele von Refaktorisierungsoperationen werfen, um ein wenig mehr Gefühl dafür zu bekommen.
Hier einige typische Refaktorisierungen:
Umbenennen
Das einfachste ist sicherlich das Umbenennen. Natürlich stellt sich die Frage was. Man kann ja eine lokale Variable, eine Elementvariable, Klasse, Templateparameter, Elementfunktion, Namespace u.v.a.m umbenennen. Die Implikationen sind jeweils ähnlich; können aber weite Kreise ziehen. Inklusive eventueller Dateiumbenennungen. So lange keine Konflikte entstehen, sollte evident sein, dass dies eine harmlose Operation ist:
private static void Gelder(int zahl)
{
int doppelt = zahl + zahl;
Console.WriteLine($"{zahl} Heller verdoppelt sind {doppelt} Heller");
}
public static void Main(string[] args)
{
Gelder(zahl: 4);
}
Durch Umbenennung des Parameters zahl
in nummer
werden alle Stellen kohärent angepasst.
private static void Gelder(int nummer)
{
int doppelt = nummer + nummer;
Console.WriteLine($"{nummer} Heller verdoppelt sind {doppelt} Heller");
}
public static void Main(string[] args)
{
Gelder(nummer: 4);
}
Extrahieren und Entrollen einer Variable
Hierbei handelt es sich um zwei gegenteilige Operationen. Extrahieren bedeutet, dass aus einem Ausdruck eine neue Variable entsteht. Entrollen ist das Gegenteil. Alle Vorkommen einer Variable werden durch den Ausdruck ersetzt. Sehen wir folgendes Beispiel an:
private static (double umfang, double fläche) Kreis(double radius)
{
double rp = radius * Math.PI;
double umfang = 2 * rp;
double fläche = radius * rp;
return (umfang, fläche);
}
Der Teilausdruck radius * Math.PI
wurde hier ein eine Variable rp
gepackt. Das könnte vielleicht eine Rechnung ersparen, verringert aber eher die Lesbarkeit. Daher konnte die Variable rp
entrollt werden und an ihrer Stelle steht nun der Ausdruck. Dieser Umbau kann durch Extraktion umgekehrt werden.
private static (double umfang, double fläche) Kreis(double radius)
{
double umfang = 2 * radius * Math.PI;
double fläche = radius * radius * Math.PI;
return (umfang, fläche);
}
Skalar zu Tupel-Transformation
Diese Refaktorisierungsoperation habe ich mir wohl selbst ausgedacht, da ich sie so noch nie gesehen habe. Es handelt sich um das umkehrbare aufblasen einer skalaren Variable in eine Kompositvariable. Also typischerweise in eine Liste, Feld, Tupel oder Struktur/Objekt. Der Anwendungsfall ist in typischen Applikationen recht häufig. Werfen wir einen Blick auf diese sehr vereinfachte Businessanwendung, die eine Person mit ihrer Adresse ausgibt:
private class Anschrift
{
public string Straße;
public int Nr;
}
private struct Person
{
public string Name;
public Anschrift Adresse;
}
private static void WriteAdresse(Person p)
{
Console.WriteLine("Name :" + p.Name);
Console.WriteLine($"Wohnhaft in {p.Adresse.Straße} {p.Adresse.Nr}");
}
Nun stellt sich heraus, dass eine Person mehrere Adressen haben kann. Also aus dem Skalar Adresse wird eine Liste von Adressen. Die Funktion bleibt zunächst dieselbe. Allerdings wird der Zugriff auf die Adresse nun mit einem Indexzugriff verziert. Die Funktion bleibt dieselbe:
private class Anschrift
{
public string Straße;
public int Nr;
}
private struct Person
{
public string Name;
public List<Anschrift> Adresse;
}
private static void WriteAdresse(Person p)
{
Console.WriteLine("Name :" + p.Name);
Console.WriteLine($"Wohnhaft in {p.Adresse[0].Straße} {p.Adresse[0].Nr}");
}
In einem nächsten Schritt kann man in WriteAdresse
eine for
-Schleife einbauen, die alle Adressen ausgibt.
Klasse extrahieren
Bei dieser Operation werden Methoden mit all ihren Abhängigkeiten aus einer Klasse in eine neue Klasse extrahiert. Diese Refaktorisierung hilft insbesonder beim S in SOLID, der „Single Responsibility„. In diesem Beispiel sehen wir, wie die beiden Methoden völlig unabhängig voneinander in derselben Klasse stehen. Über die sinnhaftigkeit ist hinwegzusehen:
public class RechenKnecht
{
private readonly int _num;
private readonly string _name;
public RechenKnecht(int num, string name)
{
_num = num;
_name = name;
}
public int Rechne()
{
return _num * 2;
}
public string Gruß()
{
return "Edler von " + _name;
}
}
Hier wurde die Methode Gruß()
in die Klasse Ansprache
extrahiert. Man hätte es natürlich auch umgekehrt machen können:
public class RechenKnecht
{
private readonly int _num;
public RechenKnecht(int num)
{
_num = num;
}
public int Rechne()
{
return(_num * 2);
}
}
public class Ritter
{
private readonly string _name;
public Ritter(string name)
{
_name = name;
}
public string Gruß()
{
return "Edler von " + _name;
}
}
Auch diese Opertion ist umkehrbar…. bis am Ende alles Methoden und der Gesamtzustand der Anwendung in einer Klasse ist. Das ist aber natürlich unwartbar.
Das war natürlich ein triviales Beispiel. Wäre aber der Gruß nun zusätzlich mit der berechneten Zahl ausgestattet gewesen, dann geht es so nicht. Aber man kann dann mit Komposition arbeiten. Stellen wir uns also diese Gruß()
-Methode vor:
public string Gruß()
{
int x = Rechne();
return $"{x}ter Edler von {_name}";
}
In diesem Fall kann man mit folgender Refaktorisierung arbeiten. Hierbei wurde RechenKnecht
auf seine Essenz redzuiert und dem Ritter
als Vehikel gegeben (Komposition). Es sind zwei Klassen entstanden:
public class RechenKnecht
{
private readonly int _num;
public RechenKnecht(int num)
{
_num = num;
}
public int Rechne()
{
return(_num * 2);
}
}
public class Ritter
{
private readonly string _name;
private readonly RechenKnecht _knecht;
public Ritter(string name, int num)
{
_name = name;
_knecht = new RechenKnecht(num);
}
public string Gruß()
{
int x = _knecht.Rechne();
return $"{x}ter Edler von {_name}";
}
}
Elemente rauf- und runterziehen
Eine Refaktorisierungsoperation möchte ich noch zeigen, ehe ich zum Aha übergehe, eine objektorientierte: Elementfunktionen in der Erbhierarchie raufschieben oder runterziehen. Auch hier besteht wieder (muss ja) eine Umkehrbarkeit und Bedeutungsgleiche in beiden Richtungen. Sehen wir uns dieses Beispiel an. Dort wird eine Elementfunktion aus der Vaterklasse aufgerufen. Außerdem ist die Methode in der Schnittstelle deklariert. Da aber niemand sonst darauf zugreift, kann der Kode kürzer und enger gefasst werden:
public interface IRechner
{
int Quadrat(int num);
void Rechne(int num);
}
public class BasisRechner : IRechner
{
public virtual int Quadrat(int num)
{
return num + num;
}
public virtual void Rechne(int num)
{
Console.WriteLine(num);
}
}
public class QuadratRechner : BasisRechner
{
public override void Rechne(int num)
{
Console.WriteLine(Quadrat(num));
}
}
Da die Methode Quadrat
() in BasisRechner
überhaupt nicht verwendet wird, kann man sie runterschieben in QuadratRechner
. Man kann sie sogar aus der Schnittstelle entfernen (soweit nie benutzt) und dabei die Funktionalität beibehalten:
public interface IRechner
{
void Rechne(int num);
}
public class BasisRechner : IRechner
{
public virtual void Rechne(int num)
{
Console.WriteLine(num);
}
}
public class QuadratRechner : BasisRechner
{
public override void Rechne(int num)
{
Console.WriteLine(Quadrat(num));
}
public virtual int Quadrat(int num)
{
return num + num;
}
}
Es gibt natürlich noch viele weitere Refaktorisierungsoperationen. Dies sollte nur eine kleine Auswahl sein, um einen Eindruck zu vermitteln.
Anwendung
Das Ganze lässt sich natürlich wunderbar automatisieren und in Werkzeuge gießen. So sind mit z.B. ReSharper, Eclipse, Visual Studio oder IntelliJ wunderbare Refaktorisierungswerkzeuge entstanden, die das Leben eines Entwicklers wirklich erleichtern. Vor allem aber arbeiten sie als Automaten viel konistenter und korrekter als es ein Entwickler langfristig könnte….
Insofern rate ich auch grundsätzlich dazu, immer Werkzeuge für solcherlei Vorhaben zu benutzen und sich dabei unbedingt vom Rechner unterstützen lassen. Ausnahmen bestätigen die Regel…
Mit diesem Wissen und diesen Werkzeugen im Gepäck kann man natürlich auch die Qualität des Quelltexts verbessern. Ich unterscheide hier mal alltägliche und gezielte Anwendung.
Alltägliche Anwendung
In der alltäglichen Anwendung nutzt ein Entwickler die Werkzeuge während des Kodeschreibens. Erstens, um architektonische Anpassungen zu machen, um die neue Funktion einzubauen. Und zweitens, um schlicht schneller zu sein. Stellt er fest, dass ein Dreizeiler öfter mehrfach im Kode steht, kann er ‚mal eben‘ eine Funktion extrahieren. Das Werkzeug macht den Rest. Im Anschluss kann er sie noch schnell umbenennen. Oder: Im Gefecht der Entstehung neuer Kodezeilen hat der Entwickler zunächst einige temporäre Variablen angelegt. Im Weiteren Verlauf stellt er fest, dass eine davon nur an einer Stelle verwendet wird. Ergo kann er sie automatisch Ausrollen lassen und direkt durch die rechte Seite der Variable austauschen. Ein weiteres gutes Beispiel ist ein fehlender/überflüssiger Parameter an einer Methode. Werkzeug anstarten und alle Aufrufe werden angepasst. Z.B. mit einem definierbaren Standardwert.
Refaktorisieren und die Werkzeuge stellen einfach einen Produktivitätsfaktor im Entwickleralltag dar und sind heute nicht mehr wegzudenken.
Gezielte Anwendung
Unter gezielter Anwendung verstehe ich dedizierte Sitzungen, in denen Refaktorisert wird. Entweder alleine oder mehrere Leute. Also z.B. ein Codereview, Paarprogrammierung oder doch wieder der einzelne Entwickler. Es geht dabei darum, dass ein Kodeabschnitt identifiziert wurde, der architektonisch oder in seiner Art so nicht mehr tragbar ist. Man macht sich also alleine oder als Gruppe Gedanken darüber, wie man umbauen sollte und tut dies dann auch. In aller Regel kommt etwas Besseres dabei heraus. Dabei sollte man sich nicht gleich ins Bochshorn jagen lassen, wenn sich zunächst ein Tal der Tränen auftut. Mit etwas gezieltem Handeln und unbeirrbarkeit bekommt man das regelmäßig wieder hin und freut sich nacher über ein besseres Ergebnis.
Probleme und Gefahren
Die Realität ist natürlich wieder viel komplexer und gemeiner als die graue Theorie und mein Geschwafel. Gerade in der imperativen objektorientierten Programmierung (OOP) haben Objekte regelmäßig Zustand und damit werden Instanzen, Referenzen und Vielfachheiten sowie die Reihenfolge interessant. Tatsächlich funktioniert die Theorie hier auch in der Praxis, aber man refaktorisiert ja gerade an Programmstücken, die es besonders nötig haben.
Die Gottklasse entmachten
Eine häufige Ursache für Wust ist, wenn eine Klasse mehrere Aufgaben erledigt = mehrere Klassen in sich vereint. Wenn das extrem wird, nennt man das Gottklasse. Quasi immer ist das die Ursache für nicht auflösbare zirkuläre Referenzen. Daher sollte man genau danach ausschau halten und die Operation Klasse Extrahieren verwenden. Lieber einmal mehr auf Verdacht anwenden. Wenn sich eine Aufteilung von Methoden und Elementvariablen finden lässt, dann war es der Fall und es sollte dann in mehrere Klassen Aufgespalten werden.
Dies ist eine der erfolgversprechendsten Refaktorisierungen. Im Nachgang ist es häufig so, dass eine der beiden extrahierten Klassen eine Vielfachheit bekommt (Skalar zu Tupel-Transformation).
Asynchron und Multithreading
Hier wird es spannend. Grundsätzlich sind auch hier alle Refaktorisierungen sinnvoll verwendbar und machen wenig Probleme. Die Probleme liegen allerdings im Detail. So kann es passieren, dass sich durch die neue Architektur die Lebenszyklen von Objekten verändern und dann bei Async-Programmierung Zugriffsfehler passieren. Oder Zustände sich früher/später als vor der Refaktorisierung verändern. Wie gesagt alles kann und es kommt immer auf die tatsächliche Implementierung an. Allgemein lässt sich aber sagen, dass man mit einer Architektur gut Async+Multithreading besteht, wenn sie „Single Responsibility“, „Interface Segregation“ und „Immutable Pattern“ beherzigt. Doch in diese Richtung kann und sollte man „hinrefaktorisieren“.
Resumé
Damit schließe ich diesen Artikel mal ab und resumiere, dass Refaktorisierung eine sehr nützliche Sache ist und sich fast immer lohnt. Insofern sollte ein jeder Projektleiter in der Software regelmäßig anberaumte Refaktorisierungs-Sitzungen veranstalten, in denen der Code an problematischen Stellen verbessert wird. Nebenbei helfen solche „öffentlichen Reviews“ auch frischen Etnwicklern etwas Neues zu lernen.