SOLID Teil 2 - Das offene / geschlossene Prinzip

Single Responsibility (SRP), Offen / Geschlossen (OCP), Liskovs Substitution, Schnittstellentrennung und Abhängigkeitsinversion. Fünf agile Prinzipien, die Sie jedes Mal leiten sollten, wenn Sie Code schreiben müssen.

Definition

Software-Entitäten (Klassen, Module, Funktionen usw.) sollten für die Erweiterung geöffnet, für Änderungen jedoch geschlossen sein.

Das Open / Closed-Prinzip, kurz OCP, ist dem französischen Programmierer Bertrand Mayer zu verdanken, der es 1988 in seinem Buch objektorientierte Softwarekonstruktion veröffentlichte.

Das Prinzip stieg in den frühen 2000er Jahren an Popularität, als es zu einem der SOLID-Prinzipien wurde, die von Robert C. Martin in seinem Buch Agile Software Development, Principles, Patterns und Practices definiert und später in der C # -Version des Buches Agile Principles, Patterns veröffentlicht wurden und Praktiken in C #.

Grundsätzlich geht es hier darum, unsere Module, Klassen und Funktionen so zu gestalten, dass wir, wenn eine neue Funktionalität benötigt wird, unseren vorhandenen Code nicht ändern, sondern neuen Code schreiben müssen, der vom vorhandenen Code verwendet wird. Das klingt ein bisschen seltsam, vor allem wenn wir in Sprachen wie Java, C, C ++ oder C # arbeiten, wo es nicht nur für den Quellcode selbst gilt, sondern auch für die Binärdatei. Wir möchten neue Funktionen auf eine Weise erstellen, bei der vorhandene Binärdateien, ausführbare Dateien oder DLLs nicht erneut bereitgestellt werden müssen.

OCP im SOLID-Kontext

Während wir mit diesen Tutorials fortfahren, können wir jedes neue Prinzip in den Kontext der bereits diskutierten bringen. Wir haben bereits die einheitliche Verantwortung (Single Responsibility, SRP) besprochen, die besagte, dass ein Modul nur einen Grund haben sollte, um es zu ändern. Wenn wir über OCP und SRP nachdenken, können wir feststellen, dass sie sich ergänzen. Code, der speziell für SRP entwickelt wurde, entspricht den OCP-Prinzipien oder kann leicht erreicht werden. Wenn wir Code haben, der einen einzigen Grund für die Änderung hat, führt die Einführung einer neuen Funktion zu einem sekundären Grund für diese Änderung. So würden sowohl SRP als auch OCP verletzt. Wenn wir über Code verfügen, der sich nur ändern sollte, wenn sich seine Hauptfunktion ändert, und wenn ein neues Feature hinzugefügt wird, also unverändert bleiben soll, wird OP in der Regel auch respektiert.

Dies bedeutet nicht, dass SRP immer zu OCP oder umgekehrt führt, aber wenn einer von ihnen respektiert wird, ist der zweite durchaus recht einfach.

Das offensichtliche Beispiel einer OCP-Verletzung

Aus rein technischer Sicht ist das Open / Closed-Prinzip sehr einfach. Eine einfache Beziehung zwischen zwei Klassen, wie die unten stehende, verstößt gegen die OCP.


Das Nutzer Klasse verwendet die Logik Klasse direkt. Wenn wir eine Sekunde implementieren müssen Logik Klasse in einer Weise, die es uns ermöglicht, sowohl die aktuelle als auch die neue, die vorhandene zu verwenden Logik Klasse muss geändert werden. Nutzer ist direkt an die Umsetzung von gebunden Logik, Es gibt keine Möglichkeit für uns, eine neue bereitzustellen Logik ohne das aktuelle zu beeinflussen. Und wenn wir über statisch typisierte Sprachen sprechen, ist es sehr wahrscheinlich, dass die Nutzer Klasse erfordert auch Änderungen. Wenn es sich um kompilierte Sprachen handelt, sind dies sicherlich die beiden Nutzer ausführbar und die Logik Für eine ausführbare oder dynamische Bibliothek müssen unsere Kunden neu kompiliert und neu eingesetzt werden. Dies ist ein Prozess, den wir möglichst vermeiden möchten.

Zeigen Sie mir den Code

