Follow my new blog

Donnerstag, 2. Oktober 2014

Responsibilities zählen

Das Single Responsibility Principle (SRP) ist eine Säule sauberer Softwareentwicklung für hohe Wandelbarkeit. Nicht umsonst macht es auch den Anfang bei den SOLID Prinzipien, würde ich sagen.

Aber: Wie finden Sie denn heraus, wieviele Responsibilities (Verantwortlichkeiten) eine Methode oder Klasse hat? "Naja, das sieht man halt", scheint mir ein zu schwammiges Kriterium für ein so zentrales Prinzip.

Was ist eine Verantwortlichkeit?

Bevor es mit dem Zählen losgehen kann, muss natürlich klar sein, was da überhaupt entdeckt und gezählt wird. Was ist eine Verantwortlichkeit?

Die Literatur spricht von "only one reason to change". Das finde ich leider nicht wirklich hilfreich. Denn um welche Gründe geht es, worauf beziehen die sich?

Ich versuche es daher einmal so:

Jede Methode soll nur Anweisungen enthalten, die Anforderungen eines Aspekts erfüllen.

Diese Definition ist knackiger, erfordert jedoch die Klärung zweier Begriffe:

  • Aspekt: Ein Aspekt ist eine Menge von zusammengehörigen Merkmalen, die sich unabhängig von anderen Merkmalen ändern können. Man könnte einen Aspekt auch eine Dimension nennen. Beispiele für Aspekte in der realen Welt sind z.B. Frisur, Kleidung, Bildung. Sie können die Merkmale Ihrer Frisur (Haarfarbe, Haarlänge, Schnitt) unabhängig von Merkmalen Ihrer Kleidung (Stoff, Jahreszeitlichkeit, Stil) oder Bildung (Dauer, Inhalt, Ort) ändern.
  • Anweisung: Anweisungen sind Sprachkonstrukte, die definieren, was eine Software tun soll. Sie fallen für mich in zwei Kategorien: Logik und Integration.
    • Logik: Mit Logik bezeichne ich die essenziellen Anweisung von Programmiersprachen, die der Erfüllung von funktionalen wie qualitativen Anforderungen dienen. Das sind Operatoren, die Daten transformieren, Kontrollstrukturen (z.B. if, while), die den Verarbeitungsfortgang steuern, und Hardwarezugriffe (vermittels API-Aufrufen). Logik beschreibt Algorithmen.
    • Integration: Integration bindet mehrere Methoden zu einem Datenfluss zusammen.

Ich denke, jetzt wird auch verständlicher, was "only one reason to change" bedeutet: Methoden sollen nur geändert werden müssen, wenn sich an einem Anforderungsaspekt etwas verändert hat.

Methoden sind die kleinsten Container von Programmiersprachen. Darüber liegen für mich in wachsender Größe Klassen, Bibliothekn, Komponenten und µServices. Für die muss das SRP natürlich auch gelten. Also lautet es ganz allgemein:

Jeder Container soll nur Anweisungen enthalten, die Anforderungen eines Aspekts erfüllen.

So zumindest das Ideal. In der Praxis kann und muss sogar davon temporär oder in überschaubarem Maße abgewichten werden. Ziel sollte jedoch ein einziger Aspekt pro Container sein.

Außerdem ist zu bedenken, dass Aspekte in Hierarchien existieren. Zum Aspekt der Kleidung mögen die Merkmale Stoff, Jahreszeitlichkeit und Stil gehören. Nur zum Stoff z.B. gehören dann jedoch weitere Sub-Aspekte wie Farbe, Material, Haptik. Kleidung kann aus grobem roten Leinen oder feiner grüner Seide oder rauer gelber Wolle oder feinem gelbem Leinen usw. bestehen.

Ein Container auf höherer Abstraktionsebene (z.B. Klasse) kann dann für einen Dachaspekt stehen, der Container von Sub-Aspekten (z.B. Funktionen) zusammenfasst.

