Hallo Habr! Hier ist eine Übersetzung eines Artikels von Robert Martin über das Open-Closed-Prinzip , den er im Januar 1996 veröffentlichte. Der Artikel ist, gelinde gesagt, nicht der neueste. Aber in RuNet werden Onkel Bobs Artikel über SOLID nur in abgeschnittener Form nacherzählt, sodass ich dachte, dass eine vollständige Übersetzung nicht überflüssig wäre.

Ich habe mich entschlossen, mit dem Buchstaben O zu beginnen, da das Prinzip der Offenheitsschließung tatsächlich von zentraler Bedeutung ist. Unter anderem gibt es viele wichtige Feinheiten, die es wert sind, beachtet zu werden:
- Kein Programm kann zu 100% "geschlossen" werden.
- Objektorientierte Programmierung (OOP) arbeitet nicht mit physischen Objekten der realen Welt, sondern mit Konzepten - zum Beispiel dem Konzept der "Ordnung".
Dies ist der erste Artikel in meiner Spalte " Engineer Notes" für den C ++ - Bericht . Die in dieser Spalte veröffentlichten Artikel konzentrieren sich auf die Verwendung von C ++ und OOP und gehen auf die Schwierigkeiten bei der Softwareentwicklung ein. Ich werde versuchen, die Materialien für praktizierende Ingenieure pragmatisch und nützlich zu machen. Zur Dokumentation des objektorientierten Designs in diesen Artikeln werde ich die Buch-Notation verwenden.
Mit der objektorientierten Programmierung sind viele Heuristiken verbunden. Beispiel: "Alle Mitgliedsvariablen müssen privat sein" oder "Globale Variablen sollten vermieden werden" oder "Typbestimmung zur Laufzeit ist gefährlich". Was ist der Grund für solche Heuristiken? Warum sind sie wahr? Sind sie immer wahr? In dieser Spalte wird das diesen Heuristiken zugrunde liegende Entwurfsprinzip untersucht - das Prinzip des Offenheitsverschlusses.
Ivar Jacobson sagte: „Alle Systeme ändern sich während des Lebenszyklus. Dies muss beim Entwurf eines Systems berücksichtigt werden, für das mehr als eine Version erwartet wird. “ Wie können wir ein System so gestalten, dass es angesichts von Veränderungen stabil ist und mehr als eine Version erwartet wird? Bertrand Meyer erzählte uns davon bereits 1988, als das mittlerweile berühmte Prinzip der Offenheit und Nähe formuliert wurde:
Programmentitäten (Klassen, Module, Funktionen usw.) müssen zur Erweiterung geöffnet und für Änderungen geschlossen sein.
Wenn eine Änderung im Programm eine Kaskade von Änderungen in den abhängigen Modulen zur Folge hat, zeigt das Programm unerwünschte Anzeichen eines „schlechten“ Designs an.
Das Programm wird zerbrechlich, unflexibel, unvorhersehbar und unbenutzt. Das Prinzip der Offenheit und Nähe löst diese Probleme auf sehr einfache Weise. Er sagt, dass es notwendig ist, Module zu entwerfen, die sich nie ändern . Wenn sich die Anforderungen ändern, müssen Sie das Verhalten solcher Module erweitern, indem Sie neuen Code hinzufügen, anstatt den alten, bereits funktionierenden Code zu ändern.
Beschreibung
Module, die das Prinzip der Offenheit und Nähe erfüllen, weisen zwei Hauptmerkmale auf:
- Zur Erweiterung öffnen. Dies bedeutet, dass das Verhalten des Moduls erweitert werden kann. Das heißt, wir können dem Modul ein neues Verhalten hinzufügen, das den sich ändernden Anforderungen für die Anwendung entspricht oder den Anforderungen neuer Anwendungen entspricht.
- Wegen Änderung geschlossen. Der Quellcode eines solchen Moduls ist unantastbar. Niemand hat das Recht, Änderungen daran vorzunehmen.
Es scheint, dass diese beiden Zeichen nicht zusammenpassen. Die Standardmethode zum Erweitern des Verhaltens eines Moduls besteht darin, Änderungen daran vorzunehmen. Ein Modul, das nicht geändert werden kann, wird normalerweise als Modul mit festem Verhalten betrachtet. Wie können diese beiden entgegengesetzten Bedingungen erfüllt werden?
Der Schlüssel zur Lösung ist die Abstraktion.
In C ++ ist es unter Verwendung der Prinzipien des objektorientierten Entwurfs möglich, feste Abstraktionen zu erstellen, die eine unbegrenzte Anzahl möglicher Verhaltensweisen darstellen können.
Abstraktionen sind abstrakte Basisklassen, und eine unbegrenzte Anzahl möglicher Verhaltensweisen wird durch alle möglichen Nachfolgeklassen dargestellt. Ein Modul kann die Abstraktion manipulieren. Ein solches Modul ist wegen Änderungen geschlossen, da es von einer festen Abstraktion abhängt. Das Verhalten des Moduls kann auch erweitert werden, indem neue Nachkommen der Abstraktion erstellt werden.
Das folgende Diagramm zeigt eine einfache Entwurfsoption, die dem Prinzip der Offenheit und Nähe nicht entspricht. Beide Klassen, Client
und Server
, sind nicht abstrakt. Es gibt keine Garantie dafür, dass Funktionen, die Mitglieder der Server
sind, virtuell sind. Die Client
Klasse verwendet die Server
Klasse. Wenn das Client
Klassenobjekt ein anderes Serverobjekt verwenden soll, müssen wir die Client
Klasse so ändern, dass sie auf die neue Serverklasse verweist.