Nur auf der Grundlage des obigen Schemas kann abgeleitet werden, dass jede Klasse, die eine andere Klasse direkt verwendet, tatsächlich das Open / Closed-Prinzip verletzt. Und genau das ist richtig. Ich fand es ziemlich interessant, die Grenzen zu finden, den Moment, in dem Sie die Grenze ziehen und entscheiden, dass es schwieriger ist, OCP zu respektieren, als den vorhandenen Code zu ändern. Andernfalls rechtfertigen die architektonischen Kosten nicht die Kosten für die Änderung des vorhandenen Codes.

Angenommen, wir möchten eine Klasse schreiben, die einen Fortschritt für eine Datei bereitstellt, die über unsere Anwendung heruntergeladen wird. Wir werden zwei Hauptklassen haben, a Fortschritt und ein Datei, und ich kann mir vorstellen, dass wir sie wie im Test unten verwenden wollen.

function testItCanGetTheProgressOfAFileAsAPercent () $ file = new File (); $ file-> length = 200; $ file-> sent = 100; $ progress = neuer Fortschritt ($ file); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

In diesem Test sind wir ein Benutzer von Fortschritt. Wir möchten unabhängig von der tatsächlichen Dateigröße einen Prozentwert erhalten. Wir gebrauchen Datei als Informationsquelle für unsere Fortschritt. Eine Datei hat eine Länge in Bytes und ein Feld namens geschickt repräsentiert die Menge der Daten, die an den Download gesendet werden. Es ist uns egal, wie diese Werte in der Anwendung aktualisiert werden. Wir können davon ausgehen, dass es eine magische Logik für uns gibt, also können wir sie in einem Test explizit setzen.

Klasse Datei public $ length; public $ gesendet; 

Das Datei Klasse ist nur ein einfaches Datenobjekt, das die beiden Felder enthält. Natürlich würde es im realen Leben wahrscheinlich auch andere Informationen und Verhalten enthalten, wie Dateiname, Pfad, relativer Pfad, aktuelles Verzeichnis, Typ, Berechtigungen, usw..

Klasse Fortschritt private $ file; Funktion __construct (Datei $ file) $ this-> file = $ file;  function getAsPercent () return $ this-> file-> gesendet * 100 / $ this-> file-> length; 

Fortschritt ist einfach eine Klasse, die ein Datei in seinem Konstruktor. Der Klarheit halber haben wir den Typ der Variablen in den Konstruktorparametern angegeben. Es gibt eine einzige nützliche Methode Fortschritt, getAsPercent (), was die gesendeten Werte und die Länge übernimmt Datei und verwandeln sie in ein Prozent. Einfach und es funktioniert.

Der Test begann um 17:39 Uhr… PHPUnit 3.7.28 von Sebastian Bergmann… Zeit: 15 ms, Speicher: 2,50 MB, OK (1 Test, 1 Assertion)

Dieser Code scheint richtig zu sein, verstößt jedoch gegen das Open / Closed-Prinzip. Aber warum? Und wie?

Anforderungen ändern

Jede Anwendung, von der erwartet wird, dass sie sich rechtzeitig entwickelt, benötigt neue Funktionen. Eine neue Funktion für unsere Anwendung könnte darin bestehen, Musik zu streamen, anstatt nur Dateien herunterzuladen. DateiDie Länge wird in Bytes angegeben, die Dauer der Musik in Sekunden. Wir möchten unseren Zuhörern einen schönen Fortschrittsbalken bieten, aber können wir den bereits vorhandenen verwenden?

Nein Wir können nicht. Unser Fortschritt ist an gebunden Datei. Es versteht nur Dateien, obwohl es auch auf Musikinhalte angewendet werden kann. Aber um dies zu tun, müssen wir es ändern Fortschritt wissen über Musik und Datei. Wenn unser Design OCP respektieren würde, müssten wir nicht anfassen Datei oder Fortschritt. Wir könnten das vorhandene einfach wieder verwenden Fortschritt und wenden Sie es an Musik.

Lösung 1: Nutzen Sie die dynamische Natur von PHP

Dynamisch typisierte Sprachen haben den Vorteil, dass sie die Objekttypen zur Laufzeit erraten. Dies erlaubt uns, den Typus von zu entfernen Fortschritt'Konstruktor und der Code wird weiterhin funktionieren.

Klasse Fortschritt private $ file; Funktion __construct ($ file) $ this-> file = $ file;  function getAsPercent () return $ this-> file-> gesendet * 100 / $ this-> file-> length; 

