IODA Architekturbeispiel im Kleinen - Die Bowling Kata

Auf der OOP 2016 habe ich einen Vortrag zur IODA Architektur gehalten. Darin taucht ein Stück Code als Gegenbeispiel auf, also nicht-IODA Code – und Teilnehmer haben gefragt, wie ich denn meine, dass es nach dem IODA-Ansatz gemacht werden könnte.

Auf der OOP war dafür leider keine Zeit. Doch gleich auf dem Rückweg habe ich mich im Zug daran gemacht, die Bowling Kata einmal aus der Sicht von IODA zu lösen. Das ist zugegeben ein grenzwertiges Beispiel, weil es nur um einen kleinen Algorithmus geht. Architektur wird darin nicht jeder am Werk sehen. Dennoch lassen sich die Prinzipien hinter IODA natürlich auch darauf anwenden. IODA steht ja für eine fraktale Sichtweise auf Software: im Kleinen ist sie strukturiert wie im Großen.

Vorher - Die Bowling Game Kata

Der Ausgangscode stammt von Robert C. Martin aus seiner Beschreibung der Lösung (PPT) der Bowling Game Kata im Vorgehen nach TDD. Ich habe ihn einfach nur nach C# übersetzt:

public class Game {
	private int[] rolls = new int[21];
	private int currentRoll = 0;

	public void roll(int pins) {
		rolls[currentRoll++] = pins;
	}

	public int score() {
		int score = 0;
		int frameIndex = 0;
		for (int frame = 0; frame < 10; frame++) {
			if (isStrike(frameIndex)) {
				score += 10 + strikeBonus(frameIndex);
				frameIndex++;
			} else if (isSpare(frameIndex)) {
				score += 10 + spareBonus(frameIndex);
				frameIndex += 2;
			} else {
				score += sumOfBallsInFrame(frameIndex);
				frameIndex += 2;
			}
		}
		return score;
	}

	private bool isStrike(int frameIndex) {
		return rolls[frameIndex] == 10;
	}

	private int sumOfBallsInFrame(int frameIndex) {
		return rolls[frameIndex] + rolls[frameIndex+1];
	}

	private int spareBonus(int frameIndex) {
		return rolls[frameIndex+2];
	}

	private int strikeBonus(int frameIndex) {
		return rolls[frameIndex+1] + rolls[frameIndex+2];
	}

	private bool isSpare(int frameIndex) {
		return rolls[frameIndex]+rolls[frameIndex+1] == 10;
	}
}

Meine Kritik daran: Die Funktion score() hat nicht nur eine einzige Verantwortlichkeit. Ihr Name klingt fokussiert, doch in Wirklichkeit hat sie zumindest zwei formale Verantwortlichkeiten. Sie stellt Verhalten durch Logik her und sie integriert Verhalten anderer Funktionen.

Außerdem vermischt sie, wie sich herausstellen wird, in ihrer Logik wiederum zwei inhaltliche Teilverantwortlichkeiten bzw. Aspekte des Scoring.

Das wiederum ist für mich ein Zeichen für eine bestimmte Form von KISS: Ich sehe darin den Willen zur Einfachheit (simplicity) für den Schreiber; der will schnell einen Test auf grün bekommen. Dabei bleibt der Leser jedoch schnell auf der Strecke.

Nachher

Robert C. Martins "Erzählung" seines Weges zum finalen Code beginnt mit "A quick design session". Darin entwirft er ein Klassenmodell - von dem am Ende jedoch außer der geforderten Klasse BowlingGame nichts im Code enthalten ist. Merkwürdig. Denn er erklärt nicht, warum es nicht manifestiert. Deshalb würde ich diese Phase der Entwicklung auch nicht "design session" nennen, sondern lediglich "analysis session". Das Klassenmodell hilft ihm (und den Lesern), das Problem besser zu verstehen. Kein schlechter Ansatz, nur sollte er eben, wie ich finde, einen passenden Namen bekommen, damit die Überraschung am Ende nicht so groß ist. Auch hier gilt das Principle of Least Astonishment ;-)

Und dann macht sich Robert C. Martin auf den Weg... Test für Test schreitet er voran. Schrittweise entsteht dabei Logik in BowlingGame, die jeweils ein bisschen mehr des gewünschten Verhaltens liefert.

Das führt zum Ziel. Allerdings ist unklar, ob das so einfach geht, wie es dargestellt ist, oder ob das Vorgehen schon durch häufige Präsentation und Kenntnis des Zielzustands stromlinienförmig abgerundet ist.

In jedem Fall kann ich nicht erkennen, dass Robert C. Martin zu Beginn der Codierung eine Vorstellung von einem Lösungsansatz hat. Er hat in der "analysis session" Verständnis erarbeitet. Aber Verständnis ist keine Lösung. Verständnis ist kein Plan und kein Entwurf. Es ist eben keine "design session" gewesen.

