- Don’t Repeat Yourself (DRY) – Wissenshäppchen #1
- You Ain’t Gonna Need It (YAGNI) – Wissenshäppchen #2
- Single Responsibility Principle (SRP) – Wissenshäppchen #3
- Open Closed Principle (OCP) – Wissenshäppchen #4
- Liskov Substitution Principle (LSP) – Wissenshäppchen #5
- Interface Segregation Principle (ISP) – Wissenshäppchen #6
- Dependency Inversion Principle (DIP) – Wissenshäppchen #7
- Law of Demeter (LoD) – Wissenshäppchen #8
In der ersten Episode meiner „Wissenshäppchen“ widme ich mich einem der wichtigsten Prinzipien der Softwareentwicklung: Don’t Repeat Yourself (DRY). Doppelter Code ist der Feind jedes Entwicklers! 🙂
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. (DontRepeatYourself)
Am Beispiel einer weit verbreiteten Programmierübung zeige ich den Weg von doppeltem zu „trockenem“ (DRY) Code.
Podcast: Play in new window | Download (Duration: 18:05 — 8.9MB)
Abonnieren: Apple Podcasts | Spotify | RSS
Inhalt
- Doppelter Code ist ein Code Smell.
- Er tritt meistens auf, wenn Entwickler Zeit sparen wollen und mit Copy/Paste arbeiten.
- Doppelter Code führt zu Inkonsistenzen und damit zu Fehlern im Programm.
- Er äußert sich durch Shotgun Surgery, das Anpassen mehrerer Stellen im Code für die Änderung eines einzigen Features.
- Es existieren viele Refactorings, die doppelten Code vermeiden sollen.
Die Aufgabe: FizzBuzz
Das hier ist die Beschreibung des zu lösenden Problems:
Print a list of the numbers from 1 to 100 to the console. For numbers that are multiples of 3 print „Fizz“ instead. For numbers that are multiples of 5 print „Buzz“ instead. For numbers that are both multiples of 3 and 5 print „FizzBuzz“ instead. These are the first 15 values the program should print:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
Lösung der Azubis
Die Implementierung der Azubis sieht dann meistens so aus:
public static void main(String[] args)
{
for (int i = 1; i <= 100; i++)
{
if (i % 3 == 0 && i % 5 == 0)
{
System.out.println("FizzBuzz");
}
else
if (i % 3 == 0)
{
System.out.println("Fizz");
}
else
if (i % 5 == 0)
{
System.out.println("Buzz");
}
else
{
System.out.println(i);
}
}
}
Diese Implementierung ist recht komplex (drei verschachtelte if
-Statements) und enthält auch sehr viel doppelten Code:
- Die auszugebenden Strings. Würden wir das Spiel auf Deutsch übersetzen, müssten wir die Strings an mehreren Stellen verändern.
- Die Prüfung auf Fizz und Buzz (Modulo-Rechnung). Würden sich die Regeln ändern (z.B. 7 und 11 statt 3 und 5 oder zusätzlich Fizz bei „enthält die Ziffer 3“), müssten sie an mehreren Stellen angepasst werden.
- Die Ausgabe auf der Konsole. Soll das Spiel in einer Webanwendung oder einer Windows-Applikation eingesetzt werden, müsste die Ausgabe an mehreren Stellen korrigiert werden.
Refactorings
Um die Komplexität und den doppelten Code zu entfernen, können verschiedene, relativ einfache Refactorings angewendet werden:
- Werte in Variablen oder Konstanten auslagern, die nur einmalig definiert werden.
- Variable für das Ergebnis einführen und diese nur einmalig ausgeben, anstatt jedes Ergebnis separat.
- Ergebnisse der einzelnen Prüfungen verketten, anstatt doppelt zu prüfen.
Schritt 1: Doppelte Werte in Variablen auslagern
Fizz
und Buzz
sollen als Wert nur noch einmalig vorkommen. So sieht eine mögliche Lösung aus:
public static void main(String[] args)
{
String fizz = "Fizz"; // <--- HIER
String buzz = "Buzz"; // <--- HIER
for (int i = 1; i <= 100; i++)
{
if (i % 3 == 0 && i % 5 == 0)
{
System.out.println(fizz + buzz); // <--- HIER
}
else
if (i % 3 == 0)
{
System.out.println(fizz); // <--- HIER
}
else
if (i % 5 == 0)
{
System.out.println(buzz); // <--- HIER
}
else
{
System.out.println(i);
}
}
}
Schritt 2: Variable für Endergebnis einführen
Anstatt viermal die Ausgabe mit System.out.println()
durchzuführen, soll das Ergebnis „gesammelt“ und nur einmal ausgegeben werden. Das könnte dann so aussehen:
public static void main(String[] args)
{
String fizz = "Fizz";
String buzz = "Buzz";
for (int i = 1; i <= 100; i++)
{
String ergebnis = ""; // <--- HIER
if (i % 3 == 0 && i % 5 == 0)
{
ergebnis = fizz + buzz; // <--- HIER
}
else
if (i % 3 == 0)
{
ergebnis = fizz; // <--- HIER
}
else
if (i % 5 == 0)
{
ergebnis = buzz; // <--- HIER
}
else
{
ergebnis = "" + i; // <--- HIER
}
System.out.println(ergebnis); // <--- HIER
}
}
Schritt 3: Doppelte Prüfungen entfernen
Die Ergebnisse der beiden Prüfungen können ebenfalls in Variablen gespeichert werden, um sie wiederzuverwenden. Beispiel:
public static void main(String[] args)
{
String fizz = "Fizz";
String buzz = "Buzz";
for (int i = 1; i <= 100; i++)
{
String ergebnis = "";
boolean isFizz = i % 3 == 0; // <--- HIER
boolean isBuzz = i % 5 == 0; // <--- HIER
if (isFizz && isBuzz) // <--- HIER
{
ergebnis = fizz + buzz;
}
else
if (isFizz) // <--- HIER
{
ergebnis = fizz;
}
else
if (isBuzz) // <--- HIER
{
ergebnis = buzz;
}
else
{
ergebnis = "" + i;
}
System.out.println(ergebnis);
}
}
Schritt 4: Komplexität reduzieren
Die Komplexität der geschachtelten if
-Statements wird zuletzt aufgehoben. Hierfür gibt es kein einfaches Refactoring, sondern man muss die grundsätzliche Struktur des Codes ändern und ein wenig nachdenken, wie man das erreichen könnte. Wichtig hierbei ist der Fokus darauf, alles Doppelte zu eliminieren. Wenn man sich das vor Augen hält, denkt man automatisch in verschiedene Richtungen und kommt (hoffentlich) auf eine mögliche Lösung.
Zunächst macht man sich deutlich, was eigentlich noch doppelt ist: die Kombination der beiden Prüfungen! Das Zutreffen beider Bedingungen ist eigentlich nur ein Sonderfall der beiden einzelnen Prüfungen. Anstatt nach jeder Prüfung das Endergebnis zu überschreiben, muss es einen Weg geben, die Ergebnisse zu kombinieren. Dem könnte man sich wie folgt annähern:
1) Sonderfall if (isFizz && isBuzz)
entfernen und Code kompilierbar machen (überflüssiges else
entfernen):
if (isFizz)
{
ergebnis = fizz;
}
if (isBuzz)
{
ergebnis = buzz; // noch falsch
}
if (false) // noch falsch
{
ergebnis = "" + i;
}
2) Anstatt bei isBuzz
das Ergebnis zu überschreiben, Buzz
anhängen:
if (isFizz)
{
ergebnis = fizz;
}
if (isBuzz)
{
ergebnis += buzz; // <--- HIER
}
if (false) // noch falsch
{
ergebnis = "" + i;
}
3) Die falsche Abfrage beim letzten if
korrigieren:
if (isFizz)
{
ergebnis = fizz;
}
if (isBuzz)
{
ergebnis += buzz;
}
if (!isFizz && !isBuzz) // <--- HIER
{
ergebnis = "" + i;
}
4) Wenn jetzt noch die doppelte Verwendung von isFizz
und isBuzz
vermieden werden soll, kann die letzte Bedingung auf ein anderes Kriterium umgestellt werden:
if (isFizz)
{
ergebnis = fizz;
}
if (isBuzz)
{
ergebnis += buzz;
}
if (ergebnis.isEmpty()) // <--- HIER
{
ergebnis = "" + i;
}
Musterlösung
Meine komplett „Musterlösung“ sieht nun so aus:
public class FizzBuzz
{
public static void main(String[] args)
{
final String fizz = "Fizz";
final String buzz = "Buzz";
for (int i = 1; i <= 100; i++)
{
String ergebnis = "";
boolean isFizz = i % 3 == 0;
boolean isBuzz = i % 5 == 0;
if (isFizz)
{
ergebnis += fizz;
}
if (isBuzz)
{
ergebnis += buzz;
}
if (ergebnis.isEmpty())
{
ergebnis += "" + i;
}
System.out.println(ergebnis);
}
}
}
Ein paar Kleinigkeiten wurden noch angepasst. Aus Gründen der besseren Symmetrie wurden alle drei Zuweisungen zu ergebnis
auf Konkatenation umgestellt. Außerdem wurden die Strings fizz
und buzz
als final
deklariert, da sich ihre Werte während der Programmausführung nicht ändern werden. Die Prüfungen wurden aus Gründen der besseren Lesbarkeit nicht wieder inline in die if
-Statements geschrieben (siehe Inline Temp, sondern die Zwischenvariablen isFizz
und isBuzz
wurden beibehalten (siehe Extract Variable).
DRY
Damit wurden alle Anforderungen von Don’t Repeat Yourself umgesetzt:
- Die Strings können an einer einzigen Stelle „übersetzt“ werden, wenn das Spiel auf Deutsch laufen soll. Beispiel:
final String fizz = "Fiss";
- Die Spielregeln können an einer einzigen Stelle angepasst werden. Beispiel:
boolean isFizz = i % 3 == 0 || ("" + i).contains("3");
- Die Ausgabe kann an einer einzigen Stelle angepasst werden. Beispiel:
System.err.println(ergebnis);
Literaturempfehlungen
Martin Fowler zeigt in seinem Standardwerk Refactoring: Improving the Design of Existing Code* viele Beispiele für „Code Smells“ (einer davon ist doppelter Code) und Schritt-für-Schritt-Anleitungen für die Refactorings, die diese Probleme beheben können. Eine absolute Leseempfehlung zum Thema DRY.
Links
- Permalink zu dieser Podcast-Episode
- RSS-Feed des Podcasts
- Don’t repeat yourself
- DontRepeatYourself
- OnceAndOnlyOnce
- Shotgun surgery
- Don´t Repeat Yourself (DRY)
Gute Erklärung des DRY Prinzips mit anschaulichem Beispiel. Was mir allerdings fehlt ist eine kritische Betrachtung. DRY ist nicht immer das absolute Ziel. Es kann auch hin und wieder von Vorteil sein doppelten Code zu erhalten. Je nach dem, wie unabhängig die Codestellen voneinander sind oder sein sollen. Was man leider nicht immer von Anfang an weiß.
Beispiel:
Die Fachabteilung arbeitet nun eine Weile mit der oben entwickelten Lösung. Irgendwann trudelt eine Änderungsanforderung rein, die besagt: „bei Vielfachen von 3 soll jetzt „Fuzz“ ausgegeben werden.“.
Ok, ist ja ein leichtes, änder ich einfach die Variable
fizz = "Fizz"
infuzz = "Fuzz"
. Teste das Programm und liefere es neu aus.Nach einer Weile kommt der Fachbereich zurück und sagt, dass das Programm jetzt aber falsch sei. Bei Vielfachen von 15 würde jetzt „FuzzBuzz“ ausgegeben, das hätte er nicht bestellt. Das muss „FizzBuzz“ bleiben!
Tja, eine ungewollter Seiteneffekt. Diese Änderung wäre mit der „naiven“ Implementierung des Azubis ohne Probleme durchführbar gewesen.
Hallo Peter,
da gebe ich dir recht! Danke für den Hinweis. Ich werde darauf achten, bei den nächsten Wissenshäppchen auch evtl. negative Konsequenzen zu erläutern.
Ich halte es immer so, dass ich meinen Azubis/Studierenden die DRY-Regel als „Daumenregel“ zum Einstieg in die Programmierung mitgebe. Dass man dann später davon abweichen kann (und manchmal auch muss), ist ein Lerninhalt für die höheren Lehrjahre. 😉
Viele Grüße!
Stefan
Meines Erachtens nach, sollte auch die Neuerzeugung von Strings unter das Prinzip DRY fallen. So werden doch in der Musterlösung 200 Strings im besten, und 300 im schlechtesten Fall erzeugt.
Weiterhin werden stets drei if -Abfragen geführt. Prüft man als erstes die geteilte Eigenschaft, könnte man bei i % 15 == 0, bereits nach dem ersten Durchgang das Ergebnis schreiben. Im besten Fall spart man 66% der Abfragen ein.
final String fizz = „Fizz“
final String buzz = „Buzz“;
StringBuilder ergebnis = new StringBuilder();
for (int i = 1; i <= 100; i++) {
boolean isFizz = i % 3 == 0;
boolean isBuzz = i % 5 == 0;
if(isFizz && isBuzz) ergebnis.append(fizz).append(buzz);
else if (isFizz) ergebnis.append(fizz);
else if (isBuzz) ergebnis.append(buzz);
else ergebnis.append(i);
ergebnis.append("\n");
}
System.out.println(ergebnis);
Hier ein etwas kompakterer Ansatz:
final String fizz = „Fizz“
final String buzz = „Buzz“;
final String fizzbuzz = fizz + buzz;
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 100; i++) {
sb.append(i % 15 == 0 ? fizzbuzz : i % 3 == 0 ? fizz : i % 5 == 0 ? buzz : i).append(„\n“);
}
System.out.println(sb);
Die vom Compiler entsprechend vorgenommene Umwandlung von String in StringBuilder und zurück, fordert einige Rechnenzeit:
String ergebnis = „“; // new String(„“);
ergebnis += fizz; // new StringBuilder().append(ergebnis).append(fizz).toString();
ergebnis += buzz // new StringBuilder().append(ergebnis).append(buzz).toString();
ergebnis += „“ + i // new StringBuilder().append(ergebnis).append((new StringBuilder().append(„“).append(i)).toString()).toString();
Es ist leicht erkennbar, um wie viel aufwendiger das Ganze ist. Insbesondere, da dieser Mehraufwand hunderte Mal erfolgt.
Danke für dein Feedback. Dabei musste ich als erstes an dieses Zitat denken: Premature optimization is the root of all evil 🙂
Aber du hast natürlich recht, was die Performance angeht. Die Frage ist aber, ob die Performance bei Programmiereinsteigern (schon) eine Rolle spielt. Dein letztes Beispiel würde ich meinen Azubis jedenfalls nicht empfehlen, weil sie meiner Erfahrung nach eher Probleme beim Verständnis des Codes hätten. Und mir geht es beim DRY-Prinzip in erster Linie um die Wartbarkeit des Codes. Da muss man leider häufig Abstriche bei anderen Kriterien – wie eben der Performance – machen. Bei Code für einen Mikrocontroller würde ich aber sicherlich deinen Weg bevorzugen! 😉
Das ist auch noch doppelt:
boolean isFizz = i % 3 == 0;
boolean isBuzz = i % 5 == 0;
Das kann in eine Funktion ausgelagert werden:
private boolean isDivisibleBy(int number, int divisor) {
return number % divisor == 0;
}
boolean isFizz = isDivisibleBy(i, 3);
boolean isBuzz = isDivisibleBy(i, 5);