Härtegrade

Für mich gibt es Aspekte in unterschiedlichen Härtegraden. Hart sind Aspekte, die man an der Form erkennt. Man muss die Anforderungen nicht verstehen, die Anweisungen erfüllen, sondern nur die Programmiersprache/Plattform.

Beispiele hierfür sind die Anweisungsaspekte Logik und Integration sowie der Aspekt Datenstruktur.

Innerhalb der Logik können dann jedoch weitere verschiedene Hardwarezugriffe unterschieden werden, z.B. Tastatureingabe, Bildschirmausgabe, Dateisystemzugriff, Datenbankzugriff. Und sogar den Zugriff auf den Heap würde ich dazurechnen, also den Umgang mit Hauptspeicher. Dazu ist zwar kein spezieller API nötig, doch Zugriffe aus globale Daten (statische Felder oder Felder von Objektinstanzen) sind klar erkennbar.

Weich hingegen sind Aspekte, bei denen man verstehen muss, worum es geht. Es geht um das Was, wohingegen harte Aspekte das Wie betreffen.

Schon die Unterscheidung zwischen lesender und schreibender Logik setzt Interpretation voraus. Es ist daher kein Wunder, dass bei weichen Aspekten schnell Diskussionen entstehen. Der eine empfindet Lesen und Schreiben als Merkmale des selben Aspekts, der andere empfindet sie als getrennt - was sich dann jeweils in unterschiedlicher Aufteilung in Container ausdrückt.

Auch hier wieder eine Hierarchie: Lesen und Schreiben sind z.B. Sub-Aspekte von Objektpersistenz (zu der auch z.B. Serialisierung gehört). Und Objektpersistenz ist ein weicher Aspekt von z.B. Personalisierung. Die wiederum ein Aspekt des Anforderungsaspektes Usability ist - welche zur Anforderungskategorie Qualität gehört.

Und was folgt aus der Unterscheidung zwischen harten und weichen Aspekten? Halten Sie sich so lange wie möglich bei der Strukturierung von Code an harte Aspekte. Darüber gibt es viel weniger Diskussion. Das macht Ihre Codierung schneller, das macht Reviews konfliktfreier.

Am Ende jedoch können Sie natürlich den weichen Aspekten nicht ausweichen. Üben Sie also immer wieder Ihre Sensibilität in der Unterscheidung und Zusammenfassung von weichen, inhaltlichen Merkmalen.

Apropos Üben...

Angewandte Aspekterkennung

Zum Abschluss ein Codebeispiel, an dem ich Ihnen die Identifikation von Aspekten praktisch demonstrieren möchte. Ich entnehme es dem Buch "Head First C#".

image

Das Szenario? Versuchen Sie es doch einmal dem Code zu entnehmen. Wie Sie feststellen werden, ist das jedoch schwierig. Weil er nicht integriert und kein Titel sichtbar ist. Die Methode Main() enthält ausschließlich Logik. Und die hat ihrer Natur nach denkbar wenig Dokumentationsqualität. Reine Logik muss immer entziffert werden. Hobbyarchäologen sind klar im Vorteil ;-)

Aber ich verrate Ihnen das Szenario: Es handelt sich um eine Anwendung zur Darstellung von Dateiinhalten in hexadezimaler Form.

Hier nun der von mir mit Aspekten kommentierte Quellcode:

image

Das Kapitel im Buch heißt "Dateien lesen und schreiben" - aber wie das geht, muss der Leser sich mühsam im ganzen Code zusammensuchen. Nicht nur wie das Problem ganz allgemein gelöst wird, wie der Prozess aussieht, der das gewünschte Verhalten herstellt, ist also unklar. Es wird auch dem technologisch Interessierten schwer gemacht, sich zu informieren.

Das ist Bullshit. Das ist dirty code par excellance. Niemandem ist mit soetwas gedient. Und das in deinem Lehrbuch...! Erschreckend.

