Warum sich ausdehnt, ist böse

Das extendsSchlüsselwort ist böse; vielleicht nicht auf der Ebene von Charles Manson, aber schlimm genug, dass es nach Möglichkeit gemieden werden sollte. Das Buch " Gang of Four Design Patterns " behandelt ausführlich das Ersetzen der Implementierungsvererbung ( extends) durch die Schnittstellenvererbung ( implements).

Gute Designer schreiben den größten Teil ihres Codes in Form von Schnittstellen, nicht in Form konkreter Basisklassen. Dieser Artikel beschreibt, warum Designer so merkwürdige Gewohnheiten haben, und führt auch einige Grundlagen der schnittstellenbasierten Programmierung ein.

Schnittstellen versus Klassen

Ich habe einmal an einem Java-Benutzergruppentreffen teilgenommen, bei dem James Gosling (Javas Erfinder) der Sprecher war. Während der denkwürdigen Q & A-Sitzung fragte ihn jemand: "Wenn Sie Java noch einmal machen könnten, was würden Sie ändern?" "Ich würde den Unterricht auslassen", antwortete er. Nachdem das Lachen nachgelassen hatte, erklärte er, dass das eigentliche Problem nicht der Unterricht an sich sei, sondern die Vererbung der Implementierung (die extendsBeziehung). Die Schnittstellenvererbung (die implementsBeziehung) ist vorzuziehen. Sie sollten die Vererbung der Implementierung nach Möglichkeit vermeiden.

Flexibilität verlieren

Warum sollten Sie die Vererbung der Implementierung vermeiden? Das erste Problem besteht darin, dass Sie durch die explizite Verwendung konkreter Klassennamen an bestimmte Implementierungen gebunden werden, was spätere Änderungen unnötig erschwert.

Im Zentrum der heutigen agilen Entwicklungsmethoden steht das Konzept des parallelen Designs und der parallelen Entwicklung. Sie beginnen mit der Programmierung, bevor Sie das Programm vollständig spezifizieren. Diese Technik widerspricht der traditionellen Weisheit, dass ein Entwurf vor Beginn der Programmierung vollständig sein sollte, aber viele erfolgreiche Projekte haben bewiesen, dass Sie auf diese Weise qualitativ hochwertigen Code schneller (und kostengünstiger) entwickeln können als mit dem herkömmlichen Pipeline-Ansatz. Im Zentrum der parallelen Entwicklung steht jedoch der Begriff der Flexibilität. Sie müssen Ihren Code so schreiben, dass Sie neu entdeckte Anforderungen so einfach wie möglich in den vorhandenen Code integrieren können.

Anstatt Funktionen zu implementieren, die Sie möglicherweise benötigen, implementieren Sie nur die Funktionen, die Sie definitiv benötigen, jedoch auf eine Weise, die Änderungen Rechnung trägt. Wenn Sie diese Flexibilität nicht haben, ist eine parallele Entwicklung einfach nicht möglich.

Die Programmierung auf Schnittstellen ist das Kernstück einer flexiblen Struktur. Um zu sehen, warum, schauen wir uns an, was passiert, wenn Sie sie nicht verwenden. Betrachten Sie den folgenden Code:

f () {LinkedList list = new LinkedList (); // ... g (Liste); } g (LinkedList-Liste) {list.add (...); g2 (Liste)}

Nehmen wir nun an, dass eine neue Anforderung für eine schnelle Suche entstanden ist, sodass dies LinkedListnicht funktioniert. Sie müssen es durch ein ersetzen HashSet. Im vorhandenen Code ist diese Änderung nicht lokalisiert, da Sie nicht nur, f()sondern auch g()(was ein LinkedListArgument erfordert) Änderungen vornehmen müssen und alles, g()an das die Liste übergeben wird.

Schreiben Sie den Code folgendermaßen um:

f () {Sammelliste = neue LinkedList (); // ... g (Liste); } g (Sammlungsliste) {list.add (...); g2 (Liste)}

macht es möglich , die Liste zu einer Hash - Tabelle durch Ersetzen der einfach zu ändern , new LinkedList()mit ein new HashSet(). Das ist es. Es sind keine weiteren Änderungen erforderlich.

Vergleichen Sie als weiteres Beispiel diesen Code:

f () {Sammlung c = neues HashSet (); // ... g (c); } g (Sammlung c) {für (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }}

dazu:

f2 () {Sammlung c = neues HashSet (); // ... g2 (c.iterator ()); } g2 (Iterator i) {while (i.hasNext ();) do_something_with (i.next ()); }}

Die g2()Methode kann jetzt CollectionAbleitungen sowie die Schlüssel- und Wertelisten durchlaufen, die Sie von a erhalten können Map. Tatsächlich können Sie Iteratoren schreiben, die Daten generieren, anstatt eine Sammlung zu durchlaufen. Sie können Iteratoren schreiben, die Informationen von einem Testgerüst oder einer Datei an das Programm senden. Hier gibt es enorme Flexibilität.

Kupplung

Ein entscheidenderes Problem bei der Vererbung von Implementierungen ist die Kopplung - die unerwünschte Abhängigkeit eines Teils eines Programms von einem anderen Teil. Globale Variablen sind das klassische Beispiel dafür, warum eine starke Kopplung Probleme verursacht. Wenn Sie beispielsweise den Typ der globalen Variablen ändern, sind möglicherweise alle Funktionen betroffen, die die Variable verwenden (dh mit der Variablen gekoppelt sind). Daher muss der gesamte Code überprüft, geändert und erneut getestet werden. Darüber hinaus sind alle Funktionen, die die Variable verwenden, über die Variable miteinander gekoppelt. Das heißt, eine Funktion kann das Verhalten einer anderen Funktion falsch beeinflussen, wenn der Wert einer Variablen zu einem ungünstigen Zeitpunkt geändert wird. Dieses Problem ist in Multithread-Programmen besonders abscheulich.

Als Designer sollten Sie sich bemühen, Kopplungsbeziehungen zu minimieren. Sie können die Kopplung nicht vollständig beseitigen, da ein Methodenaufruf von einem Objekt einer Klasse zu einem Objekt einer anderen eine Form der losen Kopplung ist. Sie können kein Programm ohne Kopplung haben. Sie können die Kopplung jedoch erheblich minimieren, indem Sie die OO-Vorschriften (objektorientiert) sklavisch befolgen (das Wichtigste ist, dass die Implementierung eines Objekts vollständig vor den Objekten verborgen bleibt, die es verwenden). Beispielsweise sollten die Instanzvariablen eines Objekts (Mitgliedsfelder, die keine Konstanten sind) immer sein private. Zeitraum. Keine Ausnahmen. Je. Ich meine es so. (Sie können gelegentlich protectedMethoden effektiv einsetzen, aberprotected Instanzvariablen sind ein Gräuel.) Sie sollten get / set-Funktionen niemals aus demselben Grund verwenden - sie sind nur zu komplizierte Methoden, um ein Feld öffentlich zu machen (obwohl Zugriffsfunktionen, die vollständige Objekte anstelle eines Basiswerts zurückgeben, dies sind sinnvoll in Situationen, in denen die Klasse des zurückgegebenen Objekts eine Schlüsselabstraktion im Entwurf ist).

Ich bin hier nicht pedantisch. Ich habe in meiner eigenen Arbeit einen direkten Zusammenhang zwischen der Strenge meines OO-Ansatzes, der schnellen Codeentwicklung und der einfachen Codewartung gefunden. Immer wenn ich gegen ein zentrales OO-Prinzip wie das Ausblenden der Implementierung verstoße, schreibe ich diesen Code neu (normalerweise, weil der Code nicht debuggt werden kann). Ich habe keine Zeit, Programme neu zu schreiben, daher befolge ich die Regeln. Mein Anliegen ist ganz praktisch - ich habe kein Interesse an Reinheit, um der Reinheit willen.

Das fragile Problem der Basisklasse

Wenden wir nun das Konzept der Kopplung auf die Vererbung an. In einem Implementierungsvererbungssystem, das verwendet extends, sind die abgeleiteten Klassen sehr eng mit den Basisklassen gekoppelt, und diese enge Verbindung ist unerwünscht. Designer haben den Spitznamen "das fragile Problem der Basisklasse" verwendet, um dieses Verhalten zu beschreiben. Basisklassen gelten als fragil, da Sie eine Basisklasse auf scheinbar sichere Weise ändern können. Dieses neue Verhalten kann jedoch zu Fehlfunktionen der abgeleiteten Klassen führen, wenn es von den abgeleiteten Klassen geerbt wird. Sie können nicht feststellen, ob eine Änderung der Basisklasse sicher ist, indem Sie die Methoden der Basisklasse isoliert untersuchen. Sie müssen auch alle abgeleiteten Klassen betrachten (und testen). Darüber hinaus müssen Sie den gesamten Code überprüfen, der sowohl die Basisklasse als auch verwendetObjekte der abgeleiteten Klasse auch, da dieser Code möglicherweise auch durch das neue Verhalten beschädigt wird. Eine einfache Änderung einer Schlüsselbasisklasse kann dazu führen, dass ein gesamtes Programm nicht mehr funktionsfähig ist.

Lassen Sie uns gemeinsam die fragilen Kopplungsprobleme der Basisklasse und der Basisklasse untersuchen. Die folgende Klasse erweitert die Java- ArrayListKlasse, damit sie sich wie ein Stapel verhält:

Klasse Stack erweitert ArrayList {private int stack_pointer = 0; public void push (Objektartikel) {add (stack_pointer ++, Artikel); } public Object pop () {return remove (--stack_pointer); } public void push_many (Object [] Artikel) {for (int i = 0; i <articles.length; ++ i) push (Artikel [i]); }}

Selbst eine so einfache Klasse hat Probleme. Überlegen Sie, was passiert, wenn ein Benutzer die Vererbung nutzt und die Methode von ArrayList' clear()verwendet, um alles vom Stapel zu entfernen:

Stack a_stack = new Stack (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

Der Code wird erfolgreich kompiliert, aber da die Basisklasse nichts über den Stapelzeiger weiß, befindet sich das StackObjekt jetzt in einem undefinierten Zustand. Beim nächsten Aufruf wird push()das neue Element auf Index 2 (den stack_pointeraktuellen Wert des Elements ) gesetzt, sodass der Stapel effektiv drei Elemente enthält - die beiden unteren sind Müll. (Javas StackKlasse hat genau dieses Problem; benutze es nicht.)

Eine Lösung für das unerwünschte Problem der Methodenvererbung besteht darin, Stackalle ArrayListMethoden zu überschreiben , die den Status des Arrays ändern können, sodass die Überschreibungen entweder den Stapelzeiger korrekt manipulieren oder eine Ausnahme auslösen. (Die removeRange()Methode ist ein guter Kandidat für das Auslösen einer Ausnahme.)

Dieser Ansatz hat zwei Nachteile. Erstens, wenn Sie alles überschreiben, sollte die Basisklasse wirklich eine Schnittstelle sein, keine Klasse. Es macht keinen Sinn, die Implementierung zu vererben, wenn Sie keine der geerbten Methoden verwenden. Zweitens, und was noch wichtiger ist, möchten Sie nicht, dass ein Stapel alle ArrayListMethoden unterstützt. Diese lästige removeRange()Methode ist zum Beispiel nicht sinnvoll. Die einzig vernünftige Möglichkeit, eine nutzlose Methode zu implementieren, besteht darin, eine Ausnahme auszulösen, da sie niemals aufgerufen werden sollte. Dieser Ansatz verschiebt effektiv einen Fehler zur Kompilierungszeit in die Laufzeit. Nicht gut. Wenn die Methode einfach nicht deklariert ist, gibt der Compiler einen Fehler aus, bei dem die Methode nicht gefunden wurde. Wenn die Methode vorhanden ist, aber eine Ausnahme auslöst, werden Sie den Aufruf erst erfahren, wenn das Programm tatsächlich ausgeführt wird.

Eine bessere Lösung für das Problem der Basisklasse besteht darin, die Datenstruktur zu kapseln, anstatt die Vererbung zu verwenden. Hier ist eine neue und verbesserte Version von Stack:

Klasse Stack {private int stack_pointer = 0; private ArrayList the_data = new ArrayList (); public void push (Objektartikel) {the_data.add (stack_pointer ++, article); } public Object pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] Artikel) {for (int i = 0; i <o.length; ++ i) push (Artikel [i]); }}

So weit so gut, aber bedenken Sie das fragile Problem der Basisklasse. Angenommen, Sie möchten eine Variante erstellen Stack, die die maximale Stapelgröße über einen bestimmten Zeitraum verfolgt. Eine mögliche Implementierung könnte folgendermaßen aussehen:

class Monitorable_stack extends Stack { private int high_water_mark = 0; private int current_size; public void push( Object article ) { if( ++current_size > high_water_mark ) high_water_mark = current_size; super.push(article); } public Object pop() { --current_size; return super.pop(); } public int maximum_size_so_far() { return high_water_mark; } } 

This new class works well, at least for a while. Unfortunately, the code exploits the fact that push_many() does its work by calling push(). At first, this detail doesn't seem like a bad choice. It simplifies the code, and you get the derived class version of push(), even when the Monitorable_stack is accessed through a Stack reference, so the high_water_mark updates correctly.

One fine day, someone might run a profiler and notice the Stack isn't as fast as it could be and is heavily used. You can rewrite the Stack so it doesn't use an ArrayList and consequently improve the Stack's performance. Here's the new lean-and-mean version:

class Stack { private int stack_pointer = -1; private Object[] stack = new Object[1000]; public void push( Object article ) { assert stack_pointer = 0; return stack[ stack_pointer-- ]; } public void push_many( Object[] articles ) { assert (stack_pointer + articles.length) < stack.length; System.arraycopy(articles, 0, stack, stack_pointer+1, articles.length); stack_pointer += articles.length; } } 

Notice that push_many() no longer calls push() multiple times—it does a block transfer. The new version of Stack works fine; in fact, it's better than the previous version. Unfortunately, the Monitorable_stack derived class doesn't work any more, since it won't correctly track stack usage if push_many() is called (the derived-class version of push() is no longer called by the inherited push_many() method, so push_many() no longer updates the high_water_mark). Stack is a fragile base class. As it turns out, it's virtually impossible to eliminate these types of problems simply by being careful.

Beachten Sie, dass Sie dieses Problem nicht haben, wenn Sie die Schnittstellenvererbung verwenden, da es keine vererbte Funktionalität gibt, die Sie beeinträchtigen könnte. Wenn Stackes sich um eine Schnittstelle handelt, die sowohl von a Simple_stackals auch von a implementiert wird Monitorable_stack, ist der Code viel robuster.