Geschlossener Client
Das folgende Diagramm zeigt die entsprechende Gestaltungsmöglichkeit, die dem Prinzip der Offenheit-Nähe entspricht. In diesem Fall ist die AbstractServer
Klasse eine abstrakte Klasse, deren Elementfunktionen virtuell sind. Die Client
Klasse verwendet die Abstraktion. Objekte der Client
Klasse verwenden jedoch Objekte der Server
Nachfolgerklasse. Wenn Objekte der Client
Klasse eine andere Serverklasse verwenden sollen, führen wir einen neuen Nachkommen der AbstractServer
Klasse ein. Die Client
Klasse bleibt unverändert.

Client öffnen
Shape
abstrakt
Stellen Sie sich eine Anwendung vor, die Kreise und Quadrate in einer Standard-GUI zeichnen soll. Kreise und Quadrate müssen in einer bestimmten Reihenfolge gezeichnet werden. In der entsprechenden Reihenfolge wird eine Liste von Kreisen und Quadraten erstellt. Das Programm sollte diese Liste in der Reihenfolge durchgehen und jeden Kreis oder jedes Quadrat zeichnen.
In C könnten wir mit prozeduralen Programmiertechniken, die nicht dem Open-Close-Prinzip entsprechen, dieses Problem lösen, wie in Listing 1 gezeigt. Hier sehen wir viele Datenstrukturen mit demselben ersten Element. Dieses Element ist ein Typcode, der die Datenstruktur als Kreis oder Quadrat identifiziert. Die DrawAllShapes
Funktion durchläuft ein Array von Zeigern auf diese Datenstrukturen, erkennt den Typcode und ruft dann die entsprechende Funktion ( DrawCircle
oder DrawSquare
) auf.
Die DrawAllShapes
Funktion DrawAllShapes
nicht das Prinzip des Offenheitsschlusses, da sie nicht aus neuen Formen geschlossen werden kann. Wenn ich diese Funktion um die Möglichkeit erweitern möchte, Formen aus einer Liste mit Dreiecken zu zeichnen, muss ich die Funktion ändern. Tatsächlich muss ich die Funktion für jeden neuen Formtyp ändern, den ich zeichnen muss.
Natürlich ist dieses Programm nur ein Beispiel. Im wirklichen Leben würde der switch
aus der DrawAllShapes
Funktion in verschiedenen Funktionen in der gesamten Anwendung immer wieder wiederholt, und jeder würde etwas anderes tun. Das Hinzufügen neuer Formen zu einer solchen Anwendung bedeutet, alle Stellen zu finden, an denen solche switch
(oder if/else
Ketten) verwendet werden, und jeder von ihnen eine neue Form hinzuzufügen. Darüber hinaus ist es sehr unwahrscheinlich, dass alle switch
und if/else
Ketten so gut strukturiert sind wie in DrawAllShapes
. Es ist viel wahrscheinlicher, dass Prädikate angeben, if
mit logischen Operatoren kombiniert werden oder if
case
von switch
so kombiniert werden, dass eine bestimmte Stelle im Code „vereinfacht“ wird. Daher kann das Problem, alle Stellen zu finden und zu verstehen, an denen Sie eine neue Figur hinzufügen müssen, nicht trivial sein.
In Listing 2 werde ich Code zeigen, der eine Quadrat- / Kreislösung demonstriert, die dem Prinzip des Offenheitsschlusses entspricht. Eine abstrakte Shape
wird eingeführt. Diese abstrakte Klasse enthält eine reine virtuelle Draw
. Die Klassen Circle
und Square
sind Nachkommen der Shape
Klasse.
Beachten Sie, dass wir nur einen neuen Nachkommen der Shape
Klasse hinzufügen müssen, wenn wir das Verhalten der DrawAllShapes
Funktion in Listing 2 erweitern möchten, um eine neue Art von Form zu zeichnen. Die DrawAllShapes
Funktion muss nicht DrawAllShapes
werden. Daher erfüllt DrawAllShapes
das Prinzip der Offenheit und Nähe. Sein Verhalten kann erweitert werden, ohne die Funktion selbst zu ändern.
In der realen Welt würde die Shape
Klasse viele andere Methoden enthalten. Das Hinzufügen einer neuen Form zur Anwendung ist jedoch immer noch sehr einfach, da Sie lediglich einen neuen Erben eingeben und diese Funktionen implementieren müssen. Sie müssen nicht die gesamte Anwendung nach Orten durchsuchen, an denen Änderungen erforderlich sind.
Daher werden Programme, die dem Prinzip der Offenheit und Nähe entsprechen, durch Hinzufügen neuen Codes und nicht durch Ändern des vorhandenen Codes geändert. Sie kaskadieren keine Änderungen, die für Programme charakteristisch sind, die diesem Prinzip nicht entsprechen.
Closed-Entry-Strategie
Offensichtlich kann kein Programm zu 100% geschlossen werden. Was passiert beispielsweise mit der Funktion DrawAllShapes
in Listing 2, wenn wir entscheiden, dass zuerst Kreise und dann Quadrate gezeichnet werden sollen? Die DrawAllShapes
Funktion DrawAllShapes
bei dieser Art von Änderung nicht geschlossen. Im Allgemeinen spielt es keine Rolle, wie "geschlossen" das Modul ist, es gibt immer eine Art von Änderung, von der es nicht geschlossen wird.
Da die Schließung nicht vollständig sein kann, muss sie strategisch eingeführt werden. Das heißt, der Designer muss die Arten von Änderungen auswählen, aus denen das Programm geschlossen wird. Dies erfordert einige Erfahrung. Ein erfahrener Entwickler kennt Benutzer und Branche gut genug, um die Wahrscheinlichkeit verschiedener Änderungen zu berechnen. Er stellt dann sicher, dass das Prinzip der Offenheit und Nähe für die wahrscheinlichsten Änderungen eingehalten wird.
Verwendung der Abstraktion, um zusätzliche Nähe zu erreichen
Wie können wir die DrawAllShapes
Funktion aufgrund von Änderungen in der Zeichnungsreihenfolge schließen? Denken Sie daran, dass der Abschluss auf Abstraktion basiert. Um DrawAllShapes
von der Bestellung DrawAllShapes
, benötigen wir daher eine Art „Ordnungsabstraktion“. Ein oben dargestellter Sonderfall der Bestellung ist das Zeichnen von Figuren eines Typs vor Figuren eines anderen Typs.
Die Bestellrichtlinie impliziert, dass Sie mit zwei Objekten festlegen können, welches zuerst gezeichnet werden soll. Daher können wir eine Methode für die Shape
Klasse mit dem Namen Precedes
, die ein anderes Shape
Objekt als Argument verwendet und den Booleschen Wert true
zurückgibt true
wenn das Shape
Objekt, das diese Nachricht empfangen hat, vor dem Shape
Objekt sortiert werden muss als Argument übergeben.
In C ++ kann diese Funktion als Überladung des Operators "<" dargestellt werden. Listing 3 zeigt die Shape
Klasse mit Sortiermethoden.
Nachdem wir nun die Möglichkeit haben, die Reihenfolge der Objekte der Shape
Klasse zu bestimmen, können wir sie sortieren und dann zeichnen. Listing 4 zeigt den entsprechenden C ++ - Code. Es verwendet die Klassen Set
, OrderedSet
und Iterator
aus der Kategorie Components
die in meinem Buch entwickelt wurden (Entwerfen objektorientierter C ++ - Anwendungen mit der Booch-Methode, Robert C. Martin, Prentice Hall, 1995).
Daher haben wir die Reihenfolge der Objekte der Shape
Klasse implementiert und sie in der entsprechenden Reihenfolge gezeichnet. Aber wir haben immer noch keine Implementierung der Abstraktion der Ordnung. Offensichtlich muss jedes Shape
Objekt die Precedes
Methode überschreiben, um die Reihenfolge zu bestimmen. Wie kann das funktionieren? Welcher Code muss in Circle::Precedes
werden, damit Kreise zu Quadraten gezeichnet werden? Achten Sie auf Listing 5.
Es ist klar, dass diese Funktion nicht dem Prinzip der Offenheit-Nähe entspricht. Es gibt keine Möglichkeit, es vor den neuen Nachkommen der Shape
Klasse zu schließen. Jedes Mal, wenn ein neuer Nachkomme der Shape
Klasse angezeigt wird, muss diese Funktion geändert werden.
Verwenden eines datengesteuerten Ansatzes zum Schließen
Die Nähe der Erben der Shape
Klasse kann mithilfe eines tabellarischen Ansatzes erreicht werden, der nicht zu Änderungen in jeder geerbten Klasse führt. Ein Beispiel für diesen Ansatz ist in Listing 6 dargestellt.
Mit diesem Ansatz haben wir die DrawAllShapes
Funktion erfolgreich DrawAllShapes
von Änderungen in Bezug auf die Reihenfolge und jedem Nachkommen der Shape
Klasse geschlossen - aufgrund der Einführung eines neuen Nachkommen oder aufgrund einer Änderung der Ordnungsrichtlinie für Objekte der Shape
Klasse in Abhängigkeit von ihrem Typ (z. B. Objekte der Squares
Klasse) zuerst gezeichnet werden).
Das einzige Element, das nicht daran gehindert wird, die Reihenfolge der Zeichnungsformen zu ändern, ist eine Tabelle. Die Tabelle kann in einem separaten Modul platziert werden, das von allen anderen Modulen getrennt ist. Daher wirken sich die Änderungen nicht auf andere Module aus.
Weitere Schließung
Dies ist nicht das Ende der Geschichte. Wir haben die Hierarchie der Shape
Klasse und der DrawAllShapes
Funktion geschlossen, um die Ordnungsrichtlinie basierend auf dem DrawAllShapes
ändern. Die Nachkommen der Shape
Klasse werden jedoch nicht von Ordnungsrichtlinien ausgeschlossen, die keinen Formtypen zugeordnet sind. Es scheint, dass wir das Zeichnen von Formen nach einer übergeordneten Struktur anordnen müssen. Eine vollständige Untersuchung solcher Probleme würde den Rahmen dieses Artikels sprengen. Ein interessierter Leser könnte jedoch darüber nachdenken, wie dieses Problem mithilfe der abstrakten OrderedObject
Klasse OrderedShape
, die in der OrderedShape
Klasse enthalten ist, die von den OrderedObject
und OrderedObject
erbt.
Heuristiken und Konventionen
Wie bereits am Anfang des Artikels erwähnt, ist das Prinzip der Offenheit und Nähe die Hauptmotivation für viele Heuristiken und Konventionen, die sich im Laufe der vielen Jahre der Entwicklung des OOP-Paradigmas herausgebildet haben. Das Folgende sind die wichtigsten.
Machen Sie alle Mitgliedsvariablen privat
Dies ist eine der dauerhaftesten Konventionen der PLO. Mitgliedsvariablen sollten nur den Methoden der Klasse bekannt sein, in der sie definiert sind. Variable Mitglieder sollten anderen Klassen, einschließlich abgeleiteten Klassen, nicht bekannt sein. Daher müssen sie mit einem private
Zugriffsmodifikator deklariert werden, nicht public
oder protected
.
In Anbetracht des Prinzips der Offenheit und Nähe ist der Grund für eine solche Konvention verständlich. Wenn sich Klassenmitgliedsvariablen ändern, muss sich jede von ihnen abhängige Funktion ändern. Das heißt, die Funktion wird nicht aufgrund von Änderungen an diesen Variablen geschlossen.
In OOP erwarten wir, dass die Methoden einer Klasse nicht für Änderungen in Variablen geschlossen sind, die Mitglieder dieser Klasse sind. Wir erwarten jedoch, dass jede andere Klasse, einschließlich Unterklassen, wegen Änderungen an diesen Variablen geschlossen wird. Dies wird als Kapselung bezeichnet.
Aber was ist, wenn Sie eine Variable haben, von der Sie sicher sind, dass sie sich niemals ändern wird? Ist es sinnvoll, es private
zu machen? In Listing 7 wird beispielsweise die Device
angezeigt, die den bool status
des variablen Mitglieds enthält. Es speichert den Status der letzten Operation. Wenn der Vorgang erfolgreich war, ist der Wert der Statusvariablen true
, andernfalls false
.
Wir wissen, dass sich der Typ oder die Bedeutung dieser Variablen niemals ändern wird. Warum also nicht public
und dem Kunden direkten Zugriff darauf gewähren? Wenn sich die Variable wirklich nie ändert, wenn alle Clients die Regeln befolgen und nur aus dieser Variablen lesen, ist nichts falsch daran, dass die Variable öffentlich ist. Überlegen Sie jedoch, was passieren wird, wenn einer der Clients die Gelegenheit nutzt, in diese Variable zu schreiben und ihren Wert zu ändern.
Plötzlich kann dieser Client den Betrieb eines anderen Clients der Device
beeinträchtigen. Dies bedeutet, dass es unmöglich ist, Clients der Device
vor Änderungen an diesem falschen Modul zu schließen. Das ist zu viel Risiko.
Nehmen wir andererseits an, wir haben die in Listing 8 gezeigte Zeitklasse. Welche Gefahr besteht für die Veröffentlichung der Variablen, die Mitglieder dieser Klasse sind? Es ist sehr unwahrscheinlich, dass sie sich ändern werden. Darüber hinaus spielt es keine Rolle, ob die Client-Module die Werte dieser Variablen ändern oder nicht, da eine Änderung dieser Variablen angenommen wird. Es ist auch sehr unwahrscheinlich, dass geerbte Klassen vom Wert einer bestimmten Mitgliedsvariablen abhängen können. Gibt es also ein Problem?
Die einzige Beschwerde, die ich gegen den Code in Listing 8 machen könnte, ist, dass die Zeitänderung nicht atomar ist. Das heißt, der Client kann den Wert der Minutenvariablen ändern, ohne den Wert der hours
zu ändern. Dies kann dazu führen, dass ein Objekt der Zeitklasse inkonsistente Daten enthält. Ich würde es vorziehen, eine einzige Funktion zum Einstellen der Zeit einzuführen, die drei Argumente benötigt, wodurch das Einstellen der Zeit zu einer atomaren Operation wird. Dies ist jedoch ein schwaches Argument.
Es ist leicht, andere Bedingungen zu finden, unter denen die Veröffentlichung dieser Variablen zu Problemen führen kann. Letztendlich gibt es jedoch keinen überzeugenden Grund, sie private
zu machen. Ich denke immer noch, dass es ein schlechter Stil ist, solche Variablen öffentlich zu machen, aber vielleicht ist es kein schlechtes Design. Ich glaube, dass dies ein schlechter Stil ist, da es fast nichts kostet, die entsprechenden Funktionen für den Zugriff auf diese Mitglieder einzugeben, und es sich definitiv lohnt, sich vor dem geringen Risiko zu schützen, das mit dem möglichen Auftreten von Problemen beim Schließen verbunden ist.
In solchen seltenen Fällen, in denen das Prinzip der Offenheit nicht verletzt wird, hängt das Verbot public
und protected
Variablen daher mehr vom Stil und nicht vom Inhalt ab.
Keine globalen Variablen ... überhaupt nicht!
Das Argument gegen globale Variablen ist dasselbe wie das Argument gegen öffentliche Mitgliedsvariablen. Kein Modul, das von einer globalen Variablen abhängt, kann von einem Modul geschlossen werden, das darauf schreiben kann. Jedes Modul, das diese Variable auf eine Weise verwendet, die nicht von anderen Modulen beabsichtigt ist, bricht diese Module. Es ist zu riskant, viele Module zu haben, abhängig von den Unwägbarkeiten eines einzelnen bösartigen Moduls.
Andererseits schaden globale Variablen in Fällen, in denen eine geringe Anzahl von Modulen von ihnen abhängt oder nicht falsch verwendet werden kann, nicht. Der Designer muss bewerten, wie viel Datenschutz geopfert wird, und feststellen, ob sich die Bequemlichkeit der globalen Variablen lohnt.
Auch hier kommen Stilprobleme ins Spiel. Alternativen zur Verwendung globaler Variablen sind normalerweise kostengünstig. In solchen Fällen ist die Verwendung einer Technik, die zwar ein geringes, aber ein Risiko für den Verschluss einführt, anstelle einer Technik, die ein solches Risiko vollständig ausschließt, ein Zeichen für einen schlechten Stil. Manchmal ist es jedoch sehr praktisch, globale Variablen zu verwenden. Ein typisches Beispiel sind die globalen Variablen cout und cin. In solchen Fällen können Sie den Stil der Einfachheit halber opfern, wenn das Prinzip der Offenheit und Nähe nicht verletzt wird.
RTTI ist gefährlich
Ein weiteres häufiges Verbot ist die Verwendung von dynamic_cast
. Sehr oft wird dynamic_cast
oder eine andere Form der Laufzeittypbestimmung (RTTI) als äußerst gefährliche Technik beschuldigt und sollte daher vermieden werden. Gleichzeitig geben sie häufig ein Beispiel aus Listing 9 an, das offensichtlich gegen das Prinzip der Offenheit und Nähe verstößt. Listing 10 zeigt jedoch ein Beispiel für ein ähnliches Programm, das dynamic_cast
ohne das Open-Close-Prinzip zu verletzen.
Der Unterschied zwischen ihnen besteht darin, dass im ersten Fall, der in Listing 9 gezeigt wird, der Code jedes Mal geändert werden muss, wenn ein neuer Nachkomme der Shape
Klasse erscheint (ganz zu schweigen davon, dass dies eine absolut lächerliche Lösung ist). In Listing 10 sind in diesem Fall jedoch keine Änderungen erforderlich. Daher verstößt der Code in Listing 10 nicht gegen das Open-Close-Prinzip.
In diesem Fall gilt als Faustregel, dass RTTI verwendet werden kann, wenn das Prinzip der Offenheitsschließung nicht verletzt wird.
Fazit
Ich konnte lange über das Prinzip der Offenheit und Nähe sprechen. In vielerlei Hinsicht ist dieses Prinzip für die objektorientierte Programmierung am wichtigsten. Die Einhaltung dieses speziellen Prinzips bietet die Hauptvorteile der objektorientierten Technologie, nämlich Wiederverwendung und Support.
, - -. , , , , , .