Ich habe vier Aspekte identifiziert, die über den Code verstreut und auch noch geschachtelt sind. Das ist das Gegenteil von Entkopplung.

Die Domäne ist die Formatierung von Bytes in hexadezimale und ASCII Darstellung. Aber weder ist diese Domäne als ein für sich stehendes Stück Logik herausgearbeitet, noch die anderen Aspekte.

Aber ich will Goethes "Besser machen, nicht nur tadeln, soll den rechten Meister adeln" folgen und Ihnen nicht vorenthalten, wie ich meine, das der Code aussehen sollte.

In der reinen Logik unterscheidet sich meine Lösung nur unwesentlich von der im Buch. Doch ich habe die Logik anders (bzw. überhaupt) in Container verpackt. Zunächst nur in Funktionen:

image

Das ist ein erster Schritt. In Main() ist der Prozess nun deutlich sichtbar. Außerdem ist klar, wo welche APIs benutzt werden. Wer sich zum Thema "Dateien lesen und schreiben" informieren will, schaut einfach bei Check_if_file_exists() und Read_blocks_from_file() rein; den Rest kann man dann ignorieren.

Wem das nun jedoch zu wenig objektorientiert ist, wer gern noch eine deutlichere Zusammenfassung der Subaspekte sehen möchte, der findet hier eine Lösung mit Klassen:

image

Zum Beispiel bündelt die Klasse FileSystemProvider die Methoden, in denen sich Logik befindet, die den API System.IO benutzt.

Die Aspekttrennug ist damit deutlicher. Der Preis dafür ist etwas Rauschen: in Main() müssen nun Klassen instanziert werden und Methodenaufrufe haben Objektnamen als Präfix.

Überhaupt haben meine Lösungen doppelt oder gar mehr als doppelt so viele LOC (lines of code). Ist das gut? Sollte Code nicht immer so kurz und knapp wie möglich sein? Trägt Knappheit nicht zur Lesbarkeit bei?

Klar, wenige Zeilen Code lassen sich leichter überschauen, sozusagen physisch. Aber inhaltlich ist das nicht unbedingt der Fall, nämlich wenn es sich um reine Logik handelt. Das war ja das Problem des ursprünglichen Codes. 40-50 LOC, also rund eine Bildschirmseite, das war nicht viel - und doch war es nur schwer verständlich.

Da bezahle ich gern den Preis von etwas Rauschen und mehr LOC, wenn ich dafür Verständlichkeit bekomme. Und die ist nun vorhanden, würde ich sagen.

Der Einstieg ins Programm ist sonnenklar. Er beschreibt lesbar, wie das Verhalten "Dateiinhalt als Hex Dump anzeigen" hergestellt wird:

image

Main() hat nun eine einzige, harte Verantwortlichkeit: Integration.

Und jede Klasse hat wiederum nur eine einzige Verantwortlichkeit, z.B. FilesystemProvider:

image

Sie kapselt die Nutzung des System.IO API. Dieser Aspekt zerfällt jedoch in zwei weiche: Prüfen, ob eine Datei existiert, und blockweises Lesen der Bytes aus einer Datei.1

Fazit

Das Single Responsibility Principle ist zentral für saubere Softwareentwicklung. Um es anwenden zu können, muss man allerdings wissen, was denn eine Responsibility eigentlich ist.

Mit der Definition, die ich gegeben habe, fällt es Ihnen hoffentlich leichter, die Verteilung von Responsibilities in Ihrem Code zu überschauen - und sie dann mit Refaktorisierung zu entzerren.


  1. Ok, ich gebe zu, ein kleinwenig unsauber ist der Code noch. Denn sowohl in Check_if_file_exists() wie in Get_filename() nutze ich zwei APIs. Zum einen den eigentlichen, um den es dort geht (Dateisystem- bzw. Kommandozeilenzugriff), zum anderen jedoch auch den für die Ausgabe auf der Konsole zur Fehlermeldung. Konsequenterweise müsste ich die Fehlermeldung in eine eigene Methode verpacken und z.B. in den ConsoleProvider verschieben. Eigentlich - denn hier lasse ich das mal so stehen. Es ist eine vergleichsweise kleine Sünde. Und wer will schon Perfektion? :-) Wenigstens bin ich mir der “Schmutzrückstände” bewusst.

