Objektorientierung – wir modellieren die Welt
Wir haben in den letzten Kapiteln schon sehr viel von Objekten und Klassen gelesen, ohne aber explizit darauf einzugehen, was „Objektorientierung“ eigentlich ist. Dies werde ich in diesem Kapitel nachholen. Bis jetzt haben wir keinen objektorientierten, sondern einen prozeduralen Ansatz bei unseren kleinen Testprogrammen verfolgt. Dieser Ansatz kommt dem internen Ablauf unseres Prozessors – also dem Maschinencode – prinzipiell am nächsten. Dieser Ansatz bringt aber ein Problem bei bestimmten Programmieraufgaben mit sich.
Top
Das prozedurale Problem
Gehen wir mal davon aus, wir wollen einen Getränkeautomaten simulieren. Unser Getränkeautomat soll sehr vereinfacht aufgebaut sein, so dass wir nur drei verschiedene Getränkedosen aus dem Automaten entnehmen können – Cola, Limonade und Wasser und alle sollen gleich viel kosten, nämlich 1 EUR:
Modell Getränkeautomat
Abb.: 1: Modell Getränkeautomat
Der Getränkeautomat hat nun zwei Eingangsgrößen – das Geld und die Information über den Knopfdruck. Für unser Modellprogramm definieren wir einfach die Auswahl mit Zahlen. 0 steht für Wasser, 1 für Limonade und 2 für Cola. Wenn wir 1 EUR einwerfen und einen der drei Knöpfe drücken, kommt eine Dose des gewählten Getränks heraus. Wenn wir zu viel oder zu wenig Geld einwerfen und einen Knopf drücken, bekommen wir einfach das Geld wieder zurück. Dies ist eigentlich ein recht einfacher Algorithmus, der aus Struktogrammsicht wie folgt aussieht:
Modellhafter Algorithmus des Getränkeautomaten
Abb.: 2: Modellhafter Algorithmus des Getränkeautomaten
Wir gehen hier einfach davon aus, dass wir nur die Werte 0, 1 oder 2 bei der Auswahl erhalten können und wir somit auch kein Errorhandling diesbezüglich benötigen. Dies ist deshalb tragbar, da der Automat ja auch nur drei Knöpfe hat – es gibt also keine Möglichkeit einen anderen Knopf außer 0, 1 oder 2 zu betätigen. Nachdem wir den Algorithmus nun erstmal prozedural umsetzen wollen, nutzen wir die einzige bis dato besprochene rein prozedurale Programmiersprache, nämlich C:
Listing 1: Prozedurale Umsetzung des Getränkeautomaten in C
Kurze Erklärung der Codezeilen: Das Unterprogramm wird als erstes definiert. Der Rückgabewert soll ein Text sein, weshalb wir hier einen Pointer auf das erste char Zeichen des Textes zurückgeben. Die Parameter sind wie beschrieben der ausgewählte Typ "selection" und das Geld ("coin"). Der Speicherbereich des Rückgabestrings muss zuerst über malloc() reserviert werden, da wir den String außerhalb des Unterprogramms weiterverarbeiten (sprich ausgeben) wollen. Danach folgen die Prüfungen entsprechend des Struktogramms. Die Konvertierung einer Zahl in einen String (also für die Rückgabe des nicht passenden Geldbetrages) können wir in C über sprintf realisieren. Diese Funktion funktioniert wie printf, lediglich wird nicht auf die Konsole geschrieben, sondern in einen String (als char*). Die Konstantwerte für die Texte „Wasser“, „Limonade“ und „Cola“ werden über strcpy in den output String übernommen. Der Aufruf in der Main-Funktion erwartet nun die Parameter für den gewählten Getränketyp und den eingeworfenen Geldbetrag. Am Ende geben wir den reservierten Speicherplatz wieder mit free() wieder frei.
Wenn wir das Programm starten, sehen wir den korrekten Wert:
Leider hat unser Programm einen kleinen Makel im Vergleich zu einem realen Getränkeautomaten. Wenn wir unsere Main-Funktion wie folgt anpassen:
Listing 2: Zyklischer Aufruf des Getränkeautomaten in C
sollte uns das Problem klar werden. Wir können streng genommen unendlich viele Getränkedosen entnehmen – unser Automat wird niemals leer. Dieses Manko können wir nun über zwei Wege korrigieren. Der erste wäre über eine globale Variable, in der wir den Füllstand unser drei verschiedenen Getränkesorten vorhalten. Hierzu erzeugen wir ein Array, welches auf Position 0 den Füllstand von Wasser, auf 1 den von Limonade und auf 2 den von Cola hinterlegt. Wenn wir nun ein Getränk wählen, welches ausverkauft ist – also an der entsprechenden Position die 0 steht, dann soll eine Meldung samt Geld herausgegeben werden. Passen wir also erstmal unser Struktogramm entsprechend an.
Modell Getränkeautomat mit Füllstandsprüfung
Abb.: 3: Modell Getränkeautomat mit Füllstandsprüfung
Auch hier verzichten wir auf die Überprüfung, ob die Auswahl eventuell außerhalb der Wertemenge 0 bis 2 ist. Dies wäre nun der zugehörige Code:
Listing 3: Zyklischer Aufruf des Getränkeautomaten in C inklusive Füllstandsprüfung
Der Code dürfte soweit inzwischen selbsterklärend sein. Wenn wir ihn laufen lassen sehen wir, dass nach 29 Aufrufen die Ausgabe nur noch Rueckgabe: 1.000000 leer ist. Insofern haben wir das Problem zwar in den Griff bekommen, es ist aber nicht wirklich „schön“. Der Grund liegt darin, dass wenn wir nun zwei Getränkeautomaten mit unserem Code realisieren wollen, wir nun zwei globale Arrays ( fuellstand0 und fuellstand1) erzeugen müssen und die Funktion getCan ebenfalls doppelt auslegen, um in dem einen Programm auf fuellstand0 und in dem anderen auf fuellstand1 zuzugreifen. Alternativ könnten wir ein zweidimensionales Array anlegen und mit Hilfe eines weiteren Parameters dem Programm sagen, um welchen Automaten es sich handelt:
Listing 4: Ergänzung um zweiten Getränkeautomaten
Grundsätzlich wäre dies eine umsetzbare Lösung, wenngleich es nicht wirklich ein zufriedenstellender Weg wäre – da wir die Daten der verschiedenen Automaten in einem Array kombinieren würden. Stellen wir uns vor, die beiden Automaten sollen unabhängig voneinander betrieben werden. Wir fügen also Daten zusammen, die eventuell nicht zusammengehören. Eine mögliche weitere Alternative wäre, pro Automaten ein Array zu erstellen und dieses dann über die Parameterliste zu übergeben. Dadurch würde die „getCan“ Funktion zustandslos werden – sprich alle notwendigen Informationen werden ausschließlich über die Parameterliste übergeben. Das Programm hätte dann in etwa folgende Form:
Listing 5: Zwei Getränkeautomaten mit stateless Unterprogramm
Mit dieser Lösung haben wir also die Möglichkeit, die Daten der beiden Getränkeautomaten unabhängig voneinander zu verwalten. Es bleibt aber ein Manko, welches wir mit prozeduralen Programmieransätzen nicht loswerden können – die Trennung von Funktion und Daten. Ein realer Getränkeautomat ist nicht ein Gerät mit Funktion oder ein Gerät mit Getränkedosenspeicher, sondern beides. Er hat die Funktionalität der Getränkeausgabe basierend auf den Eingabewerten und er hat intern den Speicher für die Getränkedosen. In unserem prozeduralen Code ist dies aber strikt getrennt. Die liegt daran, dass die Variablen, welche innerhalb der Unterprogramme deklariert und initialisiert wurden ungültig werden, sobald das Unterprogramm abgearbeitet wurde. Wir müssen die Variablen also zwingend außerhalb der Unterprogramme erzeugen. Die Welt um uns herum ist jedoch anders aufgebaut. Objekte sind im Regelfall Konstrukte mit Speichern und Funktionalität. Und genau an dieser Stelle setzt die objektorientierte Programmierung an. Ein Objekt einer objektorientierten Programmiersprache hat ebenfalls diese beiden Dimensionen – es hat Speichermöglichkeiten und Funktionalität. Da C nun keine objektorientierte Sprache ist, schwenken wir für die nächsten Gedankengänge auf eine andere Programmiersprache um. Im Prinzip ist es nun egal, welche wir verwenden. Ich habe mich hier aber für Java entschieden, da Java die Konzepte ohne große Umschweife implementiert. Am Ende des Kapitels werde ich aber für die einzelnen Konzepte der Objektorientierung die Syntaxvarianten der anderen Sprachen C++, C#, JavaScript, PHP und Python ansprechen. Den Code entwickle ich hier zwar in Java, ich werde die wesentlichen Elemente aber in allen Sprachen unter https://github.com/maikaicher/book1 zur Verfügung stellen.
Top
Zusammenschluss von Daten und Funktionalität
Beginnen wir mit der Umsetzung unseres Getränkeautomaten in objektorientierter programmierweise. Wir müssen an dieser Stelle noch nicht alles verstehen – wir sollen lediglich eine Idee bekommen, was hinter dem objektorientierten Ansatz steckt. Sehen wir uns den Code in Java an, welcher die Funktionalität in einer Klasse realisiert. Java verlangt, dass jede Klasse in einem eignen File liegt (Ausnahmen wären hier „nested Classes“, was hier aber erstmal keine Rolle spielt). Für unser Programm benötigen wir also nun zwei Files – eines für die Klasse des Getränkeautomaten und eines für das Hauptprogramm:
Listing 6: Klasse für Getränkeautomat
Kurze Erklärung der Codezeilen: Dieser Code kapselt die gesamte Funktionalität eines Getränkeautomaten, so wie wir ihn designed haben, in einem File zusammen. Die Details der einzelnen objektorientierten Elemente werden wir später genauer ansehen. Soviel sei vorab gesagt – das Array für den Füllstand der drei Getränketypen befindet sich jetzt nicht mehr im Hauptprogramm, sondern in der Klasse bzw. eigentlich dem Objekt. Die Funktionalität der getCan() Methode ist in der Lage, auf dieses Array zuzugreifen. Die Methode VendingMachine() mit dem Arrayparameter ist dafür zuständig, bei Erzeugung des Objektes die initialen Füllstandswerte zu übernehmen – man nennt diese Methode den „Konstruktor“.
Sehen wir uns nun die Nutzung dieser Klasse im Rahmen des Hauptprogramms mal an:
Listing 7: Nutzung des Getränkeautomaten als Objekt
Kurze Erklärung der Codezeilen: Wir erzeugen aus der Klassendefinition nun zwei unterschiedliche Objektinstanzen (oft kurz „Objekte“ genannt), g0 und g1. Dies sind die beiden Getränkeautomaten. Bei der Erzeugung übergeben wir ein neu erzeugtes int Array mit den Füllstandsinformationen. Danach laufen wir 100 mal durch die Schleife und wählen mit der 1 die Limonade und übergeben den korrekten Betrag von 1.0.
Wie wir sehen, sind die Funktionalitäten, welche ausschließlich den Getränkeautomaten betreffen, nun in der Klasse VendingMachine hinterlegt. Die Mainmethode nutzt somit lediglich die Methoden des Objektes, um mit ihm zu arbeiten. Mit Hilfe des Punktoperators greifen wir pro Objekt auf die dort liegenden Methoden zu. Die Idee, Daten wie die Füllstände und die Funktionen wie das Entnehmen von Dosen in einem Konstrukt zu verbinden, bietet uns aus Sicht der Nutzung nun den Vorteil der Übersichtlichkeit. Die für uns hier also wichtigste Erkenntnis ist somit:
  • Objekte haben Eigenschaften
  • Objekte haben Funktionalitäten
Die Eigenschaften sind immer Objektvariablen – auch Instanzvariablen genannt. Die Funktionalitäten wiederum sind in Unterprogrammen realisiert, die wir „Methoden“ nennen. Diese Methoden haben einen direkten Zugriff auf die Eigenschaften des Objekts. Unsere Methode getCan() kann bspw. direkt auf die Instanzvariable canCount zugreifen. Dazu müssen wir diese Variable nicht gesondert kennzeichnen.
Top
Klasse – ein Plan mit Hierarchie
Nun wollen wir diese Grundidee, die Welt in einer objektorientierten Sprache zu modellieren, noch einen Schritt weiterführen. In den vorausgegangenen Absätzen wurde immer von „Objekten“ und von „Klassen“ gesprochen. Diesen Unterschied müssen wir jetzt erst einmal klären, da er essenziell für das Verständnis dieses Programmierkonzeptes ist. Am besten vergleicht man ein Objekt mit einem Haus und eine Klasse mit dem zugehörigen Plan. Der Plan legt exakt fest, welche Eigenschaften und Funktionalitäten ein Haus haben muss. Das Objekt wiederum ist die Umsetzung des Plans.
Klasse vs. Objekt am Beispiel eines Hauses
Abb.: 4: Klasse vs. Objekt am Beispiel eines Hauses
Man sagt auch, dass man durch die „Instanziierung“ einer Klasse das Objekt erzeugt, weshalb Objekte auch als „Objektinstanzen“ bezeichnet werden. Wir werden dieses Bild des Plans und des Hauses noch öfter bemühen müssen, um die Konsequenzen mancher Konzepte besser zu verstehen. Das erste wichtige Detail ist, dass wir basierend auf einem Plan beliebig viele Häuser „bauen“ können. Genau dies haben wir ja auch mit unseren Getränkeautomaten gemacht – wir hatten eine Klasse VendingMachine und haben mit g0 und g1 zwei Instanzen dieser Klasse erstellt. Weiterhin haben wir gesehen, dass die Objekte unabhängig voneinander sind. Die beiden Getränkeautomaten-Objekte hatten für die drei Getränkekategorien unterschiedliche Füllstände. Gleiches gilt für die Häuser – sie sollten bspw. die Möglichkeit haben, unterschiedliche Werte für die Türnamensschilder zu akzeptieren. Nun, wenn wir jetzt die Klasse als den „Plan“ unserer Objekte identifiziert haben, dann wird unser Listing 6 somit zum Plan. Wir können die Klasse also in Form des Codes – oder auch des Kompilierergebnisses auf unserer Festplatte als Datei „sehen“. Die Frage ist jetzt, wo können wir jetzt unser Objekt „ansehen“? Die Antwort ist: nirgends! Das Objekt befindet sich in unserem Speicher – im Heap, bzw. während der Verarbeitung sind die Methoden im Stack. Sie existieren also nur zur Laufzeit. Wir beschäftigen uns also beim Programmieren ausschließlich mit den Klassen und die Objekte entstehen erst dann, wenn unsere Programme laufen. Die nächste zu klärende Frage wäre, warum man die Klassen jetzt „Klassen“ und nicht etwa „Pläne“ getauft hat. Wenn wir schon Pläne schreiben, warum nennen wir es nicht so? Hier kommt nun das nächste Konzept der Objektorientierung zum Tragen, die Vererbung.
Nehmen wir an, wir wollen ein Grafikprogramm realisieren. Hierzu möchten wir gerne unterschiedliche Grafikelemente verwalten:
  • Linie
  • Rechteck
  • Kreis
  • Quader
  • Kegel
Wir können nun diesen Elementen Eigenschaften zuordnen:
Linie: Rechteck: Kreis: Würfel: Kegel:
Randfarbe X X X X X
Füllfarbe X X X X
Umfang X X
Fläche X X X X
Volumen X X
Länge X
Rechtecklänge X X
Rechteckbreite X X
Kreisradius X
Kantenlänge X
Grunddurchmesser X
Spitzenhöhe X
Tabelle 1: Eigenschaften von Grafikelementen
Die ersten fünf Eigenschaften gelten jeweils für mehrere Elemente. So können wir beispielsweise die Randfarbe bei allen Elementen feststellen, einen Umfang besitzen jedoch nur die zweidimensionalen Elemente. Dies bedeutet also, dass wir die einzelnen Elemente nach deren Eigenschaften „klassifizieren“ können. Beispielsweise können wir sagen, dass alle fünf Elemente zur Klasse der Grafikelemente gehören. Das Rechteck und der Kreis gehören zur Klasse der ZweiDElm, der Würfel und der Kegel zu den DreiDElm. Diese Klassenstruktur lässt sich auch grafisch darstellen:
Vereinfachtes Klassendiagramm Grafikelemente
Abb.: 5: Vereinfachtes Klassendiagramm Grafikelemente
Das Diagramm ist in unserem Fall so gestaltet, dass die allgemeineren Klassen oben stehen. Das muss zwar nicht zwingend so sein, da die Pfeile die Richtung bestimmen, es ist aber üblich die Pfeile von unten nach oben zeigen zu lassen. Bis auf die oberste Klasse können wir also bei allen eine Klasse identifizieren, welche darübersteht. Dies sind die „Elternklassen“. Jede Eigenschaft, die eine Elternklasse besitzt, geht automatisch auf die Kindklasse über. Dadurch haben wir nicht mehr die Notwendigkeit, beispielsweise in der Quader Klasse eine Variable für die Volumen-, Flächen-, Füllfarben- und Randfarbeinformationen zu implementieren. Dieses Phänomen nennt man „Vererben“ – die Elternklassen vererben ihre Eigenschaften und Methoden an die Kindklassen. Aus Sicht der Kindklassen bezeichnet man diesen Vorgang „Erweitern“ – eine Kindklasse erweitert die Methoden und Eigenschaften der Elternklasse um neue Methoden bzw. Eigenschaften. In unserem Diagramm finden wir nun immer nur eine Elternklasse pro Kindklasse – es handelt sich hier um „Einfachvererbungen“. Die meisten objektorientierten Programmiersprachen unterstützen nur diese Einfachvererbung. C++ und Python hingegen erlauben mehrere Elternklassen pro Kindklasse, was wir als „Mehrfachvererbung“ bezeichnen. Hiermit sollte man eher vorsichtig sein, da sich die Komplexität und vor allem auch die Gefahr von Konflikten im Vergleich zu Einfachvererbungen erheblich erhöht.
Wir können nun eine beliebige Klasse nehmen, welche aus unserer Sicht sinnvolle Eigenschaften und Methoden aufweist. Diese können wir dann mit eigenen, neuen Eigenschaften und Methoden ergänzen. Angenommen, jemand hat die „ZweiDElm“ Klasse bereits geschrieben und wir möchten diese Funktionalitäten für unsere Rechteck Klasse nutzen. Wir nehmen somit einfach diese ZweiDElm Klasse und erweitern sie in der Kindklasse „Rechteck“. Hier sehen wir, dass der Begriff „Erweitern“ eigentlich sinnvoller als „Vererben“ ist, da dies der wesentliche Vorgang ist, den wir beim Programmieren verfolgen. Wenn jemand eine Klasse geschrieben hat, muss er dies nicht zwangsläufig mit der Option der „Vererbung“ gemacht haben. Erst wenn ein Programmierer den Funktionsumfang der Klasse zwar als sinnvoll, nicht aber als ausreichend ansieht, wird er davon eine Kindklasse erstellen. Nun wird erst klar, dass hier eine Vererbung – oder eben Erweiterung – stattfindet. Dies erklärt auch, warum in dem Diagramm aus Abbildung 5 die Zuordnungspfeile von der Kind- zur Elternklasse führen – man nenn diese Pfeile somit auch „Erweiterungspfeile“. In Programmiersprachen wie Java führt auch das Schlüsselwort „extends“ zu einer entsprechenden Vererbung/Erweiterung von Klassen. Wenn man allerdings nicht möchte, dass seine Klasse von anderen Klassen erweitert wird, kann man dies mit entsprechenden Schlüsselwörtern auch verhindern – man „versiegelt“ die Klasse. Wir werden die einzelnen Codefragmente hierfür gleich kennenlernen. Nun ist aber das Diagramm streng genommen nicht vollständig. Es fehlt „die Mutter aller Klassen“ – also die Elternklasse, von der jedes Objekt erbt, was aber keine expliziten Vorfahren hat – in unserem Fall müssten wir eigentlich die diese „Urklasse“ als Elternklasse bei GrafikElm ergänzen. Diese erbt ohne unser Zutun automatisch von dieser „Urklasse“. In Java ist dies die Klasse „Object“. Jede Klasse weist also die Eigenschaften und Methoden der Klasse „Object“ auf. Da aber grundsätzlich „Object“ der Urvorfahre aller Klassen ist, wird sie nicht in den Diagrammen eingezeichnet.
Top
Zugriffsmodifikatoren
Der nächste Punkt ist die Frage ob es sinnvoll ist, dass sämtliche Eigenschaften und Methoden, welche innerhalb von Klassen existieren, von außen abgreifbar sein sollen. Möchten wir beispielsweise interne Hilfsprogramme, welche zur sauberen Strukturierung von Code notwendig sind, aber für außenstehende Nutzer der Klassen irrelevant oder gar verwirrend sind, allen Nutzern der Klasse zugänglich machen? Das wäre so, als würden wir in einem Auto jeden Schalter, jede Stellschraube, alle Sicherungen, jeden Einfüllstutzen für alle möglichen Flüssigkeiten rund um den Fahrer platzieren – insofern können wir die Frage mit „nein“ beantworten, da das Ganze dann viel zu unübersichtlich wird. Wir wollen als Programmierer also die Kontrolle darüber haben, wer welche Eigenschaften und Methoden unserer Klassen nutzen kann. Hier hilft uns die „Sichtbarkeit“ von Variablen und Methoden weiter, die im Englischen „Access Modifier“ genannt werden. Diese kontrollieren die Zugriffe basierend auf den aktuellen Nutzerbereich des Zugreifenden. Im Wesentlichen gibt es vier verschiedene Nutzerbereiche von Klassen. Um diese zu verstehen, sehen wir uns folgende Situation an:
Nutzung einer Klasse
Abb.: 6: Nutzung einer Klasse
Wir haben die Anforderung, unser Klassendiagram um eine Dreiecksklasse zu erweitern und es im Rahmen eines Grafikprogramms zu nutzen (was durch den Strich, genannt „Assoziation“ zwischen Grafikprogramm und Dreieck symbolisiert wird). Insofern können wir aus Sicht der existierenden Klassen „Dreieck“ und „ZweiDElm“ drei Bereiche definieren, in denen wir Methoden finden können:
  • Alle Methoden der eigenen Klasse, bspw. „printUmfang()“ der Klasse „ZweiDElm“.
  • Alle Methoden der „Nachfahren“, bspw. „berechneFlaeche()“ der Klasse „Dreieck“ aus Sicht von „ZweiDElm“.
  • Alle Methoden außerhalb des Vererbungsbaumens, bspw. „zeichneGrafik()“.
