SOLID [2] – Das Single Responsibility Principle

Dies ist der dritte Artikel unserer Serie über die "SOLID"-Prinzipien der Softwareentwicklung. Unser Ziel ist dabei zu demonstrieren, wie diese Prinzipien uns helfen, gute objektorientierten Modelle zu bilden und damit die Implementierung zu vereinfachen. Als durchgehendes Beispiel dazu nutzen wir eine Stereoanlage. Die Lektüre des zweiten Artikels wird dabei dringend empfohlen, da wir auf dessen Ergebnissen direkt aufbauen.
Ziel dieses Artikels ist die Erklärung des "Single Responsibility Principle". Das Thema dieses Prinzips ist die Frage "Was ist die optimale Größe einer leicht wartbaren Klasse?". Es wäre ein leichtes, sofort die vollständige Definition des Prinzips zu nennen und an einem unrealistischen, trivialen Beispiel zu erklären. Diese eingeschränkte Sichtweise brächte aber nur einen geringen Lerneffekt.
Schlimmer noch – das SRP ist kein alleinstehendes Prinzip. Generell greifen bei der Softwareentwicklung permanent verschiedene Sichtweisen ineinander. Einerseits nutzen wir Prinzipien wie SOLID, Design Patterns und konkrete Programmcodes in verschiedenen Sprachen und Programmiermodellen (deklarativ, imperativ, etc.). Andererseits bewegen wir uns auf verschiedenen Abstraktionsebenen. Mal implementieren wir, dann betrachten wir die Dinge auf der Design-Ebene, um das korrekte Zusammenspiel der Systemteile zu beurteilen. Und die Requirements aus Sicht des Kunden behalten wir natürlich auch immer im Blick!
Wenn dies für unsere Arbeit so entscheidend ist, soll auch dieser Artikel so aufgebaut sein. Wir betrachten das Thema aus verschiedenen Perspektiven. Dadurch werden wir selbst ein Gefühl aufbauen, welche Größe und Struktur Klassen haben sollten und, dass Requirements dabei eine große Rolle spielen. Der konkrete Text des Single Responsibility Principle am Schluss ist dann nur noch ein Merksatz, der uns an all das Gelernte immer wieder erinnern wird.
Wo wir stehen
Im letzten Artikel haben wir uns die Interaktion zwischen dem Controller und den verschiedenen Audiogeräten angesehen. Zu Beginn kannte der Controller alle Audiogeräte. Dies machte ihn schwer verständlich, da er bei all seinen Funktionalitäten auf die Eigenheiten der verschiedenen Audiogeräte Rücksicht nehmen musste. Auch über die Zeit war die Wartung schwierig, da alle Änderungen an ihnen somit Änderungen am Controller bedingt haben. Er war von den Audiogeräten abhängig oder an sie gekoppelt. Diese Abhängigkeit wurde mit dem Dependency Inversion Principle (DIP) aufgelöst, indem der Controller eine abstrakte Klasse definierte (AudioGerät) die genau die API definierte, die der Controller für die Durchführung seiner Aufgaben brauchte:
abstract class AudioGerät { public abstract Guid Guid { get; } public abstract AudioStream AudioQuelle { get; } public abstract void PlayPause(); public abstract void Stop(); public abstract void Vorwärts(); public abstract void Rückwärts(); }
Diese wurde dann von allen Audiogeräten implementiert. Dadurch wurde der Controller von ihnen entkoppelt. Jetzt braucht er nur noch die abstrakte Klasse zu kennen. Auch die genaue Anzahl der Audiogeräte ist nicht mehr entscheidend. Anstatt Referenzen auf die einzelnen, konkreten Geräte zu haben, hält er stattdessen eine Sammlung an abstrakten Audiogeräten. Er kann, muss und soll gar nicht mehr wissen, welche konkrete Implementierung sich hinter der Abstraktion verbirgt.
Die abstrakte Klasse ist damit ein Fokuspunkt für die Zusammenarbeit zwischen dem Controller und den Audiogeräten. Verbleibende Probleme nach dieser Umstellung wären ein Zeichen dafür, dass entweder die Abstraktion noch nicht passend genug auf die Bedürfnisse des Controllers eingeht oder die einzelnen Geräte diese Bedürfnisse nicht erfüllen. D. h. die abstrakte Klasse würde von ihnen nicht korrekt implementiert. Diese Unschärfen kann man durch eine genaue Spezifikation der Abstraktion und flankierende Unit Tests beheben.
Ein konkretes Beispiel dafür, das noch aus dem letzten Artikel fehlt, ist das Radio:
class Radio : AudioGerät { public override Guid Guid { get; } private AudioStream _interneAudioQuelle; public override AudioStream AudioQuelle { get { _interneAudioQuelle.Aktiv = true; return _interneAudioQuelle; } } public override void PlayPause() { _interneAudioQuelle.Aktiv = !_interneAudioQuelle.Aktiv; } public override void Stop() { _interneAudioQuelle.Aktiv = false; } public override void Vorwärts() { NächsterSender(); } public override void Rückwärts() { VorherigerSender(); } public void NächsterSender() { /* ... */ } public void VorherigerSender() { /* ... */ } }
Wir sehen hier sehr schön, dass die Klasse aus zwei Teilen besteht. Einerseits haben wir die Implementierung der abstrakten Klasse. Man kann z. B. sehr gut erkennen, wie das Radio die PlayPause-Funktionalität simuliert. Diese beeinflusst die Implementierung sowohl von PlayPause, Stop, als auch dem Getter von AudioQuelle. Die Interaktion mit dem Controller ist also sehr gut gebündelt. Vorwärts und Rückwärts sind lediglich Weiterleitungen auf die eigentliche Logik des Radios. Diese besteht aus den Methoden NächsterSender und VorherigerSender. Die Klasse hat also zwei Rollen. Zumindest aus Sicht der Signatur ist die Logik mit nur zwei Methoden im Vergleich zur Adaption aber winzig. Das erscheint irgendwie wie ein Ungleichgewicht.
Zwar zeigen sich nach wie vor die Vorteile des DIP. Man könnte aber die Frage stellen: Was ist, wenn das Radio noch für andere Beziehungen Abstraktionen implementieren muss? Dadurch würde sich die Anzahl der Rollen in der Klasse weiter erhöhen. Die Geschäftslogik des Radios würde in den verschiedenen Rollen untergehen. Weiterhin könnte es passieren, dass sich dann wieder in der Implementierung mehrere Themen mischen, da die Klasse nach innen keine Abschirmung zwischen den einzelnen Rollen bietet. Man könnte sagen, eine einzelne Klasse der Objektorientierung kann "degenerieren" zu einem einzigen großen Klumpen von Code, der auf geteiltem, quasi "globalem", State operiert.
Damit befassen wir uns zum ersten Mal mit der Kernfrage des Artikels "Was ist die optimale Größe einer leicht wartbaren Klasse?". In der jetzigen Form ist das Radio sicher noch wartbar, wir haben aber ein erstes Zeichen dafür, dass wir ein Werkzeug zur Teilung von Rollen gebrauchen könnten – um bei einem weiteren Wachstum der Klasse Wartbarkeit und Verständlichkeit zu erhalten.
Wenn uns das allein noch nicht überzeugt, wie wäre es mit einem handfesten Grund? Nehmen wir an, das Radio wäre eine Klasse, die nicht in unserer Hoheit liegt. Z.B. eine zugelieferte Komponente, die wir nicht ändern können. Spätestens jetzt müssten wir eine alternative Möglichkeit finden, wie wir das Radio an den Controller anbinden können. Wenn wir den Code des Radios nicht ändern können, ist es auch nicht möglich die abstrakte Klasse AudioGerät zu implementieren.
Was tun wir nun? Geben wir das DIP auf? Nein, natürlich nicht.
Alternative Umsetzungen des Dependency Inversion Principle
Wir hatten den Wunsch geäußert, die beiden Rollen des Radios – Logik und Controller-Interaktion – bei Bedarf voneinander trennen zu können. Nun werden wir zu unserem Glück gezwungen. Gedanklich gesehen müssen wir den Adaptionscode extrahieren und außerhalb der unveränderlichen Radio-Klasse bereitstellen. Diese muss nämlich so bleiben wie wir sie zu Anfang definiert hatten.
class Radio { public AudioStream AudioQuelle { get; } public void NächsterSender() { /* ... */ } public void VorherigerSender() { /* ... */ } }
Vererbung
Eine mögliche Lösung dazu ist der Einsatz von Vererbung. Wir erben vom Radio, das nur die Geschäftslogik enthält, und implementieren in der abgeleiteten Klasse die abstrakte Klasse AudioGerät, also die Controller-Interaktion:
class RadioAudioGerät : AudioGerät, Radio { public override Guid Guid { get; } public override AudioStream AudioQuelle { get { base.AudioQuelle.Aktiv = true; return base.AudioQuelle; } } public override void PlayPause() { AudioQuelle.Aktiv = !AudioQuelle.Aktiv; } public override void Stop() { AudioQuelle.Aktiv = false; } public override void Vorwärts() { NächsterSender(); } public override void Rückwärts() { VorherigerSender(); } }
Achtung! Dies ist kein valider C#-Code, da wir von zwei Klassen erben. In C++ wäre dies erlaubt. In C# ist aber nur das Erben von einer Klasse erlaubt. Dies gilt auch für viele andere Sprachen, inklusive Java. Wir könnten aber AudioGerät in ein Interface umwandeln und so unser Ziel erreichen.
Durch die Vererbung haben wir in jeder Hierarchieebene jeweils eine Rolle. Unser Problem ist also gelöst. Vererbung kann allerdings schnell zu einem schwierig zu handhabenden Werkzeug werden – es ist ein sehr fragiles Zusammenspiel zwischen Basis und abgeleiteter Klasse. Neben dem potenziellen Problem der Mehrfachvererbung gibt es noch viele weitere, kleinere und größere Probleme:
- Die abgeleitete Klasse hat sehr feingranularen Zugriff auf die Implementierungsdetails der Basisklasse. Um davon zu profitieren, muss die Basis dazu die entsprechenden Daten und Methoden als protected markieren. Wenn aber eine Klasse nicht explizit als Basisklasse entworfen wurde, macht sie davon in der Regel keinen Gebrauch.
Diese Art der Interaktion bindet die Ableitungen sehr stark an die Basis. Bei Änderungen oder Erweiterungen der Basisklasse wird es sehr schwer, gleichzeitig die öffentliche (public) API, die API innerhalb der Hierarchie (protected) und die internen Abläufe unter einen Hut zu bringen. Diese Möglichkeit wird daher selbst bei Basisklassen immer seltener genutzt. Wir benötigen diese Eigenschaft auf jeden Fall hier nicht. - Abgesehen vom internen Zustand der Basisklasse kann die Ableitung die öffentlichen Methoden und Properties überschreiben und so mit eigenem Verhalten anreichern. Die konkreten Details unterscheiden sich hier stark zwischen den Sprachen. Im Wesentlichen wurden die Möglichkeiten im Übergang von C++ zu Java und C# limitiert, indem mehr explizite Syntaxangaben gemacht werden müssen. Dadurch sollen die Vorgänge klarer und ungewollte Effekte durch den Compiler erkennbar werden. Trotzdem bleibt dieses Feature sehr fehleranfällig. In unserem Beispiel wollen wir die Elemente von Radio aber gar nicht sichtbar machen. Aus Sicht des Controllers geht es uns nur um die Implementierung von AudioGeät. Das zweite Feature der Vererbung, das wir nicht benötigen.
- Ein Beispiel für die komplexe Interaktion zwischen den Symbolen innerhalb der Hierarchie sind potentielle Namenskonflikte. Diese müssen durch eine Kombination der Schlüsselwörter new, override und base oder expliziter Schnittstellenimplementierung aufgelöst werden. Im konkreten Beispiel mussten wir dieses Problem ebenso lösen.
Die Liste ließe sich noch stark erweitern. Um nicht vom Thema abzukommen, verweise ich nur noch auf ein paar Begriffe: Keine Calls von abstrakten/virtuellen Methoden im Konstruktor, komplexe Implementierung von IDisposable, etc. In C++ steigt die Zahl der Konfliktpunkte noch stark an: Virtuelle Destruktoren, Polymorphie greift nur bei Pointern etc.
Aggregation
Wir sehen also, Vererbung kann zwar unser Problem lösen, ist aber selbst mit Wartungsproblemen verbunden. Es stellt sich die Frage: warum kappen wir nicht einfach die Verbindung zwischen der abgeleiteten Klasse und dem Radio? Statt zu erben, schreiben wir eine Wrapper-Klasse, die die AudioGeräte-API implementiert und alle notwendigen Aufrufe einfach an ein internes Radio weiterleitet. Dies ist ein Beispiel für die Anwendung des Adapter Design-Patterns.
class RadioAudiogerätAdapter : Audiogerät { private readonly Radio _radio = new Radio(); public override Guid Guid { get; } public override AudioStream AudioQuelle { get { _radio.AudioQuelle.Aktiv = true; return _radio.AudioQuelle; } } public override void PlayPause() { _radio.AudioQuelle.Aktiv = !_radio.AudioQuelle.Aktiv; } public override void Stop() { _radio.AudioQuelle.Aktiv = false; } public override void Vorwärts() { _radio.NächsterSender(); } public override void Rückwärts() { _radio.VorherigerSender(); } }
Wir haben mit diesem Weg ebenfalls unser Problem der Unveränderlichkeit des Radios gelöst. Da wir keine Vererbung nutzen fallen zwei typische Eigenschaften weg:
- Da wir nicht mehr vom Radio erben, haben wir keinen Zugriff auf potentielle protected-Member. Da das Radio aber gar keine anbietet, verlieren wir nichts.
- Eine abgeleitete Klasse übernimmt die API ihrer Basisklasse und erweitert sie. In unserem Fall wäre dieser Vorteil aber unnötig – sogar ein Nachteil. Beim Adapter sehen wir wirklich nur die Implementierung von AudioGerät. Je weniger der Controller über die konkreten Audiogeräte hinter dieser Abstraktion weiß, desto besser!
- Durch das Wrappen werden Namenskonflikte generell vermieden.
Wir haben also unser Ziel ohne Nachteile erreicht. Im Gegenteil. Die beiden Objektrollen Adaption und Logik sind klar voneinander getrennt. Wenn wir den Code verstehen oder verändern wollen, wissen wir damit genau, wo wir Änderungen vornehmen müssen. Auch Erweiterungen lassen sich mit diesem Ansatz gut umsetzen. Wenn das Radio eine weitere Rolle in einer Interaktion spielen muss, definieren wir einen weiteren Adapter. Dadurch kann das Radio nutzbringend in vielen Situationen eingesetzt werden, ohne weiteres Aufblähen der Klasse.
Ein weiteres, interessantes Detail ist die Zugehörigkeit des Adapters. Wenn die Audiogeräte direkt die Basisklasse implementieren, muss dies auch von den Entwicklern geleistet werden, die den Code dieser Klasse pflegen. RadioAdapter kann aber in den Verantwortungsbereich des Controllers übergehen und von dessen Entwickler implementiert und gepflegt werden.
Klassen – Viele oder wenige?
Treten wir einen Schritt zurück. Bisher haben wir sehr konkret über die Radio-Klasse und deren Verbesserungsmöglichkeiten gesprochen. Wir haben dabei festgestellt, dass es die Wartbarkeit verbessert, Klassen aufzuteilen, wenn diese mehrere unabhängig betrachtbare Dinge umsetzen – wir sprachen hier von Rollen. Doch hat das auch Nachteile? Wir haben nun zwei Klassen statt einer. Jetzt reden wir davon, noch weitere von ihnen für zusätzliche Interaktionen zu definieren. Ist diese große Zahl an Klassen nicht schlecht? Nicht unbedingt. Wir sollten natürlich nicht künstlich versuchen, ihre Anzahl zu erhöhen, indem wir banale Dinge in einzelne Klassen auslagern. Wenn es aber darum geht, eine gegebene Aufgabe umzusetzen, sind viele einfache Klassen besser als wenige komplexe.
Damit sind wir beim zentralen Punkt des Artikels angekommen. Unsere Prämisse ist: Wir wollen unsere Klassen klein und fokussiert halten. Kleine Klassen haben einen übersichtlichen Funktionsumfang und damit eine überschaubare Anzahl an relevanten Datenzuständen. Sie haben tendenziell weniger Abhängigkeiten und können damit flexibler kombiniert werden.
Diese generelle Aussage ist sicher Konsens. Auch, weil typische Gegenbeispiele für schlecht wartbaren Code in der Regel große, undurchschaubare Klassen sind. Aber, wenn es sinnvoll ist, kleine Klassen anzustreben, warum haben wir diesen Zustand in typischen Systemen dann so selten? Oft spielt die Dynamik eine große Rolle. Wir starten mit einem kleinen, übersichtlichen Klassenmodell. Dann wächst im Laufe der Entwicklung die Anzahl der Codezeilen durch die Umsetzung neuer Anforderungen. Dadurch werden Klassen quasi "automatisch" im Laufe der Zeit größer. Im Laufe des Entwicklungsprozesses müssen Klassen also immer wieder aufgesplittet werden um die Wartbarkeit zu erhalten.
Daraus ergeben sich im Entwickleralltag zwei Fragen:
- Wann muss ich eine Klasse aufteilen?
- Wie kann ich eine Klasse aufteilen?
Frage zwei lässt sich nicht endgültig beantworten. Je nach konkreter Fragestellung gibt es viele Prinzipien und Patterns, um Code aufzuteilen. Wir haben bereits zwei konkrete Werkzeuge besprochen: Das DIP und das Adapter-Pattern. Wenn man von der prinzipiellen Wirksamkeit der Codeaufteilung überzeugt ist, sollte man in der Lage sein, sich weitere dieser Werkzeuge anzueignen. Auch diese Artikelserie wird immer wieder an diesem Punkt ansetzen. Hilfreich ist dabei, dass die Toolunterstützung inzwischen ziemlich gut ist. Typische IDEs und deren Plugins erlauben es einem, sich sehr stark auf den konzeptionellen Teil des OO-Designs zu konzentrieren. Sie nehmen einen großen Teil des manuellen Tippens ab, wenn es darum geht, Codeumformungen vorzunehmen. Sie bieten z. B. dialoggestützte Wizards an, um einzelne Methoden oder gar ganze Klassenfragmente aus einer Klasse heraus zu extrahieren. Rein mechanisch sollte uns also das Aufteilen des Codes nicht lange aufhalten.
Es verbleibt die Antwort auf Frage eins. Im Grunde die zentrale Frage dieses Artikels. Uns ist intuitiv klar, dass auch die Zerteilung von Klassen ihre sinnvollen Grenzen hat. Doch was ist eine "große" Klasse? Messen wir es in Codezeilen? In Methoden? Und wie weit gehen wir? Sicher nicht, bis nur noch eine Methode übrigbleibt? Diese Fragen können wir bald abschließend beantworten.
Um es vorweg zu nehmen: Immer wieder werden Metriken diskutiert, die sinnvolle Größenangaben anhand von Codezeilen zu definieren versuchen. In etwa "Eine Methode sollte immer komplett auf den Bildschirm passen", oder "Eine Klasse sollte maximal eine dreistellige Zahl an Zeilen haben". Dies Ansätze sind aber zurecht umstritten. Komplexität lässt sich nicht direkt auf die Anzahl an Codezeilen abbilden. Im Gegenteil. Um Wartbarkeit zu erreichen, brauchen wir ein intelligentes, differenziertes Vorgehen. Das Einhalten strikter Vorgaben zu fordern wird aber dazu führen, dass plötzlich blinde Regelerfüllung vor sinnvolles Engineering tritt – mit verheerenden Auswirkungen auf die Codequalität. Diese negativen sozialen/technischen Effekte von Codemetriken wären einen eigenen Artikel wert.
Class-Responsibility-Collaboration
Wenn wir also strikte Kennzahlen ablehnen, brauchen wir umso mehr eine Leitlinie, die uns den Weg zu einer sinnvollen Aufteilung zeigt. Dafür müssen wir unsere Perspektive ändern. Wir werden versuchen, Komplexität nicht mehr aus Codesicht zu beurteilen, sondern aus der Kundenperspektive – aus Sicht der Requirements.
Wir werden dafür die CRC-Technik verwenden. Dies steht für: Class-Responsibility-Collaboration. Der Titel allein beschreibt schon fast die komplette Bedeutung. Wir listen alle Klassen unseres Modells auf (Class) und notieren zu jeder, was ihre Aufgabe ist (Responsibility) und mit welchen anderen Klassen sie zusammenarbeitet (Collaboration). Es ist also ein Mittel, um Requirements auf ein Klassenmodell abzubilden.
Uns sollte jetzt schon klar sein, dass eine Klasse mit vielen Aufgaben sicher größer sein wird, als eine mit wenigen. Es lässt sich bei diesem Punkt also eine Tendenz festmachen – je weniger desto besser.
Gleiches gilt für die Zusammenarbeit. Wenn eine Klasse A eine Klasse B aufruft, so ist A an B gekoppelt, mit all den negativen Auswirkungen, die wir bereits besprochen haben. B wiederum ist von dieser ganzen Thematik nicht betroffen – sie weiß ja nicht einmal, von wem sie aufgerufen wird. Für unsere Tabelle heißt das, dass B ein Collaborator von A ist, aber nicht umgekehrt. Daraus folgt: je mehr Einträge bei Collaboration, desto mehr Kopplung muss eine Klasse schultern. Auch hier gilt – weniger ist mehr.
Modell Schritt 1
Wir beginnen mit einer Darstellung des Modells in der Form, wie sie zu Anfang der Artikelreihe definiert war.
Class | Controller |
---|---|
Responsibility |
|
Collaboration | CdPlayer, Kassettendeck, Radio |
Class | CdPlayer |
---|---|
Responsibility |
|
Collaboration | Keine |
Class | Kassettendeck |
---|---|
Responsibility |
|
Collaboration | Keine |
Class | Radio |
---|---|
Responsibility |
|
Collaboration | Keine |
Wir sehen sofort, dass es hier ein Schwergewicht gibt. Die Audiogeräte sind noch recht übersichtlich. Der Controller jedoch hat viele Aufgaben und Abhängigkeiten. Dies muss sich negativ auswirken. Im letzten Artikel diagnostizierten wir beim ihm dementsprechend auch diverse Probleme. Der Kern war die Tatsache, dass die verschiedenen Adaptionsnotwendigkeiten nicht klar voneinander getrennt darstellbar waren. Stattdessen waren sie miteinander vermischt und in vielen redundanten switch/case-Anweisungen über die Klasse verteilt. Dieses konkrete Problem haben wir unter Einsatz des Dependency Inversion Principle behoben. Das war zumindest damals unsere Sichtweise. Jetzt betrachten wir die Klasse unter dem Gesichtspunkt ihrer umgesetzten Anforderungen. Dabei fällt uns auf, dass die konkreten Probleme im Code lediglich Auswirkungen eines grundsätzlicheren Problems waren – dass die Klasse zu viele Aufgaben hatte. Das heißt nicht, dass die Lösung mittels DIP falsch war, ganz im Gegenteil. Die Erkenntnis schärft jedoch unseren Blick darauf, dass wir über diese neue Sichtweise eine Möglichkeit haben, Probleme abstrakter und früher zu sehen. Wir müssen nicht erst die technischen Probleme im Code aufspüren, um zu wissen, wann wir ein Designproblem haben, bereits die Sichtweise durch die Requirements weist uns auf Defizite hin.
Modell Schritt 2
Nach dem Umbau des Modells durch das DIP hatten wir das folgende Bild:
Class | Controller |
---|---|
Responsibility |
|
Collaboration | AudioGerät |
Class | AudioGerät |
---|---|
Responsibility |
|
Collaboration | Keine |
Class | CdPlayer |
---|---|
Responsibility |
|
Collaboration | AudioGerät |
Class | Kassettendeck |
---|---|
Responsibility |
|
Collaboration | AudioGerät |
Class | Radio |
---|---|
Responsibility |
|
Collaboration | AudioGerät |
Die technischen Probleme waren nun gelöst. Aber im Wesentlichen dadurch, dass unser Design nun viel besser ist. Wir wollen nicht erneut wiederholen, welche konkreten Vorteile das DIP uns gebracht hat. Das müssen wir auch nicht. Allein der Blick auf die Anzahl an Einträgen bei Responsibility und Collaboration zeigt, dass wir das Modell deutlich vereinfacht haben.
Modell Schritt 3
Zur Mitte dieses Artikels haben wir durch das Splitten der konkreten Audiogeräte mittels Adapter-Pattern das folgende Bild erreicht:
Class | Controller |
---|---|
Responsibility |
|
Collaboration | AudioGerät |
Class | CdPlayerAudioGerät |
---|---|
Responsibility |
|
Collaboration | AudioGerät, CdPlayer |
Class | CdPlayer |
---|---|
Responsibility |
|
Collaboration | Keine |
Class | KassettendeckAudioGerät |
---|---|
Responsibility |
|
Collaboration | AudioGerät, Kassettendeck |
Class | Kassettendeck |
---|---|
Responsibility |
|
Collaboration | Keine |
Class | RadioAudioGerät |
---|---|
Responsibility |
|
Collaboration | AudioGerät, Radio |
Class | Radio |
---|---|
Responsibility |
|
Collaboration | Keine |
Wieder haben wir uns auf einen technischen Aspekt gestützt, als wir erklärten, warum wir Klassen aufteilen wollen – hier die Tatsache, dass die unveränderliche Radio-Klasse die Abstraktion AudioGerät nicht direkt implementieren kann. Wieder hat diese Aufteilung positive Effekte auf die Verteilung von Requirements auf Klassen. Am Ende haben wir eine große Menge an einfachen Klassen, statt einer kleinen Menge an komplizierten. Und das, obwohl wir bisher diese Requirements-Sicht nicht explizit eingenommen haben.
In unserem vereinfachten Modell fällt auf, dass wir bei fast allen Klassen nur noch einen einzigen Satz in der Spalte Responsibility haben. Das ist kein Zufall. Ebenso wie eine Klasse mit vielen Anforderungen in der Regel Wartungsprobleme entwickelt, führt die bewusste Vereinfachung eines Klassenmodells mit Sicherheit zu Klassen, die klarere, einfachere Zuständigkeiten haben. Auch die Entwickler der CRC-Methode hatten dies sicher im Hinterkopf, als sie die Spalte Responsibility nannten – im Singular.
Wenn wir nun aber wissen, dass es erstrebenswert ist, die Anzahl der Verantwortlichkeiten einer Klasse zu minimieren, führt das zu einer interessanten Feststellung: Das Problem der Überladung von Klassen mit Anforderungen ist eines der Hauptprobleme von schlechten Designs. Die konkreten Auswirkungen im Code können sich aber stark von Fall zu Fall unterscheiden. Immer wenn wir also vor einer Wand an verschlungenem Code stehen lohnt es sich, einen Schritt zurück zu gehen. Der Blick auf die Requirements weist uns einen Weg, wo sinnvolle konzeptionelle Schnittlinien verlaufen. Mit diesem Wissen im Hinterkopf können wir uns wieder ganz in die Details stürzen. Denn so vergessen wir auch im Kleinteiligen nie, wo wir hinwollen.
Die formale Definition des Single Responsibility Principle
Schauen wir uns abschließend die Definition des Single Responsibility Principle an. Sie ist sehr kompakt. Daher ergänzen wir sie durch unsere gesammelten Erkenntnisse:
- A class should have only a single responsibility (i.e. changes to only one part of the software's specification should be able to affect the specification of the class).
Wir haben eine Metrik gefunden, die uns ein gutes Zeichen dafür liefert, wann wir eine Klasse so weit in ihrem Umfang reduziert haben, dass sie klein und gut wartbar ist: Wenn wir ihren Zweck mit einem Satz beschreiben können. Dieser steht dann auf einer CRC-Karte oder in einem Dokumentations-Tag über dem Quellcode der Klasse. Und wenn Sie ein Kollege fragt, was eine ihrer Klassen tut, ist dieser Satz ihre Antwort.
Ein System, das so aufgebaut ist, hat einen positiven Effekt zur Folge: Änderungen an den Requirements führen gezielt zu Anpassungen an einer überschaubaren, leicht wartbaren Klasse, die genau diesen Anforderungsaspekt abdeckt.
Zusammenfassung
Der Projektfortschritt treibt den Codeumfang, und damit die Größe der einzelnen Klassen, automatisch nach oben. Wie eine Hecke, die regelmäßig beschnitten werden muss, um nicht zu wuchern, müssen Klassen immer wieder in ihrer Größe hinterfragt werden. Dafür haben Sie nun eine passende Metrik. Codemetriken verdammen Sie dazu, willkürliche Limitierungen ihres Arbeitsergebnisses zu akzeptieren, indem ihr Quellcode gemessen wird. Sie fordern Sie auf zur Regeleinhaltung. Das SRP wiederum lenkt die Aufmerksamkeit auf die Analyse. Es motiviert Sie, darüber nachzudenken, wie Anforderungen in Ihrem Klassenmodell abgebildet werden. Diese Betrachtung lenkt Sie bei neuen Designs und treibt sie an, bestehende Designs sinnvoll weiterzuentwickeln. Es gibt Ihnen den Impuls, Klassen zu splitten oder neu aufzuteilen, wenn die Anforderungen dies nahelegen.
Versetzen Sie sich kurz in diese Lage: Sie öffnen eine beliebige Quellcodedatei ihres aktuellen Projekts und versuchen, die Frage zu beantworten: "Was macht diese Klasse?" Können Sie diese Frage schnell beantworten? Mit einem einzigen, klaren Satz? Und Sie schummeln auch nicht, weil dieser Satz fünf Mal das Wort "und" enthält? Spielen Sie dieses Spiel ruhig einige Male durch. Was denken Sie – wäre es nicht gut, immer diesen einen Satz zu haben? Zur Kommunikation, zur Dokumentation, als Triebfeder für die Implementierung?
Einen Kompass für dieses Ziel besitzen Sie nun. Einige konkrete Werkzeuge für Umformungen, mit denen Sie diesem Ziel näher kommen, haben wir bereits besprochen. Wenn Sie die nächsten Artikel lesen, kommen noch einige dazu – bleiben Sie am Ball!