Vererbung ist eine der Säulen von OOP. Vererbung wird verwendet, um allgemeinen Code wiederzuverwenden. Allgemeiner Code ist jedoch nicht immer für die Wiederverwendung erforderlich, und Vererbung ist nicht immer der beste Weg, um Code wiederzuverwenden. Es stellt sich oft heraus, dass es einen ähnlichen Code in zwei verschiedenen Codeteilen (Klassen) gibt, aber die Anforderungen für sie sind unterschiedlich, d.h. Klassen erben tatsächlich voneinander und sind es möglicherweise nicht wert.
Verwenden Sie zur Veranschaulichung dieses Problems normalerweise das Beispiel zum Erben der Square-Klasse von der Rectangle-Klasse oder umgekehrt.
Lassen Sie uns eine Rechteckklasse haben:
class Rectangle: def __init__(self, width, height): self._width = width self._height = height def set_width(self, width): self._width = width def set_height(self, height): self._height = height def get_area(self): return self._width * self._height ...
Jetzt wollten wir die Square-Klasse schreiben, aber um den Flächenberechnungscode wiederzuverwenden, erscheint es logisch, das Square vom Rechteck zu erben:
class Square(Rectangle): def set_width(self, width): self._width = width self._height = width def set_height(self, height): self._width = height self._height = height
Es scheint, dass der Code der Klassen Square und Rectangle konsistent ist. Es scheint, dass Square die mathematischen Eigenschaften des Quadrats beibehält, d.h. und ein Rechteck. Das heißt, wir können quadratische Objekte anstelle von Rechteck übergeben.
Wenn wir dies tun, können wir das
Verhalten der Rectangle-Klasse verletzen:
Zum Beispiel gibt es einen Client-Code:
def client_code(rect): rect.set_height(10) rect.set_width(20) assert rect.get_area() == 200
Wenn Sie dieser Funktion eine Instanz der Square-Klasse als Argument übergeben, verhält sich die Funktion anders. Dies ist eine Vertragsverletzung für das Verhalten der Rectangle-Klasse, da Aktionen mit einem Objekt der Basisklasse genau das gleiche Ergebnis liefern sollten wie mit dem Objekt der untergeordneten Klasse.
Wenn die Quadratklasse ein Nachkomme der Rechteckklasse ist, wenn wir mit dem Quadrat arbeiten und die Methoden des Rechtecks ausführen, sollten wir nicht einmal bemerken, dass es kein Rechteck ist.
Sie können dieses Problem beispielsweise folgendermaßen beheben:
- Machen Sie eine Zusicherung, die genau zur Klasse passt, oder machen Sie eine, wenn dies für verschiedene Klassen unterschiedlich funktioniert
- Machen Sie in Square die Methode set_size () und überschreiben Sie die Methoden set_height und set_width, sodass sie Ausnahmen auslösen
etc etc.
Solcher Code und solche Klassen funktionieren in dem Sinne, dass der Code funktioniert.
Eine andere Frage ist, dass der Clientcode, der die Square-Klasse oder die Rectangle-Klasse verwendet, entweder über die Basisklasse und ihr Verhalten oder über die untergeordnete Klasse und ihr Verhalten Bescheid wissen muss.
Im Laufe der Zeit können wir das bekommen:
- Die untergeordnete Klasse überschreibt die meisten Methoden
- Durch Refactoring oder Hinzufügen von Methoden zur Basisklasse wird der Code mithilfe von Nachkommen beschädigt
- In dem Code, der die Objekte der Basisklasse verwendet, gibt es ifs, die nach der Klasse des Objekts suchen, und das Verhalten für Nachkommen und die Basisklasse ist unterschiedlich
Es stellt sich heraus, dass der für die Basisklasse geschriebene Clientcode von der Implementierung der Basisklasse und der untergeordneten Klasse abhängt. Das erschwert die Entwicklung im Laufe der Zeit erheblich. Und OOP wurde nur erstellt, damit Sie die Basisklasse und die Nachkommenklasse unabhängig voneinander bearbeiten können.
In den 80er Jahren des letzten Jahrhunderts haben wir festgestellt, dass die Klassenvererbung für die Wiederverwendung von Code gut funktioniert. Wir müssen sicher sein, dass die untergeordnete Klasse anstelle der Basisklasse verwendet werden kann. Das heißt, Vererbungssemantik - dies sollte nicht nur und weniger Daten als Verhalten sein. Erben sollten das Verhalten der Basisklasse nicht "brechen".
Tatsächlich ist dies das
Prinzip der Lisk-Substitution oder das Prinzip der Bestimmung eines Subtyps basierend auf dem Verhalten starker Klassen:
Wenn Sie mindestens einen aussagekräftigen Code schreiben können, in dem das Ersetzen eines Basisklassenobjekts durch ein untergeordnetes Klassenobjekt ihn beschädigt, lohnt es sich nicht erben sie voneinander. Wir sollten das Verhalten der Basisklasse in Nachkommen erweitern und nicht wesentlich ändern. Funktionen, die die Basisklasse verwenden, sollten in der Lage sein, Unterklassenobjekte zu verwenden, ohne es zu wissen. Tatsächlich ist dies die Semantik der Vererbung in OOP.
Im realen Industriekodex wird dringend empfohlen, diesem Prinzip zu folgen und die beschriebene Vererbungssemantik einzuhalten. Und mit diesem Prinzip gibt es mehrere Feinheiten.
Das Prinzip sollte nicht mit Abstraktionen der Domänenebene zufrieden sein, sondern mit Code-Abstraktionen - Klassen. Aus geometrischer Sicht ist ein Quadrat ein Rechteck. Aus Sicht der Klassenvererbungshierarchie hängt es von dem Verhalten ab, das wir von diesen Klassen benötigen, ob die Klasse eines Quadrats der Erbe des Klassenrechtecks ist. Kommt darauf an, wie und in welchen Situationen wir diesen Code verwenden.
Wenn die Rectangle-Klasse nur zwei Methoden hat - Berechnen der Fläche und Rendern ohne die Möglichkeit des Neuzeichnens und Änderns der Größe -, erfüllt Square mit einem überschriebenen Konstruktor in diesem Fall das Lisky-Prinzip des Ersetzens.
Das heißt, solche Klassen erfüllen das Substitutionsprinzip:
class Rectangle: def draw(): ... def get_area(): ... class Square(Rectangle): pass
Dies ist zwar natürlich kein sehr guter Code und wahrscheinlich sogar das Gegenmuster des Klassendesigns, aber aus formaler Sicht erfüllt es das Liskov-Prinzip.
Ein weiteres
Beispiel . Ein Set ist ein Subtyp eines Multisets. Dies ist das Verhältnis der Domänenabstraktionen. Der Code kann jedoch so geschrieben werden, dass wir die Set-Klasse von Bag erben und das Substitutionsprinzip verletzt wird, oder wir können so schreiben, dass das Prinzip eingehalten wird. Mit der gleichen Fachdomänensemantik.
Im Allgemeinen kann die Vererbung von Klassen als Implementierung der Beziehung „IS“ betrachtet werden, jedoch nicht zwischen den Entitäten des Themenbereichs, sondern zwischen Klassen. Ob die untergeordnete Klasse ein Subtyp der Basisklasse ist, hängt davon ab, welche Einschränkungen und Klassenverhaltensverträge der Clientcode verwendet (und im Prinzip verwenden kann).
Einschränkungen, Invarianten und ein Basisklassenvertrag sind nicht im Code festgelegt, sondern in den Köpfen der Entwickler, die den Code bearbeiten und lesen. Was „bricht“, was den „Vertrag“ bricht, wird nicht durch den Code bestimmt, sondern durch die Semantik der Klasse im Kopf des Entwicklers.
Jeder Code, der für ein Objekt einer Basisklasse von Bedeutung ist, sollte nicht beschädigt werden, wenn wir ihn durch ein Objekt einer untergeordneten Klasse ersetzen. Sinnvoller Code ist jeder Client-Code, der ein Objekt einer Basisklasse (und deren Nachkommen) im Rahmen der Semantik und Einschränkungen der Basisklasse verwendet.
Es ist äußerst wichtig zu verstehen, dass die Einschränkungen der Abstraktion, die in der Basisklasse implementiert ist, normalerweise nicht im Programmcode enthalten sind. Diese Einschränkungen werden vom Entwickler verstanden, bekannt und unterstützt. Es überwacht die Konsistenz von Abstraktion und Code. Damit der Code ausdrückt, was er bedeutet.
Ein Rechteck verfügt beispielsweise über eine andere Methode, die eine Ansicht in json zurückgibt
class Rectangle: def to_dict(self): return {"height": self.height, "width": self.width}
Und in Square definieren wir es neu:
class Square: def to_dict(self): return {"size": self.height}
Wenn wir den Grundvertrag für das Verhalten der Rectangle-Klasse to_json als Höhe und Breite betrachten, dann den Code
r = rect.to_dict() log(r['height'], r['width'])
ist für ein Objekt der Basisklasse Rectangle von Bedeutung. Wenn ein Objekt einer Basisklasse durch eine Klasse ersetzt wird, ändert der Square-Erbencode sein Verhalten und verstößt gegen den Vertrag und damit gegen das Prinzip der Lisk-Substitution.
Wenn wir glauben, dass der Grundvertrag für das Verhalten der Rectangle-Klasse lautet, dass to_dict ein Wörterbuch zurückgibt, das serialisiert werden kann, ohne auf bestimmte Felder zu legen, ist eine solche to_dict-Methode in Ordnung.
Dies ist übrigens ein gutes Beispiel, das den Mythos zerstört, dass Unveränderlichkeit vor einer Verletzung des Prinzips bewahrt.
Formal ist jedes Überschreiben einer Methode in einer untergeordneten Klasse gefährlich, ebenso wie Änderungen an der Logik in der Basisklasse. Beispielsweise passen sich die untergeordneten Klassen häufig an das „falsche“ Verhalten der Basisklasse an, und wenn der Fehler in der Basisklasse behoben ist, brechen sie ab.
Es ist möglich, alle Vertragsbedingungen und Invarianten so weit wie möglich auf den Code zu übertragen, aber im Allgemeinen liegt die Verhaltenssemantik dennoch außerhalb des Codes - im Problembereich und wird vom Entwickler unterstützt. Das Beispiel zu to_dict ist ein Beispiel, in dem der Vertrag im Code beschrieben werden kann. Beispielsweise kann jedoch nicht überprüft werden, ob die Methode get_hash einen Hash mit allen Eigenschaften des Hash und nicht nur eine Zeile zurückgibt.
Wenn ein Entwickler Code verwendet, der von anderen Entwicklern geschrieben wurde, kann er die Semantik einer Klasse nur direkt anhand von Code, Methodennamen, Dokumentation und Kommentaren verstehen. In jedem Fall ist die Semantik jedoch häufig eine menschliche Domäne und daher fehlerhaft. Die wichtigste Konsequenz: Nur durch Code - syntaktisch - ist es unmöglich, die Einhaltung des Liskov-Prinzips zu überprüfen, und Sie müssen sich auf (oft) vage Semantik verlassen. Es gibt keine formalen (mathematischen) Mittel für eine überprüfbare und garantierte Methode zur Überprüfung einer starken Verhaltenstypisierung.
Daher werden häufig anstelle des Liskov-Prinzips formale Regeln für Vor- und Nachbedingungen aus der Vertragsprogrammierung verwendet:
- Voraussetzungen in einer Unterklasse können nicht gestärkt werden - eine Unterklasse sollte nicht mehr als die Basisklasse erfordern
- Die Nachbedingungen der Unterklasse können nicht gelockert werden - die Unterklasse sollte nicht weniger als die Basisklasse liefern (versprechen)
- Invarianten der Basisklasse müssen in der Nachkommenklasse erhalten bleiben.
Beispielsweise können wir in einer Nachkommenklassenmethode keinen erforderlichen Parameter hinzufügen, der nicht in der Basisklasse enthalten war, da auf diese Weise die Voraussetzungen gestärkt werden. Oder wir können keine Ausnahmen in der überschriebenen Methode auslösen, weil die Invarianten der Basisklasse verletzen. Usw.
Was zählt, ist nicht das aktuelle Verhalten der Klasse, sondern welche Klassenänderungen die Verantwortung oder Semantik der Klasse implizieren.
Der Code wird ständig korrigiert und geändert. Wenn der Code jetzt das Substitutionsprinzip erfüllt, bedeutet dies nicht, dass die Änderungen im Code dies nicht ändern.
Angenommen, es gibt einen Entwickler der Rectangle-Bibliotheksklasse und einen Anwendungsentwickler, der Square von Rectangle erbt. In dem Moment, als der Anwendungsentwickler Square von Rectangle erbte - alles war in Ordnung, erfüllten die Klassen das Prinzip der Substitution.
Und irgendwann fügte der für die Bibliothek zuständige Entwickler der Rectangle-Basisklasse eine Umformungs- oder set_width / set_height-Methode hinzu. Aus seiner Sicht ist gerade eine Erweiterung der Basisklasse passiert. Tatsächlich gab es jedoch eine Änderung in der Semantik und den Verträgen, auf die sich die Nachkommenklasse stützte. Jetzt erfüllen Klassen nicht mehr das Prinzip.
Im Allgemeinen können beim Erben in OOP Änderungen in der Basisklasse, die wie eine Erweiterung der Schnittstelle aussehen - eine andere Methode oder ein anderes Feld wird hinzugefügt, frühere „natürliche“ Verträge verletzen und dadurch tatsächlich die Semantik oder Verantwortlichkeiten ändern. Daher ist das Hinzufügen einer Methode zur Basisklasse gefährlich. Sie können den Vertrag versehentlich versehentlich ändern.
Aus praktischer Sicht ist es im Beispiel mit einem Rechteck und einer Klasse wichtig, ob es jetzt eine Umformungs- oder eine set_width / set_height-Methode gibt. Aus praktischer Sicht ist es wichtig, wie hoch die Wahrscheinlichkeit solcher Änderungen im Bibliothekscode ist. Bedeutet die Semantik oder die Grenzen der Klassenverantwortung solche Änderungen? Wenn dies impliziert ist, ist die Wahrscheinlichkeit eines Fehlers und / oder eines weiteren Umgestaltungsbedarfs erheblich erhöht. Und wenn es auch nur eine kleine Möglichkeit gibt, ist es wahrscheinlich besser, solche Klassen nicht voneinander zu erben.
Das Verwalten von Subtypdefinitionen basierend auf dem Verhalten ist selbst für einfache Klassen mit klarer Semantik schwierig, ganz zu schweigen von Unternehmen mit komplexer Geschäftslogik. Trotz der Tatsache, dass die Basisklasse und die Erbenklasse unterschiedliche Codeteile sind, müssen Sie für sie die Schnittstellen und die Verantwortung sorgfältig und sorgfältig durchdenken. Und selbst bei einer geringfügigen Änderung der Semantik der Klasse - die in keiner Weise vermieden werden kann - müssen wir den Code verwandter Klassen überprüfen und prüfen, ob der neue Vertrag oder die neue Invariante gegen das verstößt, was bereits geschrieben (!) Und verwendet wurde. Bei fast jeder Änderung in der Hierarchie der Verzweigungsklassen müssen wir viele andere Codes suchen und überprüfen.
Dies ist einer der Gründe, warum manche Menschen die klassische Vererbung in OOP nicht wirklich mögen. Und deshalb bevorzugen sie oft die Zusammensetzung von Klassen, die Vererbung von Schnittstellen usw. usw. anstelle der klassischen Vererbung von Verhalten.
Fairerweise gibt es einige Regeln, die höchstwahrscheinlich nicht gegen das Substitutionsprinzip verstoßen. Sie können sich so gut wie möglich schützen, wenn Sie alle gefährlichen Strukturen verbieten. Zum Beispiel hat
Oleg für C ++ darüber geschrieben. Im Allgemeinen verwandeln solche Regeln Klassen jedoch nicht in Klassen im klassischen Sinne.
Mit administrativen Methoden ist die Aufgabe auch nicht sehr gut gelöst.
Hier können Sie lesen, wie Onkel Martin in C ++ war und wie es nicht funktionierte.
Aber im realen Industriekodex wird ziemlich oft das Liskov-Prinzip verletzt, und das ist nicht beängstigend . Es ist schwierig, dem Prinzip zu folgen, weil 1) Die Verantwortung und Semantik einer Klasse sind oft nicht explizit und werden nicht im Code ausgedrückt. 2) Die Verantwortung einer Klasse kann sich ändern - sowohl in der Basisklasse als auch in der Nachkommenklasse. Dies führt jedoch nicht immer zu wirklich schrecklichen Konsequenzen. Die häufigste, einfachste und grundlegendste Verletzung besteht darin, dass eine überschriebene Methode das Verhalten ändert. Wie zum Beispiel hier:
class Task: def close(self): self.status = CLOSED ... class ProjectTask(Task): def close(self): if status == STARTED: raise Exception("Cannot close a started Project Task") ...
Die Methode close von ProjectTask löst eine Ausnahme aus, wenn die Objekte der Task-Klasse einwandfrei funktionieren. Im Allgemeinen führt die Neudefinition von Methoden einer Basisklasse sehr oft zu einer Verletzung des Substitutionsprinzips, wird jedoch nicht zum Problem.
In diesem Fall sieht der Entwickler die Vererbung NICHT als Implementierung der IS-Beziehung, sondern lediglich als Möglichkeit, den Code wiederzuverwenden. Das heißt, Eine Unterklasse ist nur eine Unterklasse, kein Untertyp. In diesem Fall ist es aus pragmatischer und praktischer Sicht wichtiger - aber wie hoch ist die Wahrscheinlichkeit, dass Client-Code vorhanden ist oder bereits vorhanden ist, der unterschiedliche Semantiken der Methoden der Nachkommenklasse und der Basisklasse feststellt?
Gibt es viel Code, der ein Objekt einer Basisklasse erwartet, an den wir aber das Objekt der untergeordneten Klasse übergeben? Für viele Aufgaben wird ein solcher Code überhaupt nicht existieren.
Wann führt eine LSP-Verletzung zu großen Problemen? Wenn aufgrund von Verhaltensunterschieden der Client-Code mit Änderungen in der Nachkommenklasse neu geschrieben werden muss und umgekehrt. Dies ist insbesondere dann ein Problem, wenn dieser Clientcode ein Bibliothekscode ist, der nicht geändert werden kann. Wenn die Wiederverwendung des Codes in Zukunft keine Abhängigkeiten zwischen dem Clientcode und dem Klassencode mehr erzeugen kann, kann ein solcher Code trotz der Verletzung des Liskov-Substitutionsprinzips keine großen Probleme verursachen.
Im Allgemeinen kann die Vererbung während der Entwicklung aus zwei Perspektiven betrachtet werden: Unterklassen sind Untertypen mit allen Einschränkungen der Vertragsprogrammierung und des Lisk-Prinzips, und Unterklassen sind eine Möglichkeit, Code mit all seinen potenziellen Problemen wiederzuverwenden. Das heißt, Sie können entweder Klassenverantwortlichkeiten und -verträge denken und entwerfen und sich nicht um den Clientcode kümmern. Denken Sie entweder darüber nach, was clientseitiger Code sein könnte, wie Klassen verwendet werden, und seien Sie auf potenzielle Probleme vorbereitet, aber in geringerem Maße ist es wichtig, das Substitutionsprinzip einzuhalten. Die Entscheidung liegt wie üblich beim Entwickler. Das Wichtigste ist, dass die Wahl in einer bestimmten Situation bewusst ist und dass ein Verständnis dafür besteht, welche Vor-, Nachteile und Fallstricke mit dieser oder jener Lösung einhergehen.