Neben diesen drei offensichtlichen Bereichen gibt es noch eine vierte. Oftmals möchte man funktional zusammengehörige Files eines Projektes so zusammenfassen, dass sie in anderen Projekten oder auch von anderen Programmierern später genutzt werden können. Man „verpackt“ also seinen Code in größere Einheiten. Diese Einheiten nennt man in Java „Packages“, in C# Assemblys und in anderen Programmiersprachen unter Anderem „Module“. Bei vielen Programmiersprachen kann man die Sichtbarkeit nun auch in Bezug auf diese Packages einstellen:
  • Alle Methoden, welche im gleichen Paket zusammengefasst wurden.
Die zentrale Frage der Sichtbarkeit ist nun, welche Methoden und Variablen „sieht“ jetzt eine Methode einer Klasse (bzw. wer kann was nutzen). Kann ich in der Methode „berechneFlaeche()“ in „Dreieck“ auf die Variable „flaeche“ der darüberliegenden Klasse „FlaechenElm“ zugreifen oder nicht? Versuchen wir dies mal durchzuspielen. Unsere neue Dreiecksklasse bildet Dreiecke ab, die durch drei Seiten bestimmt sind, Seite A, B und C. Nun wollen wir den Umfang und die Fläche berechnen. Da gerade die Flächenberechnung bei drei gegebenen Seiten über die Heronische Flächenformel etwas „komplizierter“ ist, lagern wir die Berechnungsalgorithmen jeweils in eigene Methoden aus, „berechneFlaeche()“ und wenn wir schon dabei sind „berechneUmfang()“ auch. Diese sollen automatisch aufgerufen werden, sobald ein Dreiecksobjekt erzeugt wird. Um diesen Aufruf werden wir uns später kümmern, sobald wir über den Konstruktor sprechen. Aus Sicht der Klassen „FlaechenElm“ und „ZweiDElm“ wäre es jetzt also gut, wenn das Dreieck als „Nachfahre“ im Vererbungsbaum einen Zugriff auf die Variablen „Flaeche“ und „Umfang“ darüber hat. Allerdings möchte der Autor dieser beiden Klassen nicht, dass andere außerhalb des Vererbungsbaumes – bspw. das Grafikprogramm – auf diese Variablen Zugriff hat, damit sie nicht „aus Versehen“ verändert werden. Es sollen also ausschließlich „Nachfahren“ den Zugriff erhalten. Aus Sicht des Dreiecks wiederum sind die Methoden berechneUmfang() und berechneFlaeche() nur Hilfsmethoden für den internen Gebrauch. Man möchte nicht, dass bspw. eine Erweiterung der Dreiecksklasse (also ein „Kind“ des Dreiecks – sagen wir mal eine eigene Klasse für rechtwinklige Dreieecke) auf diese Methoden Zugriff hat. Diese Methoden sind also nur für den „Eigengebrauch“. Der Wert „Fuellfarbe“ jedoch, soll für alle zugreifbar sein – also auch außerhalb der „Familie“ und somit auch durch das Grafikprogramm. Es handelt sich also um eine Eigenschaft, welche aus Sicht der FlaechenElm Klasse nach Außen in die Öffentlichkeit geführt wird.
Diese Zugriffsbereiche haben in den objektorientierten Programmiersprachen eigene Namen bekommen:
Bezeichnung: Bedeutung: Diagrammsymbol:
public Jeder, der eine Referenz auf das Objekt besitzt darf zugreifen. +
private Nur das eigene Objekt darf auf zugreifen -
protedted Nur die Nachfahren dürfen zugreifen. #
package Alle Klassen des aktuellen Pakets (oder Assembly) dürfen zugreifen. ~
Tabelle 2: Access Modifier
Wenn wir dieses Wissen nun auf unsere Klassen anwenden, dann wäre bspw. die Methode "berechneFlaeche()"" private, die Variable "Flaeche" protected und die Variable "Fuellfarbe" public. Sammeln wir nun mal die Access Modifier der einzelnen objektorientierten Programmiersprachen:
Gleiches Paket: Anderes Paket:
Nutzbar innerhalb: Klasse Kind-
klasse
Fremd-
klasse
Kind-
klasse
Fremd-
klasse
Modifier: Keyword(1):
Java private private X
package X X X
protected protected X X X X
public public X X X X X
C# private Private X
package Internal X X X
protected Private Protected X X
Protected X X X
Protected Internal/span> X X X X
public Public X X X X X
C++ private private X
protected protected X X
public public X X X
Python private __myVariable(2) X
protected _myVariable(2) X X X
public public X X X X X
PHP private private X
protected protected X X
public public X X X
JavaScript private private X
public public X X X
Tabelle 3: Access Modifier und Auswirkungen der einzelnen Programmiersprachen
(1) Leer: Defaulteinstellung - also ohne zusätzliches Keyword
(2) Nur Konvention – also ein unverbindlicher Hinweis an andere Programmierer. Privater und protected Zugriff von außen ist nach wie vor möglich. Details finden sich weiter unten.
Da C++ und PHP keine Packages kennen, sind die entsprechenden Felder leer.
Top
Konstruktoren - Bob der Baumeister
Was uns in unserem kleinen Grafikprojekt jetzt noch fehlt, ist der Konstruktor. Dies ist eine Methode, welche immer dann aufgerufen wird, wenn wir aus einer Klasse ein Objekt erstellen wollen. Bleiben wir kurz bei unserem Dreieck. Wenn wir ein Dreiecksobjekt erstellen wollen, benötigen wir die Informationen über die drei Seiten A, B und C. Ohne diese Information können wir mit unserem Dreiecksobjekt nichts anfangen. Wir haben also die Notwendigkeit, bei der Erzeugung von dem Nutzer unserer Klasse zwingend abzufragen, wie die Werte für die drei Seiten sind. Dieses Abfragen übernimmt der Konstruktor. Wir haben in den oberen Kapiteln schon öfter Objekte erzeugt und haben dort mehr oder weniger bewusst den Konstruktor genutzt. In den allermeisten Programmiersprachen rufen wir den Konstruktor auf, indem wir den Klassennamen verwenden. Wenn also unsere Klasse „Dreieck“ heißt, dann heißt der Konstruktor „Dreieck()“. Wir werden sehen, dass in den meisten anderen Programmiersprachen (und somit auch Java) nicht nur der Konstruktoraufruf dem Klassennamen entspricht, sondern auch die Definition der Konstruktormethode im Code so heißt wie die Klasse, was ja auch intuitiv sinnvoll ist, aber nicht bei allen Programmiersrpachen so gehandhabt wird. Ausnahmen hier bilden bspw. Python und PHP. Meist wird vor dem Konstruktor noch das Schlüsselwort „new“ benötigt. Bevor wir uns nun in den Code stürzen, benötigen wir noch eine allgemeine Überlegung zum Thema „Dreiecke“. Ein Dreieck, das wir über die drei Seiten bestimmen, darf nicht mit beliebigen Seitenlängen belegt werden. Wenn eine Länge A = 3, Länge B = 4 und Länge C = 5 ist, haben wir kein Problem. Erhöhen wir aber bspw. die Länge C so, dass sie größer oder gleich der Summe von A und B ist, können wir kein Dreieck mehr zeichnen:
Mögliches und unmögliches Dreieck
Abb.: 7: Mögliches und unmögliches Dreieck
Wir müssen also vor der Belegung der Werte im Konstruktor prüfen, ob die Werte überhaupt sinnvoll sind. Die Frage ist aber, was wir dann tun. In einem späteren Kapitel werde ich auf die Fehlerbehandlung mittels Exceptions eingehen – bis dahin müssen wir uns anders behelfen. Wir belegen einfach die Instanzvariablen der drei Seiten mit einem sinnvollen Wert, bspw. jeweils die 1. Wenn die über den Konstruktor übergebenen Werte sinnvoll sind, dann übernehmen wir sie, ansonsten belassen wir die ursprünglichen Werte. Diese Überprüfung lagern wir in eine eigene Methode aus, die wiederum private sein soll, da sie nur für den internen Gebrauch innerhalb des Dreiecks bestimmt ist.
Schreiben wir also unsere Klassen – am Beispiel von Java. Wir erzeugen in VSCode erstmal ein package, namens „mygraphs“. Hierzu gehen wir im VSCode Explorer auf den Bereich „JAVA PROJECTS“ und klicken hier mit der rechten Maustaste auf unser Projekt (Standard wäre hier der Name des Ordners, in dem sich das Paket befindet) und wählen „New Package“ aus:
Erstellung eines Java Packages in VSCode
Abb.: 8: Erstellung eines Java Packages in VSCode
Danach erscheint das Package im Projekt: {} mygraphs(3). Dadurch wird auch ein Unterordner auf dem Filesystem mit diesem Namen erzeugt. Mit dem + neben dem Package können wir jetzt die Klassen hinzufügen. Die Angabe des Packages wird dann automatisch in das File eingefügt. Für unser Dreieck benötigen wir also nun eine eigene Klasse, welche die drei Werte für die drei Seiten A, B und C und die sonstigen Funktionalitäten vorhält. Sehen wir uns den Code für die Klassen mal an. Hierbei finden wir in Java jede Klasse in einem eigenen *.java File, welches den gleichen Namen wie die Klasse trägt. Die Klasse GrafikElm liegt also im File GrafikElm.java:
(3) Wenn der Code innerhalb des Projektes in einem Unterordner liegt, so werden die darüberliegenden Ordner eben-falls in den Package-Namen aufgenommen
Listing 8: Klasse GrafikElm
Listing 9: Klasse FlaechenElm
Listing 10: Klasse ZweiDElm
Listing 11: Klasse Dreieck
Kurze Erklärung der Codezeilen: Die Klassen GrafikElm, FlaechenElm und ZweiDElm sind in diesem Stand unseres Codes lediglich Behälter für Daten wie fuellfarbe oder flaeche, da wir hier (noch) keine Methoden eingebaut haben. Die beiden Farbwerte initialisieren wir gleich mit dem Wert 0. FlaechenElm wird mit dem Schlüsselwort „extends“ aus der Klasse GrafikElm erzeugt – also erweitert FleachenElm die Klasse GrafikElm – in unserem Fall um die Eigenschaft flaeche. Die Variable randfarbe wird als public deklariert und ist somit von außen zugreifbar. Die Variable flaeche wiederum ist protected, weshalb wir bei den Nachfahren darauf zugreifen können, nicht aber außerhalb des Vererbungsbaumes. Wir werden dies gleich bei der Nutzung im Rahmen des Hauptprogramms sehen. Die Klasse Dreieck wiederum hat die drei Variablen für die Seitenlängen (welche wir gleich mit 1 belegen), die beiden Berechnungsmethoden, die Prüfmethode für die Längen und den Konstruktor. Hier fällt die Verwendung des Schlüsselwortes „final“ bei der Klassendefinition auf, wodurch wir verhindern, dass weitere Klassen von unserer Dreiecksklasse abgeleitet werden können. Solche Klassen nennen wir „versiegelt“. Im Konstruktor sehen wir, dass er in Java genauso heißt wie die Klasse und dass er keinen Rückgabedatentypen aufweist. Dies ist nicht notwendig, da wir beim Konstruktoraufruf ohnehin nur das Objekt erwarten. Die Werte der Parameterliste a, b und c werden zuerst durch die checkValues() Methode überprüft und bei korrekten Werten direkt in Seitenlängenvariablen übernommen. Die checkValues() Methode prüft also lediglich, ob die Summe von zwei beliebigen Seiten nicht größer ist als die restliche Seite. Bei fehlerhaften Werten wird false zurückgegeben, ansonsten true. Man hätte die Methode auch einfacher gestalten können, indem wir nur eine Zeile hinterlegen: return (a + b > c && b + c > a && c + a > b); wobei dies für manche als zu unübersichtlich empfunden wird, vor allem, wenn wir später noch zusätzlich eine Prüfung auf negative Werte ergänzen würden. Nach der Zuweisung der Seiten im Konstruktor rufen wir dann die Generierung der Fläche auf, welche implizit auch den Umfang berechnet. Hier sehen wir auch, dass wir eine Zugriffsmöglichkeit auf die Variablen flaeche und umfang haben, obwohl sie nicht in der Dreieck, sondern in der FlaechenElm und ZweiDElm Klasse definiert wurden.
Nun kommen wir auf das Hauptprogramm, die Klasse Grafikprogramm, in dem die Main Methode liegt. Wir schreiben zu diesem Zeitpunkt natürlich nicht ein voll funktionsfähiges Grafikprogramm – wir wollen hier nur die Nutzung unserer Klassen ausprobieren. Hierzu erzeugen wir im Package „mygraphs“ die Klasse Grafikprogramm. Im folgenden Code erzeugen wir einfach nur das Dreieck und geben dann testweise einige Daten aus um zu sehen, ob wir darauf zugreifen können und ob die Werte korrekt sind.
Listing 12: Klasse Grafikprogramm
Für den Start des Codes bleibt noch anzumerken, dass wir aus „Projektsicht“ in VSCode je nach Vorgehensweise noch ein kleines Problem haben. Es existieren eventuell mehrere Main Methoden. Diese sind aber der Einstieg in unser Programm, weshalb VSCode mitunter nicht weiß, welche Klasse gestartet werden soll. Insofern kümmern wir uns darum, dass keine weitere Klasse mit der Main Methode existiert. In meinem Arbeitsbereich würde bspw. noch HelloWorld.java existieren, allerdings außerhalb des Packages mygraphs. Insofern können wir diese Klasse einfach löschen. Aber Achtung – das geht natürlich nur dann, wenn es in dem gesamten Projekt auch wirklich nur eine Main-Methode geben soll. Dadurch, dass wir nun nicht nur ein File kompilieren müssen, sondern 5, können wir nur über den CodeRunner gehen, wenn die Klasse Grafikprogramm gerade sichtbar ist. In größeren Projekten geht man üblicherweise über Hilfstools, wie bspw. Maven, wo man diverse Abhängigkeiten hinterlegen und den Kompilierprozess steuern kann. Für kleinere Projekte ganz ohne Hilfsmittel können wir auch den manuellen weg gehen. Dies erledigen wir, indem wir im Terminal mit Hilfe von cd (also Change Directory) in den obersten Projektordner gehen und mit javac alle Java Files dem Kompiler übergeben..
Danach kann das Programm manuell mint javac gestartet werden:
Der von VSCode erzeugte „Run“ Button direkt im Code oberhalb der Main-Methode macht diese Arbeit zwar obsolet (inklusive dem Entfernen von weiteren Main-Methoden) – es ist aber immer gut, wenn man versteht, was im Hintergrund läuft. Ansonsten können wir auch den Startbutton rechts neben dem Projekt verwenden – hier wieder vorausgesetzt es existiert nur eine Main-Methode. Dadurch werden alle relevanten Files vor dem Ausführen kompiliert. VSCode müsste dann in etwa wie folgt aussehen:
Java Projekt unseres Grafikprogramms
Abb.: 9: Java Projekt unseres Grafikprogramms
Wenn wir das Projekt nun starten, gibt das Programm folgenden Text aus:
Unser Programm funktioniert also. Nun wollen wir die Klasse Grafikprogramm aus unserem Package herausnehmen. Hierzu erzeugen wir eine weitere Klasse Grafikprogramm direkt auf Projektebene (durch einen Klick auf + neben „Java“) und fügen den folgenden Code ein:
Listing 13: Grafikprogramm außerhalb des Packages mygraphs
Kurze Erklärung der Codezeilen: Die Klasse ist nun nicht mehr im Package mygraphs (in unserem Fall ist sie sogar in keinem Package mehr, weswegen die Angabe package komplett fehlt). Damit wir nun aber auf unsere Klassen im Package mygraphs überhaupt zugreifen können, benötigen wir den import. Die beiden Zugriffe auf umfang und flaeche funktionieren jedoch nun nicht mehr, da sie aufgrund ihrer „protected“ Eigenschaft außerhalb des Packages nur noch für direkte Erben zugreifbar wären. Aus diesem Grunde habe ich den Code mit einem Kommentarzeichen // versehen – wir werden gleich testweise diese beiden Kommentarzeichen entfernen und sehen, was passiert.
Danach löschen wir das Grafikprogramm im Package mygraphs und starten das Programm. Die Farbe wird wieder korrekt mit 0 angegeben. Wenn wir nun aber versuchen die beiden // Zeichen zu entfernen, können wir das Programm nicht mehr starten. Die beiden Variablen umfang und flaeche sind rot unterstrichen, was in VSCode auf einen Fehler hinweist:
Zugriffsfehler bei protected Variablen außerhalb des Packages
Abb.: 10: Zugriffsfehler bei protected Variablen außerhalb des Packages
Top
Getter und Setter, denn Kontrolle ist alles
Jetzt mag mancher denken, dass es recht umständlich ist, die Sichtbarkeit möglichst klein zu halten. Warum machen wir nicht einfach alles public und es gibt keinerlei Zugriffsprobleme mehr. Schließlich haben wir in unseren Klassen nichts zu verbergen! Die Antwort auf diese Frage lautet – ja, das ist eine Möglichkeit, aber es birgt auch eine Gefahr! Beginnen wir mit dieser Gefahr und sehen uns danach die Ausnahmen an. Gehen wir mal davon aus, dass wir das Dreiecksobjekt nachträglich ändern wollen und ändern die Sichtbarkeit der drei Variablen für die Seite A, B und C auf public. Wir haben in unserem Code das Dreieck ja mit den Seitenlängen 3.0, 4.0 und 5.0 erzeugt. Sagen wir, das existierende Objekt soll nun auf die Längen 3.5 für die Seite C angepasst werden. Da wir die drei Seitenvariablen (jetzt) als public erzeugt haben, können wir dies relativ einfach:
Listing 14: Änderung der Seite C auf 3.5
So weit ist das alles problemlos, wir würden genau das gleiche machen, wie bei der initialen Erzeugung des Dreiecks mit den Werten 3.0, 4.0 und 3.5 – mit zwei Ausnahmen! Erstens haben wir nicht geprüft, ob die Werte überhaupt ein sinnvolles Dreieck ergeben. Wir könnten also ohne Probleme die Werte 3.0, 4.0 und 9.0 hinterlegen, was kein Dreieck wäre. Und zweitens haben wir die Fläche und somit auch den Umfang nicht berechnet. Wir müssen also irgendwie sicherstellen, dass das Unterprogramm checkValues() ausgeführt wird und berechneFlaeche(). Wir könnten dem Nutzer unserer Klasse dies per Manual mitteilen – aber ob er das liest, ist fraglich. Wir müssen den Nutzer also zwingen, den Wert über eine Methode zu setzen, in der wir unser checkValues() und berechneFlaeche() einbauen können. Dies heißt in der Konsequenz, dass wir den direkten Zugriff auf die Variable durch das Setzen von private verhindern müssen. Damit der Nutzer unserer Klassen jetzt den Wert einer Seite verändern kann, kommen die „Getter“ und „Setter“ ins Spiel. Getter und Setter sind Methoden, welche einzig und allein dafür da sind, die Werte von privaten Variablen nach außen zu führen. Wir ändern die Sichtbarkeit unserer drei Seitenwerte wieder auf private und sehen uns am Beispiel der Seite A die Getter und Setter an:
Kurze Erklärung der Codezeilen: Der „Getter“ heißt üblicherweise „get“ + Variablenname, wobei über das sogenannte CamelCasing (also Großbuchstabe pro Wortelement) das „s“ von seiteA großgeschrieben wird. Der Rückgabedatentyp ist immer der gleiche wie der Datentyp der Variable, die ausgelesen werden soll. Im Regelfall gibt es für den Getter auch keinen Parameter. Der Setter wiederum benötigt einen Parameter – natürlich wieder mit dem gleichen Datentypen, wie den der zu belegenden Variable – er hat aber keinen Rückgabetyp (also void). In manchen Programmiersprachen (wie bspw. C#) können wir den Compiler übrigens dazu bringen, dass wenn wir einen Setter haben und eine normale Zuweisung durchführen (bspw. myTr.seiteA = 5.0), der Setter hierfür genutzt wird. In unserem Setter haben wir nun die Prüfung über checkValues() und belegen die Seite a in der Parameterliste mit dem neu zu setzenden Wert und den Rest über die bereits existierenden Seiten B und C. Somit können wir die gleiche Methode wie der Konstruktor nutzen und wir würden von eventuellen „Verbesserungen“ im Code der Prüfung, wie bspw. den nachträglichen Check auf negative Werte, hier profitieren. Am Schluss wird noch berechneFlaeche() aufgerufen.
Für die Seiten B und C sieht der Code entsprechend aus. Damit der Code nun einheitlich gestaltet wird, sollte somit für alle Instanzvariablen gelten, dass sie private (oder protected) sind und eventuelle Zuweisungen über Setter und der Lesezugriff über Getter erledigt wird. Dieses Konzept nennt man „Kapselung“ oder „Information Hiding“. Man zeigt nur die notwendigen Informationen nach außen und kontrolliert den Zugriff über Methoden. Nun gibt es aber wie immer auch Ausnahmen. Entwickler von sehr performancekritischer Software – beispielsweise im 3D-Spielebereich – neigen dazu, auf private Variablen zu verzichten, wenn die Werte außerhalb zugreifbar sein müssen. Dadurch sparen sie sich während der Laufzeit das Laden der Getter und Setter-Methoden in den Stack, was zu Performanceverbesserungen führt. Wenn beispielsweise die Flugbahn eines 3DModells 60 mal die Sekunde berechnet werden soll, dann würde entsprechend oft der Getter und Setter von einzelnen Variablen bemüht werden. Hier gilt es, die Prioritäten richtig zu setzen – sauberes und wartungsfreundliches Programmieren versus Performance.
Top
Aufrufreihenfolge bei Konstruktoren
Gehen wir aber nochmal kurz auf die Konstruktoren ein. Wir haben in unserem kleinen Beispiel einen Konstruktor für die Dreiecksklasse erstellt, der sich um die Vorbereitung der internen Werte des Objektes kümmert. Es ist immer eine gute Idee, den Konstruktor mit allen zwingend notwendigen Informationen zu versorgen, damit das Objekt nach der Erzeugung sinnvoll nutzbar ist. In unserem Beispiel war es, die drei Dreiecksseiten zu belegen und die Fläche bzw. den Umfang zu berechnen. Nun kann man in den meisten Programmiersprachen auch auf das explizite Programmieren eines Konstruktors verzichten. Da wir den Konstruktor aber beim Erzeugen des Objektes aus der Klasse zwingend benötigen, existiert hier trotzdem ein Konstruktor, den der Computer implizit erstellt – den Standardkonstruktor. In Java existiert also immer ein Konstruktor, auch wenn wir ihn nicht selbst schreiben. Dieser Standardkonstruktor hat keinen Parameter und außer der Funktionalität das Objekt zu erstellen, sonst keine weiteren Aufgaben. Wir könnten also einen ZweiDElm Objekt in unserem Code erstellen, obwohl wir hierzu keinen Konstruktor geschrieben haben:
Listing 16: Erzeugung eines Objektes mit Standardkonstruktor
Gehen wir aber nun davon aus, dass uns das zu wenig ist. Wir wollen bspw. in der Konsole ausgeben, dass ein ZweiDElm Objekt erstellt wurde. Im impliziten Standardkonstruktor haben wir aber keine Eingriffsmöglichkeit – er existiert ja ohne unser Zutun. Die Lösung für dieses Problem ist, dass wir den Standardkonstruktor „überschreiben“. „Überschreiben“ nicht mit „Überladen“ verwechseln – Überladen war die Erstellung von mehreren Unterprogrammen mit gleichem Namen, aber unterschiedlichen Parameterlisten. Die Signatur unterscheidet sich also ausschließlich über die Parameter. Überschreiben ist dagegen die Erstellung einer Methode in einer Kindklasse mit exakt der gleichen Signatur, wie sie in einer höher liegenden (Eltern-)Klasse existiert. Da die Existenz des Standardkonstruktors von der Urklasse (in Java die Klasse „Object“) vorgegeben wird, können wir diesen also Überschreiben. Unser Code für die Klasse ZweiDElm würde sich also wie folgt verändern:
Listing 17: Überschriebener Standardkonstruktor
Der ursprüngliche Standardkonstruktor ist von außen nun nicht mehr aufrufbar. Nun passiert etwas Interessantes. Wenn wir nun die Main Methode mit folgendem Code ausführen:
Listing 18: Erzeugung eines Dreiecksobjektes
sehen wir auf der Konsole:
Der überschriebene Standardkonstruktor wurde also von dem Dreiecksobjekt aufgerufen. Dies ist insofern sinnvoll, als dass der Konstruktor ja dafür da war das Objekt so weit vorzubereiten, dass wir mit ihm arbeiten können. Wenn wir nun eine Klasse um einem Konstruktor erweitern, so bleibt die Forderung, die Elemente der Elternklasse „vorzubereiten“, bestehen. Diese Aufgabe können wir nicht dem Konstruktor der Kindklasse übergeben, da im Zweifelsfall die Kindklasse gar keinen Zugriff auf alle Eigenschaften und Methoden der Elternklasse hat – sie könnten ja private sein. Um nun das Verhalten noch genauer zu studieren, ergänzen wir den Konstruktor der Klasse Dreieck ebenfalls um zwei Konsolenausgaben:
Listing 19: Kontrollausgaben im Dreieck Konstruktor
Bei den beiden Klassen FlaechenElm und GrafikElm ergänzen wir ebenfalls einen Konstruktor, der jeweils „FlaechenElm erzeugt.“ und „GrafikElm erzeugt.“ Ausgibt. Führen wir unser Programm aus sehen wir:
Bevor also irgendwas im Kindkonstruktor passiert, wird der Elternkonstruktor aufgerufen. Wir können uns also darauf verlassen, dass die Konstruktoren sich um die Elternaufrufe kümmern. Nun ist noch ein Detail sehr wichtig. Bei der Dreieckklasse haben wir ja den Konstruktor mit Parametern versehen, damit wir sicherstellen konnten, dass die drei Seiten mit korrekten Werten belegt werden. Wenn nun aber der Standardkonstruktor ohne Parameter immer existiert, könnten wir diesen „Schutzmechanismus“ mit den drei Parametern ja umgehen, indem wir nicht mehr new Dreieck(3.0, 4.0, 5.0);, sondern new Dreiek(); schreiben. Java verhindert dies aber, indem der Standardkonstruktor aus der Urklasse nicht mehr gültig ist, sobald mindestens ein anderer Konstruktor existiert, egal ob mit oder ohne Parameter. Wenn wir also versuchen new Dreieck() aufzurufen ohne, dass wir den Standardkonstruktor Dreieck() aus der Urklasse überschrieben haben, werden wir einen Fehler erhalten:
Fehlerhafte Nutzung des Standardkonstruktors
Abb.: 11: Fehlerhafte Nutzung des Standardkonstruktors
Jetzt stellt sich aber noch eine weitere wichtige Frage. Was ist, wenn wir einer Elternklasse einen Konstruktor mit Parametern geben. Ändern wir unseren Code von FlaechenElm mal wie folgt:
Listing 20: Elternkonstruktor mit Parametern
Wir haben Java also mitgeteilt, dass wir bei einem FlaechenElm zwingend einen Wert für die Füllfarbe übergeben haben wollen. Die Kindklasse ZweiDElm erweitert nun diese Klasse – somit auch den Konstruktor – und kümmert sich aber nicht um die Füllfarbe! Dadurch entsteht eine Compilerfehler in der Klasse ZweiDElm:
Compilerfehler aufgrund Konstruktorfehler
Abb.: 12: Compilerfehler aufgrund Konstruktorfehler
Die Klasse FlaechenElm zwingt die Kindklasse nun, den parametrierten Konstruktor zu nutzten. Das bedeutet, dass sobald ein einziger Konstruktor vom Programmierer in einer Klasse mit Parametern geschrieben wurde, der Standardkonstruktor aus der Urklasse nicht mehr existiert und wir gezwungen werden, in der Kindklasse diesen parametrierten Konstruktor der Elternklasse aufzurufen. Dadurch hat der Programmierer der Eleternklasse die Möglichkeit denjenigen, der seine Klasse erweitert zu „leiten“, je nachdem wie viele und welche Konstruktoren er vorsieht. Aber wie ruft man den Konstruktor der Elternklasse auf? Der Konstruktor ohne Parameter wird ja implizit aufgerufen – aber mit Parameter kann das nun nicht mehr funktionieren, da wir die Parameter ja mit irgendwelchen Werten belegen müssen! In Java erfolgt dies über das Schlüsselwort „super“, welches einen weiteren Namen der Elternklasse offenbart – die „Superklasse“. Programmiersprachen mit Mehrfachvererbung wie bspw. C++ kennen dieses Schlüsselwort jedoch nicht, da hier mehrere Elternklassen existieren können. Hierauf werden wir aber später noch eingehen.
Listing 20: Elternkonstruktor mit Parametern
Kurze Erklärung der Codezeilen: Der Superkonstruktor muss in Java immer am Anfang des Konstruktorcodes der Kindklasse stehen – also in der ersten Zeile. In unserem Fall setze ich die Farbe auf ff000016 was die Farbe Rot bedeuten würde. Alternativ kann man auch den ZweiDElm Konstruktor um einen Füllfarbenparameter erweitern und diesen Wert einfach 1:1 an den Superkonstruktor weitergeben. Das werden wir später so implementieren. Dadurch müsste aber auch der Konstruktor der Klasse Dreieck ebenfalls angepasst werden, da dieser nun wiederum ZweiDElm als Elternkonstruktor aufrufen muss.
In Programmiersprachen wie C# rufen wir den Elternkonstruktor nicht im Rumpf des Kindkonstruktors auf, sondern mit einem Doppelpunkt nach der Signatur des Kindkonstruktors. Dadurch stellt sich die Frage des Aufrufs in der ersten Zeile wie in Java nicht. Die Details der anderen Programmiersrpachen werden wir weiter unten noch beleuchten. Fassen wir nun nochmal die Möglichkeiten zusammen. Wie müssen wir ein Objekt instanziieren, wenn wir unterschiedliche Konstellationen bezüglich des Konstruktors haben und muss im Konstruktor der Kindklasse der Superkonstruktor aufgerufen werden? Ich gehe in der Tabelle davon aus, dass es in der Kindklasse nur einen Konstruktor gibt – obwohl es in der Tat mehrere sein dürfen:
Ohne Konstructor Code Mit Konstruktor Code
Ohne Parameter Mit Parameter
Instanziierung new MyClass() new MyClass() new MyClass(param1, param2, ...)
Kindklasse mit super() call nein nein, wird implizit aufgerufen Ja: super(param1, param2, ...)
Tabelle 4: Verhaltensweisen bei Konstruktornutzung in Java
Somit haben wir schon einige interessante Konzepte der objektorientierten Programmierung kennengelernt. Mit das wichtigste aber ist das Konzept des Überschreibens von Methoden, also dass die Kindklassen exakt die gleiche Signatur einer Methode vorsehen wie die Elternklassen (oder auch Großeltern etc.). Im Zusammenhang mit Typecasts können wir hier die eigentliche Stärke von Objektorientierung voll ausnutzen. Gehen wir hierzu mal davon aus, wir wollen eine Methode haben, welche die wesentlichen Informationen unserer Objekte auf der Konsole ausgibt. Diese printInfo() Methode soll nun in allen Klassen umgesetzt werden. Als erstes erweitern wir hierfür unser Klassendiagramm aus Abbildung 5. Wir tragen auch gleich alle anderen Methoden, Datentypen und Sichtbarkeiten ein, wodurch das Klassendiagramm dem UML Standard entspricht(4). Da wir uns ab hier auf die 2D Elemente konzentrieren, verzichte ich auf alle Klassen außerhalb dieses Fokus. Weiterhin lasse ich (noch) das Dreieck weg, da wir es später nochmal integrieren werden.
(4) UML: Unified Modeling Language – eine Sammlung von normierten Diagrammen für Dokumentationen von Software
UML Klassendiagram für 2D Elemente
Abb.: 13: UML Klassendiagram für 2D Elemente
Wir haben also nun die Methode printInfo() fünf mal in unserem Klassendiagramm hinterlegt. Zuerst in GrafikElm:
Listing 22: Ergänzung GrafikElm um printInfo()
Die einzige Information, welche ein Objekt der Klasse GrafikElm besitzt, ist die Randfarbe. Diese setzte ich nun gleich über den Konstruktor und erstelle die Getter- und Settermethoden. In der printInfo() Methode wird somit auch nur die randfarbe ausgegeben. Gehen wir nun auf die nächste Klasse des Klassendiagramms, FlaechenElm:
Listing 23: Ergänzung FlaechenElm um printInfo()
Kurze Erklärung der Codezeilen: Sowohl die fuellfarbe, als auch die flaeche wurde auf protected gesetzt, da die Kindklassen darauf zugreifen sollen. Dadurch werden auch die Gettermethoden notwendig, da wir sonst von außen (sprich von der Klasse Grafikprogramm) nicht zugreifen können. Der Setter wird allerdings nur für die fuellfarbe erstellt, da die flaeche idealerweise berechnet wird. Das kann aber nicht in dieser Klasse erfolgen, da die Fläche nicht auf dieser Ebene berechnet werden kann. Ein Objekt der Klasse FlaechenElm weiß nicht, ob es bspw. ein Kreis oder ein Rechteck ist, bzw. wird. Insofern müssen sich die Klassen Kreis bzw. Rechteck darum kümmern die Fläche zu berechnen. Die printInfo() Methode der Klasse FlaechenElm ruft zuerst die printInfo() Methode der Superklasse (also Elternklasse, was GrafikElm ist) auf und gibt danach seine eigenen Informationen aus.
Wir dürfen natürlich nun nicht vergessen, in der Klasse ZweiDElm den Superkonstruktoraufruf auf zwei Parameter anzupassen, indem wir bspw. die beiden Farben Rot und Blau hinterlegen: super(0xff0000, 0x0000ff);
Top
13.8 „this“ und „super“
An dieser Stelle müssen wir noch etwas genauer über die Schlüsselwörter „super“ und „this“ sprechen. „super“ haben wir ja bereits beim Superkonstruktor kennengelernt und auch erfahren, dass dies nur bei Programmiersprachen sinnvoll ist, welche keine Mehrfachvererbung unterstützen. Da wir mit Java eine solche Sprache vorliegen haben, gehe ich hier auf diese Schlüsselwörter ein. Am Ende des Kapitels kümmern wir uns um die Syntaxvarianten der anderen Sprachen. Das Schlüsselwort „super“ finden wir in zwei verschiedenen Kontexten. Zum einen als Konstruktoraufruf der Superklasse super(initRandfarbe); und zum anderen als Referenz auf die Methoden der Superklasse super.printInfo(); Hier ist der Ausdruck super so zu verstehen, wie eine Variable, welche eine Referenz auf ein Objekt besitzt. Das Gleiche gilt auch für this. Es zeigt ebenfalls auf ein Objekt – sprich es beinhaltetet auch eine Referenz. Wir haben ja weiter Oben gesehen, dass eine Variable welche ein Objekt hält, lediglich eine Referenz auf das Objekt beinhaltet – eine Art Adresse. Wenn wir also schreiben FlaechenElm myElm = new FlaechenElm(0, 0); dann liegt in myElm die Referenz auf das Objekt der Klasse FlaechenElm, bei dem wir mit dem Punktoperator auf die einzelnen Methoden dieser Klasse zugreifen können (bspw. myElm.getFuellfarbe();) Wir können nun innerhalb einer Klasse FlaechenElm mit this bzw. super genauso auf dieses Objekt zugreifen. Der Aufruf myElm.getFuellfarbe(); außerhalb der Klasse FlaechenElm ruft also exakt die gleiche Methode auf, wie der Aufruf this.getFuellfarbe(); innerhalb der Klasse FlaechenElm. Hier ist also myElm die Variable welche außenstehende Klassen nutzen, um auf die Objektmethoden zuzugreifen und this das Konstrukt, welche für die Objekte der Klasse intern genutzt werden kann, um auf eigene Objektmethoden (und auch Eigenschaften) zuzugreifen – also eine Art Selbstreferenz. super wiederum ist das Gleiche wie this, lässt aber ausschließlich den Zugriff auf die Methoden der Superklasse zu, was im Fall von FlaechenElm die Klasse GrafikElm wäre. Sehen wir uns dieses Konzept nun mal beim Aufruf der Methode printInfo() über die Objektvariable, dem Schlüsselwort this und super an:
Zugriff über Variable, this und super
Abb.: 14: Zugriff über Variable, this und super
Sehr praktisch erweist sich das this Schlüsselwort dann, wenn eine lokale Variable genauso heißt, wie eine Instanzvariable. In solch einer Situation wird die Instanzvariable immer mit this davor notiert und die lokale Variable ohne this. Weiter unten im Listing 25 kann man das im Konstruktor der Rechteck Klasse sehen. Nun ergänzen wir die Klasse ZweiDElm:
Listing 24: Ergänzung ZweiDElm um komplette Parameter für Konstruktor
Soweit nichts wirklich Neues. Nun widmen wir uns den Klassen Rechteck und Kreis:
Listing 25: Ergänzung Klasse Rechteck
Die detaillierte Darstellung der Getter- und Settermethoden habe ich mir hier gespart, da sie nach Schema erzeugt werden. Es geht sogar soweit, dass die meisten IDEs die Getter- und Settermethoden (meist bei einem Rechtsklick auf eine private oder protected Variable) automatisch generieren. Nun fehlt nur noch die Klasse Kreis:
Listing 26: Ergänzung Klasse Kreis
Top
13.9 Typecasts mit Klassen – von Verpackungen und Inhalten
Was nun inzwischen klar sein sollte ist die Tatsache, dass alle Methoden und Eigenschaften der Elternklasse jeweils an die Kindklasse vererbt werden (dies gilt übrigens auch für die Methoden und Eigenschaften, die private sind. Sie sind zwar aus Sicht der Kindklassen nicht zugreifbar, aber sie existieren). Dies hat nun eine wichtige Konsequenz für den Typecast. Wir haben gesehen, dass eine Klasse nach außen hin die Eigenschaften und Methoden anbietet, welche public sind. Wenn nun bspw. die Klasse GrafikElm eine public Methode implementiert hat, dann hat auch die Kindklasse FlaechenElm diese Methode. Sehen wir uns diesen Gedanken für alle Methoden tabellarisch an. Die Tabelle zeigt alle Methoden und Eigenschaften der einzelnen Klassen. Jeder nicht leere Eintrag bedeutet, sie existiert in der Klasse, egal ob von außen sichtbar oder unsichtbar. Das X indiziert, dass die Eigenschaft bzw. die Methode implementiert wurde und das V, dass die Existenz nur wegen der Vererbung vorliegt. Ü wiederum heißt, eine erweiternde Klasse überschreibt die Methode.
Methoden und Eigenschaften: Typ: GrafikElm FlaechenElm ZweiDElm Rechteck Kreis
private randfarbe Eigenschaft X V V V V
public int getRandfarbe() Methode X V V V V
public void setRandfarbe(int newRandfarbe) Methode X V V V V
public void printInfo() Methode X Ü Ü Ü Ü
protected int fuellfarbe Eigenschaft X V V V
protected double flaeche Eigenschaft X V V V
public int getFuellfarbe() Methode X V V V
public void setFuellfarbe(int newFuellfarbe) Methode X V V V
public double getFlaeche() Methode X V V V
protected double umfang Eigenschaft X V V
public double getUmfang() Methode X V V
public void printInfo() Methode X V V
protected double laenge Eigenschaft X
protected double breite Eigenschaft X
private void berechneUmfang() Methode X X
private void berechneFlaeche() Methode X X
public double getLaenge() Methode X
public void setLaenge(double laenge) Methode X
public double getBreite() Methode X
public void setBreite(double breite) Methode X
protected double radius Eigenschaft X
public double getRadius() Methode X
public void setRadius(double radius) Methode X
Tabelle 5: Existenz von Methoden und Eigenschaften
Man erkennt, dass sich die Methoden und Eigenschaften komplett in den Kindklassen wiederfinden. Vor allem bei den Methoden eröffnet sich nun eine sehr schöne Möglichkeit. Wir haben bei der Nutzung von Objekten immer den Konstruktor benannt, der bestimmt welches Objekt im Speicher erzeugt wird. Weiterhin haben wir immer eine Variable definiert, welche das Objekt aufnehmen kann. Hier haben wir auch wieder die Klasse als „Datentyp“ vorgegeben: FlaechenElm myVar = new FlaechenElm(0,0);. Der Datentyp der Variablen FlaechenElm heißt also genauso wie der Konstruktor FlaechenElm(). Nun kann man sich die Variable in diesem Beispiel als eine exakt angepasste Hülle für das Objekt vorstellen. Für jede nach außen sichtbare Methode bzw. Eigenschaft wird von der Variable eine Zugriffsmöglichkeit vorgesehen. Wenn das Objekt also die Methode im Sinne eines „Knopfes“ hat, den man drücken kann, ist das im Falle der Variable nicht ein Knopf, sondern ein Loch in der Hülle, welche den Knopf freigibt, so dass man ihn von außen drücken kann. Wenn wir nun das Objekt in eine andere Hülle stecken, die nicht für alle „Knöpfe“ ein passendes Loch vorsieht, dann kann man eben nur noch diejenigen „Knöpfe“ drücken, welche von außen zugreifbar sind. Alle anderen „Knöpfe“ existieren zwar, sind aber nicht zu betätigen, da die Aussparung fehlt. Sehen wir uns diesen Gedanken mal grafisch an, wenn bspw. ein Objekt vom Typ FlaechenElm einmal in eine Variable vom Typ FlaechenElm gelegt wird und einmal in eine Variable vom Typ GrafikElm:
Vorstellungshilfe für Objekte in verschiedenen Variablentypen
Abb.: 15: Vorstellungshilfe für Objekte in verschiedenen Variablentypen
Das Schöne daran ist nun, dass wir ein Objekt vor diesem Hintergrund in Variablen von allen Datentypen legen können, welche entweder identisch mit dem Objekt sind (also „Objekt FlaechenElm liegt in Variable vom Typ FlaechenElm“), oder aber von jedem Vorfahren (also bspw. „Objekt FlaechenElm liegt in Variable vom Typ GrafikElm“). Dies ist deshalb möglich, da die Kinder ja alle Methoden der Eltern erben – die „Aussparungen“ der Eltern also mit den „Knöpfen“ der Kinder übereinstimmen müssen. Entscheidend bei dieser Vorstellung ist, dass das Objekt in der Variable immer das gleiche Objekt ist. Wir finden in unserem rechten Beispiel von Abbildung 15 also ein FlaechenElm Objekt in einer Variablen vom Typ GrafikElm.
Top
Polymorphie - eine Variable mit vielen Gesichtern
Die Möglichkeit, Kindobjekte in Elternvariablen zu setzen hat nun eine sehr wichtige Konsequenz zur Folge. Sehen wir uns hierzu folgenden Code an, den wir bspw. in der Klasse Grafikprogramm finden könn-ten:
Listing 27: Ausgabe aller Objektinformationen
Wenn wir in der ArrayList allElements nun einen Kreis und ein Rechteck haben würden, könnte unser Hauptprogramm wie folgt aussehen:
Listing 28: Bewirtschaftung der ArrayList
Wir speichern also sowohl Rechtecke als auch Kreise in der ArrayList und erhalten die Ausgabe:
Die Meldungen über die Objekterzeugungen dürften nun keine Überraschung darstellen. Spannender ist nun, dass bei den printInfo() Aufrufen offensichtlich nicht die Methoden der GrafikElm Klassen aufgerufen wurden, sondern die der Kreis und Rechteck Klassen. Das deckt sich aber mit unserer Überlegung aus Abbildung 15. Die eigentliche Funktionalität wird nicht durch den Variablentyp bestimmt, sondern einzig und alleine durch das Objekt, welches in dieser Variable „steckt“. Die Variable ist lediglich dafür zuständig, welche Methoden von außen nutzbar sind. Insofern rufen wir zwar die Methode printInfo() in einer Variablen vom Typ GrafikElm auf, die Methode wird aber durch das Objekt vom Typ Rechteck bzw. Kreis bestimmt. Durch die Tatsache, dass die Funktionalität und die Eigenschaften in Objekten zusammengefasst sind, entstehen nun zwei entscheidende Vorteile. Zuerst sehen wir, dass die Methode printAllInfos() relativ einfach gestrickt ist. Überlegen wir uns mal, wie ein prozedurales Programm mit dieser Funktionalität aussehen müsste:
Prozedurale Lösung für Ausgabe der Dateninfos
Abb.: 16: Prozedurale Lösung für Ausgabe der Dateninfos
Das Errorhandling im „sonst“ Zweig ist notwendig, weil wir nicht 100% sicher sein können, dass in data.type ausschließlich „Rechteck“ und „Kreis“ zu finden ist. Zum Vergleich nochmal die objektorientierte Lösung:
Objektorientierte Lösung für Ausgabe der Dateninfos
Abb.: 17: Objektorientierte Lösung für Ausgabe der Dateninfos
Wir sehen, dass die eigentliche Ausgabemechanik nicht Teil des zentralen Programms ist, sondern in den jeweiligen Objekten hinterlegt ist. Dadurch haben wir auch keine Notwendigkeit mehr, ein Errorhandling zu implementieren. Es gibt nicht mehr die Möglichkeit, dass wir in data.type einen unbekannten Wert finden, weil wir kein Unterscheidungsmerkmal wie „Typ“ benötigen. Der zweite unschätzbare Vorteil dieses Ansatzes ist, dass wir keinerlei Änderungen in der printAllInfos() Funktion mehr machen müssen, wenn wir eine neue Klasse hinzufügen. Wenn wir beispielsweise das Dreieck hinzufügen wollen, so müssen wir lediglich den Code für das Dreieck ergänzen:
Listing 29: Code für Dreieck Klasse
Wenn wir in unserem Hauptprogramm nun ein Dreieck hinzufügen:
Listing 30: Bewirtschaftung der ArrayList um zusätzliches Dreieck
…so werden diese Daten ganz normal mitverarbeitet:
Das heißt, die printAllInfos() Methode kann Daten von Klassen verarbeiten, die zu seiner Compilezeit noch gar nicht vorhanden waren. Dies öffnet ein ganzes Universum an Möglichkeiten, da wir Codemodule wie das Dreieck nachträglich ergänzen können, ohne den eigentlichen Kern des Programms wie bspw. printAllInfos() anzufassen. Dieses Verhalten wird auch als „späte Bindung“ oder englisch „late binding“ bezeichnet. Die Zuordnung des eigentlich auszuführenden Codes erfolgt somit nicht bei der Programmierung des printAllInfos() Programms aus Listing 27, sondern zum Zeitpunkt der Ausführung. Dieses Verhalten von (Eltern-)Variablen wird mit „Polymorphie“ umschrieben. Hierunter versteht man also, dass eine Methode (definiert durch die Signatur) auf mehreren Ebenen des Vererbungsbaumes implementiert wurde – also genau das, was wir bei der Implementierung der Methode printInfo() finden.
Top
Abstrakte Elemente – die Kinder sollen es machen
Als nächstes müssen wir noch eine Ungenauigkeit aus unserem Code „ausbauen“, damit die Klassen nicht falsch genutzt werden können. In den Klassen FlaechenElm und ZweiDElm finden wir die Eigenschaften flaeche und umfang. Diese Werte existieren zwar auf den Ebenen dieser beiden Klassen, können aber erst auf der Ebene der Kreis-, Rechteck- und Dreiecksklasse berechnet werden, da nur hier ein Berechnungsalgorithmus definierbar ist. Dies bedeutet mit anderen Worten, dass es nicht sinnvoll ist, Objekte zu instanziieren, welche oberhalb dieser drei Klassen liegen. Alle Klassen oberhalb von Kreis, Rechteck und Dreieck dienen also lediglich als Sammlung von Methoden und Eigenschaften, nicht aber als Klasse für die direkte Instanziierung. Wir können mit Hilfe des Schlüsselwortes „abstract“ dafür sorgen, dass aus einer Klasse kein Objekt erzeugt werden kann. Beginnen wir mit der Klasse GrafikElm:
Listing 31: Abstrakte Klasse GrafikElm
Wenn wir nun versuchen würden, mit Hilfe von new GrafikElm(0) ein Objekt zu erzeugen, erhalten wir eine Fehlermeldung. Bei den Klassen FlaechenElm und ZweiDElm können wir sogar noch einen Schritt weiter gehen. Wir würden bei beiden Klassen auf den ersten Blick erwarten, dass es die Möglichkeit geben sollte, die Fläche und den Umfang zu berechnen – sprich es sollten die Methoden berechneUmfang() und berechneFlaeche() geben. Wie wir aber festgestellt haben, können wir diese Methoden auf dieser Ebene nicht umsetzen, sondern nur auf Ebene Kreis, Dreieck oder Rechteck. Die Programmiersprachen bieten auch hier eine Möglichkeit, den Programmierer als Nutzer der Klassen sinnvoll zu „führen“. Wir können das Schlüsselwort abstract auch vor eine Methode schreiben:
Listing 32: Abstrakte Methode
Wenn eine Methode als abstract definiert wird, benötigt sie keinen Rumpf mehr und die zugehörige Klasse muss dann ebenfalls abstract sein. Die Idee ist, dass in solch einem Fall die Klasse als „nicht komplett“ geflagged ist und man dem Nutzer der Klasse damit mitteilt, es ist sinnlos aus dieser Klasse ein Objekt zu erzeugen, da noch etwas fehlt – in unserem Fall eben die Methode berechneFlaeche(). Der einzige Weg nun diese Klasse zu nutzen ist, sie zu erweitern. Bei der Erweiterung muss nun die Methode berechneFlaeche() implementiert werden, weshalb wir die Sichtbarkeit mindestens auf protected setzen müssen. Wird die Methode nicht in der erweiterten Klasse implementiert, muss die erweiternde Klasse ebenfalls abstract sein. In unserem Fall ist diese Option sinnvoll, da wir in der Klasse ZweiDElm ebenfalls eine Methode haben, die abstract sein muss:
Listing 33: Abstrakte Methode
Wenn wir in den Klassen Kreis, Dreieck und Rechteck nun die Methoden berechneUmfang() und berechneFlaeche() als protected Methoden implementieren (also ohne abstract), so haben wir der Forderung genüge getan und die von den Elternklassen geforderten Methoden umgesetzt. Da die Sichtbarkeit von den Eltern als protected vorgegeben wurden, muss diese bei den Kindern ebenfalls auf protected gesetzt werden. Es wäre auch sinnlos, dass von den Eltern eine Methode gefordert werden würde, welche nur für den internen Gebrauch der Kinder vorzusehen ist. Ähnlich problematisch wäre es, wenn eine Elternklasse etwas als protected von der Außenwelt schützt und die Kinder es mit public nun doch freigeben würden.
Top
Interfaces – von Steckern und Steckdosen
Neben den abstract Klassen (und Methoden) gibt es eine weitere Möglichkeit eine Klasse dazu zu zwingen, bestimmte Methoden zu realisieren – das Interface, also die „Schnittstelle“. Der Gedanke hier ist, dass wir mit einem Interface nur noch die Methoden einfordern, welche vorhanden sein müssen. Dies wäre vergleichbar mit einer abstrakten Klasse, welche ausschließlich abstrakte Methoden vorsieht und vom Prinzip her keinerlei Instanzvariablen besitzt (wobei es hierfür auch Ausnahmen gibt). Jede Methode, welche im Interface hinterlegt ist, muss von der Klasse implementiert werden. Die einzige Ausnahme wäre, wenn die Klasse selbst abstract wäre. Dann müssten die Kindklassen diese Implementierung übernehmen. Soweit die „reine Lehre“. Je nach Programmiersprache können Interfaces aber noch mehr als nur Methodenrümpfe enthalten – bspw. Konstanten, oder eben auch Eigenschaften, Konstruktoren etc.
Grundsätzlich ist der Name „Interface“ recht treffend gewählt. Eine Interfacedefinition beschreibt eine Art „Norm“, welche erfüllt werden muss. Genauso wie bei Steckdosen eine Norm für die Maße existiert – somit können wir beliebige Geräte in die Steckdose einstecken. Auch solche, die wir zum Zeitpunkt der Montage der Steckdose noch gar nicht kennen – solange der Ste-cker der Norm entspricht. In der Programmierung legt das Interface die „Norm“ für die notwendigen Methoden einer Klasse fest, die zu einem späteren Zeitpunkt erst „in die Variable gesteckt wird“. Somit können wir die Klasse, welche dem Interface entsprechen muss erst zu einem späteren Zeitpunkt realisieren.
Schreiben wir nun ein Interface, welches die Methode „toJsonString()“ einfordert. Hierzu erzeugen wir ein neues File „Jsonifyable.java“ und schreiben folgenden Inhalt hinein:
Listing 34: Interface
Der Einfachheit halber habe ich es in das Package mygraphs gesetzt – es darf aber in einem beliebigen Package gespeichert sein. Die Parallele zu abstrakten Methoden ist offensichtlich. In unserem Beispiel habe ich „nur“ eine Methode eingetragen(5), es dürfen aber auch mehrere sein. Nun wollen wir beispielhaft in der Klasse Rechteck das Interface implementieren:
(5) Interfaces mit nur einer einzigen Methode nennt man auch „funkitonale Interfaces“ und spielen bei der funktio-nalen Programmierung eine Rolle.
Listing 35: Implementierung einer Interfacemethode
Kurze Erklärung der Codezeilen: Mit dem Schlüsselwort implements wird in Java die Implementierung aller im Interface Jsonifyable definierten Methoden gefordert. Diese müssen lediglich die gleiche Signatur und Sichtbarkeit wie im Interface aufweisen. JSON steht übrigens für „Java Script Object Notation“ und ist eine an JavaScript angelehnte Auszeichnungssprache für den Datentransport. Variablennamen und Stringwerte werden in dieser Notation in Anführungszeichen gesetzt. Da in Java ein String auch immer in doppelten Anführungszeichen gesetzt werden muss, können wir die für JSON geforderten Anführungszeichen nicht direkt eintragen, sondern wir müssen sie „escapen“, was für Java bedeutet, wir schreiben ein Backslash davor. Wenn wir die Filezugriffe bei Textdateien ansehen, kommen wir nochmal auf das Thema „Escapezeichen“ zu sprechen.
Die Methode getJsonString() muss mit exakt der Signatur implementiert werden, wie sie im Interface gefordert wird – genauso, wie es bei einer abstrakten Methode gefordert werden würde. Im Gegensatz zu abstrakten (Eltern-)Klassen können aber Kindklassen beliebig viele Interfaces implementieren – auch in Java. Nach implements dürfen also mehrere Interfaces kommasepariert geschrieben werden. Die Klasse muss somit sämtliche Methoden von allen Interfaces implementieren (oder eben abstrakt sein und die Implementierung der Kindklassen überlassen). Wir können nun ein Objekt der Klasse Rechteck instanziieren und dieses dann in eine Variable vom Typ Jsonifyable legen:
Listing 36: Varablen vom Typ eines Interfaces
Die Ausgabe ist entsprechend:
Eine Variable vom Typ eines Interfaces ist also wieder wie ein Behälter für Objekte zu verstehen, welche die im Interface hinterlegten Methoden des Objektes zugreifbar macht. Wenn nun abstrakte Klassen und Interfaces so ähnlich sind, was sollte man nun wann verwenden? Die beiden großen Unterscheidungsmerkmale sind ja, dass man (zumindest in Java und C#) nur von einer Klasse erben kann, aber mehrere Interfaces implementieren darf und dass man in Klassen Methoden mit Funktionalität und auch Eigenschaften einbauen kann, nicht aber in Interfaces. Insofern sollte man immer dann, wenn man keine für alle Kindklassen gemeinsame Funktionalität vorab implementieren möchte, ein Interface nehmen. Sobald man jedoch auf der gemeinsamen Ebene Funktionalitäten (oder auch Eigenschaften) benötigt, sollte man über Superklassen nachdenken.
Es gibt bspw. mit C++ jedoch auch objektorientierte Programmiersprachen, welche das Konzept „Interface“ so nicht kennen und stattdessen voll auf abstrakte Klassen setzen. Da C++ mehr als eine Elternkasse erlaubt, ist dies auch kein Problem
Eine Facette, welche man in Java mit Interfaces realisieren kann, sind anonyme Klassen. Bis jetzt haben Klassen immer einen Namen erhalten der uns garantiert, dass die Objekte exakt die Funktionalitäten aufweisen, welche in der Klasse als Methoden vorgegeben waren. Ein Interface hingegen definiert nur die Signaturen der Methoden. Wir können nun basierend auf dieser Definition ein Objekt erstellen, welches einzig und allein die Methoden realisiert, die im Interface vorgegeben sind. Dieses Objekt muss anschließend einer Variable vom Typ des Interfaces abgelegt werden:
Listing 37: Anonyme Klasse aus Interface
Wenn wir diesen Code ausführen, sehen wir auf der Konsole:
Da das Objekt der anonymen Klasse immer innerhalb des Kontexts eines Objekts und dort innerhalb einer Methode existiert drängt sich die Frage auf, ob das anonyme Objekt auf diese Variablen zugreifen kann. Hier unterscheiden wir die Instanzvariablen (also die Variablen, welche in der Klasse deklariert wurden) und die lokalen Variablen (also diejenigen, welche in der Methode deklariert wurden). Während Instanzvariablen einen lesenden und schreibenden Zugriff des anonymen Objektes erlauben, dürfen lokale in Java Variablen nur gelesen werden. Um den Code lesbarer zu gestalten, sollten die lokalen Variablen, welche für die anonyme Klasse benötigt werden, sogar als final deklariert werden – also nicht änderbar:
Listing 38: Zugriffe des anonymen Objektes auf externe Variablen
Kurze Erklärung der Codezeilen: Die Variable instanceVar ist eine Instanzvariable der Klasse Grafikprogramm. localOuterVar wiederum ist eine lokale Variable der Methode doSomething(), in welcher auch das Objekt der anonymen Klasse erzeugt wird. Dort wiederum greifen wir auf localOuterVar nur lesend zu. Jeder schreibende Zugriff würde zu einem Fehler führen – selbst wenn wir auf das Schlüsselwort final bei der Deklaration der localOuterVar verzichten würden. instanceVar kann hingegen verändert werden. Die Variable localInnerVar habe ich nur zum Vergleich für die innere Variable eingebaut.
Die Ausgabe des Programms ist:
In diesem Zusammenhang müssen wir nochmal kurz auf das Schlüsselwort this eingehen. Da die anonyme Klasse innerhalb des Kontextes der Klasse Grafikprogramm existiert, muss hier geklärt werden, auf welches Objekt this zeigt – auf das anonyme Objekt oder auf das Objekt vom Typ Grafikprogramm. Wie man vielleicht instinktiv bereits richtig vermutet, zeigt this innerhalb der anonymen Klasse auf das Objekt eben dieser Klasse und nicht auf das Objekt der Klasse Grafikprogramm. Wenn wir jedoch auf das Grafikprogramm Objekt zeigen wollen, müssen wir ein „qalifiziertes this“ verwenden, indem wir den Klassennamen davor schreiben. Wir könnten bei der Inkrementierung also anstatt nur instanceVar++; auch Grafikprogramm.this.instanceVar++; schreiben. Dies ist vor allem dann wichtig, wenn es Namensgleichheiten innerhalb und außerhalb der anonymen Klasse gibt.
Zugriffe aus anonymen Klassen heraus
Abb.: 18: Zugriffe aus anonymen Klassen heraus
Interfaces werden auch häufig dafür genutzt, in einer Methode ein Objekt zu übergeben, welches einen exakt definierten Funktionsumfang aufweist. Beispielsweise gibt es Objekte für grafische Nutzeroberflä-chen, welche eine ganz bestimmte Methode aufrufen wollen, sobald sie geklickt wurden, oder wenn der Mauszeiger sie berührt. Sie erwarten also Objekte, welche genau diese Methoden aufweisen. Gehen wir in unserem Beispiel mal davon aus, dass die Klassen Dreieck, Rechteck und Kreis eine Referenz auf ein Loggingobjekt benötigen. Dieses Objekt soll gewisse Informationen schreiben – man möchte aber offen halten, ob das Objekt auf die Konsole, in ein File oder gar per eMail die Informationen sendet. Je nach Ziel soll ein anderes Loggingobjekt eingebaut werden können:
Interface für verschiedene Loggerobjekte
Abb.: 19: Interface für verschiedene Loggerobjekte
Je nachdem welcher Logger nun an das Interface angedockt wird, kommen die Logingformationen nun auf der Konsole oder in einem Loggfile an. Der Code für das Interface ist sehr simpel:
Listing 39: Logger Interface
Der Übersicht halber habe ich für den Logger ein eigenes Package definiert. Nun ergänzen wir die Klasse Grafikelement um folgenden Code, der dafür sorgt, dass ein Logger in den Objekten zur Verfügung steht:
Listing 40: Ergänzung der Klassen um das Logger Objekt
Nun können wir an jeder Stelle der Klasse Grafikelement (bzw. allen Nachfahren) anstatt System.out.println(…) den Logger nutzen. Bspw. können wir jeden Lesezugriff auf umfang in der Klasse ZweiDElm loggen:
Listing 41: Nutzung des Loggers in der ZweiDElm Klasse
Wenn also ein Logger registriert wurde, dann logged er die Infos, ansonsten passiert nichts. Den Logger können wir nun als aufwändige Klasse implementieren, welche die Logginginformationen per Mail versendet oder als Logger, der in eine Datenbank schreibt oder als ganz simplen Logger für die Konsole. Und für letzteren können wir nun wieder eine anonyme Klasse nutzen:
Listing 42: Registrierung eines Loggers als anonyme Klasse
…oder wir bauen uns zwei verschiedene Logger, welche wir dann beliebig einsetzen können. Da wir (noch) keine Filezugriffe kennen, realisieren wir einfach einen simplen Logger, der einfach nur den data Parameter auf die Konsole schreibt und einen JSON Logger, der die Infos in einen JSON String eingliedert. Beginnen wir mit dem simplen Logger:
Listing 43: Simpler Logger
Und anschließene den nicht minder simplen JSON Logger:
Listing 44: JSON Logger
Nun instanziieren wir einen Logger – bspw. den JSON Logger und registrieren ihn in der Dreieck Klasse:
Listing 45: Registrierung des JSON Loggers
In der Kreis Klasse probieren wir einfach den simplen Logger MyPlainLogger aus:
Listing 46: Registrierung des simplen Loggers
Je nachdem, welcher Logger registriert wurde, erhalten wir nun bei einem doLog Aufruf eine Konsolenausgabe mit oder ohne JSON Formatierung. Wenn wir also bei myR, myD oder myK den Umfang auslesen:
Listing 47: Triggern der Logvorgänge durch auslesen des Umfangs
…erhalten wir auf der Konsole die passenden Logeinträge:
Da wir nun unser Interface MyLogger mit nur einer einzigen Methode ausgestattet haben, sprechen wir von einem „funktionalen Interface“. Java ermöglicht hier eine alternative Schreibweise bei der Registrierung unseres Loggers, und zwar mit Hilfe eines Lambda Ausdrucks. Diese Ausdrücke wurden mit Java 8 eingeführt und sollen dem Java Programmierer helfen, die Sprache im Sinne von funktionalen Programmierparadigmen zu nutzen. Wir sprechen hier also streng genommen nicht von einem objektorientierten Feature. Der Vollständigkeit halber bespreche ich es hier trotzdem. Wir werden später noch grob auf die Ideen dieses funktionalen Paradigmas eingehen. So viel vorab – man versucht bei einer funktionalen Programmierung alle Methoden so zu gestalten, dass sie keinerlei Abhängigkeiten nach außen haben, mit Ausnahme der übergebenen Parameter und Rückgabewerte. Also keinerlei Zugriffe auf bspw. globale Variablen. Da unsere Loggingfunktion über die Konsole genau diese Eigenschaft erfüllt, können wir die Registrierung unseres Loggers auch wie folgt schreiben:
Listing 48: Lambda Ausdruck in Java
Kurze Erklärung der Codezeilen: Im Wesentlichen ist es nur eine verkürzte Darstellung der Registrierung aus Listing 42. In Klammern befinden sich die Übergabeparameter. In unserem Fall steht im Interface MyLogger für die Methode doLog, dass dort eine String Variable benötigt wird – also schreiben wir im Lambda Ausdruck auch nur eine Variable – eben vom Typ String hinein. Der Datentyp wird nicht benötigt, da er bereits im Interface beschrieben wurde. Somit müsste klar sein, warum wir hier ein funktionales Interface eben mit nur einer Methode akzeptieren dürfen – sobald mindestens zwei Methoden im Interface stehen weiß Java nicht, welche der beiden gemeint ist. Danach erfolgt der -> Operator, welcher die Variable(n) in Klammern dem Code zuordnet. Unsere Methode hat lediglich eine einzige Zeile, nämlich die Ausgabe auf der Konsole.
Top
Statische Elemente – es geht auch ohne Objekte
Das letzte Schlüsselwort im Zusammenhang mit Objektorientierung, welches zu besprechen gilt, ist static. Dieses Wort haben wir ja bereits in unserem ersten Java Programm „HelloWorld“ in der Main Methode gesehen. Um den Begriff static zu verstehen, müssen wir wieder an den Anfang gehen, wo wir uns den Unterschied zwischen einem Objekt und der Klasse angesehen haben. Die Klasse haben wir dort mit dem Bauplan eines Hauses verglichen, während das Objekt das eigentliche Haus war. Die Instanzvariablen waren Speicherzellen, welche jeweils pro Objekt erzeugt werden mussten. In unserem Haus Beispiel war das bspw. das Namensschild am Haus. Jedes Haus hat ein eigenes Namensschild und jeder Hausbesitzer kann seinen individuellen Namen dort hinterlegen. Nun gibt es aber auch Eigenschaften, welche für alle Häuser, oder besser gesagt für den Plan gelten. Sie unterscheiden sich nicht pro Haus. Beispielsweise ist der Name des Architekten für alle Häuser gleich. Selbst wenn noch gar kein Haus gebaut wurde – sprich kein Objekt instanziiert wurde – möchten wir vielleicht den Namen des Architekten festhalten. Und dies würde in diesem Fall nur auf dem Plan selbst gehen:
Statische Klassenvariablen vs. Instanzvariablen
Abb.: 20: Statische Klassenvariablen vs. Instanzvariablen
Gleiches gilt auch für Methoden. Wir können Methoden realisieren, welche nicht auf Objektebene implementiert werden, sondern auf Klassenebene. Und diese Methoden und Eigenschaften, welche nur auf Klassenebene existieren sollen, sind static. Oftmals sind statische Eigenschaften auch als final implementiert, was bedeutet, sie können ihren Wert nicht mehr ändern – sogenannte Konstanten. Aber es ist nicht zwingend notwendig. Man kann in der Tat statische Variablen auch ohne final implementieren, so dass sie auch änderbar sind. Dies ist jedoch nur dann notwendig, wenn wir im Rahmen der aktuellen JVM(6) (Java Virtual Machine) über Objekte hinaus Daten austauschen müssen. Ich persönlich rate davon ab, Informationen massiv über statische Variablen zu verteilen, da der Gültigkeitsbereich dieser Variable nicht durch unser Programm, sondern über die Laufzeitumgebung bestimmt ist. Wenn wir bspw. ein Programm zweimal parallel starten, so startet Java zwei Laufzeitumgebungen. Wenn nun in einer der Wert geändert wird, gilt dies nicht für die zweite Umgebung. Auf Servern kann sichim Rahmen von HTTP Verarbeitung durch Servlets die Situation jedoch anders darstellen, was zu merkwürdigen Verhaltensweisen führen kann. Bei statischen Methoden wiederum muss man aufpassen, dass wir keinen Zugriff auf eine nicht statische Variable (also Instanzvariablen) implementieren. Eine statische Methode muss ja ohne dem Objekt funktionieren. Wenn also kein Objekt instanziiert wurde, gibt es auch keine Instanzvariable. Grundsätzlich kann man aber genau daran erkennen, ob eine Methode nicht vielleicht als statisch implementiert werden sollte, wenn sie keinerlei Zugriffe auf nicht statische Eigenschaften oder Methoden aufweist. Das Handling ist dann etwas einfacher, weil man hier ja kein Objekt instanziieren muss. Ein Beispiel einer statischen Methode haben wir bereits bei den Datentypen kennengelernt, dem String nach Integer Inhalten parsen:
(6) Eigentlich nur im Rahmen des aktuellen Class Loaders
Listing 49: Aufruf einer statischen Methode
Kurze Erklärung der Codezeilen: Die Methode parseInt() übernimmt den Stringparameter und konvertiert ihn wenn möglich in eine int Zahl. Hier wird keinerlei Zugriff auf irgendwelche Instanzvariablen der Integer Klasse benötigt. Insofern wurde die Methode statisch implementiert. Der Aufruf erfolgt sinnvollerweise nicht über eine Variable in der ein Objekt steckt – da wir kein Objekt benötigen, sondern direkt über den Klassennamen Integer.
Top
Package – zusammen, was zusammen gehört
Soweit zu den grundsätzlichen Ideen der objektorientierten Programmiersprachen. Bevor wir nun einen kurzen Blick auf die anderen Programmiersprachen werfen, müssen wir noch einen Begriff dazwischensetzen, welcher zwar kein reines objektorientiertes Thema darstellt, dort aber eine zentrale Bedeutung hat, die „Namespaces“ oder zu Deutsch „Namensräume“. Wir haben ja gesehen, dass wir unsere Klassen mehr oder weniger beliebig benennen können. Die Frage ist nun was passiert, wenn zwei Entwickler sich jeweils für eine ihrer Klassen den gleichen Namen ausgesucht haben? Wie können wir in solch einem Fall diese beiden Namen unterscheiden? Hier greift nun der Gedanke des Namespaces. In Java sind diese sehr eng mit den Paketen verknüpft – sprich die Namespaces definieren sich durch die Paketstrukturen. In unserem Programm haben wir die Geometrieklassen in das Paket mygraphs gepackt und das Interface MyLogger in das Paket mylogger – sprich wir haben für unsere weiteren Untersuchungen schon zwei Pakete. Gehen wir nun mal davon aus, dass wir jeweils in beiden Paketen die Klasse „ConsoleLogger“ einfügen wollen, welche lediglich die statische Methode „logToConsole“ hat. Ich habe die Methoden statisch realisiert, damit wir kein Objekt instanziieren müssen und somit der Testcode schlanker wird. Wenn wir vorab zwar wissen, dass es bereits eine Klasse mit dem gleichen Namen in dem Projekt gibt, würden wir nicht auf die Idee kommen unsere Klasse genauso zu nennen. Aber es passiert durchaus mal, dass mehrere Projekte und Bibliotheken zusammengefasst werden und solche Namensgleichheiten zufällig auftreten. Insofern ist unser theoretisches Beispiel hier durchaus nicht komplett aus der Luft gegriffen. Die Frage ist nun, wie können wir die beiden Klassen voneinander unterscheiden? Hierfür erzeugen wir erstmal die neuen Klassen:
Listing 50: Beispielklasse mit einfacher Methode im package mygraphs
Und das gleiche machen wir nun auch im package mylogger:
Listing 51: Beispielklasse mit einfacher Methode im package mygraphs
Zur Unterscheidung geben die beiden Methoden noch einen Hinweis auf ihr Paket aus. Nun greifen wir in der Main Methode auf die beiden Klassen und deren statischen Methoden zu und testen das Ganze:
Listing 52: Testcode für den Zugriff auf Namespaces
Kurze Erklärung der Codezeilen: Ich habe hier auf die import Statements verzichtet (wenn mit VSCode gearbeitet wird und alles in einem Projekt zusammengefasst wurde, muss darüberhinaus noch der Gesamtpfad hinterlegt werden). Um nun auf den ConsoleLogger des mygraphs Paktes zuzugreifen, muss man den Paketnamen mit einem Punkt vor die Klasse schreiben. Sollten die Pakete Unterordner aufweisen, werden diese ebenfalls durch Punkte getrennt.
Wenn wir den Code ausführen, sehen wir:
In den vorausgegangenen Listings konnten wir jeweils mit Hilfe des import Statements auf die explizite Angabe des Pakets bei der Klassennutzung verzichten. Dies geht allerdings nur dann, wenn wir keine Namensgleichheit haben. Wenn wir nun die Klasse ConsoleLogger des Paktes mygraph in unserem Code relativ häufig nutzen und die Klasse in mylogger eher selten, könnten wir nur mygraph importieren und die explizite Angabe entfernen:
Listing 53: Testcode für den Zugriff auf Namespaces mit import
Wenn wir allerdings beide Pakete importieren, dann müssten wir beim Zugriff auf ConsoleLogger wieder explizite Angaben machen, da der Compiler nicht weiß, welche der beiden Klassen wir meinen. Die Paktenamen bilden also die Namespaces, wodurch wir Eindeutigkeit bei den Zugriffen erlangen. Es liegt zwar nun wieder an uns, diese Paktestrukturen selbst zu benennen – hier haben sich aber ein paar Grundregeln etabliert, wenn wir den Programmcode anderen Personen zur Verfügung stellen möchten. Der wichtigste Punkt ist die Eindeutigkeit. Da (fast) alle Firmen, welche professionellen Code produzieren, einen Webauftritt und somit eine eindeutige Webadresse haben, orientieren wir uns an der URL. Die Pakete legen wir nun in Unterstrukturen an und setzen die Ordnersturktur basierend auf der URL in folgender Form um:
URL: www.codeconcert.de
Projektname: buchprojekt
Teilpaket: grafikbeispiel
Paketname: de.codeconcert.buchprojekt.grafikbeispiel
Top
Generics – bloß nicht festlegen
Bevor wir uns auf die Syntaxdetails der einzelnen Programmiersprachen stürzen, gehen wir noch kurz auf eine Konsequenz von statisch typisierten Programmiersprachen ein. Hierfür erinnern wir uns nochmal an dynamische Arras in Java, den „ArrayList“ Objekten aus Kapitel 7. Wir wollen nun diese Klasse „nachbauen“, um das Konzept von Generics zu verstehen. Eine ArrayList ist ein Objekt, welches intern ein Array vorsieht (daher der Name), welches bei Bedarf erweitert werden kann. Hierbei haben wir folgende Methoden dieser Klasse kennengelernt:
  • ArrayList“ als Konstruktor für die Vorbereitung der inneren Datenhaltung.
  • add(newElement)“, welche das Element newElement hinten anstellt und ggf. das interne Ar-ray erweitert.
  • add(position, newElement)“, welche an position das newElement einfügt und alle ande-ren Elemente um eins nach hinten schiebt.
  • set(position, newElement)“ ersetzt an der gegebenen position das Element mit newEle-ment.
  • remove(position)“ löscht an position das Element und schiebt alle folgenden Element um eins nach vorne.
  • size()“ gibt die Anzahl der Elemente zurück.
  • get(position)“ gibt das Element an der Position position zurück.
Unsere Klasse soll „MyArrayList“ heißen und – ebenso wie die Klasse ArrayList – beliebige Klassen aufnehmen können.
Beginnen wir mit der internen Datenhaltung. Wir benötigen ein Array vom Typ „Object“, damit wir nicht für jedes Objekt einer anderen Klasse eine eigene „MyArrayList“ bauen müssen. Weiterhin wird die Anzahl der bereits gesetzten Objekte benötigt:
Listing 54: Grundgerüst für MyArrayList in Java
Als nächstes erstellen wir eine Methode, welche das Array um 10 Elemente erweitert. Hierzu müssen wir ein neues, entsprechend vergrößertes Array erzeugen, die Daten umkopieren und dieses dann anstelle des alten Arrays platzieren:
Listing 55: Hilfsmethode für die Erweiterung des inneren Arrays in Java
Nun können wir die add und set Methoden realisieren. Bei „add“ müssen wir zuerst von rechts nach links bis zur gewünschten Position alle Elemente um eins nach rechts schieben. Hierzu beginnen wir bei „size“, was ja der erste freie Platz im Array ist, da das Array von 0 bis size-1 belegt ist und übernehmen jeweils den links stehenden Wert. Sollte das Array jedoch zu klein sein (also wenn das innerArray die Größe von size hat), müssen wir es mit einem Aufruf von increaseSize() erweitern. Die set Methode setzt einfach nur den Übergabewert an die gewünschte Position des innerArray.
Listing 56: Implementierung für „add“ und „set“ in Java
Nun fehlen noch size(), was einfach nur ein Getter für die Variable size ist und remove. Bei remove gehen wir genau umgekehrt wie in add vor, indem wir von position ausgehend das jeweils rechte Element um eins nach links schieben. Am Schluss setzen wir das letzte Element auf null, da es sonst doppelt referenziert wird.
Listing 57: Implementierung für „size“ und „remove“ in Java
Die letzte – und für das Thema „Generics“ wichtigste – Methode ist get, welche von der gegebenen Position das Element zurückgibt. Da wir intern das Array vom Typ „Object[]“ implementiert haben, muss die get Methode auch ein Element vom Typ „Object“ zurückgeben:
Listing 58: Implementierung für „get“ in Java
Nun können wir unser Programm genauso testen, wie in Kapitel 7. Hierzu übernehmen wir 1:1 den Code und tauschen ArrayList<String> bzw. ArrayList<> mit MyArrayList aus:
Listing 59: Test unsere MyArrayList Klasse
Soweit ist also alles erstmal gut. Nun haben wir jedoch ein Problem. Wenn wir bspw. in der ersten Ausgabezeile den Code myList.get(1) in myList.get(1).toUpperCase() ändern, erhalten wir einen Compilerfehler! Bei statisch typisierten Programmiersprachen müssen die angewendeten Methoden zum Datentyp der Variablen passen – anders wie bei den dynamisch typisierten Skriptsprachen, in denen wir mangels Datentyp der Variablen, die Methoden auf den Datentyp des Objektes anwenden. Um nun doch „toUpperCase“ anwenden zu können, müssten wir einen Typecast einbauen:
((String)myList.get(1)).toUpperCase()
Das widerspricht aber dem Grundgedanken von statischer Typisierung. Als Programmierer möchte ich mir nicht merken müssen, von welcher Klasse die verarbeiteten Objekte nun sind. Genau diese Information möchte man nun als „Klassenparameter“ in die Definition unserer Klasse einbauen, welche durch die spitzen Klammern bei der Deklaration der Variablen für MyArrayList eingetragen werden soll. Sehen wir uns hierzu die angepasste Klassendeklaration und die get Methode an:
Listing 60: Implementierung eines Typparameters in einer Klasse in Java
Kurze Erklärung der Codezeilen: Die Klassendeklaration wird um <ParType> ergänzt – also einem Parameter, welcher einen Datentyp übergibt. Der Name des Parameters kann wie jeder andere Variablenname gewählt werden, wobei man ihn in Java gemäß der Konvention, Klassen in Großbuchstaben zu benennen, auch groß schreibt. Benötigt man mehrere Klassentypen als Parameter, so kann man diese per Komma trennen. Nun können wir intern mit ParType genauso arbeiten, wie mit jeder anderer Klasse auch (mit Ausnahme von speicherallokierenden Funktionalitäten wie bspw. dem Anlegen von Arrays – hier müssen wir bei Object[] bleiben). Die Methode get() wird nun so ergänzt, dass sie den Datentyp ParType zurückgibt, weshalb wir beim return Statement den Typecast auf ParType durchführen müssen. Der Einfachheit verzichten wir auf eine Typüberprüfung und ignorieren das Warning. Wenn wir nun die Klasse für eine Variablendeklaration nutzen, können wir einen Klassennamen als Parameter hinterlegen. Wollen wir bspw. String in MyArrayList verarbeiten, so würden wir schreiben: MyArrayList<String> myList = new MyArray-List<>(); wodurch intern in der Klasse ParType durch String ersetzt wird. Der notwendige Typecast, wie wir ihn weiter oben für toUpperCase() benötigt haben, fällt dadurch weg.
Hinweis: Bis jetzt habe ich den parameter für den Klassentyp „ParTyp“ genannt, um den Sinn dieses Parameters hervorzuheben. Dieser Name ist aber eher unüblich – im Regelfall wird er mit „T“ benannt, weshalb ich mich ab jetzt auch an diese Konvention halte. Sehen wir uns das Ganze mal in C# an. Die Grundidee ist erstmal die gleiche – wir parametrieren einfach die Klasse. Im Gegensatz zu Java, können wir hier auch ein Array vom Typ ParType erzeugen, weshalb wir auf das Array vom Typ object verzichten:
Listing 61: Implementierung eines Typparameters in einer Klasse in C#
Kurze Erklärung der Codezeilen: Der Code ist größtenteils identisch mit Java. Lediglich die Nutzung von Object wurde durch die direkte Nutzung von T ersetzt. Dadurch ändert sich in gewissen Grenzen das Verhalten von Arrays, weshalb wir hier anstatt auf „Length“, auf Count() umstellen müssen. Dadurch, dass das innere Array nun gleich vom Typ T ist, können wir bei der get() Methode auf den Typecast verzichten.
Beim Konstruktoraufruf müssen wir im Gegensatz zu Java den Typparameter nicht nur bei der Variable, sondern auch beim Konstruktor eintragen:
MyArrayList<string> myList = new MyArrayList<string>();
An dieser Stelle könnten wir noch auf C++ eingehen – da die Generics hier aufgrund der komplizierteren Speicherverwaltung etwas aufwändiger sind und dies meines Erachtens den Scope dieses Buches sprengen würde, verzichte ich hier auf eine nähere Betrachtungsweise von C++. Wer sich hier trotzdem ein Bild machen möchte, sollte sich das Thema „Generics“ und „Templates“ für C++ in den diversen Internetquellen näher ansehen.
Top
Syntaxüberblick
Da wir die einzelnen Elemente und Konzepte bis jetzt auf Basis von Java Code kennengelernt haben, sehen wir uns nun die Syntaxvarianten der anderen objektorientierten Programmiersprachen an. Nachdem manche Programmiersprachen extrem viele „Extras“ anbieten – allen voran C# - könnte man dieses Kapitel noch ewig fortführen. Ziel dieses Buches ist es aber nicht, alle Programmiersprachen vollumfänglich zu dokumentieren, sondern vielmehr die grundsätzlichen Ideen und Konzepte aufzuzeigen und diese anhand der wichtigsten Sprachen zu demonstrieren. Insofern sind die folgenden Ausführungen als grober Überblick und nicht als vollständige Aufstellung zu verstehen. Die Syntaxspezifikationen werden tabellarisch aufgeführt und eventuelle „Spezialfälle“ danach noch mit aufgenommen.
Top
Java
Damit der Vergleich zu Java möglichst einfach durchzuführen ist, wird der Java Syntax auf die gleiche Art nochmal wiederholt:
Was? Wie? Bemerkung
Klassendefinition public class Kreis {
}
Klassen müssen je in einem eigenen File liegen (außer nested Classes).
Erweitern public class Kreis extends ZweiDElm {
}
Es gibt keine Mehrfachvererbung.
Klasse versiegeln public final class Kreis {
}
Abstrakte Klasse abstract public class Kreis extends ZweiDElm { }
Abstrakte Methode abstract void berechneUmfang();
Konstruktor public Kreis (int rf, int ff, double radius) {
  …
}
Standardkonstruktor existiert immer. Es dürfen mehrere Konstruktoren existieren.
Superkonstruktor public Kreis (int rf, int ff, double radius) {
  super(rf, ff);
}
Der Superkonstruktoraufruf muss an erster Stelle des Konstruktorcodes stehen.
Überschreiben public void berechneUmfang() {
  …
}
Es muss lediglich die Signatur übereinstimmen. Mit final wird dies unterbunden.
Interfacedefinition public interface Jsonifyable {
  public String getJsonString();
}
Muss ebenfalls in einem eigenen File stehen.
IF Implementierung public class Kreis implements Jsonifyable {
  …
}
Es können mehrere Interfaces gleichzeitig implementiert werden (kommasepariert).
Anonyme Klasse Jsonifyable myObj = new Jsonifyable() {
  …
}
;
Methoden des Interfaces müssen komplett umgesetzt werden.
Statische Elemente static public void doSth() {
  …
}
Ist für Methoden und Eigenschaften möglich.
Konstanten final float MY_PI = 3.14;
Selbstreferenz this.radius
Referenz myObject.setRadius(1.0);
Elternreferenz super.umfang
Namensraum package myproject; Referenziert wird über import.
Destruktor Werden nicht unterstützt (bzw. sind nicht notwendig).
Tabelle 6: Objektorientierte Sprachelemente von Java
Top
C#
Sehen wir uns nach Java einer der wichtigsten objektorientierten Programmiersprache an, nämlich C#:
Was? Wie? Bemerkung
Klassendefinition public class Kreis {
}
Klassen sollten je in einem File liegen, müssen aber nicht.
Erweitern public class Kreis : ZweiDElm {
}
Es gibt keine Mehrfachvererbung.
Klasse versiegeln public sealed class Kreis {
}
Abstrakte Klasse abstract public class Kreis extends ZweiDElm { }
Abstrakte Methode abstract void berechneUmfang();
Konstruktor public Kreis (int rf, int ff, double radius) {
  …
}
Standardkonstruktor existiert immer. Es dürfen mehrere Konstruktoren existieren.
Superkonstruktor public Kreis (int rf, int ff, double radius) : base(rf, ff);{
  …
}
Die Superklasse heißt hier Basisklasse.
Überschreiben public override void berechneUmfang() {
  …
}
Ohne override gibt es ein Warning bzw. Fehler. Die 1. überschriebene Methode der Vererbungskette muss virutal sein. Mit zusätzlich sealed wird weiteres Überschreiben verhindert.
Interfacedefinition public interface Jsonifyable {
  public String getJsonString();
}
Sollte in einem eigenen File stehen.
IF Implementierung public class Kreis : Jsonifyable {
  …
}
Gleicher Syntax wie Erweiterung, bei mehreren Interfaces kommasepariert.
Anonyme Klasse Wird nicht unterstützt.
Statische Elemente static public void doSth() {
  …
}
Ist für Methoden und Eigenschaften möglich.
Konstanten const float MY_PI = 3.14;
Selbstreferenz this.radius
Referenz myObject.setRadius(1.0);
Elternreferenz base.umfang
Namensraum namespace myproject {
  public class MyClass {
    …
  }
}
Referenziert wird über using.
Destruktor Werden nicht unterstützt (bzw. sind nicht notwendig).
Tabelle 7: Objektorientierte Sprachelemente von C#
Besonderheiten von C#:
C# bietet einen ganzen Strauß an „Besonderheiten“, welche von der mehr oder weniger „reinen Lehre“ objektorientierter Ansätze abweichen bzw. sie ergänzen sollen. Von allen diesen Besonderheiten sticht zumindest auf den ersten Blick eine Sache besonders im täglichen Gebrauch heraus. Die intensive Nutzung von Getter- und Settermethoden hat die C# Entwickler dazu bewegt, die Getter- und Setterdefinition zu vereinfachen. Im folgenden Beispiel werden die beiden Variablen laenge und breite zwar „public“ deklariert, allerdings automatisch Getter und Settermethoden generiert:
Listing 62: Automatische Getter- und Settergenerierung
Kurze Erklärung der Codezeilen: Trotzdem laenge und breite als public deklariert sind, werden sie vom Compiler als „private“ Variablen erzeugt. Bspw. wird aus
public double breite {get; protected set;}
folgender Code kompiliert:
private double breite;
public double getBreite() { return breite;}
protected void setBreite(double breite) {this.breite = breite;}
Hiermit wird der Vorteil gerade von Settern im Sinne der Kontrolle im Rahmen von Programmen jedoch etwas ad absurdum geführt, da wir ja keine Kontrolle des eigentlichen Codes haben. Um nun doch wieder einen eigenen Settercode zu realisieren, können wir folgenden Syntax verwenden:
Listing 63: Automatische Getter- und Setter mit custom Code
Kurze Erklärung der Codezeilen: Die private Instanzvariable wird nun mit einem Kleinbuchstaben am Anfnag notiert. Der Getter/Setterbereich wiederum groß. Nun können wir, ohne die beiden Methodensignaturen explizit zu notieren, die Getter- und Settermethode lediglich als „Rumpf“ implementieren. Die Zuweisung erfolgt nun über die private Instanzvariable breite und der Parameter des Setters ist standardmäßig value. Manchen Entwicklern passt jedoch die Nutzung von Klein/Großbuchstaben nicht in ihre Namenskonventionen, weshalb sie die private Instanzvariable mit einem Unterstrich beginnen (also _breite) und den Getter/Setterbereich mit einem Kleinbuchstaben beginnen. Wichtig ist aus Syntaxsicht lediglich, dass sie unterschiedlich sind.
Nun mag man fragen, was das eigentlich soll – schließlich haben wir nun im Vergleich zur klassischen Notation keinen Code wirklich gespart. Der Unterschied liegt nun darin, dass C# den Lese- und Schreibzugriff auf diese Methoden nun ebenfalls „versteckt“. Wenn wir im Hauptprogramm nun die Breite mit -3 setzen wollen, können wir das mit folgendem Code realisieren:
Listing 64: Setzen der Breite mit fehlerhaften Wert
Lassen wir den Code nun laufen, sehen wir auf der Konsole:
Das bedeutet, dass C# trotz einer „normalen“ Zuweisung über den Setter geht. Nun mag der eine das als Erleichterung im Code verstehen und der andere als ein Verstecken des eigentlichen Geschehens – insofern muss hier wieder jeder für sich selbst entscheiden, ob er diesen Weg geht oder nicht.
Ein weiterer auffälliger Unterschied zu Java ist, dass Methoden, welche überschrieben werden dürfen, mit virtual gekennzeichnet werden müssen. Wenn also Klasse A eine Methode vorsieht (die nicht abstrakt ist), darf sie von einer Kindklasse B nur überschrieben werden, wenn sie „virtual“ ist. Wenn B nun von einer Kindklasse C erweitert wird und die Methode wiederum überschrieben wird, muss in B kein virtual stehen. Sollte die Überschreibungskette unterbrochen werden, muss bei der letzten möglichen Überschreibung „sealed“ stehen.
Top
C++
Die nächste wichtige objektorientierte Programmiersprache ist C++. Sie ist insofern wichtig, als dass sie nach wie vor für performante Implementierungen eine sehr gute Wahl ist und einer der ersten breit eingesetzten objektorientierten Sprachen ist. Das soll aber nicht darüber hinwegtäuschen, dass im Vergleich zu Java und C# es auf jeden Fall aufwändiger und komplizierter ist, C++ zu lernen. Dies liegt natürlich an der Tatsache, dass sich die Programmieransätze in den moderneren Sprachen weiterentwickelt haben. Hier wäre beispielsweise der gesamte Kompilierprozess, der bei mehreren zu kompilierenden Files diese sinnvoll zusammenfassen muss. Während vor allem Java in diesem Punkt sehr unkritisch ist und der Compiler externe Abhängigkeiten sehr gut erkennt und auflöst und – noch wichtiger- bei der Ausführung der „Class Loader“ sich um die Konzertierung der einzelnen Bausteine kümmert, müssen bei C++ viele Abhängigkeiten manuell in den „Buildprozess“ eingebaut werden, damit der „Linker“ die kompilierten Elemente zu einem File zusammensetzt. In unserem Beispielcode für die Grafikelemente unter https://github.com/maikaicher/book1 habe ich bspw. den Compilerbefehl in einem eigenen Build.cmd hinterlegt. Das spiegelt zwar immer noch nicht die Realität von „echten“ Projekten wieder, gibt aber schon mal einen kurzen Vorgeschmack. Aber auch, dass C++ sehr viel „hardwarenäher“ ist und bspw. das Speichermanagement zum großen Teil „per Hand“ programmiert werden muss, schreckt viele Neulinge ab. Trotzdem ist es ganz hilfreich, sich das ein- oder andere Konzept in C++ anzusehen. Wer dann hier weitermachen möchte, dem empfehle ich dann die entsprechenden Tutorials im Netz, die recht umfangreichen Informationsseiten im Internet oder entsprechende Bücher, wie bspw. „The C++ Programming Language“ von Bjarne Stroustrup.
Was? Wie? Bemerkung
Klassendefinition class Kreis {
}
Mehrere Klassen werden üblicherweise in einem File editiert.
Erweitern class Kreis : public ZweiDElm, public MyClass {
}
Es gibt Mehrfachvererbung. Vererbungen können private sein, sind in der Regel aber public, sprich man kann auf die Elemente der Elternkasse zugreifen.
Klasse versiegeln Wird nicht mit eigenem Keyword unterstützt.
Abstrakte Klasse Kann nur durch abstrakte Methoden geschehen – es gibt keine abstrakten Klassen ohne abstrakte Methoden.
Abstrakte Methode virtual void berechneUmfang(){}; Geschweifte Klammern sind notwendig.
Konstruktor Kreis (int rf, int ff, double radius) {
  …
}
Standardkonstruktor existiert immer. Es dürfen mehrere Konstruktoren existieren.
Superkonstruktor public Kreis (int rf, int ff, double radius) : ZweiDElm(rf, ff);{
  …
}
Die Superklasse(n) müssen benannt werden, es gibt kein „base“ oder „super“.
Überschreiben public void berechneUmfang() {
  …
}
Es muss die Signatur übereinstimmen.
Interfacedefinition Interfaces werden mit rein abstrakten Klassen realisiert.
IF Implementierung Wird nicht unterstützt.
Anonyme Klasse Wird nicht unterstützt.
Statische Elemente const static int version = 1; Statische Variablen müssen konstant sein.
Konstanten const float MY_PI = 3.14;
Selbstreferenz this->radius
Referenz myObject->setRadius(1.0);
Elternreferenz ZweiDElm::printInfo()
Namensraum Wird nicht unterstützt.
Destruktor ~Kreis() {
  …
}
Notwendig um allokierte Ressourcen wie-der freizugeben. Wird automatisch oder durch delete aufgerufen.
Tabelle 8: Objektorientierte Sprachelemente von C++
Besonderheiten von C++:
Im Vergleich zu Java und C# macht aus Sicht des Syntax C++ relativ viel auf eine andere Art und Weise, wobei man bei einer zeitlichen Betrachtung sagen muss, dass es eigentlich andersherum ist. Java und C# haben viele neue Ansätze im Vergleich zu C++ eingeführt. Wie auch bei C# gilt hier, dass ich nur auf einige wenige Details eingehen kann und werde. Die grundlegende Basis können wir hier aber schaffen. Auf die Notwendigkeit der manuellen Speicherverwaltung bin ich ja schon zur Genüge eingegangen. In diesem Zusammenhang sei nochmal auf die Notwendigkeit des Destruktors hinweisen, welche bei den Programmiersprachen mit einem Garbage Collector nicht vonnöten war. Eine weitere Besonderheit im Vergleich zu den neueren Sprachen ist die Möglichkeit, die Klassenstruktur unabhängig von der Implementierung zu formulieren. Somit können die Definitionen in ein eigenes Header File ausgelagert werden. Dieses „Feature“ ist jedoch bei C++ zwingend notwendig, da die Verarbeitung beim kompilieren von Oben nach Unten erfolgt und somit die Klassen bzw. deren Eigenschaften und Methoden dem Compiler vorab bekannt sein müssen, bevor er einen Aufruf verarbeiten kann. Sehen wir uns hierzu folgenden Code an:
Listing 65: Trennung von Klassendeklaration und Implementierung
Kurze Erklärung der Codezeilen: Ganz oben steht der Import, damit wir cout verwenden können. Danach wird lediglich die Klasse spezifiziert. Hierbei sind nur die Instanzvariablen und Methodensignaturen samt Modifier notwendig. Diese müssen übrigens nicht vor jeder Variablen und Methode stehen. Man kann nach „public:“ auch mehrere Variablen hintereinander deklarieren, wodurch sie alle public sind. Die Implementierung kann dann an einer beliebigen Stelle stehen. Wichtig ist, dass die Klassenspezifikation vor der Nutzung – in unserem Fall vor der Main Methode steht. Die Implementierung erfolgt in unserem Beispiel am Ende. Da sie von der Spezifikation getrennt ist, muss nun für jede implementierte Methode die Klasse gefolgt von zwei Doppelpunkten angegeben werden. Die Spezifikation der Klassen kann auch in eigene „Headerfiles“ ausgelagert werden. Damit ist es möglich, Spezifikation und Implementierung zu trennen. Wer sich dies ansehen möchte, kann unter https://github.com/maikaicher/book1 die C++ Implementierung unseres Grafikprogramms ansehen. Dort wurde der Logger ausgelagert und in einem Logger.h und Logger.cpp File realisiert.
Noch ein Hinweis – die Objekte habe ich hier in einer Pointervariablen hinterlegt. Dies ist durchaus üblich. Es ist jedoch auch möglich, sie in eine normale Variable zu legen, wodurch der Konstruktoraufruf ohne das new erfolgen würde. In der Grafikprogramm-Implementierung kann diese Variante eingesehen werden.
Nun ist es aber auch möglich, die Spezifikation und Implementierung in einem Bereich durchzuführen, wodurch dies natürlich wiederum vor der Nutzung erfolgen muss. Im Folgenden sehen wir die gleiche Funktionalität ohne getrennte Spezifikation und Implementierung. Man erkennt, dass dies schon etwas näher an die Konzepte von Java und C++ erinnert:
Listing 66: Kombinierte Klassendeklaration und Implementierung
Soweit zu den C++ Besonderheiten, welche in diesem Rahmen sinnvoll zu besprechen wären.
Top
PHP
Die nächste objektorientierte Sprache ist PHP. Sie wurde anfangs eher als prozedurale Sprache aufgesetzt, welche versuchte die C und Pearl Entwickler zu binden. Dies erklärt bspw. die explizite „call by value“ und „call by reference“ Implementierung auf die bspw. Java und C# verzichtet. Später entwickelte man jedoch zunehmend objektorientierte Features hinzu. Die für Puristen mitunter als „bunte“ Mischung aus objektorientierten und prozeduralen Features belächelte Vielfalt hat aber durchaus zur gesteigerten Beliebtheit von PHP für die serverseitige Webentwicklung geführt. Die objektorientierten Umsetzungen bieten trotz allem mehr, als man beispielsweise von JavaScript erwarten kann. Insofern ist die Handhabung von PHP aus objektorientierter Sicht durchaus als „gewohnt“ zu verstehen, wenn man davon absieht, dass einige Hilfsfunktionen nicht als statische Klassenmethoden umgesetzt wurden. Was dem ein oder anderen Programmierer aus der Welt von Java & Co. eventuell fehlt sind die Überladungen. PHP kennt dies aufgrund der fehlenden Datentypen und der Möglichkeit Funktionen auf Variablen zuzuweisen nicht – dies haben wir bereits im Kapitel 8 angesprochen. Wenn man jedoch durch die PHP Manuals blättert, findet man trotzdem den Begriff „überladen“ als Feature. Dies gilt jedoch nur für die sogenannten „magischen Methoden“. Dies sind Methoden, welche implizit in PHP existieren und mitunter automatisch aufgerufen werden und immer mit zwei Unterstrichen beginnen. Ein Beispiel ist der Konstruktor. PHP kennt, genauso wie bspw. Java den Standardkonstruktor, den wir nicht explizit schreiben müssen. Bei Instanziierung wird dieser automatisch aufgerufen. In PHP heißt dieser Konstruktor nun nicht so wie die Klasse, sondern einfach nur __constructor(); Diesen wiederum können wir nun überschreiben:
Listing 67: Überschreibung mit Überladung des Standardkonstruktors
Bis auf die Besonderheit, dass der Konstruktor __construct() heißt, gibt es aus Sicht eines Java Entwicklers nichts auffallendes. Aus Sicht von PHP jedoch sehr wohl, da der implizit existierende Standardkonstruktor ja __construct() heißt und unserer überschriebener Konstruktor zusätzlich mit einem neuen Parameter versehen – also nun doch „überladen“ wurde.
PHP kennt neben dem Konstruktor einige weitere „magische Methoden“ und ich werde hier nur noch auf eine weitere interessante, die __call() Methode eingehen. Diese Methode wird immer dann aufgerufen, wenn für das Objekt ein nicht definierter Methodenaufruf erfolgt:
Listing 68: Überschreiben von __call()
Kurze Erklärung der Codezeilen: Das Objekt überschreibt die __call() Methode. Hierbei müssen zwei Parameter existieren. Der erste wird mit dem Methodennamen des Aufrufes belegt. Im zweiten finden wir alle Aufrufparameter als Array. Beide Werte geben wir hier lediglich mit echo aus. Beim Objekt $myObj wird nun die nicht vorhandene Methode doSomething() mit dem Parameter „hello“ aufgerufen.
Die Ausgabe erfolgt dann erwartungsgemäß:
Mit diesem Konstrukt können wir nun – wenn auch etwas umständlicher – eine Art Überladungsverhalten realisieren. Alle weiteren in diesem Rahmen relevanten Syntaxelemente für die Objektorientierung in PHP sind in der folgenden Tabelle.
Was? Wie? Bemerkung
Klassendefinition class Kreis {
}
Es werden oft mehrere Klassen in einem File formuliert.
Erweitern class Kreis extends ZweiDElm {
}
Es gibt keine Mehrfachvererbung.
Klasse versiegeln final class Kreis {
}
Abstrakte Klasse abstract class ZweiDElm {
}
Abstrakte Methode abstract public function berechneUmfang(); Geschweifte Klammern sind notwendig.
Konstruktor public function __construct ($rf, $ff, $radius) {
  …
}
Standardkonstruktor existiert immer. Es dürfen mehrere Konstruktoren existieren.
Superkonstruktor public function __construct($rf, $ff, $radius) {
  parent::__construct($rf, $ff);
    …
  }
}
Der Superkonstruktoraufruf sollte an erster Stelle des Konstruktorcodes stehen.
Überschreiben public void berechneUmfang() {
  …
}
Es muss die Signatur übereinstimmen.
Interfacedefinition interface Jsonifyable {
  public function getJsonString();
}
IF Implementierung public class Kreis implements Jsonifyable {
  …
}
Es können mehrere Interfaces gleichzeitig implementiert werden. Parameter sollten genauso benannt werden, wie im Interface.
Anonyme Klasse Logger myObj = new class {…}; Anonyme Klassen benötigen keine Methodenbeschreibung via Interface.
Statische Elemente public static function doSth(){
  …
}
Ist für Methoden und Eigenschaften möglich. Zugriff erfolgt über „::“Kreis::doSth().
Konstanten define("MY_PI", 3.14);
Selbstreferenz $this->radius
Referenz $myObject->setRadius(1.0);
Elternreferenz parent::printInfo()
Namensraum namespace mygraphs; Auflösung: …new \mygraphs\Kreis(…)
oder: use mygraphs\Kreis as MyKreis;
…new Kreis(…)
Destruktor public function __destruct() {
  …
}
Wird bei Beendigung des Skripts oder bei Aufruf von unset($myObjVar); aufgerufen
Tabelle 9: Objektorientierte Sprachelemente von PHP
Top
Python
Python zählt wie PHP ebenfalls zu den dynamisch typisierten und nicht kompilierten Sprachen. Während PHP jedoch versucht, den klassischen Programmierern möglichst entgegenzukommen, hat dies in Python keine Priorität. Beispielsweise sind Interfaces bei nicht typisierten Sprachen nicht wirklich sinnvoll einsetzbar, da wir hier ja eigentlich „nur“ die Struktur des zu erwarteten Objekts festlegen. Wir machen also eine Art Typisierung des Objektes – und gleiches gilt natürlich auch für die Typecasts in Richtung Elternklassen um die Vorteile der Polymorphie auszunutzen. Wenn wir aber keine Typen haben, können wir auch keine Typecasts durchführen. Wir wissen also basierend auf der Variablen nicht, welche Objekte wir vorfinden – da dies in Sprachen wie Java durch den Datentypen der Variable bestimmt wird. Das ist nun aber eine enorme Einschränkung! Die Lösung dieses Dilemmas ist, dass bei nicht typisierten Skriptsprachen einfach niemand vorab prüft, ob die Methode überhaupt vorhanden ist. Wenn also eine aufgerufene Methode eines Objektes nicht existiert, wird zur Laufzeit einfach ein Fehler erzeugt. Man nutzt also ein Objekt nicht nach seiner Definition, sondern einfach nur danach, welche Methoden vorhanden sind. Dieses Vorgehen nennt man auch „Ducktyping“, nach einem amerikanischen Gedicht von James Whitcomb Riley: „When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.“ – also die Eigenschaften und Methoden bestimmen den Typen, nicht andersherum. Dies führt zwar dazu, dass Fehler mitunter erst zur Laufzeit gefunden werden können, aber auch zu einer erhöhten Flexibilität.
Weiterhin waren sich die Python Entwickler wohl ziemlich im Klaren darüber, dass Python eine Skriptsprache ist und somit eingebundene Bibliotheken jederzeit vom Entwickler abgeändert werden können. Somit haben die Python Macher vermutlich keinen wirklichen Sinn mehr in solchen Dingen wie Zugriffsmodifikatoren gesehen. Wenn ich eine Klasse von einer Bibliothek nutze und mir der private Zugriffsmodifikator einer Variablen nicht passt, kann mich niemand daran hindern, den Modifikator einfach auszutauschen. Also hat man einfach darauf verzichtet, die Zugriffsmodifikatoren in Python vorzusehen. Um den Code trotzdem in Bezug auf die Modifikatoren „lesbarer“ zu machen hat man einfach die Konvention eingeführt, private mit zwei und protected mit einem Unterstrich vor der Variable zu markieren. Dies hat – wie oben bereits erwähnt – zwar keinerlei Einfluss auf das Zugriffsverhalten, zeigt aber dem Nutzer von Klassen, welche Variable wofür gedacht ist.
Ein weiterer Punkt bei den objektorientierten Elementen von Python ist – wie bei Python ja häufig – der reduzierte Ansatz was Codeelemente angeht. Sehen wir uns hierzu mal zwei Klassen an, bei denen eine von der anderen erbt:
Listing 69: Klassendefinition und Instanziierung in Python
Wir sehen hier bekannte Konzepte wieder. Python verzichtet weitestgehend auf geschweifte Klammern, welche durch Einrückungen ersetzt werden. Der Konstruktor heißt in Python __init__(). Erweiterungen kommen lediglich in eine Klammer nach dem Klassennamen – hier dürfen auch mehrere Elternklassen stehen – Python unterstützt Mehrfachvererbung. Die Elternklasse wird als „super“ bezeichnet, wobei wir hier über eine Methode „super()“ auf die Elternklassenelemente zugreifen. Wenn wir mehrere Elternklassen haben, sucht super().Methode() die Elternklassen von links nach rechts ab, bis eine passende Methode „Methode()“ gefunden wurde. Was weiterhin auffällt ist, dass Python nicht „this“ kennt, sondern dies als „self“ bezeichnet. Da diese Selbstreferenz jedoch bei jeder Instanzmethode in der Parameterliste am Anfang steht, könnten wir sie theoretisch auch anders bezeichnen – wovon aus Übersichtsgründen aber abzuraten ist. Der Pythoninterpreter schreibt also automatisch bei jedem Methodenaufruf am Anfang der Parameterliste die Referenz des eigenen Objektes hinein. Dies ist auf jeden Fall für „nicht Python Entwickler“ ungewohnt.
Dieses „self“ hat nun noch eine wichtige Eigenschaft. Instanzvariablen werden immer mit dem „self“ Keyword definiert. Wir erzeugen diese Variablen auch nicht außerhalb der Methoden, sondern innerhalb. Theoretisch geht das in jeder beliebigen Methode, wobei ich aber empfehle, dies im Konstruktor zu tun – sprich die erstmalige Erzeugung und Initialisierung der Variablen im Konstruktor durchzuführen, womit man die Garantie hat, dass sie mit einem definierten Wert existieren. Hier ist noch ein kleiner Hinweis auf Python sinnvoll. Python kennt den Zustand „null“ nicht, sondern nutzt „None“ – was lediglich ein „Markerobjekt“ für „nicht definiert“ ist. Dies bringt mit sich, dass es explizit gesetzt werden muss. Eine Variablendeklaration in Python ist immer mit einer Zuweisung verbunden – wenn ich also eine Variable ohne Inhalt erzeugen muss, dann muss ich das eben definieren mit myVar = None.
Ungewohnt ist auch, dass Python auf das Schlüsselwort „new“ verzichtet, sondern der Aufruf des Konstruktors ausreicht. Dies ist vor dem Hintergrund des Verzichts auf alles „nicht zwingend Notwendige“ allerdings konsequent. Bei abstrakten Klassen müssen wir in Python einen bis jetzt ungewohnten Weg gehen. Für Abstraktionen hat Python ein Modul namens „abc“ (Abstract Base Class) geschaffen. Wenn wir nun eine abstrakte Klasse benötigen, so erben wir von der ABCMeta Klasse über den Zusatz „metaclass=ABCMeta“). Mit dem „Dekorierer“ @abc.abstractmethod über einer Methode verhindern wir nun, dass die entsprechende Methode direkt aufgerufen wird. Wenn dieser nun vor dem Konstruktor steht, ist die Klasse damit nicht mehr instanziierbar.
Listing 70: Abstrakte Klasse in Python
Fehlt dieser Dekorierer, dann können wir die Klasse instanziieren. Wir könnten aber theoretisch diesen Dekorierer auch über jede beliebige Methode schreiben, was auch zur Verhinderung der Instanziierung führt. Anders als bspw. in Java können wir aber als abstrakt geflaggte Methoden durchaus mit Funktionalität belegen. Diese Funktionalität können wir somit lediglich über den super() Aufruf nutzen:
Kurze Erklärung der Codezeilen: Die Klasse GarikElm ist abstrakt, da sie ABCMeta erweitert und eine abstrakte Methode printInfo() aufweist. Diese Methode hat jedoch einen Rumpf und somit Funktionalität. In der Kindklasse kann nun diese über super().printInfo() aufgerufen werden. Da in Python jedoch jede Methode einen Rumpf benötigt, müssen wir bei abstrakten Methoden ohne jegliche Funktionalität mindestens „return“ eintragen.
Was? Wie? Bemerkung
Klassendefinition class Kreis:
    …
