1. August 2022

Immutable Code #1

Von steffenboe
rss

Das Open-Closed-Prinzip besagt:

“Ein Modul sollte offen für Erweiterungen, aber geschlossen für Änderungen sein.”

Eine Konsequenz dieses Prinzips ist es, neue Funktionalität über neue Objekte zu implementieren, statt über die Modifikation von bestehenden Objekten. 

Das ist durchaus sinnvoll, denn Modifikationen an bereits bestehendem Code bergen immer die Gefahr, Fehlverhalten in bis dahin fehlerfreien Code einzubauen. Daneben müssen, neben den Unit-Tests für die neue Funktionalität, meist auch bestehende Unit-Tests angepasst werden.

Wird eine Klasse um neue Funktionalitäten erweitert, indem sie modifiziert wird, zieht sie außerdem neue Abhängigkeiten an, und ihr “Zweck” wird immer mehr verschleiert.

Ein daraus resultierendes, klassisches Anti-Pattern ist das sogenannte “God-Objekt”, das zu viel Verantwortung und Funktionalität kapselt, und damit groß und umständlich zu warten ist.

Seit ich mich mit dem OCP näher auseinander setze, kristallisiert sich mehr und mehr folgende Konsequenz heraus: bestehende Klassen sollten grundsätzlich nicht mehr modifiziert werden. Sie sind geschrieben, haben eine klare Verantwortlichkeit, und sind durch Tests abgesichert.

Aber es gibt Ausnahmen von der Regel:

  1. die Klasse soll refaktorisiert werden,
  2. die Klasse enthält Fehler.

Konkreter würde die Regel des Open-Closed-Prinzips also bedeuten: bestehende Klassen werden nicht zum Zwecke der Erweiterung ihrer Funktionalität modifiziert

Oder anders ausgedrückt: Neue Funktionalität wird über neue Klassen implementiert.

Funktioniert das in der Praxis, oder ist das utopisch? In diesem Blogbeitrag möchte ich genau das herausfinden, indem ich ein sogenanntes “Agility Kata” unter strikter Anwendung dieser Regel implementiere.

Bei Agility-Katas implementiert man in Iterationen, wobei die Anforderungen der nächsten Iteration erst aufgedeckt werden, wenn die aktuelle Iteration abgeschlossen ist.

Hier ein Link zum Kata: https://ccd-school.de/coding-dojo/agility-katas/word-count-i/

In dem Agility-Kata wird eine Anwendung für eine Wortzählung implementiert. 

Fangen wir an!

Iteration I

Hier die Anforderungen der ersten Iteration:

Write an application to count the number of words in a text. The app will ask the user for the text upon start. It will then output the number of words found in the text. Words are stretches of letters (a-z,A-Z). Sample usage:

$ wordcount Enter text: Mary had a little lamb Number of words: 5
Code-Sprache: Bash (bash)