Jetzt können wir alles anwerfen Fortschritt. Und mit irgendetwas meine ich wörtlich alles:

Klasse Musik public $ length; public $ gesendet; öffentlicher $ artist; öffentliches $ album; public $ releaseDate; function getAlbumCoverFile () return 'Bilder / Abdeckungen /'. $ this-> künstler. '/'. $ this-> album. '.png'; 

Und ein Musik Klasse wie die oben genannte wird gut funktionieren. Wir können es leicht mit einem sehr ähnlichen Test testen Datei.

Funktion testItCanGetTheProgressOfAMusicStreamAsAPercent () $ music = new Music (); $ music-> length = 200; $ music-> sent = 100; $ progress = neuer Fortschritt ($ music); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Grundsätzlich kann jeder messbare Inhalt mit der verwendet werden Fortschritt Klasse. Vielleicht sollten wir dies im Code ausdrücken, indem auch der Name der Variablen geändert wird:

Klasse Fortschritt private $ messbareInhalt; Funktion __construct ($ messbarer Inhalt) $ this-> messbarer Inhalt = $ messbarer Inhalt;  function getAsPercent () return $ this-> messbarerInhalt-> gesendet * 100 / $ this-> messbarerInhalt-> Länge; 

Gut, aber wir haben ein riesiges Problem mit diesem Ansatz. Wann hatten wir Datei Als typisiert bezeichnet, waren wir uns sicher, was unsere Klasse bewältigen kann. Es war eindeutig und wenn etwas anderes kam, sagte uns ein netter Fehler.

Argument 1, das an Progress :: __ construct () übergeben wird, muss eine Instanz von File sein, Instanz von Music.

Aber ohne den Tippfehler müssen wir uns auf die Tatsache verlassen, dass das, was hereinkommt, zwei öffentliche Variablen mit genauen Namen wie "Länge" und "geschickt". Sonst haben wir ein abgelehntes Vermächtnis.

Abgelehntes Vermächtnis: Eine Klasse, die eine Methode einer Basisklasse derart überschreibt, dass der Vertrag der Basisklasse nicht von der abgeleiteten Klasse berücksichtigt wird. ~ Quelle Wikipedia.

Dies ist einer der Code riecht im Premium-Kurs Detecting Code Smells ausführlicher dargestellt. Kurz gesagt, wir möchten am Ende nicht versuchen, Methoden oder Felder auf Objekte aufzurufen, die nicht unserem Vertrag entsprechen. Wenn wir einen Tippfehler hatten, wurde der Vertrag damit festgelegt. Die Felder und Methoden der Datei Klasse. Jetzt, da wir nichts haben, können wir etwas einschicken, sogar eine Zeichenfolge, und dies würde zu einem hässlichen Fehler führen.

function testItFailsWithAParameterThatDoesNotRespectTheImplicitContract () $ progress = new Progress ('some string'); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Ein Test wie dieser, bei dem wir eine einfache Zeichenfolge senden, erzeugt ein abgelehntes Vermächtnis:

Der Versuch, die Eigenschaft eines Nichtobjekts abzurufen.

Während das Endergebnis in beiden Fällen dasselbe ist, was bedeutet, dass der Code bricht, erzeugte das erste eine nette Nachricht. Dieser ist jedoch sehr dunkel. Es gibt keine Möglichkeit zu wissen, was die Variable ist - in unserem Fall eine Zeichenfolge - und welche Eigenschaften gesucht und nicht gefunden wurden. Es ist schwierig zu debuggen und das Problem zu lösen. Ein Programmierer muss das öffnen Fortschritt Unterricht und Lesen und Verstehen. Der Vertrag wird in diesem Fall, wenn wir den Typus nicht explizit angeben, durch das Verhalten von definiert Fortschritt. Es ist ein impliziter Vertrag, der nur bekannt ist Fortschritt. In unserem Beispiel wird es durch den Zugriff auf die beiden Felder definiert, geschickt und Länge, in dem getAsPercent () Methode. Im wirklichen Leben kann der implizite Vertrag sehr komplex und schwer zu entdecken sein, wenn er nur einige Sekunden in der Klasse sucht.

Diese Lösung wird nur empfohlen, wenn keine der anderen unten aufgeführten Vorschläge leicht implementiert werden kann oder wenn sie gravierende architektonische Änderungen nach sich ziehen würde, die den Aufwand nicht rechtfertigen.