Für mich gibt es deshalb eine Lücke in der Entwicklung. Eine Lücke, die TDD nicht schließt, nicht schließen kann. Sie kann nur im Kopf geschlossen werden. Und das zeigt sich im resultierenden Code.

Wie könnte es anders laufen? Wie könnte hier IODA helfen?

Hier ist mein Vorschlag:

1. Inhaltliche Verantwortlichkeiten trennen

Ich würde nicht versuchen, die Logik direkt in score() zu implementieren. Insbesondere bei unbekanntem Umfang der Logik würde ich das Gesamtproblem in Teilprobleme zerlegen und Ergebnisse in einem kleinen Prozess herstellen lassen:

public int score() {
	var frames = Detect_frames (rolls);
	var scores = Score_frames (frames, rolls);
	return scores.Sum ();
}

class Frame {
	public enum KindsOfFrame {
		Regular,
		Spare,
		Strike
	}

	public KindsOfFrame Kind;
	public int RollIndex;
}

score() hat nun eine einzige Verantwortlichkeit: Integration. In der Funktion werden nur andere Funktionen "zusammengesteckt", um im Sinne einer Gesamtlösung miteinander zu arbeiten.

So werden auch zentrale Begriffe der Analyse erhalten:

  • In der Aufgabenstellung ist von frames die Rede, d.h. der Zusammenfassung von Würfen. Deren Erkennung ist nun klar herausgestellt, z.B. bei einem Strike enthält ein Frame nur einen Wurf, ansonsten zwei. Die Wichtigkeit der Frames als Gliederungsrahmen für Würfe findet zudem Ausdruck in einer eigenen Klasse für sie.
  • Die Aufgabenstellung dreht sich um scoring, d.h. die Bewertung einzelner Frames. Das ist unabhängig von den Pins im Frame, z.B. bei einem Strike werden die nächsten beiden Würfe unabhängig von ihren Frames hinzugerechnet. Deshalb bekommt Score_frames() auch sowohl frames wie rolls hineingereicht.

Indem score() keine Logik enthält, ist die Funktion besser zu verstehen. Sie lesen einfach von oben nach unten, was passiert. Außerdem muss die Gesamtfunktionalität der Funktion nicht mit vielen kleinen Tests ermittelt werden. Um die Korrektheit der Gesamtlösung festzustellen, ist lediglich ein Integrationstest zu schreiben. Dessen Aufgabe ist es festzustellen, dass die Integration korrekt ist, d.h. die einzelnen Funktionen in passender Weise "zusammengesteckt" sind. Da es sich bei score() auch noch um die Funktion an der Wurzel der Lösung handelt, ist so ein Integrationstest außerdem ein Akzeptanztest.

2. Ebenen der Abstraktion

Auf der obersten Ebene ist für mich damit der Lösungsansatz gut zu verstehen. Sie bietet einen Überblick über alles. Das Abstraktionsniveau ist einheitlich und hoch.

Jetzt die nächste Abstraktionsebene:

IEnumerable<Frame> Detect_frames(int[] rolls) {
	const int MAX_FRAMES = 10;

	var fc = new FrameClassifier (rolls);
	return Enumerable.Range (1, MAX_FRAMES)
		         .Select (fc.Classify);
}

IEnumerable<int> Score_frames(IEnumerable<Frame> frames, int[] rolls) {
	return frames.Select (f => Score_frame(f, rolls));
}

Auch hier wieder zwei Integrationsfunktionen; noch keine Logik weit und breit.

Detect_frames() macht hier ganz deutlich, dass ein Spiel aus einer fixen Frame-Anzahl besteht, deren jeder einzelne bestimmt werden muss. Konkret geschieht das durch die Klasse FrameClassifier. Aber warum so umständlich? Auch das ist Ausdruck einer Eigenheit der Domäne. Die Frames sind nicht gleich groß. Deshalb muss während ihrer Bestimmung Zustand gehalten werden. Das tut Robert C. Martins Code natürlich auch. Nur ist dort diese Verantwortlichkeit zu einem Logik-Geflecht mit anderen verwoben.

Durch die Trennung von Frame-Bestimmung und Punktzählung ist nun auch klar, dass die Anzahl der Frames nur für erstere wichtig ist. Deshalb auch die lokale Konstante MAX_FRAMES.

Score_frames() zeigt ein Integrationsmuster. Hier von n auf 1 umgesetzt. Dieselbe Funktion soll einfach nur mehrfach ausgeführt werden, um ein Gesamtergebnis - eine Liste von Frame-Punktzahlen - zu ermitteln. Statt Score_frame() also wiederholt hinzuschreiben, wird die Funktion mittels Linq auf alle Frames angewandt.

