Vor kurzem habe ich Onkel Bobs „Clean Architecture: A Craftsman’s Guide to Software Structure and Design“ gelesen. Und es gibt ein Kapitel über das Prinzip der einzigen Verantwortung (also das S in SOLID), das er mit folgender Aussage beginnt:
Das Prinzip der einheitlichen Verantwortung (Single Responsibility Principle, SRP) ist möglicherweise am wenigsten verstanden
Zu sagen, dass ich sehr fasziniert war, wäre eine Untertreibung.
Wie ist es überhaupt möglich, dass die Menschen ein so einfaches Prinzip nicht verstehen? Also las ich weiter und erkannte, dass ich einer dieser Menschen bin!
Nach dem ersten Schock googelte ich „SOLID-Prinzipien“ und öffnete jeden einzelnen Artikel, den ich auf der ersten und zweiten Seite der Google-Ergebnisse finden konnte.
Ich wäre noch weiter gegangen, aber auf der zweiten Seite war bereits klar, dass die Qualität der Artikel rapide abnahm und einige nur Kopien von anderen waren.
In keinem einzigen Artikel wurde es richtig erklärt. Alle wiederholen immer wieder das gleiche Mantra:
Eine Klasse sollte nur eines tun
Und weißt du was? Das ist falsch.
Werfen wir einen Blick darauf, was die meisten von uns unter dem Single Responsibility Principle (SRP, das S in SOLID also) verstehen und was es tatsächlich bedeutet.
Grundsatz der Einheitsverantwortung
Zunächst möchte ich die Definition rekapitulieren:
Es sollte nie mehr als einen Grund geben, eine Klasse zu ändern.
Diese Aussage ist schon vage genug. Was ist der Grund, woher kommt es?
Wenn wir einen Kurs entwerfen, wie verstehen wir den Grund?
Diese Definition kann kaum direkt verwendet werden; Es bedarf der Interpretation.
Wie ich bereits erwähnt habe, ist die häufigste (und irreführende) Interpretation, dass „eine Klasse nur einen Zweck haben sollte“.
Der Zweck ist eine weitere abstrassierende Sache, nicht weit entfernt von der ursprünglichen „Vernunft“. Dies erzwingt die Anwendung einer weiteren Iteration, bei der wir normalerweise Folgendes erhalten:
Eine Klasse sollte nur eines tun
In Wirklichkeit ist dies jedoch etwas, das wir nicht erreichen wollen/müssen, weil es praktisch keinen Sinn ergibt.
Ich stimme zu, dass es richtig wäre zu sagen: „Eine Funktion sollte eine Sache tun“. Für eine Funktion oder eine Methode – das ist eine wirklich gute Regel, die man befolgen sollte. Aber wenn wir über den Unterricht sprechen – die Dinge sind etwas komplizierter…
Betrachten wir ein einfaches Beispiel:
class Dog {
public name: string;
constructor(name) {
this.name = name;
}
public run(): void {
// do something
}
public eat(): void {
// do something
}
}
Diese Klasse macht schon andere Dinge, oder? Laufen und Essen können nicht als derselbe Zweck angesehen werden. Er rennt, um seinen Körper von einem Ort zum anderen zu bewegen, und er isst, um Energie zu gewinnen und dem Hungertod zu entgehen.
Wenn wir dem populären Verständnis von SRP folgen, sollten wir es wie folgt umgestalten:
class Dog {
public name: string;
}
class DogCreator {
public name: string;
constructor(name) {
this.name = name;
}
public createDog(): Dog {
const dog = new Dog();
dog.name = this.name;
return dog;
}
}
class DogRunner {
public run(): void {
// do something
}
}
class DogEater {
public eat(): void {
// do something
}
}
Das macht keinen Sinn. Objektorientierte Programmierung geht davon aus, dass wir Objekte erstellen können, die ungefähre Modelle realer Entitäten sind. Wenn ein echter Hund rennen, fressen und beißen kann, sollte die Hundeklasse nicht darauf beschränkt sein, dasselbe zu tun.
Das Lustige daran ist, dass zur Veranschaulichung des SRP-Prinzips tatsächlich jeder Beispiele wie das obige verwendet, mit einem recht ähnlichen Refactoring-Ansatz.
Ihre Beispiele mögen zwar praktisch erscheinen, aber das liegt nur daran, dass sie uns austricksen, indem sie an erster Stelle extrem schlecht gestaltete Kurse präsentieren, die zum Beispiel Mitarbeitergehälter mit Auftragsabwicklung vermischen.
Dann führen sie ein wirklich nützliches Refactoring durch, indem sie diese Bedenken in verschiedene Klassen unterteilen.
Wenn Sie den Quellcode Ihrer Anwendung untersuchen, verstoßen die meisten Klassen wahrscheinlich gegen das allgemeine Verständnis von SRP. Sie verfügen über mehrere Methoden, die unterschiedliche Funktionen erfüllen – warum sollten zwei Methoden in derselben Klasse genau das Gleiche tun?
Aber warum machen wir das? Wenn die Regel so einfach ist, warum brechen wir sie dann immer wieder?
Werfen wir einen Blick auf die von Onkel Bob vorgeschlagene Definition für S in SOLID:
Ein Modul sollte nur für einen Akteur verantwortlich sein.
Ich denke, wir werden keinen Fehler machen, wenn wir hier module durch class ersetzen, da die Unterscheidung für den Zweck dieses Prinzips irrelevant ist.
Wenn wir diese Definition mit der vorherigen vergleichen – „Klasse sollte für einen Zweck verantwortlich sein“. – werden Sie einen entscheidenden Unterschied bemerken: Es ist nicht „für einen Zweck„, sondern „für einen Akteur„.
Um beim Thema Tiere zu bleiben, nehmen wir an, wir möchten einen einfachen Übersetzer zwischen Tier- und Menschensprache erstellen:
class AnimalToHumanTranslator {
private animalType: string
constructor(animalType) {
this.animalType = animalType
}
public translate(text: string): string {
if (this.animalType === 'dog') {
switch (text) {
case 'Woof-Woof':
return 'play with me'
default:
return 'unknown dog sound';
}
} else if (this.animalType === 'cat') {
switch (text) {
case 'Meow':
return 'I am hungry';
default:
return 'unknown cat sound';
}
}
return 'Unknown animal type';
}
}
Die Klasse tut eine einzige Sache, sie dient nur einem Zweck, sie hat sogar eine einzige Methode, die ebenfalls nur einem Zweck dient. Und doch verstößt es gegen die SRP.AnimalToHumanTranslator
Die Probleme liegen auf der Hand:
- Wenn Wissenschaftler einen neuen Weg finden, die Katzensprache zu übersetzen, müssen wir die Klasse wechseln, die auch für die Übersetzung der Hundesprache verantwortlich ist.
- Wenn sich beide Sprachen schnell weiterentwickeln, besteht eine höhere Wahrscheinlichkeit, dass sich gleichzeitig Änderungen vornehmen, die zu einer Verschmelzung führen – eine weitere Ursache für Probleme in der Softwareentwicklung.
Versuchen wir, das Problem zu beheben.
Ich gehe davon aus, dass das Problem an dieser Stelle gut definiert ist – wir möchten nicht, dass eine einzelne Klasse sowohl Katzen- als auch Hundeübersetzungen verarbeitet. Und auch die Richtung für das Refactoring ist klar – wir brauchen für jedes Tier eine eigene Klasse:
class DogToHumanTranslator {
constructor() {}
public translate(text: string): string {
switch (text) {
case 'Woof-Woof':
return 'play with me'
default:
return 'unknown dog sound';
}
}
}
class CatToHumanTranslator {
constructor() {}
public translate(text: string): string {
switch (text) {
case 'Meow':
return 'I am hungry';
default:
return 'unknown cat sound';
}
}
}
Nun, und sind separate Klassen, die jeweils ihrem eigenen Akteur dienen: DogToHumanTranslator und CatToHumanTranslator
Natürlich haben wir derzeit zwei Klassen anstelle von einer, daher benötigen wir eine Möglichkeit, um zu verwalten, welches Objekt erstellt werden soll. Das ist kein großes Problem. Wir können es lösen, und die Lösung dafür könnte ein Factory Method Pattern sein:
class AnimalToHumanTranslatorFactory {
static createTranslator(animalType: string): HumanToDogTranslator | HumanToCatTranslator | null {
switch (animalType.toLowerCase()) {
case 'dog':
return new DogToHumanTranslator();
case 'cat':
return new CatToHumanTranslator();
default:
console.log('Unsupported animal type');
return null; // Handle unsupported animals
}
}
}
// Usage Example
const dogTranslator = AnimalToHumanTranslatorFactory.createTranslator('dog');
if (dogTranslator) {
console.log(dogTranslator.translate('Woof-Woof')); // Outputs: play with me
}
const catTranslator = AnimalToHumanTranslatorFactory.createTranslator('cat');
if (catTranslator) {
console.log(catTranslator.translate('Meow')); // Outputs: I am hungry
}
Am Ende hatten wir ungefähr die gleiche Benutzerfreundlichkeit wie zuvor – man übergibt einfach „Hund“ oder „Katze“ und erhält das richtige Übersetzerobjekt.
Fazit
Es ist schockierend, wie leicht wir ein falsches Gefühl des Verständnisses gewinnen, wenn der Name oder die Definition eines Konzepts wie das S in SOLID so einfach und unkompliziert erscheint, dass wir nicht einmal in Betracht ziehen, tiefer zu graben.