Wir beginnen mit der grundlegenden Funktionalität: das Wörter zählen. (Auch wenn ich diszipliniert testgetrieben entwickle, lasse ich die Tests in dem Blogbeitrag außen vor. Wen sie interessieren, der kann sie unter https://github.com/steffenb91/wordcount nachschlagen.)

public class WordCounter { public int countWords(String input) { String[] words = input.split(" "); return words.length; } }
Code-Sprache: Java (java)

So weit, so gut. Um diese “Kernlogik” zirkuliert technische Funktionalität, wie Aus- und Eingaben. Diese sind gegen Interfaces implementiert, um das Medium austauschbar zu gestalten (die Ein- und Ausgaben erfolgen zunächst über die Kommandozeile). Die main-Methode führt die Dependency Injection aus, “steckt” die Objekte der Anwendung also zusammen und startet letztlich die Wortzählung:

public class App { public static void main(String[] args) { InputHandler inputHandler = new CommandlineInputHandler(); Messenger messenger = new ConsoleMessenger(); WordCounter wordCounter = new WordCounter(); WordCounterApp wordCounterApp = new WordCounterApp(inputHandler, messenger, wordCounter); wordCounterApp.run(); } }
Code-Sprache: Java (java)

Die Anwendung liefert die gewünschten Ausgaben:

$ Enter text: Some text to test the app Number of words: 6
Code-Sprache: Bash (bash)

Zum Nachverfolgen ist hier der Commit für die erste Iteration: https://github.com/steffenb91/wordcount/commit/2adafb33af31dce543c688249b02ceb9621c8fa4.

Iteration II

Weiter geht’s mit der Implementierung der zweiten Iteration:

Not all words are relevant for counting. A list of stop words (provided in the file „stopwords.txt“) defines which words not to count. Example of stopwords.txt with each line containing a stop word:

the a on off

Unsere Anwendung soll nun also zusätzlich eine Textdatei mit Stopwörtern einlesen und die dort enthaltenen Wörter bei der Wortzählung ignorieren. Das naheliegendste Design würde die neue Funktionalität in die “WordCounter”-Klasse einführen, mit der Konsequenz, dass wir den bestehenden Test anpassen müssten und der Wahrscheinlichkeit, die bis hierhin fehlerfreie Funktionalität der Wortzählung kaputt zu machen.

Die vorhandene Klasse WordCounter modifizieren wir deshalb nicht, das neue Verhalten soll durch neue Klassen abgebildet werden. Dafür müssen wir den bestehenden Code aber zunächst refaktorisieren.

Refactoring: Auslagerung der “Wort erkennen” Funktionalität

Die “WordCounter”-Klasse kapselt die Funktionalität des “Worterkennens” (input.split(“ „);) und des “Wörterzählens” (words.length). Dass diese beiden Aufgaben besser getrennt zu halten sind, haben die Anforderungen der zweiten Iteration offengelegt. Denn nun muss zwischen dem Erkennen und Zählen der Wörter eine zusätzliche Aufgabe erledigt werden, und zwar ein Filtern.

Wir trennen also das “Erkennen” von Wörtern heraus und verlagern diese Aufgabe in eine neue Klasse, WordRecognition.

Die Refaktorisierung sieht dann folgendermaßen aus:

public class WordRecognition { public Collection<String> recognizeWords(String text) { return Arrays.asList(text.split(" ")); } }
Code-Sprache: Java (java)

Im WordCounter referenzieren wir nun die neue WordRecognition-Klasse:

public class WordCounter { public int count(String input) { Collection<String> recognizedWords = new WordRecognition().recognizeWords(input); return recognizedWords.size(); } }
Code-Sprache: Java (java)

Damit können wir mit der Implementierung der eigentlichen Erweiterung fortfahren.

Erweiterung: Wortzähler mit Filterung

Da wir die ursprüngliche Funktionalität des WordCounter nicht anpassen wollen, aber die für die Erweiterung notwendige Funktionalität im letzten Schritt in eine eigene Klasse ausgelagert haben, können wir die neue Klasse implementieren: einen Wortzähler, der mit zusätzlichen Filtern ausgestattet werden kann.

Wir implementieren dafür einen neuen “FilteringWordCounter”:

public class FilteringWordCounter { private List<WordFilter> filters = new ArrayList<>(); public FilteringWordCounter(WordFilter... filter) { filters = Arrays.asList(filter); } public int countWords(String string) { Collection<String> recognizedWords = new WordRecognition().recognizeWords(string); for (WordFilter filter : filters) { recognizedWords = filter.filter(recognizedWords); } return recognizedWords.size(); } }
Code-Sprache: Java (java)

Refactoring: Auslagerung WordCounter-Interface

Neben dieser neu gewonnenen Funktionalität haben wir eine neue Schnittstelle aufgedeckt: das Zählen von Wörtern in einem Text, zu der wir nun bereits 2 Implementierungen haben: Das Zählen aller Wörter eines Textes, und das Zählen von gefilterten Wörtern. Wir lagern die Methodendeklaration daher in ein gemeinsames Interface aus, das beide Klassen implementieren:

public interface WordCounter { int count(String text); }
Code-Sprache: Java (java)

Die bisherige Klasse benennen wir um in “TotalWordsCounter” und lassen beide die WordCounter-Schnittstelle implementieren. Die “WordsCounterApp” lassen wir die neue Schnittstelle referenzieren, und ersetzen damit die Referenz auf die konkrete Klasse durch eine stabilere Abstraktion:

public WordCounterApp(InputHandler inputHandler, Messenger messenger, WordCounter wordCounter) { [...] this.wordCounter = wordCounter; }
Code-Sprache: Java (java)

Erweiterung: Filterung nach Stopwortliste

Nun können wir einen ersten “WordFilter” implementieren, der basierend auf einer Stopwort-Liste filtert. Dafür benötigen wir zunächst eine Klasse, die eine Liste an Stopwörtern aus einer stopwords.txt-Datei liest.

public class TextfileStopWordReader implements StopWordReader { private File textfileWithStopwords; public TextfileStopWordReader(File textfileWithStopwords) { this.textfileWithStopwords = textfileWithStopwords; } @Override public Collection<String> getStopwords() throws StopWordListReadFailedException { Collection<String> stopWords = null; try { stopWords = read(); } catch (IOException e) { throw new StopWordListReadFailedException("Could not read stopword list", e); } return stopWords; } private Set<String> read() throws IOException { Set<String> words = new HashSet<>(); try (BufferedReader br = new BufferedReader(new FileReader(textfileWithStopwords))) { String line = br.readLine(); while (line != null) { words.add(line); line = br.readLine(); } } return words; } }
Code-Sprache: Java (java)

In der main-Methode unserer App-Klasse können wir nun die neu hinzugefügten Klassen einfügen. Die Referenz auf die “TotalWordsCounter” ersetzen wir nun mit den neu implementierten Klassen, die eine Stopwortliste einlesen und den “FilteringWordCounter” mit dem Stopwortfilter instanziieren:

public class App { public static void main(String[] args) { InputHandler inputHandler = new CommandlineInputHandler(); Messenger messenger = new ConsoleMessenger(); WordCounter wordCounter = null; try { wordCounter = getWordCounterWithStopwordFilter(); } catch (StopWordListReadFailedException e) { System.out.println("Failed to read stopword list, continuing without stopword list..."); wordCounter = new TotalWordsCounter(); } WordCounterApp wordCounterApp = new WordCounterApp(inputHandler, messenger, wordCounter); wordCounterApp.run(); } private static WordCounter getWordCounterWithStopwordFilter() throws StopWordListReadFailedException { File stopwordList = new File("src/main/resources/stopwords.txt"); StopWordReader stopWordReader = new TextfileStopWordReader(stopwordList); WordFilter stopWordFilter = new StopwordFilter(stopWordReader.getStopwords()); return new FilteringWordCounter(stopWordFilter); } }
Code-Sprache: Java (java)

Hier der Link zum Commit für die zweite Iteration: https://github.com/steffenb91/wordcount/commit/325e9e9233045f98f0fcf5a862f37ef02689a5da

Ein schöner Nebeneffekt: da wir keinen bestehenden Code modifiziert haben, können wir im Fehlerfall einfach auf eine Default-Implementierung wechseln: wenn die Liste mit den Stopwörtern nicht gelesen werden kann, kann die Anwendung trotzdem starten und Wörter zählen, ohne Stopwörter mit einzurechnen. 

Ganz ohne Modifikationen kommen wir, wie an diesem Beispiel gesehen haben, jedoch trotzdem nicht aus. An irgendeiner Stelle in der Anwendung wird es Klassen geben, in denen die neue Funktionalität eingebunden werden muss. Die Stelle ist hier unsere main-Methode, denn dort müssen die neuen Klassen registriert und aufgebaut werden. 

Dennoch haben wir die ursprüngliche Implementierung der Wortzählung (ohne Stopwörter) beibehalten, mussten dort keine Tests nachziehen und sind auch nicht Gefahr gelaufen, die ursprüngliche Funktionalität mit Fehlverhalten zu versehen.

rss