Wenn man es genau nimmt, geschieht da da etwas in einer Schleife. Ist nicht also Logik im Spiel? Ich behaupte, nein. Denn frames.Select() ist nur eine generische Form für etwas, das auch speziell hätte gebaut werden können, z.B.

return Create_stream_from_frame_list(frames,
           f => Score_frame(f, roll));

Wo ist jetzt die Logik? Versteckt in Create_stream_from_frame_list(). Aber lohnt sich das? Nein. Für mich ist Score_frames() eine Integration im Sinne von IODA.

Operationen kommen erst auf der nächsten Ebene ins Spiel:

class FrameClassifier {
	int[] rolls;
	int rollIndex = 0;

	public FrameClassifier(int[] rolls) {
		this.rolls = rolls;
	}

	public Frame Classify(int frameNumber) {
		const int MAX_FRAME_PINS = 10;

		dynamic frame = new Frame{ Kind = Frame.KindsOfFrame.Regular, 
                                           RollIndex = rollIndex};

		if (rolls [rollIndex] == MAX_FRAME_PINS) {
			frame.Kind = Frame.KindsOfFrame.Strike;
			rollIndex += 1;
		}  else {
			if (rolls [rollIndex] + rolls [rollIndex + 1] == MAX_FRAME_PINS)
				frame.Kind = Frame.KindsOfFrame.Spare;
			rollIndex += 2;				
		}  
		return frame;
	}
}


int Score_frame(Frame frame, int[] rolls) {
	switch ((Frame.KindsOfFrame)frame.Kind) {
	case Frame.KindsOfFrame.Strike:
	case Frame.KindsOfFrame.Spare:
		return rolls [frame.RollIndex] + 
                       rolls [frame.RollIndex + 1] + 
                       rolls [frame.RollIndex + 2];
	default:
		return rolls [frame.RollIndex] + 
                       rolls [frame.RollIndex + 1];
	}
}

Classify() macht deutlich, dass normale und Spare-Frames gleich groß sind. Beide setzen den rollIndex um zwei weiter für den nächsten Frame. Und zur Bestimmung der Frame-Größe wird immer die maximal werfbare Pinzahl herangezogen (MAX_FRAME_PINS).

Der rollIndex, d.h. der Index des Wurfs, mit dem ein Frame beginnt, ist der im Objekt geschützte Zustand. Wenn die Frame-Bestimmung nicht so einfach ist, sollte das nicht unter den Tisch gekehrt werden. Deshalb eine eigene kleine Klasse dafür, auch wenn die nur eine Methode enthält. Sollte sich an der Methode zur Framebestimmung etwas ändern, ist nur dort einzugreifen. Das scheint mir sehr im Sinne des Single Responsibility Principle vor.

Bei Robert C. Martin findet die Berechnung des Gesamtergebnisses verwoben mit der Bestimmung der Frame-Arten statt. Hier ist das jedoch herausgelöst. Die Frame-Art ist bestimmt. Score_frame() konzentriert sich damit nur auf die Bestimmung der Punktzahl für einen Frame je nach Art. Deutlich sichtbar nun: Für Strike und Spare werden dieselben Würfe herangezogen.

Das ist natürlich wie Classify() pure Logik. Damit ist die Unterste Ebene der IODA Architektur erreicht.

IODA Architektur

Den Code im Überblick finden Sie hier in einem Gist.

Diskussion

Hat es etwas gebracht, den Code in eine IODA Architektur zu überführen? Ist er nun besser lesbar, besser wandelbar?

Beide Frage würde ich ganz klar mit Ja beantworten.

Die Lesbarkeit steigt aus drei Gründen:

  • Die Methoden ohne Logik (Integrationen) zeigen auf einen Blick, "was abgeht", wie der Lösungsansatz also aussieht. Sie können sie von oben nach unten einfach lesen. Sie erzählen quasi eine kleine Geschichte.
  • Die Methoden mit Logik sind allesamt sehr überschaubar.
  • Auch mehrzeilige Logik ist nun sehr fokussiert (single responsibility).

Die Wandelbarkeit steigt aus zwei Gründen:

  • Methoden mit und ohne Logik haben fokussiertere Verantwortlichkeiten. Bedeutung ist feingranularer zugewiesen.
  • Methoden mit Logik sind nicht mehr funktional abhängig und können deshalb leichter getestet werden.

Aber der Code ist nun umfangreicher, mögen Sie einwänden. Ist das aber in diesem Ausmaß wirklich schlimm? Etwas bessere Lesbarkeit braucht eben manchmal etwas mehr Text.

Aber das ist doch jetzt alles völlig overengineert, mögen Sie dagegen halten. Da stimme ich sogar bis zu einem gewissen Grad zu. Aber es ging ja darum, an einem überschaubaren Beispiel fühlbar zu machen, was IODA bedeutet.