Kommentare:

Peter Conrad hat gesagt…

"Sie können die Merkmale Ihrer Frisur (Haarfarbe, Haarlänge, Schnitt) unabhängig von Merkmalen Ihrer Kleidung (Stoff, Jahreszeitlichkeit, Stil) [...] ändern."

Dem würde meine Frau widersprechen. :-)

Das (vermutlich ungewollt) Schöne an dem Beispiel ist natürlich, dass es die Subjektivität der Einteilung unterstreicht.

Ralf Westphal - One Man Think Tank hat gesagt…

Deine Frau ist auch ein Aspekt deines Lebens - ein weicher :-) Der kann sich unabhängig von deiner Frisur ändern. Ich hoffe, du spürst diesen Freiheitsgrad :-)

Klar, Frisur, Kleidung... das sind zu einem gewissen Grad weiche Aspekte. Es mag Leute geben, die Haarfarbe mit Rocklänge enger zusammen sehen als Haarfarbe und Haarlänge.

Wie gesagt: Darüber lässt sich trefflich diskutieren. Deshalb so lange wie möglich an harte Aspekte halten.

McZ hat gesagt…

SRP soll korrekte Modularität erzwingen. Korrekte Modularität ermöglicht Testbarkeit, Flexibilität und Evolvierbarkeit.

Deshalb gehört Environment.Exit auf keinen Fall in die IO-Operationen (Testbarkeit?). Die Aussage, dass bei einer (in diesem Fall erwarteten) Ausnahmesituation in den Fehlerausgabestrom geschrieben und die Anwendung mit Exitcode = 2 beendet wird, gehört definitiv in die Main-Methode; die Behandlung (oder das Unterlassen) von Ausnahmen ist eine essentielle Aufgabe der Integration.

Zusammen mit der bereits erkannten Sünde des Konsolenaufrufs wird hier letztendlich um das Weglassen einer an dieser Stelle in Check_if_file_exists opportunen System.IO.FileNotFoundException herumgedoktert. Beim Öffnen des FileStream gibt es eine solche Ausnahme sowieso, das gehört zur Domäne File-IO. Deshalb ist Check_if_file_exists redundant. Will man es weniger "hart", prüft man mit File.Exists; emfände ich hier aber als überflüssig.

Was die IO-Kapsel Read_blocks_from_file tut ist: Datenstrom aus Datei öffnen, aus dem Datenstrom eine Sequenz von Blöcken erzeugen und für jeden Block zwingend eine Aktion durchführen. Das sind drei Aspekte.

Es fehlt eine Methode Read_blocks_from_stream mit Eingabe IO.Stream und Ergebnis IEnumerable. Der Code sieht per Yield gar nicht so anders aus. Der Name der Klasse wäre mit "BlockReader" besser gewählt.

Read_blocks_from_file würde als high-level API als Integration "lokales Dateisystem > Block-Iteration" weiterleben. Da spricht nichts dagegen.

Diese Design-Entscheidung ist übrigens mit eine wenig Spieltheorie und mit Blick auf die Vererbungshierarchie von IO.FileStream "hart messbar": was wäre, wenn der Datenstrom auf einem Webserver liegt oder in einer Zip-Datei verpackt ist? Oder zur einfacheren Testbarkeit in einem MemoryStream? Was ist, wenn die Blöcke mehrfach iteriert werden müssen, oder sie für Sprünge indiziert werden müssten?