Es werden oft mehrere Klassen in einem File formuliert. Klassenelemente müssen vier Spaces nach rechts eingerückt stehen.
Erweitern class Kreis(ZweiDElm):
    …
Es gibt Mehrfachvererbung (kommasepariert).
Klasse versiegeln Nur über Workarounds machbar und in einer Skriptsprache eigentlich nicht sinnvoll.
Abstrakte Klasse @abc.abstractmethod
def __init__(self, …):
Details siehe oben.
Abstrakte Methode @abc.abstractmethod Details siehe oben.
Konstruktor def __init__(self, randfarbe):
    …
Standardkonstruktor existiert immer. Es darf nur ein Konstruktor formuliert werden.
Superkonstruktor def __init__(self, randfarbe):
    super().__init__(randfarbe)
    …
Der Superkonstruktoraufruf sollte an erster Stelle des Konstruktorcodes stehen. Bei mehreren Elternklassen „super()“ durch Elternklassenname ersetzen.
Überschreiben def printInfo(self): Es muss die Signatur übereinstimmen.
Interfacedefinition Wird nicht unterstützt, bzw. muss als abs-trakte Klasse erfolgen.
IF Implementierung Über Mehrfachvererbung von abstrakten Klassen möglich.
Anonyme Klasse Wird eigentlich nicht unterstützt, es gibt aber die Möglichkeit über type(name, bases, dict) Funktion.
Statische Elemente @staticmethod
def doSomething():
    …