Lösung 2: Verwenden Sie das Strategieentwurfsmuster

Dies ist die gebräuchlichste und wahrscheinlich am besten geeignete Lösung, um OCP zu respektieren. Es ist einfach und effektiv.


Das Strategiemuster führt lediglich die Verwendung einer Schnittstelle ein. Eine Schnittstelle ist eine spezielle Art von Entität in der objektorientierten Programmierung (Object Oriented Programming, OOP), die einen Vertrag zwischen einem Client und einer Serverklasse definiert. Beide Klassen halten den Vertrag ein, um das erwartete Verhalten sicherzustellen. Es kann mehrere, nicht zusammenhängende Serverklassen geben, die denselben Vertrag einhalten und somit dieselbe Clientklasse bedienen können.

interface Measurable function getLength (); Funktion getSent (); 

In einer Schnittstelle können wir nur Verhalten definieren. Deshalb müssen wir, anstatt öffentliche Variablen direkt zu verwenden, über die Verwendung von Gettern und Setters nachdenken. Die Anpassung der anderen Klassen wird an dieser Stelle nicht schwierig sein. Unsere IDE kann die meiste Arbeit machen.

function testItCanGetTheProgressOfAFileAsAPercent () $ file = new File (); $ file-> setLength (200); $ file-> setSent (100); $ progress = neuer Fortschritt ($ file); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Wie üblich fangen wir mit unseren Tests an. Wir müssen Setzer verwenden, um die Werte festzulegen. Wenn dies als verbindlich gilt, können diese Setter auch in der definiert werden Messbar Schnittstelle. Seien Sie jedoch vorsichtig, was Sie dort ablegen. Die Schnittstelle dient dazu, den Vertrag zwischen der Client-Klasse zu definieren Fortschritt und die verschiedenen Serverklassen mögen Datei und Musik. Tut Fortschritt müssen die Werte einstellen? Wahrscheinlich nicht. Es ist daher sehr unwahrscheinlich, dass die Setter in der Schnittstelle definiert werden müssen. Wenn Sie die Setter dort definieren würden, würden Sie außerdem alle Serverklassen zwingen, Setter zu implementieren. Für einige von ihnen kann es logisch sein, Setter zu haben, aber andere können sich völlig anders verhalten. Was ist, wenn wir unsere nutzen möchten Fortschritt Klasse, um die Temperatur unseres Ofens zu zeigen? Das Ofentemperatur class kann mit den Werten im Konstruktor initialisiert werden oder die Informationen von einer dritten Klasse erhalten. Wer weiß? Setter für diese Klasse zu haben, wäre seltsam.

Klasse Datei implementiert messbare private $ length; privat $ gesendet; öffentlicher $ Dateiname; öffentlicher $ Besitzer; Funktion setLength ($ length) $ this-> length = $ length;  function getLength () return $ this-> length;  Funktion setSent ($ sent) $ this-> sent = $ sent;  function getSent () return $ this-> sent;  function getRelativePath () return dirname ($ this-> filename);  function getFullPath () return realpath ($ this-> getRelativePath ()); 

Das Datei Klasse wird leicht modifiziert, um die oben genannten Anforderungen zu erfüllen. Es implementiert jetzt die Messbar Interface und hat Setter und Getter für die Bereiche, die uns interessieren. Musik ist sehr ähnlich, Sie können den Inhalt im angefügten Quellcode überprüfen. Wir sind fast fertig.

Klasse Fortschritt private $ messbareInhalt; Funktion __construct (Messbarer $ messbarer Inhalt) $ this-> messbarer Inhalt = $ messbarer Inhalt;  function getAsPercent () return $ this-> measureableContent-> getSent () * 100 / $ this-> measureableContent-> getLength (); 

Fortschritt brauchte auch ein kleines Update. Wir können jetzt einen Typ im Konstruktor angeben, indem wir typintintinting verwenden. Der erwartete Typ ist Messbar. Jetzt haben wir einen ausdrücklichen Vertrag. Fortschritt Ich kann sicher sein, dass die aufgerufenen Methoden immer vorhanden sind, da sie in der definiert sind Messbar Schnittstelle. Datei und Musik können auch sicher sein, dass sie alles bieten können, was sie brauchen Fortschritt Durch einfaches Implementieren aller Methoden auf der Schnittstelle ist dies eine Voraussetzung, wenn eine Klasse eine Schnittstelle implementiert.