Ich würde dem Code auch unbedingt ansehen wollen, ob der Inhalt Binär, als Text, JSON, XML oder "my-favourite-worst-nightmare-markup" gelesen wird. Auch der refaktorisierte Code operiert direkt auf dem Stream, anstatt einen BinaryReader zu verwenden. Deshalb ist die Implementierung an dieser Stelle auch jener in IO.BinaryReader.ReadBytes bis aufs Haar ähnlich, mit zwei Performance-Optimierungen.

Dass dies weggelassen wird, macht den Code weniger verständlich. Das ein Lehrbuch direkte Stream-Lese-Operationen als Tugend verkauft, halte ich für schlicht katastrophal.

Würde man all dies beherzigen, käme eine Line-Count heraus, der deutlich unter der Originallösung liegt.

Ralf Westphal - One Man Think Tank hat gesagt…

Um Missverständnisse zu vermeiden: Meine Refaktorisierung hat bewusst möglichst wenig an der vorhandenen Logik verändert. Ich habe sie möglichst komplett übernommen - und nur in Container zusammengefasst, um Verantwortlichkeiten kenntlich zu machen.

Bei der Fehlermeldung habe ich allerdings auf eine "vollständige" Refaktorisierung verzichtet.

@McZ: Du lieferst ein schönes Beispiel dafür, wie weit man die Zerlegung in harte und weiche Aspekte noch treiben könnte.

Und wir können trefflich darüber diskutieren, ob die Methode (bzw. die dahinter stehende Logik) Check_if_file_exists() nötig ist. Du meinst, nein, weil es eh knallt, wenn die Datei nicht vorhanden ist. Der Autor des Originals meint, ja, weil die explizite Prüfung (bzw. das Abfangen einer solchen Exception) die Gelegenheit gibt, anders auf das Fehlen zu reagieren (z.B. sprechendere Fehlermeldung, Meldungsausgabe auf einem bestimmten Kanal, Exit-Code setzen).

Das "Was wäre wenn"-Spiel lässt sich natürlich weit und weiter treiben. Hier muss dann auch irgendwann abgebrochen werden. SRP darf nicht zur Glaskugelguckerei verleiten.

Worüber ich mich gefreut hätte, das wäre eigener Code von dir gewesen. Warum hast du uns nicht deine optimierte Lösung in einem Gist präsentiert?

Epi hat gesagt…

Hallo,

ich hatte mich schon immer gefragt, wie das mit dem "one reason to change" gemeint ist. Insofern interessanter Artikel.

Das Thema "Härtegrade" hat mich an die "Softwarekategorien" aus Siederslebens Buch "Moderne Softwarearchitekturen" erinnert.

Siedersleben unterteilt in "Kategorie 0" für "Allgemeinwissen", also bspw. Typen der Programmiersprache, Collection-API der Standarbibliothek, Reflection-API etc.

"Kategorie A" für "Anwendung" wird bestimmt durch der Anwendungsdomäne.

"Kategorie T" für "Technik" wird bestimmt durch den Umgang mit technischen APIs, z.B. I/O, DB-Persistenz etc.

Kann ich Deine Härtegrade so verstehen: hart für "T", weich für "A"?

Ziel für Siedersleben ist eine "Trennung von A und T", weil sich beides unterschiedlich schnell ändert.

Für die Kommunikation von A + T Software gibt es dann noch die Kategorie "R" für "Repräsentation", die nur transformiert, also z.B. DB-Record nach Objekt oder Objekt nach GUI-Element.

Ralf Westphal - One Man Think Tank hat gesagt…

Siedersleben kategorisiert Aspekte stark. Das mache ich hier nicht so sehr. "Hart" und "weich" sollen nicht mit "T" und "A" korrespondieren.

Eher bei der Softwarezellen unterscheide ich zwischen "Domäne" ("A"), "Portal" ("T"), "Provider" ("T") und dann mit Flow Design nochmal Integration (fällt fasst in die Kategorie "R"). Aber das ist halt differenzierter - und auch nur der Anfang. Denn gerade die Domäne muss weiter zerlegt werden. Aber das sind dann weiche Aspekte.