Mastering OOP: Ein praktischer Leitfaden für Vererbung, Schnittstellen und abstrakte Klassen
Veröffentlicht: 2022-03-10Soweit ich das beurteilen kann, trifft man im Bereich der Softwareentwicklung selten auf Ausbildungsinhalte, die eine angemessene Mischung aus theoretischen und praktischen Informationen bieten. Wenn ich raten sollte, warum, dann nehme ich an, dass es daran liegt, dass Personen, die sich auf die Theorie konzentrieren, dazu neigen, zu unterrichten, und Personen, die sich auf praktische Informationen konzentrieren, dazu neigen, dafür bezahlt zu werden, bestimmte Probleme zu lösen, indem sie bestimmte Sprachen und Werkzeuge verwenden.
Dies ist natürlich eine weit gefasste Verallgemeinerung, aber wenn wir sie nur kurz als Argumente akzeptieren, folgt daraus, dass viele Menschen (keineswegs alle Menschen), die die Rolle eines Lehrers übernehmen, dazu neigen, entweder schlecht oder völlig unfähig zu sein das für ein bestimmtes Konzept relevante praktische Wissen zu erklären.
In diesem Artikel werde ich mein Bestes tun, um drei Kernmechanismen zu erörtern, die Sie in den meisten objektorientierten Programmiersprachen (OOP) finden: Vererbung , Schnittstellen (auch bekannt als Protokolle ) und abstrakte Klassen . Anstatt Ihnen technische und komplexe verbale Erklärungen zu den einzelnen Mechanismen zu geben, werde ich mein Bestes tun, um mich darauf zu konzentrieren, was sie tun und wann sie zu verwenden sind.
Bevor ich sie jedoch einzeln anspreche, möchte ich kurz darauf eingehen, was es bedeutet, eine theoretisch fundierte, aber praktisch nutzlose Erklärung zu geben. Ich hoffe, dass Sie diese Informationen verwenden können, um verschiedene Bildungsressourcen zu sichten und sich nicht selbst die Schuld zu geben, wenn die Dinge keinen Sinn ergeben.
Unterschiedliche Grade des Wissens
Namen kennen
Den Namen von etwas zu kennen, ist wohl die oberflächlichste Form des Wissens. Tatsächlich ist ein Name nur insofern allgemein nützlich, als er entweder von vielen Menschen gemeinsam verwendet wird, um sich auf dieselbe Sache zu beziehen, und/oder er hilft, die Sache zu beschreiben. Wie jeder, der Zeit in diesem Bereich verbracht hat, festgestellt hat, verwenden leider viele Menschen unterschiedliche Namen für dasselbe (z. B. Schnittstellen und Protokolle ), dieselben Namen für verschiedene Dinge (z. B. Module und Komponenten ) oder Namen, die esoterisch sind Punkt, absurd zu sein (z. B. Entweder Monad ). Letztendlich sind Namen nur Hinweise (oder Verweise) auf mentale Modelle, und sie können von unterschiedlichem Grad an Nützlichkeit sein.
Um das Studium dieses Gebiets noch schwieriger zu machen, wage ich die Vermutung, dass das Schreiben von Code für die meisten Menschen eine sehr einzigartige Erfahrung ist (oder zumindest war). Noch komplizierter ist es zu verstehen, wie dieser Code letztendlich in Maschinensprache kompiliert und in der physischen Realität als eine Reihe von elektrischen Impulsen dargestellt wird, die sich im Laufe der Zeit ändern. Selbst wenn man sich an die Namen der Prozesse, Konzepte und Mechanismen erinnern kann, die in einem Programm verwendet werden, gibt es keine Garantie dafür, dass die mentalen Modelle, die man für solche Dinge erstellt, mit den Modellen eines anderen Individuums übereinstimmen; geschweige denn, ob sie objektiv korrekt sind.
Aus diesen Gründen, neben der Tatsache, dass ich von Natur aus kein gutes Gedächtnis für Fachjargon habe, halte ich Namen für den am wenigsten wichtigen Aspekt, um etwas zu wissen. Das soll nicht heißen, dass Namen nutzlos sind, aber ich habe in der Vergangenheit viele Designmuster gelernt und in meinen Projekten verwendet, nur um Monate oder sogar Jahre später von dem häufig verwendeten Namen zu erfahren.
Verbale Definitionen und Analogien kennen
Verbale Definitionen sind der natürliche Ausgangspunkt für die Beschreibung eines neuen Konzepts. Wie bei Namen können sie jedoch von unterschiedlichem Grad an Nützlichkeit und Relevanz sein; Vieles davon hängt davon ab, was die Endziele des Lernenden sind. Das häufigste Problem, das ich bei verbalen Definitionen sehe, ist angenommenes Wissen, typischerweise in Form von Jargon.
Nehmen wir zum Beispiel an, ich würde erklären, dass ein Thread einem Prozess sehr ähnlich ist, außer dass Threads denselben Adressraum eines bestimmten Prozesses belegen. Für jemanden, der bereits mit Prozessen und Adressräumen vertraut ist, habe ich im Wesentlichen gesagt, dass Threads mit ihrem Verständnis eines Prozesses in Verbindung gebracht werden können (dh sie besitzen viele der gleichen Eigenschaften), aber sie können basierend auf einer bestimmten Eigenschaft unterschieden werden.
Für jemanden, der dieses Wissen nicht besitzt, habe ich bestenfalls keinen Sinn ergeben und im schlimmsten Fall dazu geführt, dass sich der Lernende in irgendeiner Weise unzulänglich fühlt, weil er die Dinge nicht weiß, von denen ich annehme, dass sie sie wissen sollten. Fairerweise ist dies akzeptabel, wenn Ihre Lernenden wirklich über solche Kenntnisse verfügen sollten (z. B. lehrende Doktoranden oder erfahrene Entwickler), aber ich halte es für ein monumentales Versäumnis, dies in jedem Material der Einführungsebene zu tun.
Oft ist es sehr schwierig, eine gute verbale Definition eines Konzepts zu liefern, wenn es anders ist als alles andere, was der Lernende zuvor gesehen hat. In diesem Fall ist es sehr wichtig, dass der Lehrer eine Analogie auswählt, die dem Durchschnittsbürger wahrscheinlich vertraut und auch insofern relevant ist, als sie viele der gleichen Eigenschaften des Konzepts vermittelt.
Beispielsweise ist es für einen Softwareentwickler von entscheidender Bedeutung zu verstehen, was es bedeutet, wenn Softwareentitäten (verschiedene Teile eines Programms) eng oder lose gekoppelt sind. Beim Bau eines Gartenhauses denkt ein junger Schreiner vielleicht, dass es schneller und einfacher ist, es mit Nägeln anstelle von Schrauben zusammenzubauen. Dies gilt bis zu dem Punkt, an dem ein Fehler gemacht wird oder eine Änderung in der Konstruktion des Gartenhauses den Umbau eines Teils des Gerätehauses erforderlich macht.
An diesem Punkt hat die Entscheidung, Nägel zu verwenden, um die Teile des Gartenhauses fest miteinander zu verbinden, den Bauprozess als Ganzes schwieriger und wahrscheinlich langsamer gemacht, und das Herausziehen von Nägeln mit einem Hammer birgt die Gefahr, dass die Struktur beschädigt wird. Umgekehrt kann die Montage von Schrauben etwas mehr Zeit in Anspruch nehmen, aber sie lassen sich leicht entfernen und bergen nur ein geringes Risiko, nahe gelegene Teile des Schuppens zu beschädigen. Das meine ich mit locker gekoppelt . Natürlich gibt es Fälle, in denen Sie wirklich nur einen Nagel brauchen, aber diese Entscheidung sollte von kritischem Denken und Erfahrung geleitet werden.
Wie ich später ausführlich erörtern werde, gibt es verschiedene Mechanismen, um Teile eines Programms miteinander zu verbinden, die unterschiedliche Grade der Kopplung bieten; wie Nägel und Schrauben . Während meine Analogie Ihnen vielleicht geholfen hat zu verstehen, was dieser äußerst wichtige Begriff bedeutet, habe ich Ihnen keine Vorstellung davon gegeben, wie Sie ihn außerhalb des Kontexts des Baus eines Gartenhauses anwenden können. Dies führt mich zu der wichtigsten Art des Wissens und dem Schlüssel zum tiefen Verständnis vager und schwieriger Konzepte in jedem Forschungsgebiet; obwohl wir in diesem Artikel beim Schreiben von Code bleiben werden.
Code kennen
Meiner Meinung nach ist die wichtigste Form, ein Konzept zu kennen, streng genommen in Bezug auf die Softwareentwicklung, die Fähigkeit, es im funktionierenden Anwendungscode zu verwenden. Diese Form des Wissens kann einfach dadurch erreicht werden, dass man viel Code schreibt und viele verschiedene Probleme löst; Fachjargonnamen und verbale Definitionen müssen nicht aufgenommen werden.
Aus meiner eigenen Erfahrung erinnere ich mich, dass ich das Problem der Kommunikation mit einer entfernten Datenbank und einer lokalen Datenbank über eine einzige Schnittstelle gelöst habe (Sie werden bald wissen, was das bedeutet, falls Sie es noch nicht getan haben); Anstatt dass der Client (welche Klasse auch immer mit der Schnittstelle kommuniziert) die entfernte und lokale (oder sogar eine Testdatenbank) explizit aufrufen muss. Tatsächlich hatte der Client keine Ahnung, was sich hinter der Schnittstelle verbirgt, sodass ich sie nicht ändern musste, unabhängig davon, ob sie in einer Produktionsanwendung oder einer Testumgebung ausgeführt wurde. Ungefähr ein Jahr nachdem ich dieses Problem gelöst hatte, stieß ich auf den Begriff „Fassadenmuster“ und kurz darauf auf den Begriff „Repository-Muster“, die beide Namen sind, die Leute für die zuvor beschriebene Lösung verwenden.
Diese ganze Präambel soll hoffentlich einige der Fehler beleuchten , die am häufigsten bei der Erklärung von Themen wie Vererbung , Schnittstellen und abstrakten Klassen gemacht werden . Von den dreien ist die Vererbung wahrscheinlich am einfachsten zu verwenden und zu verstehen. Nach meiner Erfahrung sowohl als Programmierstudent als auch als Lehrer sind die anderen beiden fast immer ein Problem für die Lernenden, es sei denn, es wird ganz besonders darauf geachtet, die zuvor besprochenen Fehler zu vermeiden. Von diesem Punkt an werde ich mein Bestes tun, um diese Themen so einfach zu machen, wie sie sein sollten, aber nicht einfacher.
Eine Anmerkung zu den Beispielen
Da ich mich selbst am besten mit der Entwicklung mobiler Android-Anwendungen auskenne, werde ich Beispiele von dieser Plattform verwenden, damit ich Ihnen beibringen kann, wie man GUI-Anwendungen erstellt, während ich Sprachfunktionen von Java vorstelle. Ich werde jedoch nicht so sehr ins Detail gehen, dass die Beispiele für jemanden mit einem flüchtigen Verständnis von Java EE, Swing oder JavaFX unverständlich sein sollten. Mein oberstes Ziel bei der Erörterung dieser Themen ist es, Ihnen zu helfen, zu verstehen, was sie im Zusammenhang mit der Lösung eines Problems in nahezu jeder Art von Anwendung bedeuten.
Ich möchte Sie auch warnen, lieber Leser, dass es manchmal den Anschein hat, als würde ich unnötig philosophisch und pedantisch in Bezug auf bestimmte Wörter und ihre Definitionen sein. Der Grund dafür ist, dass wirklich eine tiefe philosophische Untermauerung erforderlich ist, um den Unterschied zwischen etwas Konkretem (Realem) und etwas Abstraktem (weniger detailliert als eine reale Sache) zu verstehen. Dieses Verständnis gilt für viele Dinge außerhalb des Computerbereichs, aber es ist für jeden Softwareentwickler von besonders großer Bedeutung, die Natur von Abstraktionen zu verstehen. In jedem Fall, wenn Ihnen meine Worte fehlen, werden die Beispiele im Code hoffentlich nicht.
Vererbung und Implementierung
Wenn es darum geht, Anwendungen mit einer grafischen Benutzeroberfläche (GUI) zu erstellen, ist die Vererbung wohl der wichtigste Mechanismus, um eine Anwendung schnell erstellen zu können.
Obwohl es einen weniger verstandenen Vorteil der Verwendung von Vererbung gibt, der später besprochen wird, besteht der Hauptvorteil darin, die Implementierung zwischen den Klassen zu teilen . Dieses Wort „Implementierung“ hat zumindest für die Zwecke dieses Artikels eine eindeutige Bedeutung. Um eine allgemeine Definition des Wortes auf Englisch zu geben, könnte ich sagen, dass etwas zu implementieren bedeutet, es real zu machen .
Um eine spezifische technische Definition für die Softwareentwicklung zu geben, könnte ich sagen, dass das Implementieren einer Software das Schreiben konkreter Codezeilen ist, die die Anforderungen dieser Software erfüllen. Angenommen, ich schreibe eine Summenmethode : private double sum(double first, double second){
private double sum(double first, double second){ //TODO: implement }
Das obige Snippet, obwohl ich es so weit geschafft habe, einen Rückgabetyp ( double
) und eine Methodendeklaration zu schreiben, die die Argumente ( first, second
) und den Namen angibt, der zum Aufrufen der Methode ( sum
) verwendet werden kann, hat es nicht umgesetzt worden . Um es zu implementieren , müssen wir den Methodenkörper wie folgt vervollständigen:
private double sum(double first, double second){ return first + second; }
Natürlich würde das erste Beispiel nicht kompilieren, aber wir werden gleich sehen, dass Schnittstellen eine Möglichkeit sind, diese Art von nicht implementierten Funktionen fehlerfrei zu schreiben.
Vererbung in Java
Wenn Sie diesen Artikel lesen, haben Sie vermutlich mindestens einmal das Java-Schlüsselwort „ extends
“ verwendet. Die Mechanik dieses Schlüsselworts ist einfach und wird meistens anhand von Beispielen beschrieben, die mit verschiedenen Arten von Tieren oder geometrischen Formen zu tun haben. Dog
und Cat
erweitern Animal
und so weiter. Ich gehe davon aus, dass ich Ihnen die rudimentäre Typtheorie nicht erklären muss, also lassen Sie uns direkt auf den Hauptvorteil der Vererbung in Java extends
das Schlüsselwort extend eingehen.
Das Erstellen einer konsolenbasierten „Hello World“-Anwendung in Java ist sehr einfach. Angenommen, Sie besitzen einen Java-Compiler ( javac ) und eine Laufzeitumgebung ( jre ), können Sie eine Klasse schreiben, die eine Hauptfunktion wie folgt enthält:
public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }
Das Erstellen einer GUI-Anwendung in Java auf fast allen Hauptplattformen (Android, Enterprise/Web, Desktop) mit ein wenig Hilfe von einer IDE zum Generieren des Skelett-/Boilerplate-Codes einer neuen App ist dank der ebenfalls relativ einfach extends
Schlüsselwort.
Angenommen, wir haben ein XML-Layout namens activity_main.xml
(wir erstellen normalerweise Benutzeroberflächen deklarativ in Android über Layoutdateien), das eine TextView
(wie ein Textlabel) namens tvDisplay
:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>
Angenommen, wir möchten, dass tvDisplay
„Hallo Welt!“ sagt. Dazu müssen wir einfach eine Klasse schreiben, die das Schlüsselwort extends
verwendet, um von der Activity
-Klasse zu erben:
import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }
Die Auswirkung der Vererbung der Implementierung der Activity
-Klasse lässt sich am besten erkennen, wenn man einen kurzen Blick auf ihren Quellcode wirft. Ich bezweifle stark, dass Android die dominierende mobile Plattform geworden wäre, wenn man auch nur einen kleinen Teil der über 8000 Zeilen implementieren müsste, die für die Interaktion mit dem System erforderlich sind, nur um ein einfaches Fenster mit etwas Text zu generieren. Die Vererbung ermöglicht es uns, das Android-Framework oder die Plattform, mit der Sie gerade arbeiten, nicht von Grund auf neu erstellen zu müssen.
Vererbung kann zur Abstraktion verwendet werden
Vererbung ist relativ einfach zu verstehen, da sie verwendet werden kann, um die Implementierung über Klassen hinweg zu teilen. Es gibt jedoch noch einen anderen wichtigen Weg, auf dem Vererbung verwendet werden kann, der konzeptionell mit den Schnittstellen und abstrakten Klassen verwandt ist, die wir bald besprechen werden.
Bitte nehmen Sie für die nächste kleine Weile an, dass eine Abstraktion im allgemeinsten Sinne eine weniger detaillierte Darstellung einer Sache ist . Anstatt das mit einer langwierigen philosophischen Definition zu relativieren, werde ich versuchen aufzuzeigen, wie Abstraktionen im täglichen Leben funktionieren, und sie kurz darauf ausdrücklich in Bezug auf die Softwareentwicklung diskutieren.
Angenommen, Sie reisen nach Australien und sind sich bewusst, dass die Region, die Sie besuchen, eine besonders hohe Dichte an Inland-Taipan-Schlangen beherbergt (sie sind anscheinend ziemlich giftig). Sie beschließen, Wikipedia zu konsultieren, um mehr über sie zu erfahren, indem Sie sich Bilder und andere Informationen ansehen. Dadurch werden Sie sich jetzt einer bestimmten Schlangenart bewusst, die Sie noch nie zuvor gesehen haben.
Abstraktionen, Ideen, Modelle oder wie man sie sonst nennen will, sind weniger detaillierte Darstellungen einer Sache. Es ist wichtig, dass sie weniger detailliert sind als das Original, da eine echte Schlange Sie beißen kann; Bilder auf Wikipedia-Seiten tun dies normalerweise nicht. Abstraktionen sind auch deshalb wichtig, weil sowohl Computer als auch menschliche Gehirne eine begrenzte Kapazität zum Speichern, Kommunizieren und Verarbeiten von Informationen haben. Genügend Details zu haben, um diese Informationen praktisch zu nutzen, ohne zu viel Speicherplatz zu beanspruchen, macht es Computern und menschlichen Gehirnen gleichermaßen möglich, Probleme zu lösen.
Um dies wieder mit der Vererbung zu verknüpfen, können alle drei Hauptthemen, die ich hier erörtere, als Abstraktionen oder Mechanismen der Abstraktion verwendet werden. Angenommen, wir entscheiden uns in der Layoutdatei unserer App „Hello World“, ImageView
, Button
und ImageButton
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>
Angenommen, unsere Aktivität hat View.OnClickListener
implementiert, um Klicks zu verarbeiten:
public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }
Das Schlüsselprinzip hierbei ist, dass Button
, ImageButton
und ImageView
von der View
-Klasse erben. Das Ergebnis ist, dass diese Funktion onClick
von unterschiedlichen (wenn auch hierarchisch verwandten) UI-Elementen empfangen kann, indem sie sie als ihre weniger detaillierte übergeordnete Klasse referenziert. Dies ist viel bequemer, als eine eigene Methode für die Handhabung aller Arten von Widgets auf der Android-Plattform schreiben zu müssen (ganz zu schweigen von benutzerdefinierten Widgets).
Schnittstellen und Abstraktion
Möglicherweise fanden Sie das vorherige Codebeispiel etwas wenig inspirierend, auch wenn Sie verstanden haben, warum ich es ausgewählt habe. Die Möglichkeit, die Implementierung über eine Klassenhierarchie hinweg gemeinsam zu nutzen, ist unglaublich nützlich, und ich würde argumentieren, dass dies der Hauptnutzen der Vererbung ist. Was uns erlaubt, eine Menge von Klassen, die eine gemeinsame Elternklasse haben, als vom Typ her gleich zu behandeln (dh als die Elternklasse ), hat dieses Merkmal der Vererbung nur begrenzten Nutzen.
Mit begrenzt spreche ich von der Anforderung, dass sich untergeordnete Klassen in derselben Klassenhierarchie befinden müssen, um über die übergeordnete Klasse referenziert oder als Elternklasse bezeichnet zu werden. Mit anderen Worten, die Vererbung ist ein sehr einschränkender Mechanismus für die Abstraktion . Wenn ich annehme, dass Abstraktion ein Spektrum ist, das sich zwischen verschiedenen Detailebenen (oder Informationen) bewegt, könnte ich sagen, dass Vererbung der am wenigsten abstrakte Mechanismus für Abstraktion in Java ist.
Bevor ich mit der Erörterung von Schnittstellen fortfahre, möchte ich erwähnen, dass ab Java 8 zwei Funktionen namens Standardmethoden und statische Methoden zu Schnittstellen hinzugefügt wurden. Ich werde sie irgendwann diskutieren, aber für den Moment möchte ich, dass wir so tun, als ob sie nicht existieren. Damit möchte ich es einfacher machen, den Hauptzweck der Verwendung einer Schnittstelle zu erklären, die ursprünglich der abstrakteste Mechanismus für die Abstraktion in Java war und wohl immer noch ist.
Weniger Details bedeuten mehr Freiheit
Im Abschnitt über Vererbung habe ich eine Definition des Wortes Implementierung gegeben, die als Kontrast zu einem anderen Begriff gedacht war, auf den wir jetzt eingehen werden. Um es klar zu sagen, ich interessiere mich nicht für die Wörter selbst oder ob Sie mit ihrer Verwendung einverstanden sind; nur dass Sie verstehen, worauf sie konzeptionell hinweisen.
Während Vererbung in erster Linie ein Werkzeug ist, um die Implementierung über eine Menge von Klassen hinweg zu teilen, könnten wir sagen, dass Schnittstellen in erster Linie ein Mechanismus sind, um das Verhalten über eine Menge von Klassen hinweg zu teilen. In diesem Sinne verwendetes Verhalten ist wirklich nur ein nicht-technisches Wort für abstrakte Methoden . Eine abstrakte Methode ist eine Methode , die eigentlich keinen Methodenkörper enthalten kann:
public interface OnClickListener { void onClick(View v); }
Die natürliche Reaktion für mich und eine Reihe von Personen, die ich unterrichtet habe, nachdem sie sich zum ersten Mal eine Schnittstelle angesehen hatten, war, sich zu fragen, welchen Nutzen es haben könnte, nur einen Rückgabetyp , einen Methodennamen und eine Parameterliste zu teilen. Oberflächlich betrachtet sieht es nach einer großartigen Möglichkeit aus, zusätzliche Arbeit für sich selbst oder andere Personen zu schaffen, die die Klasse schreiben, die die Schnittstelle implements
. Die Antwort ist, dass Schnittstellen perfekt für Situationen sind, in denen Sie möchten, dass sich eine Reihe von Klassen auf die gleiche Weise verhalten (dh sie besitzen die gleichen öffentlichen abstrakten Methoden ), aber Sie erwarten, dass sie dieses Verhalten auf unterschiedliche Weise implementieren .
Um ein einfaches, aber relevantes Beispiel zu nennen: Die Android-Plattform besitzt zwei Klassen, die hauptsächlich dazu dienen, einen Teil der Benutzeroberfläche zu erstellen und zu verwalten: Activity
und Fragment
. Daraus folgt, dass diese Klassen sehr oft die Anforderung haben, auf Ereignisse zu hören, die auftauchen, wenn ein Widget angeklickt wird (oder anderweitig mit einem Benutzer interagiert wird). Nehmen wir uns der Argumentation halber einen Moment Zeit, um zu verstehen, warum die Vererbung ein solches Problem fast nie lösen wird:
public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }
Wenn wir unsere Aktivitäten und Fragmente von OnClickManager
erben lassen würden, wäre es nicht nur unmöglich, Ereignisse auf andere Weise zu handhaben, sondern der Kicker ist, dass wir nicht einmal das tun könnten, wenn wir wollten. Sowohl Activity als auch Fragment erweitern bereits eine übergeordnete Klasse , und Java erlaubt nicht mehrere übergeordnete Klassen . Unser Problem ist also, dass wir möchten, dass sich eine Reihe von Klassen auf die gleiche Weise verhalten , aber wir müssen flexibel sein, wie die Klasse dieses Verhalten implementiert . Dies bringt uns zurück zum vorherigen Beispiel des View.OnClickListener
:
public interface OnClickListener { void onClick(View v); }
Dies ist der eigentliche Quellcode (der in der View
-Klasse verschachtelt ist), und diese wenigen Zeilen ermöglichen es uns, ein konsistentes Verhalten über verschiedene Widgets ( Views ) und UI-Controller ( Activities, Fragments usw. ) hinweg sicherzustellen.
Abstraktion fördert lose Kopplung
Ich habe hoffentlich die allgemeine Frage beantwortet, warum Schnittstellen in Java existieren; unter vielen anderen Sprachen. Aus einer Perspektive sind sie nur ein Mittel, um Code zwischen Klassen zu teilen, aber sie sind absichtlich weniger detailliert, um unterschiedliche Implementierungen zu ermöglichen. Aber ebenso wie die Vererbung sowohl als Mechanismus zum Teilen von Code als auch als Abstraktion verwendet werden kann (wenn auch mit Einschränkungen in der Klassenhierarchie), folgt daraus, dass Schnittstellen einen flexibleren Mechanismus für die Abstraktion bieten.
In einem früheren Abschnitt dieses Artikels habe ich das Thema der losen/festen Kopplung in Analogie zum Unterschied zwischen der Verwendung von Nägeln und Schrauben zum Aufbau einer Art Struktur eingeführt. Um es noch einmal zusammenzufassen, die Grundidee ist, dass Sie Schrauben in Situationen verwenden sollten, in denen eine Änderung der bestehenden Struktur (die das Ergebnis von Befestigungsfehlern, Designänderungen usw. sein kann) wahrscheinlich ist. Nägel sind gut zu verwenden, wenn Sie nur Teile der Struktur aneinander befestigen müssen und sich keine Sorgen machen, sie in naher Zukunft auseinander zu nehmen.
Nägel und Schrauben sollen analog zu konkreten und abstrakten Bezügen (es gilt auch der Begriff Abhängigkeiten ) zwischen Klassen sein. Nur damit keine Verwirrung entsteht, zeigt das folgende Beispiel, was ich meine:
class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }
Hier haben wir eine Klasse namens Client
, die zwei Arten von Referenzen besitzt. Beachten Sie, dass, vorausgesetzt, der Client
hat nichts mit dem Erstellen seiner Referenzen zu tun (das sollte er wirklich nicht), er von den Implementierungsdetails eines bestimmten Netzwerkadapters entkoppelt ist.
Es gibt einige wichtige Implikationen dieser losen Kopplung . Für den Anfang kann ich Client
absolut isoliert von jeder Implementierung von INetworkAdapter
. Stellen Sie sich für einen Moment vor, Sie arbeiten in einem Team von zwei Entwicklern; einer für den Aufbau des Frontends, einer für den Aufbau des Backends. Solange beide Entwickler sich der Schnittstellen bewusst sind, die ihre jeweiligen Klassen miteinander koppeln, können sie die Arbeit praktisch unabhängig voneinander fortsetzen.
Zweitens, was wäre, wenn ich Ihnen sagen würde, dass beide Entwickler überprüfen könnten, ob ihre jeweiligen Implementierungen ordnungsgemäß funktionieren, auch unabhängig vom Fortschritt des anderen? Mit Schnittstellen ist das sehr einfach; Erstellen Sie einfach ein Test Double , das die entsprechende Schnittstelle implements
:
class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }
Grundsätzlich ist zu beobachten, dass die Arbeit mit abstrakten Referenzen die Tür zu einer erhöhten Modularität, Testbarkeit und einigen sehr leistungsfähigen Designmustern wie dem Fassadenmuster , dem Beobachtermuster und mehr öffnet. Sie können es Entwicklern auch ermöglichen, eine glückliche Balance beim Entwerfen verschiedener Teile eines Systems auf der Grundlage des Verhaltens ( Program To An Interface ) zu finden, ohne sich in Implementierungsdetails zu verzetteln.
Ein letzter Punkt zu Abstraktionen
Abstraktionen existieren nicht in gleicher Weise wie ein konkretes Ding. Dies spiegelt sich in der Java-Programmiersprache dadurch wider, dass abstrakte Klassen und Schnittstellen möglicherweise nicht instanziiert werden.
Zum Beispiel würde dies definitiv nicht kompilieren:
public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }
Tatsächlich ist die Idee, zu erwarten, dass eine nicht implementierte Schnittstelle oder abstrakte Klasse zur Laufzeit funktioniert, genauso sinnvoll wie die Erwartung, dass eine UPS-Uniform herumschwebt und Pakete ausliefert. Etwas Konkretes muss hinter der Abstraktion stehen, damit sie nützlich ist; auch wenn die aufrufende Klasse nicht wissen muss, was sich eigentlich hinter abstrakten Referenzen verbirgt .
Abstrakte Klassen: Alles zusammenfügen
Wenn Sie es bis hierher geschafft haben, freue ich mich, Ihnen mitteilen zu können, dass ich keine philosophischen Umlaute oder Fachausdrücke mehr zu übersetzen habe. Einfach ausgedrückt sind abstrakte Klassen ein Mechanismus zum Teilen von Implementierung und Verhalten über eine Reihe von Klassen hinweg. Nun, ich gebe gleich zu, dass ich abstrakte Klassen nicht allzu oft verwende. Trotzdem hoffe ich, dass Sie am Ende dieses Abschnitts genau wissen, wann sie angefordert werden.
Trainingsprotokoll-Fallstudie
Ungefähr ein Jahr nachdem ich Android-Apps in Java erstellt hatte, baute ich meine erste Android-App von Grund auf neu. Die erste Version war die Art von horrender Masse an Code, die Sie von einem Autodidakten mit wenig Anleitung erwarten würden. Als ich neue Funktionen hinzufügen wollte, wurde klar, dass die eng gekoppelte Struktur, die ich ausschließlich mit Nägeln gebaut hatte, so unmöglich zu warten war, dass ich sie komplett neu aufbauen musste.
Die App war ein Trainingsprotokoll, das eine einfache Aufzeichnung Ihrer Trainingseinheiten und die Möglichkeit bietet, die Daten einer vergangenen Trainingseinheit als Text- oder Bilddatei auszugeben. Ohne zu sehr ins Detail zu gehen, habe ich die Datenmodelle der App so strukturiert, dass es ein Workout
-Objekt gibt, das aus einer Sammlung von Exercise
besteht (neben anderen Feldern, die für diese Diskussion irrelevant sind).
Als ich die Funktion zur Ausgabe von Trainingsdaten auf einem visuellen Medium implementierte, stellte ich fest, dass ich mich mit einem Problem auseinandersetzen musste: Verschiedene Arten von Übungen würden verschiedene Arten von Textausgaben erfordern.
Um Ihnen eine ungefähre Vorstellung zu geben, wollte ich die Ausgänge je nach Art der Übung wie folgt ändern:
- Langhantel: 10 WIEDERHOLUNGEN @ 100 LBS
- Kurzhantel: 10 WIEDERHOLUNGEN @ 50 LBS x2
- Körpergewicht: 10 WIEDERHOLUNGEN @ Körpergewicht
- Körpergewicht +: 10 WIEDERHOLUNGEN @ Körpergewicht + 45 LBS
- Zeitgesteuert: 60 SEK. @ 100 LBS
Bevor ich fortfahre, beachten Sie, dass es andere Arten gab (das Training kann kompliziert werden) und dass der Code, den ich zeigen werde, gekürzt und geändert wurde, damit er gut in einen Artikel passt.
In Übereinstimmung mit meiner vorherigen Definition besteht das Ziel beim Schreiben einer abstrakten Klasse darin, alles zu implementieren (sogar Zustände wie Variablen und Konstanten ), was von allen untergeordneten Klassen in der abstrakten Klasse gemeinsam genutzt wird. Erstellen Sie dann für alles, was sich über die untergeordneten Klassen hinweg ändert, eine abstrakte Methode :
abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }
Ich sage vielleicht das Offensichtliche, aber wenn Sie Fragen dazu haben, was in der abstrakten Klasse implementiert werden sollte oder nicht, ist der Schlüssel, sich jeden Teil der Implementierung anzusehen, der in allen untergeordneten Klassen wiederholt wurde.
Nachdem wir festgestellt haben, was allen Übungen gemeinsam ist, können wir damit beginnen, untergeordnete Klassen mit Spezialisierungen für jede Art von String-Ausgabe zu erstellen:
Langhantel-Übung:
class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }
Hantel Übung:
class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }
Körpergewichtsübung:
class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }
Ich bin sicher, dass einige aufmerksame Leser Dinge finden werden, die auf effizientere Weise hätten abstrahiert werden können, aber der Zweck dieses Beispiels (das gegenüber der Originalquelle vereinfacht wurde) besteht darin, den allgemeinen Ansatz zu demonstrieren. Natürlich wäre kein Programmierartikel vollständig ohne etwas, das ausgeführt werden kann. Es gibt mehrere Online-Java-Compiler, mit denen Sie diesen Code ausführen können, wenn Sie ihn testen möchten (es sei denn, Sie haben bereits eine IDE):
public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }
Executing this toy application yields the following output: Barbell Bench Press
10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight
Further Considerations
Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .
I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.
I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class
containing the requisite implementation in a static method :
public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }
Using this static method in an external class (or another static method ) looks like this:
public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }
Cheat Sheet
We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.
I Want A Set Of Child Classes To Share Implementation
Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.
While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .
I Want A Set Of Classes To Share Behavior
Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.
By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .
I Want A Set Of Child Classes To Share Behavior And Implementation
Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class
, and anything which requires flexibility may be specified as an abstract method .