Der Decorator sorgt dafür, dass „self“ nicht als Parameter akzeptiert wird. Dadurch sind keine Referenzen auf Instanzvariablen und Methoden mehr möglich.
Konstanten Wird nicht unterstützt – es gilt die Konvention: Wenn nur Großbuchstaben verwendet werden, soll die Variable nicht neu belegt werden.
Selbstreferenz self.breiteradius Nur, wenn „self“ in Parameterliste so genannt wurde.
Referenz myObject.setBreite(1.0);
Elternreferenz super().umfang
Namensraum import mygraphs.MyClasses as myg Namensräume werden durch die Datei („Modul“) gebildet. Beim Import kann ein individueller Name (bspw. myg) vergeben werden. Zugriff erfolgt dann über myg.GrafikElm
Destruktor def __del__():
    …
Wird aufgerufen, wenn keine Referenz auf das Objekt mehr existiert. aufgerufen
Tabelle 10: Objektorientierte Sprachelemente von Python
Top
JavaScript
Kommen wir zum nächsten Kandidaten in unserer objektorientierten Sprachsammlung, nämlich JavaScript (bzw. ECMA Script). Diese Sprache unterscheidet sich in puncto Objektorientierung wesentlich von den bisher besprochenen Sprachen C++, C# und Java. Kennen diese Sprachen das Konzept einer Klasse – sprich dem Plan eines Objektes, welches zu instanziieren gilt, gibt es soetwas in JavaScript nicht! Wir werden zwar das Schlüsselwort „class“ kennenlernen, welches aber ein eher jüngeres Konstrukt im Rahmen von ES6 eingeführt wurde und lediglich zur besseren „Akzeptanz“ dieser Sprache führen soll. Hiermit wird das Konzept der Klasse lediglich „simuliert“(7). JavaScript gehört nämlich zu den prototypbasierten Programmiersprachen. Dies bedeutet vereinfacht gesagt, dass man nicht eine Klasse erstellt und aus dieser die Objekte erzeugt, sondern dass man ein Objekt erzeugt und dieser dann zu einem neuen Objekt geklont wird (wodurch der geklonte Teil des Elternobjekts zum Prototypen des Kindobjektes wird). In diesem Klonvorgang kann man dann wieder neue Eigenschaften und Methoden hinzufügen. Dadurch können solche Konzepte wie abstrakte Klassen und Interfaces nicht Teil dieser Sprache sein.
Syntaktische Erweiterungen zur einfacheren Handhabung, welche jedoch das dahinterliegende Grundprinzip nicht ändern, nennt man auch „syntaktischer Zucker“
Da JavaScript ständig weiterentwickelt wird um die Flexibilität und Nutzbarkeit zu erhöhen, gibt es für viele Techniken mehrere Wege, diese zu implementieren. Dies ist vor allem für Neueinsteiger meist verwirrend. Liest man sich zu verschiedenen Aufgaben unterschiedliche Tutorials durch kann es sein, dass jedes Tutorial einen anderen Weg für das gleiche Ergebnis geht. Da der Ansatz von JavaScript ziemlich von dem der klassischen Objektorientierung abweicht, gehe ich hier etwas intensiver auf die Konzepte ein. Sehen wir uns mal verschiedene Wege zur Erzeugung eines Objektes an und beginnen mit der „klassischen“ Variante, welche schon sehr früh von JavaScript unterstützt wurde. Diese ähnelt sehr des Konzepts der anonymen Klasse von Java, wo wir ein Objekt ohne Klasse erzeugt haben. Da Java aber kompiliert wird, mussten wir die Klassenstruktur in einem Interface vorab hinterlegen, was bei JavaScript als zur Laufzeit interpretierten Sprache nicht notwendig ist:
Listing 72: Klassische Objekterzeugung in JavaScript
Kurze Erklärung der Codezeilen: Die Variable myObject wurde anstatt mit let oder var, mit const deklariert, was bedeutet, wir können ihr keinen anderen Wert mehr zuweisen. Wenn wir also irgendwo myObject = 2; schreiben würden, erhalten wir einen Laufzeitfehler. Da wir üblicherweise Objekte nicht neu belegen, ist const hier sinnvoller als let oder var. Die einzelnen Instanzvariablen wie bspw. myName werden über einen Doppelpunkt direkt mit einem Wert zugewiesen. Aus diesem Grund wird (erstmal) kein Konstruktor benötigt. Die Funktionen werden ebenso mit Doppelpunkt und dem Schlüsselwort function hinzugefügt – Funktionen werden in JavaScript ja genauso behandelt wie Objekte. Wenn wir nun innerhalb einer Funktion auf eine Instanzvariable zugreifen wollen, müssen wir immer das Schlüsselwort this davor schreiben. Anders als in Java ist this hier also Pflicht. Das liegt im Wesentlichen daran, dass ausschließlich anhand this erkannt wird, dass es sich um eine Instanzvariable handelt. Der Zugriff auf die Methoden und Eigenschaften eines Objektes erfolgt wie in Java über den Punktoperator.
Nun können wir jedoch einen alternativen Zugriff auf die Instanzvariablen eines Objektes umsetzen. Anstatt der Zeile console.log(myObject.myName); können wir auch console.log(myObject['myName']); schreiben. Die Objekte funktionieren also ähnlich einem assoziativen Array. Dies erklärt auch, warum wir zur Laufzeit dynamisch neue Instanzvariablen hinzufügen können. Wir ergänzen unseren Code aus Listing 72 um folgenden Code:
Listing 73: Erweiterung des Objektes zur Laufzeit um eine Eigenschaft
Dies ist eine „Stärke“ von prototypbasierten Sprachen – sie sind sehr flexibel. Hartgesottene C++ Programmierer hingegen sehen diese Flexibilität als ein Einfallstor für unsaubere Codefragmente an, da es sehr schwierig wird, die eigentliche Struktur eines Objektes irgendwo zentral zu begutachten. Aber gehen wir noch einen Schritt weiter! Wenn die Eigenschaften dynamisch erweiterbar sind, müssten es die Funktionen nicht auch sein? Probieren wir es aus:
Listing 74: Erweiterung des Objektes zur Laufzeit um eine Methode
Objekte sind in JavaScript also hochdynamische Elemente, welche jederzeit angepasst werden können. Kommen wir hier aber nochmal auf einen wichtigen Aspekt der Skriptsprachen, welcher sich aus der fehlenden Notwendigkeit der Deklarationen heraus ergibt. Sehen wir uns folgenden Code an und achten auf die Variable innerText:
Listing 75: Globale Gültigkeit von Variablen
Wenn wir diesen Code laufen lassen, sehen wir auf der Konsole:
Die Variable innerText ist also innerhalb und außerhalb des Objektes gültig, da sie einen globalen Charakter hat. Dies ist natürlich aus Sicht der Kapselung eine sehr unvorteilhafter Vorgehensweise. Aus diesem Grunde wurde das uns bereits bekannte Schlüsselwort „let“ bzw. „var“ eingeführt, wodurch die Variable nun innerhalb der Funktion nochmal parallel erzeugt wird. Wenn wir also innerhalb der Funktion var innerText = "Logging: "; schreiben, so sehen wir trotzdem als letzte Ausgabe "Äußerer Text". Neben var kennt aber JavaScript ja noch let, was bewirkt, dass die Variable innerhalb des Scopes nicht nochmal neu deklariert werden kann und den Scope nur bis innerhalb des Blocks setzt – also innerhalb der einschließenden geschweiften Klammern und in unserem Fall bis zur schließenden geschweiften Klammer der Funktion. Dieses Verhalten wäre wie eine normale Deklaration einer lokalen Variablen in Java und sollte dem „var“ auf jeden Fall vorgezogen werden. Sehen wir uns einen weiteren Weg an, Objekte zu erzeugen. Diesmal ausschließlich über einen Konstruktor:
Listing 76: Objekterzeugung via Konstruktor in JavaScript
Kurze Erklärung der Codezeilen: Wir definieren hier lediglich eine Funktion, welche ein Objekt erzeugt. Da diese Funktion somit einem Konstruktor gleichkommt, können wir sie auch gleich wie eine Klasse benennen. Nun können wir das Objekt auch mit Hilfe von new erzeugen. Die restlichen Eigenschaften und Vorgehensweisen decken sich mit denen aus den vorausgegangenen Listings.
Das nächste Verfahren ein Objekt zu erzeugen ist durch die Nutzung des „create“ Befehls des Standardobjekts Object.
Listing 77: Objekterzeugung via Object.create() in JavaScript
Die Deklaration der Klasse MyClass ist wiederum nichts anderes als die Objektdeklaration aus Listing 72. Dies ist deshalb einleuchtend, weil es in der prototypbasierten Sprache keine Klassen gibt. Wir behandeln im Listing 77 das Objekt MyClass lediglich wie eine Klasse. Prinzipiell können wir als Parameter der create() Methode jedes Objekt eintragen. Wir erstellen somit einen „Klon“ des Objektes, den wir anschließend erweitern können, was wiederum einer Vererbung gleichkommt. Der Klon des Originalobjektes wird damit zur „Elternklasse“ (oder hier besser zum Elternobjekt bzw. noch besser zum Prototypen) und der Rest des Klons zum Erweiterungsteil der Kindklasse. Intern löst dies JavaScript mit Listen, in denen die Eigenschaften und Methoden eingetragen werden. Eine Liste existiert für die selbst erzeugten Eigenschaften und Methoden, eine für den Prototypen, eine für den Prototypen des Prototypen usw., bis kein Prototyp mehr vorhanden ist (bzw. die Referenz auf den Prototypen null ist).
Die letzte hier vorgestellte Methodik ist erst seit ES6 möglich. Hier hat man (zumindest aus Sicht des Syntax) ähnliche Methodiken vorgesehen wie bei den klassischen objektorientierten Sprachen wie C# oder Java, wenngleich der interne Aufbau in JavaScript sich nicht verändert hat. Man kommt hier im Wesentlichen denjenigen entgegen, welche in diesen Sprachen das Programmieren gelernt haben. Sehen wir uns dies im Code an:
Listing 78: Objekterzeugung via Object.create() in JavaScript
Dieses Konstrukt erinnert am ehesten an die Konzepte von C# und Java. Sollte eine Methode einen Wert zurückgeben müssen, so wird dies auch mit „return“ erledigt. Ansonsten ist zu beachten, dass mangels Deklaration die Instanzvariablen immer dann existieren, wenn sie mit „this.“ belegt wurden und der Interpreter diese Stelle auch verarbeitet hat. Ergänzen wir bspw. die Methode printValue() wie folgt:
Listing 79: Ergänzung um Instanzvariable in JavaScript
Wenn wir nun das Objekt erzeugen und vor dem Aufruf myObject.printValue("Hello!"); versuchen auf myObject.aValue zuzugreifen, ist der Inhalt nicht definiert. Rufen wir die printValue() Methode jedoch vorher auf, existiert die Variable aValue. Insofern ist es eine gute Idee, alle Instanzvariablen wie auch bei Python im Konstruktor erstmal festzulegen, oder sie wie im folgenden Beispiel als eine Art Instanzvariable zu platzieren:
Listing 80: Variableninitialisierung auf Objektebene
Gehen wir nun nochmal auf den Begriff des Prototypen ein. Die Idee ist, dass jede Klasse – genaugenom-men jeder Konstruktor – auf eine Liste von Eigenschaften und Methoden eines sogenannten „Prototypen“ eine Referenz hat. Um dies zu verstehen, sehen wir uns folgenden Code an:
Listing 81: Erzeugung von Objekten aus Vererbungshierarchie
Kurze Erklärung der Codezeilen: Der grundsätzliche Code sollte klar sein – wir erzeugen eine Elternklasse mit einer Instanzvariablen aValue und erweitern sie durch eine Kindklasse, welche eine weitere Instanzvariable bValue ins Spiel bringt. Neu ist nun, dass wir nachträglich in der ChildClass einen Prototypenwert cValue definieren. Danach erzeugen wir zwei Objekte vom Typ ChildClass und geben die drei Variablen aValue, bValue und cValue aus.
Wir führen den Code aus und sehen die zugewiesenen Werte. Soweit ist erstmal nichts Besonderes am Code zu entdecken. Nun ergänzen wir den Code um folgende Zeilen:
Listing 82: Manipulation des prototype Wertes
Bei der Ausgabe sehen wir nun:
Dies bedeutet, dass beide Objekte die gleiche prototype Eigenschaft „cValue“ teilen. Gehen wir nun einen Schritt weiter und verändern bei child1 das Attribut cValue:
Listing 83: Shadowing eines Prototypenwertes
Das Ergebnis mag jetzt auf den ersten Blick überraschen:
Was hier passiert ist Folgendes: child1 und child2 sind beide vom Typ ChildClass. Diese Klasse wiederum hat die prototype Eigenschaft cValue. Mit dem Code child1.cValue = 5 fügen wir nun eine weiter Eigenschaft mit dem Namen cValue zu dem Objekt hinzu. Es existieren nun also zwei cValue, eine vom Prototypen und eine vom Objekt. Beim Lesezugriff sucht nun der JavaScript Interpreter vom „nahen“ zum „fernen“ – also er beginnt beim Objekt und sucht danach erst beim Prototypen. Da child1 keine Instanzvariable cValue, sondern nur die Prototyp Eigenschaft cValue hat, wird hier noch die 4 ausgegeben. Dies ist eine Form der Überschreibung, welche man in JavaScript in Bezug auf den Prototypen „shadowing“ nennt.
Gehen wir nun noch einen Schritt weiter. Die Prototypen werden ja weitervererbt. Wir können also nun bei der ParentClass ebenfalls einen Prototypenwert festlegen:
Listing 84: Schaffung eines neuen Prototypenwertes bei der Elternklasse
Die Ausgabe ist tatsächlich der Wert 6. Es gibt also eine sogenannte „Prototypenkette“, welche von unten nach oben durchsucht wird. Jede Klasse hat eine Referenz auf den Prototypen der Eltern. Diese liegt in der Variablen __proto__:
Listing 85: Zugriff auf Elternprototyp über Kindklasse
Wir haben also mit ChildClass.__proto__.prototype.eValue auf das Prototypenobjekt der Elternklasse zugegriffen und dort ein Attribut hinterlegt. Somit können wir vom Kind über die __proto__ Variable auf die Elterndefinition zugreifen, wodurch automatisch alle Erben von ParentClass diese Eigenschaft erhalten. Diese Prototypenkette hat als vorletztes Glied „Object“ und als letztes „null“. Nun können wir dieses Wissen für ein Experiment nutzen, welches für C# oder Java Entwickler undenkbar wäre:
Listing 86: Manipulation des prototype Wertes der Object Klasse
Wir können also tatsächlich eine Funktion für alle Objekte innerhalb unserer Interpretersession hinzufügen. Jedes beliebige Objekt hat nun die Funktion sayHi(). Wir sehen also, das Konzept des Prototypen ist durchaus mächtig, sollte aber auf jeden Fall sehr gut geplant sein, damit der Code noch verständlich bleibt. Insgesamt gäbe es noch sehr viel mehr über Prototypen zu sagen – da dies aber kein JavaScript Buch ist, sondern die grundsätzlichen Gedanken von Programmierung abdecken soll, belasse ich es bei den hier beschriebenen Grundlagen.
Nun ist es an der Zeit, über die Access-Modifier zu sprechen. Wir haben bis jetzt alle Eigenschaften und Methoden public angelegt, da wir keinerlei Modifikatoren formuliert haben. Da wir aber bei JavaScript keine Deklaration haben, müssen die Access-Modifier anderweitig hinterlegt werden. JavaScript kennt neben public nur noch private, also hat man kurzerhand das private mit einem Hashzeichen # als Präfix festgelegt(8) . Eine private Variable namens myVar wird also #myVar geschrieben. Damit wir das Ganze nun übersichtlich testen können, erzeugen wir ein eigenes File MyClass.js, in der die Klasse exportiert wird:
(8) Ja – man hat tatsächlich das Hashzeichen für private genommen! Aus UML Sicht ist dies eine extrem schlechte Wahl, da in UML das Hashzeichen für protected steht.
Listing 87: Klassendeklaration mit export und privaten Eigenschaften und Methoden
Im Hauptprogramm können wir nun diese Klasse nutzen:
Listing 88: Nutzung einer Klasse in JavaScript
Ein Zugriffsversuch auf die privaten Eigenschaften und Methoden im „Hauptprogramm“ würde nun zu einem Fehler führen. Da wir inzwischen den Unterschied von private und public kennen, dürfte die weitere Interpretation des Codes keine Schwierigkeiten bereiten. Alternativ kann man den Export und Import auch über Module durchführen, wie es in Kapitel 10 beschrieben wurde.
Die folgenden Syntaxvorgaben orientieren sich an der ES6 Klassenimplementierung, da wir hier die aus der objektorientierten Sicht „sauberste“ Annäherung an das eigentliche objektorientierte Arbeiten haben.
Was? Wie? Bemerkung
Klassendefinition class Kreis {
}
Klassen sind eigentlich Objektprototypen.
Erweitern class Kreis extends ZweiDElm {
}
Es gibt keine Mehrfachvererbung.
Klasse versiegeln Object.seal(Kreis); Man kann keine „Klassen“ versiegeln (da es keine gibt), sondern nur Objekte bzw. Objektprototypen.
Abstrakte Klasse constructor() {
  if (new.target === Kreis) {
    throw TypeError("Error!");
  }
  …
Wird nicht unterstützt. Der Code links ist lediglich ein Workaround(9).
Abstrakte Methode berechneUmfang() {
  throw TypeError("Error!");
}
Wird nicht unterstützt. Der Code links ist lediglich ein Workaround(9).
Konstruktor constructor (rf, ff, radius) {
  …
}
Konstruktoren können nicht überladen werden.
Superkonstruktor constructor (rf, ff, radius) {
  super(rf, ff);
  …
}
Der Superkonstruktoraufruf muss vor dem Auftauchen des ersten „this“ Schlüsselwort genutzt werden.
Überschreiben berechneUmfang() {
  …
}
Es muss lediglich die Signatur übereinstimmen.
Interfacedefinition Wird nicht unterstützt - bringt bei nicht typisierten und klassenlosen Sprachen keinen Mehrwert.
IF Implementierung Wird nicht unterstützt.
Anonyme Klasse Wird nicht unterstützt – ist bei prototypischen Sprachen nicht sinnvoll.
Statische Elemente Wird nicht unterstützt – ist bei prototypischen Sprachen nicht sinnvoll.
Konstanten const MY_PI = 3.14;
Selbstreferenz this.radius
Referenz myObject.setRadius(1.0);
Elternreferenz super.umfang Bzw. über prototpye (siehe Codebeispiele Oben)
Namensraum Nicht notwendig, da individuelle Namensräume beim Modulimport vergeben werden können.
Destruktor Werden nicht unterstützt (bzw. sind nicht notwendig).
Tabelle 11: Objektorientierte Sprachelemente von JavaScript (ES6)
(9) Dieser Workaround sorgt dafür, dass zur Laufzeit ein Fehler ausgelöst wird.
Bevor ich dieses Kapitel abschließe, noch ein kurzer Exkurs zu den JavaScript Modulen. Der Beispielcode für das Grafikprogramm-Projekt wurde in Module zerlegt, so dass die einzelnen Elemente sich möglichst einfach nutzen lassen. Hierzu ist folgendes notwendig:
1.: Zuerst hinterlegen wir im package.json die Information, dass wir mit Modulen arbeiten (siehe hierzu das Kapitel 10 zum Thema package.json).
2.: Danach platzieren wir die einzelnen Klassen als je ein File in ein *.mjs File – also bspw. die Klasse Dreieck in Dreieck.mjs.
3.: Die Klasse wird dann über export default class Dreieck extends ZweiDElm { für die Außenstehenden freigegeben.
4.: Bei der Verwendung importieren wir das Modul mittels import Dreieck from "./mygraphs/Dreieck.mjs";
Grundsätzlich gibt es noch andere Optionen – für mich war die oben beschriebene Vorgehensweise jedoch meist die einfachste und zuverlässigste.
Top
CC Lizenz (BY NC SA)
CC Lizenz (BY NC SA)