Das Single Responsibility Principle, auch als Prinzip der einzigen Verantwortung bekannt,
auch das Prinzip der einzigen Veränderlichkeit - ein sehr glitschiges Konzept und ein nervenaufreibendes Thema in Vorstellungsgesprächen für Programmierer.
Mein erstes ernsthaftes Kennenlernen mit diesem Prinzip fand zu Beginn meines ersten Studienjahres statt, als wir, jung und unerfahren, in den Wald gebracht wurden, um aus frischlingshaften Studenten echte Studenten zu machen.
Im Wald wurden wir in Gruppen von 8-9 Personen eingeteilt und in einen Wettkampf verwickelt - welche Gruppe es schneller schafft, eine Flasche Wodka zu leeren, wobei die erste Person aus der Gruppe den Wodka in ein Glas einschenkt, die zweite ihn trinkt und die dritte einen Snack dazu hat. Die Person, die ihre Aufgabe erfolgreich abgeschlossen hat, stellt sich am Ende der Gruppenreihe an.
Ein Beispiel, wenn die Größe der Warteschlange durch drei teilbar war, stellte eine gute Umsetzung des SRP dar.
Definition 1. Einzige Verantwortung.
Die offizielle Definition des Prinzips der einzigen Verantwortung (SRP) besagt, dass jedes Objekt seine eigene Verantwortung und einen Grund für seine Existenz hat, und diese Verantwortung ist ausschließlich.
Betrachten wir das Objekt „Wodka-Trinker“ (Tippler).
Um das SRP zu erfüllen, teilen wir die Aufgaben auf drei Personen auf:
- Eine schenkt ein (PourOperation)
- Eine Person trinkt (DrinkUpOperation)
- Eine Person beißt (TakeBiteOperation)
Jeder Teilnehmer des Prozesses ist für eine Komponente verantwortlich, das heißt, er hat eine atomare Verantwortung – zu trinken, zu gießen oder zu beißen.
Der Trinkende hingegen ist ein Fassadenelement für diese Operationen:
class Tippler {
//...
void Act(){
_pourOperation.Do() // gießen
_drinkUpOperation.Do() // trinken
_takeBiteOperation.Do() // beißen
}
}
Warum?
Der Mensch-Programmierer schreibt Code für den Menschen-Affen, und der Mensch-Affe ist unaufmerksam, dumm und immer in Eile. Er kann etwa 3 bis 7 Begriffe gleichzeitig behalten und verstehen.
Im Fall des Trinkenden sind es drei Begriffe. Wenn wir jedoch Code in einem einzigen Block schreiben, erscheinen Hände, Gläser, Schlägereien und endlose Streitigkeiten über Politik. Und all dies wird im Körper einer Methode enthalten sein. Ich bin mir sicher, dass Sie solchen Code in Ihrer Praxis gesehen haben. Es ist nicht gerade eine humanitäre Prüfung für die Psyche.
Auf der anderen Seite ist der Mensch-Affe darauf fokussiert, Objekte der realen Welt in seinem Kopf zu modellieren. In seiner Vorstellung kann er sie zusammenstoßen, aus ihnen neue Objekte zusammensetzen und sie ebenso wieder zerlegen. Stellen Sie sich ein altes Automodell vor. Sie können in Gedanken die Tür öffnen, die Türverkleidung abnehmen und die Fensterhebermechanismen mit den Zahnrädern darin sehen. Aber Sie können nicht alle Komponenten des Fahrzeugs gleichzeitig in einem einzigen „Listing“ sehen. Zumindest kann es der „Mensch-Affe“ nicht.
Daher zerlegen Programmierer komplexe Mechanismen in eine Reihe weniger komplexer und funktionsfähiger Elemente. Allerdings kann man unterschiedlich dekomponieren: Bei vielen alten Fahrzeugen führt der Luftkanal zur Tür, während bei modernen Autos ein elektrischer Fehlfunktion des Schlosses den Motorstart verhindert, was bei Reparaturen problematisch ist.
Nun, SRP ist ein Prinzip, das erklärt, WIE man dekomponiert, das heißt, wo man die Trennlinie zieht..
Es besagt, dass die Dekomposition nach dem Prinzip der "Verantwortungsteilung" erfolgen sollte, also nach den Aufgaben der jeweiligen Objekte.
Lassen Sie uns zurück zum Trinken und den Vorteilen kommen, die ein Mensch-Affe beim Dekompilieren erhält:
- Der Code ist auf jeder Ebene klarer geworden.
- Der Code kann von mehreren Programmierern gleichzeitig geschrieben werden (jeder bearbeitet ein separates Element).
- Automatisiertes Testen wird einfacher — je einfacher das Element, desto leichter ist es zu testen.
- Es entsteht eine Kompositionalität im Code — man kann ersetzen. DrinkUpOperation durch eine Operation, bei der der Trinkende Flüssigkeit unter den Tisch gießt. Oder man ersetzt die Einschenken-Operation durch eine, bei der man Wein und Wasser oder Wodka und Bier mischt. Je nach geschäftlichen Anforderungen können Sie alles tun, ohne den Code der Methode zu berühren. Tippler.Act.
- Aus diesen Operationen können Sie einen Schlemmer zusammenstellen (unter Verwendung von nur TakeBitOperation), einen Alkoholiker (unter Verwendung von nur DrinkUpOperation direkt aus der Flasche) und viele weitere geschäftliche Anforderungen erfüllen.
(Oh, das scheint bereits das OCP-Prinzip zu sein, und ich habe die Verantwortung dieses Beitrags verletzt.)
Und natürlich die Nachteile:
- Es müssen mehr Typen erstellt werden.
- Der Trinkende wird zum ersten Mal ein paar Stunden später trinken, als er könnte.
Definition 2. Einheitliche Variabilität.
Lassen Sie uns klarstellen! Die Klasse der Trinker hat ebenfalls eine einzige Verantwortung – sie trinken! Und überhaupt ist der Begriff „Verantwortung“ äußerst vage. Manche sind verantwortlich für das Schicksal der Menschheit, andere dafür, gefallene Pinguine am Pol wieder aufzuheben.
Betrachten wir zwei Implementierungen des Trinkers. Die erste, oben erwähnt, umfasst drei Klassen – einschenken, trinken und snacken.
Die zweite, geschrieben nach der Methodologie „Vorwärts und nur vorwärts“, enthält die gesamte Logik in der Methode Act:
//Не тратьте время на изучение этого класса. Лучше съешьте печеньку
сlass BrutTippler {
//...
void Act(){
// наливаем
if(!_hand.TryDischarge(from:_bottle, to:_glass, size:_glass.Capacity))
throw new OverdrunkException();
// выпиваем
if(!_hand.TryDrink(from: _glass, size: _glass.Capacity))
throw new OverdrunkException();
//Закусываем
for(int i = 0; i< 3; i++){
var food = _foodStore.TakeOrDefault();
if(food==null)
throw new FoodIsOverException();
_hand.TryEat(food);
}
}
}Beide Klassen erscheinen aus der Sicht eines Außenstehenden absolut identisch und erfüllen die einzige Verantwortung „trinken“.
Peinlich!
Dann gehen wir ins Internet und erfahren eine andere Definition des SRP – Prinzip der Einheitlichen Änderbarkeit (Single Changeability Principle).
SCP besagt, dass „ein Modul hat nur einen einzigen Grund zur Änderung“. Das bedeutet „Verantwortung ist der Grund für eine Änderung“.
(Es scheint, als wären die Typen, die die ursprüngliche Definition erfunden haben, von den telepathischen Fähigkeiten einer Menschenaffen überzeugt gewesen.)
Jetzt wird alles klar. Die einzelnen Abläufe für Eingießen, Trinken und Essen können separat geändert werden, während wir im eigentlichen Trinkprozess nur die Reihenfolge und den Inhalt der Schritte ändern können, beispielsweise indem wir den Snack vor dem Trinken platzieren oder das Lesen eines Trinkspruches hinzufügen.
Im Ansatz „Vorwärts und nur vorwärts“ kann alles, was geändert werden kann, nur in der Methode geändert werden. Act. Dies kann lesbar und effizient sein, wenn die Logik einfach ist und selten geändert wird, aber oft endet es in schrecklichen Methoden mit 500 Zeilen, mit mehr if-Bedingungen, als für den Beitritt Russlands zur NATO erforderlich sind.
Definition 3. Lokalisierung von Änderungen.
Trinker verstehen oft nicht, warum sie in einer fremden Wohnung aufgewacht sind oder wo ihr Handy ist. Es ist an der Zeit, detailliertes Logging hinzuzufügen.
Lassen Sie uns das Logging mit dem Prozess des Eingießens beginnen:
class PourOperation: IOperation{
PourOperation(ILogger log /*....*/){/*...*/}
//...
void Do(){
_log.Log($"Vor dem Eingießen mit {_hand} und {_bottle}");
//Eingieß-Logik ...
_log.Log($"Nach dem Eingießen mit {_hand} und {_bottle}");
}
}indem wir es in PourOperation, wir haben in Bezug auf Verantwortung und Kapselung weise gehandelt, aber nun haben wir ein Dilemma mit dem Prinzip der Veränderlichkeit. Neben der eigentlichen Operation, die variabel ist, wird auch das Logging selbst veränderlich. Wir müssen es aufteilen und einen speziellen Logger für die Pour-Operation erstellen:
interface IPourLogger{
void LogBefore(IHand, IBottle){}
void LogAfter(IHand, IBottle){}
void OnError(IHand, IBottle, Exception){}
}
class PourOperation: IOperation{
PourOperation(IPourLogger log /*....*/){/*...*/}
//...
void Do(){
_log.LogBefore(_hand, _bottle);
try{
//... Business-Logik
_log.LogAfter(_hand, _bottle);
}
catch(Exception e){
_log.OnError(_hand, _bottle, e);
}
}
}Ein detailgetreuer Leser wird bemerken, dass LogAfter, LogBefore und OnError auch einzeln variabel sein können, und analog zu den vorherigen Aktionen werden drei Klassen erstellt: PourLoggerBefore, PourLoggerAfter und PourErrorLogger.
Und da wir uns daran erinnern, dass es für den Trinker drei Operationen gibt, erhalten wir neun Logging-Klassen. Insgesamt besteht der gesamte Trinker aus 14 (!!!) Klassen.
Hyperbel? Kaum! Ein Mensch-Affe mit einer Dekompktionsgranate wird den „Nalle-Automaten“ in einen Krug, ein Glas, Ausgabetreiber, einen Wasserservice, ein physikalisches Modell der Molekülkollision zerlegen, und im nächsten Quartal wird er versuchen, die Abhängigkeiten ohne globale Variablen zu entwirren. Glauben Sie mir, er wird nicht aufhören.
Genau in diesem Moment kommen viele zu dem Schluss, dass SRP nur Märchen aus pinken Königreichen sind und winden sich ab...
… ohne je von der Existenz der dritten Definition von SRP zu erfahren:
„Das Prinzip der einzelnen Verantwortung besagt, dass Änderungen an ähnlichen Dingen an einem Ort gesammelt werden müssen.„. oder “Das, was zusammen geändert wird, sollte an einem Ort aufbewahrt werden.”
Das heißt, wenn wir die Protokollierung einer Operation ändern, sollten wir dies an einem Ort tun.
Das ist ein sehr wichtiger Punkt – da alle vorherigen Erklärungen zu SRP besagten, dass man Typen so lange zerlegen soll, bis sie zerlegt sind, also eine „Obergrenze“ für die Größe des Objekts auferlegten, sprechen wir jetzt auch von einer „Untergrenze“. Anders ausgedrückt, SRP verlangt nicht nur, dass wir „so lange zerlegen, bis es zerlegt ist“, sondern auch, dass wir es nicht übertreiben – „nicht zusammenhängende Dinge zerlegen“.. Dies ist ein großer Kampf zwischen Ockhams Rasiermesser und dem Mensch-Affe!
Nun sollte es für den Barkeeper einfacher werden. Neben der Tatsache, dass wir den IPourLogger nicht in drei Klassen aufteilen müssen, können wir auch alle Logger in einen Typ zusammenfassen:
class OperationLogger{
public OperationLogger(string operationName){/*..*/}
public void LogBefore(object[] args){/*...*/}
public void LogAfter(object[] args){/*..*/}
public void LogError(object[] args, exception e){/*..*/}
}Wenn uns ein vierter Operationstyp hinzugefügt wird, ist die Protokollierung bereits dafür vorbereitet. Der Code der Operationen ist sauber und frei von infrastrukturellem Rauschen.
Insgesamt haben wir 5 Klassen zur Lösung der Getränkewirtschaft:
- Einschenken-Operation
- Trink-Operation
- Snack-Operation
- Logger
- Fassade des Barkeeper
Jede dieser Klassen ist strikt für eine Funktionalität verantwortlich und hat einen einzigen Änderungsgrund. Alle ähnlichen Regeln zur Änderung liegen nah beieinander.
Beispiel aus dem echten Leben
Einmal haben wir einen Service zur automatischen Registrierung von B2B-Kunden geschrieben. Und es gab eine GOD-Methode mit 200 Zeilen ähnlichen Inhalts:
- Gehe zu 1C und lege ein Konto an
- Mit diesem Konto gehe zum Zahlungsmodul und registriere es dort
- Prüfe, ob ein Konto mit diesem Konto im Hauptsystem nicht vorhanden ist Server
- Erstelle ein neues Konto
- Ergebnis der Registrierung im Zahlungsmodul und Nummer 1c, füge in den Registrierungsresultate-Service hinzu
- Füge diese Informationen zum Konto in die Tabelle ein
- Erstelle eine Punktnummer für diesen Kunden im Punktedienst. Übermittle die 1c Rechnungsnummer an diesen Dienst.
Zusätzlich gab es in dieser Liste etwa 10 weitere Geschäftsoperationen mit einer schrecklichen Verknüpfung. Der Kontenobjekt wurde fast überall benötigt. Die Punktkennung und der Name des Kunden waren in der Hälfte der Aufrufe erforderlich.
Nach einer Stunde Refactoring konnten wir den infrastrukturellen Code und einige Details zur Kontoarbeit in separate Methoden/Klassen ausgliedern. Die God-Methode wurde vereinfach, aber es blieben 100 Codezeilen, die sich nicht auflösen lassen wollten.
Erst nach einigen Tagen wurde klar, dass der Kern dieser 'vereinfacht' Methode — der Geschäftsalgorithmus ist. Und dass die ursprüngliche Beschreibung des Lastenheftes ziemlich komplex war. Der Versuch, diese Methode in Teile zu zerlegen, wäre ein Verstoß gegen das SRP, und nicht umgekehrt.
Formalisierung.
Es ist Zeit, unseren Trinker in Ruhe zu lassen. Wische deine Tränen weg — wir werden irgendwann zu ihm zurückkehren. Aber jetzt formalisieren wir das Wissen aus diesem Artikel.
Formalisierung 1. Definition des SRP
- Trennen Sie Elemente so, dass jedes von ihnen für etwas Bestimmtes verantwortlich ist.
- Verantwortung wird als „Grund für eine Änderung“ verstanden. Das heißt, jedes Element sollte nur einen alleinigen Grund für eine Änderung haben, im Sinne der Geschäftslogik.
- Potenzielle Änderungen der Geschäftslogik müssen lokalisiert werden. Elemente, die synchron geändert werden, sollten nebeneinander liegen.
Formalismus 2. Notwendige Kriterien zur Selbstüberprüfung.
Mir sind nicht genügend Kriterien für die Umsetzung des SRP begegnet. Aber es gibt notwendige Bedingungen:
1) Stellen Sie sich die Frage – was macht diese Klasse/Methode/Modul/Dienst? Sie müssen darauf mit einer einfachen Definition antworten können. (Danke) )
Erklärungen
Manchmal ist es jedoch sehr schwierig, eine einfache Definition zu finden.
2) Das Beheben eines Bugs oder das Hinzufügen einer neuen Funktion betrifft die minimal mögliche Anzahl an Dateien/Klassen. Idealerweise – eine.
Erklärungen
Da die Verantwortung (für die Funktion oder den Bug) in einer einzigen Datei/Klasse gekapselt ist, wissen Sie genau, wo Sie suchen und was Sie ändern müssen. Zum Beispiel: Das Feature zur Änderung der Ausgabe der Protokollierung von Vorgängen erfordert nur Änderungen am Logger. Es ist nicht notwendig, den gesamten restlichen Code zu durchforsten.
Ein weiteres Beispiel ist die Hinzufügung eines neuen UI-Elements, das den vorherigen ähnelt. Wenn das dazu führt, dass Sie 10 verschiedene Entitäten und 15 unterschiedliche Konverter hinzufügen müssen, scheint es, als hätten Sie "überkompliziert".
3) Wenn mehrere Entwickler an verschiedenen Funktionen Ihres Projekts arbeiten, ist die Wahrscheinlichkeit für Merge-Konflikte, also dass dieselbe Datei/Klasse von mehreren Entwicklern gleichzeitig verändert wird, minimal.
Erklärungen
Wenn Sie bei der Hinzufügung einer neuen Aktion "Wodka unter den Tisch gießen" den Logger, die Trink- und Gießoperation ansprechen müssen, deutet das darauf hin, dass die Verantwortlichkeiten schlecht verteilt sind. Sicherlich ist das nicht immer möglich, aber man sollte versuchen, diesen Wert zu verringern.
4) Bei einer klärenden Frage zur Geschäftslogik (vom Entwickler oder Manager) beziehen Sie sich streng auf eine Datei/Klasse und erhalten Informationen nur von dort.
Erklärungen
Funktionen, Regeln oder Algorithmen sind kompakt verfasst, jeweils an einem Ort, anstatt durch Flaggen im gesamten Code verstreut zu sein.
5) Die Benennung ist verständlich.
Erklärungen
Unsere Klasse oder Methode ist für eine bestimmte Aufgabe verantwortlich, und diese Verantwortung spiegelt sich im Namen wider.
AllManagersManagerService ist höchstwahrscheinlich ein God-Klasse.
LocalPayment — wahrscheinlich nicht verfügbar
Formalismus 3. Methode der Entwicklung ‚Ockham-first‘.
Zu Beginn des Designs weiß der Mensch-Affe nicht um die Feinheiten der zu lösenden Aufgabe und kann Fehler machen. Fehler können auf verschiedene Weise passieren:
- Zu große Objekte erstellen, indem verschiedene Verantwortlichkeiten zusammengefügt werden
- Zerlegen, indem eine einzige Verantwortung in viele verschiedene Typen aufgeteilt wird
- Verantwortungsgrenzen falsch definieren
Es ist wichtig, sich an die Regel zu erinnern: ‚Lieber zu groß als zu klein fehlerhaft‘ oder ‚wenn Sie sich nicht sicher sind — zerlegen Sie nicht‘. Wenn beispielsweise Ihre Klasse zwei Verantwortlichkeiten sammelt, ist sie nach wie vor verständlich, und man kann sie mit minimalen Änderungen am Client-Code in zwei Teile zerlegen. Ein Glas aus Glassplittern zusammenzusetzen, ist in der Regel aufgrund des vermischten Kontexts über mehrere Dateien und der fehlenden notwendigen Abhängigkeiten im Client-Code schwieriger.
Es ist Zeit, zum Ende zu kommen
Der Anwendungsbereich des SRP beschränkt sich nicht nur auf OOP und SOLID. Es ist anwendbar auf Methoden, Funktionen, Klassen, Module, Mikrodienste und Services. Es findet gleichermaßen Anwendung in "figaks-figaks-and-in-prod"- und "rocket-science"-Entwicklungen und verbessert überall die Welt ein wenig. Wenn man darüber nachdenkt, könnte man sagen, es ist fast ein grundlegendes Prinzip der gesamten Ingenieurkunst. Maschinenbau, Steuerungssysteme und überhaupt alle komplexen Systeme werden aus Komponenten erstellt. Das "Nicht-Zerkleinern" beraubt die Konstrukteure ihrer Flexibilität, das "Über-Zerkleinern" der Effizienz und falsche Grenzen gefährden Verstand und innere Ruhe.
SRP ist nicht von der Natur erfunden worden und gehört nicht zur exakten Wissenschaft. Es entsteht aus unseren biologischen und psychologischen Einschränkungen. Es ist lediglich eine Methode, um komplexe Systeme unter Kontrolle zu bringen und zu entwickeln, durch den Verstand des Menschen. Es zeigt uns, wie man ein System dekonstruiert. Die ursprüngliche Formulierung erforderte beträchtliches telepathisches Geschick, aber ich hoffe, dieser Artikel hat etwas von dem Rauchvorhang gelüftet.
Quelle: habr.com