Dieses Designmuster wird im Kurs Agile Design Patterns näher erläutert.

Ein Hinweis zur Benennung der Schnittstelle

Menschen neigen dazu, Schnittstellen mit einem Kapital zu benennen ich vor ihnen oder mit dem Wort "Schnittstelle"am Ende angehängt, wie IFile oder FileInterface. Dies ist eine Notation im alten Stil, die von veralteten Standards vorgegeben wird. Wir sind so weit über die ungarischen Notationen hinaus oder müssen den Typ einer Variablen oder eines Objekts in ihrem Namen angeben, um sie leichter identifizieren zu können. IDEs identifizieren für uns in Sekundenbruchteilen alles. Dies erlaubt uns, uns auf das zu konzentrieren, was wir eigentlich abstrahieren wollen.

Schnittstellen gehören zu ihren Kunden. Ja. Wenn Sie eine Schnittstelle benennen möchten, müssen Sie an den Client denken und die Implementierung vergessen. Als wir unser Interface als Measurable bezeichnet haben, haben wir über Fortschritt nachgedacht. Wenn ich ein Fortschritt wäre, was müsste ich, um den Prozentsatz liefern zu können? Die Antwort ist einfach, etwas, das wir messen können. So der Name messbar.

Ein weiterer Grund ist, dass die Implementierung aus verschiedenen Domänen stammen kann. In unserem Fall gibt es Dateien und Musik. Aber wir können unser gut wiederverwenden Fortschritt in einem Rennsimulator. In diesem Fall wären die gemessenen Klassen Geschwindigkeit, Kraftstoff usw. Schön, nicht wahr??

Lösung 3: Verwenden Sie das Template Method Design Pattern

Das Vorlagenmusterentwurfsmuster ist der Strategie sehr ähnlich, verwendet jedoch anstelle einer Schnittstelle eine abstrakte Klasse. Es wird empfohlen, ein Vorlagenmethode-Muster zu verwenden, wenn wir einen für unsere Anwendung sehr spezifischen Client mit reduzierter Wiederverwendbarkeit haben und wenn die Serverklassen ein gemeinsames Verhalten aufweisen.


Dieses Designmuster wird im Kurs Agile Design Patterns näher erläutert.

Eine übergeordnete Ansicht

Wie wirkt sich das auf unsere Architektur auf hohem Niveau aus??


Wenn das Bild oben die aktuelle Architektur unserer Anwendung darstellt, sollte das Hinzufügen eines neuen Moduls mit fünf neuen Klassen (den blauen) das Design moderat beeinflussen (rote Klasse)..


In den meisten Systemen können Sie bei der Einführung neuer Klassen absolut keine Auswirkungen auf den vorhandenen Code erwarten. Die Beachtung des Open / Closed-Prinzips reduziert jedoch die Klassen und Module, die eine ständige Änderung erfordern, erheblich.

Versuchen Sie, wie bei jedem anderen Prinzip, nicht an alles zuvor zu denken. Wenn Sie dies tun, erhalten Sie für jede Ihrer Klassen eine Schnittstelle. Ein solches Design ist schwer zu pflegen und zu verstehen. In der Regel ist es am sichersten, über die Möglichkeiten nachzudenken und zu bestimmen, ob es andere Arten von Serverklassen gibt. Oft können Sie sich leicht ein neues Feature vorstellen oder Sie finden eines im Backlog des Projekts, das eine andere Serverklasse erzeugt. Fügen Sie in diesen Fällen die Schnittstelle von Anfang an hinzu. Wenn Sie nicht feststellen können, oder wenn Sie sich unsicher sind, lassen Sie es meistens weg. Lassen Sie den nächsten Programmierer oder vielleicht auch sich selbst die Schnittstelle hinzufügen, wenn Sie eine zweite Implementierung benötigen.

Abschließende Gedanken

Wenn Sie Ihrer Disziplin folgen und Schnittstellen hinzufügen, sobald ein zweiter Server benötigt wird, sind nur wenige Änderungen möglich. Denken Sie daran, wenn der Code einmal geändert werden muss, besteht eine hohe Wahrscheinlichkeit, dass er erneut geändert werden muss. Wenn diese Möglichkeit Wirklichkeit wird, sparen Sie mit OCP viel Zeit und Mühe.

Danke fürs Lesen.