 Ja, herzlich willkommen zurück zur Veranstaltung Programmierparadigmen. Wir sind hier im Modul Datenabstraktion und Objektorientierung und das ist der dritte von fünf Teilen, in dem es um die Frage geht, wie Datenabstraktionen, also insbesondere Klassen, überhaupt initialisiert und finalisiert werden. Also wir werden uns Dinge anschauen, wie Konstrukturen und Destrukturen, die es in Programmiersprachen gibt und welche semantischen Feinheiten da denn so existieren. Bevor wir uns das Ganze ein bisschen genauer anschauen, fangen wir mal mit dem kleinen Quiz an, in dem es um Vererbung in C++ geht und zwar insbesondere um die Frage von Initialisierung und Finalisierung. Wer jetzt nicht taktäglich C++ programmiert, wird das vielleicht gar nicht so offensichtlich finden, aber die Idee von dem Quiz ist eben genau einfach mal zu zeigen, was es da so für semantische Feinheiten gibt und die Lösung wird dann hoffentlich im Laufe der Vorlesungen auch ein bisschen klarer. Also die Frage hier ist, was macht eigentlich dieser Code und was genau gibt dieser Code dann aus? Und ich würde Sie bitten, wie immer, über die Lösung erst kurz nachzudenken selbstständig, anschließend dann im Illias abzustimmen, während das Video pausiert ist und erst dann weiterzuschauen. So, schauen wir uns die Lösung mal an. Also was hier rauskommt, ist abc tilde c tilde b tilde a und jetzt ist die Frage warum. Also schauen wir erstmal in die Main-Methode rein. Was hier passiert ist, dass eine Instanz von C erstellt wird, also diese Deklaration von C, C erstellt implizit ein Objekt der Klasse C. C ist hier oben und diese Klasse erbt von zwei Superklassen, nämlich a und b. Und was wir hier dann haben und auch in den anderen Klassen ist jeweils der Konstruktor und Destruktor der entsprechenden Klasse. Also die Konstruktoren wie in vielen anderen Sprachen werden einfach als Methode mit dem Namen der Klasse angegeben und die Destruktoren sehen so ähnlich aus bloß, dass außerdem noch die tilde vor dem Namen steht, was an gibt, dass das ganze ein Destruktor ist, also eine Operation die aufgerufen wird, wenn das Objekt entsprechend dann zerstört wird, zum Beispiel wenn das Programm beendet wird. Und der Grund warum wir als Ergebnis jetzt eben das hier rausbekommen, hängt mit der Reihenfolge zusammen, in der die Konstruktoren und Destruktoren aufgerufen werden und wie genau die definiert ist, sehen wir dann im Laufe der Folie sind noch ein bisschen genauer. Fangen wir erstmal an uns ein bisschen genauer anzuschauen, wie die Initialisierung von Instanzen einer Klasse denn üblicherweise aussieht. Also das funktioniert durch die Konstruktoren, also diese Operation die aufgerufen werden, wenn die Klasse instanziert wird, also wenn eine Instanz der Klasse erstellt wird und üblicherweise kann eine Klasse 0, 1 oder auch mehrere solche Konstruktoren angeben. Die werden in den meisten Sprachen durch die Anzahl und Typen der Argumente, die ich dem Konstruktor übergeben kann, unterscheiden. Also insbesondere ist das in C++, Java und C-Sharp der Fall. In manchen Sprachen zum Beispiel Eiffel kann ich auch verschiedene Konstruktoren mit verschiedenen Namen haben. Also der Name ist nicht wie in C++ oder Java oder C-Sharp gleich dem Namen der Klasse, sondern ich kann die Klasse A nennen und dann Konstruktoren namens B, C und D haben, um einfach verschiedene Arteninstanzen dieser Klasse A zu erstellen, beschreiben zu können. Weil das vielleicht für manche ein bisschen ungebündet ist, schauen wir uns hier mal ein Beispiel für diese Konstruktoren in Eiffel an. Wir haben ja eine Klasse namens Complex und was die repräsentiert ist einfach eine komplexe Zahl. Diese sogenannten Features sind Felder dieser Klasse, das sind also zwei Felder X und Y, die jeweils vom Typ Real sind. Und hier oben in diesem Creation Bereich wird angegeben, welche Konstruktoren diese Klasse denn haben, und das sind in dem Fall zwei, nämlich New Cartesian und New Polar, die jeweils kathesische Koordinaten bzw. Polarkoordinaten erstellen, die man beide ja als komplexe Zahl darstellen kann und das macht also Sinn, das dann dafür diese Klasse Complex zu benutzen. Und jeder dieser Konstruktoren hat dann seine eigene Implementierung, in der die Parameter, die sich auch unterscheiden können, übernommen werden und anschließend dann in irgendeine Form in diese Felder X und Y eingetragen werden. Neben der Frage, wie viele Konstruktoren es eigentlich gibt und wie ich die genau deklariere oder beschreibe, ist eine andere interessante Frage bei der Initialisierung, ob die explizit oder implizit geschehen muss. In manchen Programmiersprachen muss der Konstruktor einer Klasse immer explizit aufgerufen werden. Also das ist zum Beispiel in Java der Fall, wo ich nie einfach nur ein Objekt so bekomme, implizit, sondern ich muss immer New Name der Klasse und dann Klammer auf, Klammer zu oder vielleicht noch ein paar Argumente dahinter hinschreiben, um die Klasse tatsächlich explizit zu initialisieren. In der anderen Programmiersprache, zum Beispiel C++, werden die Konstruktoren unter Umständen auch implizit aufgerufen. Wir haben das ja schon ein paar Beispiele gesehen, wo ich einfach nur eine Variable der Klasse deklariere und implizit magischerweise dann der Konstruktor entsprechend aufgerufen wurde. Was da passiert ist, dass das Deklarieren dieser Variable implizit einfach den Konstruktor mit null Argumenten aufruft und diesen dann benutzt, selbst wenn der nicht explizit hingeschrieben ist, dann macht er eben einfach nichts, um die Instanz dieser Klasse zu erstellen. Der Grund, warum das in C++ so ist, ist, dass C++ das Value Model für Variablen benutzt. Das heißt, wenn ich die Variable deklariere und dann verwende, dann muss ich dahinter eben auch der Wert, der in dieser Variable gespeichert ist, befinden. Also in dem Fall muss ich dahinter ein konkretes Objekt befinden. Und deshalb muss das Objekt, in dem Moment, wo die Variable deklariert wird, auch gleich initialisiert werden. Denn sonst hätte ich quasi eine Variable, die etwas beschreibt, was es noch gar nicht gibt. In Java ist das anders, denn da wird für Variablen von nicht primitiven Typen das Reference Model benutzt. Das heißt, ich kann eine Variable einer Klasse fu deklarieren, die existiert. Und die Variable gibt es dann schon, und das ist doch eine Referenz auf ein fu-Objekt, aber das ist eben initial dann erst mal null. Schauen wir uns das Ganze vielleicht noch mal an einem konkreten Beispiel an. Also hier sehen wir erst mal Java, wo ich eine Klasse fu habe und dann so eine Variable f vom Typ fu deklariere. Das bedeutet in Java, dass ich hier eine noch nicht initializierte Referenz auf dieses fu-Objekt habe. Und die hat einfach den Wert null. Wenn ich jetzt also versuchen würde, zum Beispiel eine Methode auf f aufzurufen, dann würde ich eine Null-Pointer-Exception bekommen, denn dieses Objekt existiert noch gar nicht. In c++ kann ich eigentlich genau dasselbe hinschreiben, aber es bedeutet etwas anderes. Denn in dem Moment, wo ich diese variabliche deklariere, muss dahinter auch tatsächlich ein Wert sein. Und das wird so gemacht, dass eben dieser Default constructor, also der ohne Parameter, implizit aufgerufen wird. Und die Variable dann auch gleich dieses Objekt enthält. Und ich könnte dann auch die Methoden, die fu anbietet, direkt auf diese Variable f aufrufen, denn da ist ja schon ein Objekt dahinter. Wenn man jetzt diese zwei Konzepte, Initialisierung und Vererbung, was wir ja im letzten Teil schon gesehen haben, zusammenbringt, dann ergibt sich die Frage, inwiefern denn die Konstruktoren der Superklasse aufgerufen werden, wenn ich die Subklasse initialisiere. Und das funktioniert in den meisten Sprachen so, dass diese Konstruktoren der Superklasse entweder implizit oder explizit aufgerufen werden. Hier machen wir wieder zwei Beispiele, einmal Java, einmal c++, die mehr oder weniger das Gleiche machen. Also in beiden Fällen habe ich eine Oberklasse a und dann eine Unterklasse namens b. Und in beiden Fällen habe ich in irgendeiner Form einen Aufruf des entsprechenden Superkonstruktors. Also im Konstruktor der Unterklasse, hier b bzw. hier auch b, rufe ich den Konstruktor der Oberklasse auf, in dem Java-Fall, in dem ich das Superkey wird verwende, was im Prinzip einfach nur den entsprechenden Konstruktor von a aufruft, und im Falle von c++, in dem ich hinter diesen Doppelpunkt, der angibt, dass der Konstruktor von diesem anderen Konstruktor erweitert, den entsprechenden Aufruf des Konstruktors auch hinschreibe. Und in beiden Fällen wird dieser Parameter k, der hier an den Konstruktor von b übergeben wird, dann auch an den Superkonstruktor von a übergeben, so dass der Konstruktor dann damit machen kann, was er machen möchte. Wenn es jetzt mehrere Konstruktoren gibt, in der Unterklasse und in der Oberklasse ist die Frage, in welche Reihenfolge diese Konstruktoren jetzt ausgeführt werden. Und praktisch allen Sprachen ist das so, dass die Konstruktoren der Oberklasse vor den Konstruktoren der Unterklasse ausgeführt werden. Das macht in dem Sinn, dass die Oberklasse in der Regel Felder initialisiert und schon erstellt und beschreibt, die dann anschließend von der Unterklasse wiederverwendet werden können, indem man diese Felder oder diesen Zustand der Oberklasse zuerst initialisiert, kann die Initialisierung des Unterklassenzustandes dann darauf aufbauen. In C++ passiert das Ganze implizit. Wie wir gerade gesehen haben, gibt es ja kein Statement, was jetzt direkt diesen Aufruf des Superklassenkonstruktors darstellt, sondern wenn die Unterklasse erstellt wird, wenn implizit auch die Konstruktoren der Oberklasse aufgerufen. In Java ist das Ganze etwas expliziter und die Tatsache, dass die Oberklasse zuerst initialisiert werden muss, bevor ich dann den Konstruktor der Unterklasse ausführen kann, wird dadurch durchgesetzt, dass die Sprache verlangt, dass das Superstatement, sofern es dann existiert, vor jedem anderen Statement im Konstruktor geschieht. Also ich kann nicht im Konstruktor erst ein bisschen was machen und dann super aufrufen, sondern der Superaufruf muss tatsächlich immer als allererstes Statement im Konstruktor hingeschrieben werden. Konstruktoren gibt es in so praktisch jeder objektorientierten Sprache mit Klassen. Ein zweites Feature, was es nun in manchen Sprachen gibt, sind die Destruktoren. Das ist auch eine Operation, die mit dem Erstellen bzw. zerstören in dem Fall, der Instanzen der Klasse zu tun haben und zwar wird dieser Destruktor aufgerufen, wenn das Objekt aufhört zu existieren. Das kann entweder passieren, weil das Objekt nicht mehr im aktuellen Scope ist und einfach deshalb implizit zerstört wird. Oder der Destruktor wird auch aufgerufen, wenn man ein Objekt explizit zerstört, also im Falle von C++ zum Beispiel, mithilfe des Delete-Operators, der explizit das Objekt zerstört. Destruktoren muss man nicht schreiben. Also wenn die Klasse in C++ implementiert, kann auch einfach immer nur Konstruktoren implementieren, aber nie ein Destruktor implementieren. Es wird aber sehr empfohlen, das zu tun, zumindest wenn die Klasse dynamisch Speicher anizuiert. Also wenn ich innerhalb dieser Klasse bzw. das Konstruktor ist der Klasse dynamisch Speicher anizuieren mit sowas wie Melok, dann muss ich diesen Speicher natürlich irgendwo wieder freigeben. Denn ansonsten ist mein Speicher irgendwann voll und das Programm ist dann vielleicht out of memory oder hat zumindest ein Memory-Leak, was dazu führt, dass das Programm viel mehr Speicher verwendet, als es eigentlich braucht. Und um das zu verhindern, muss, wenn im Konstruktor Speicher anizuiert wird, dieser Speicher dann im Destruktor wieder freigegeben werden, indem ich zum Beispiel Free aufrufe und diesen Speicher dann entsprechend wieder freigebe. Schauen wir uns mal ein kleines Beispiel für diese Destruktoren in C++ an und zwar ohne, dass wir uns die Deklaration der Klasse direkt anschauen. Was hier passiert, ist, dass wir einen String erstellen, den dann auch benutzen, indem wir zum Beispiel die Länge des Strings aufrufen und anschließend ist dieser String aber dann auch wieder weg, weil wir ihn nie irgendwo in eine Variable gespeichert haben. Das heißt, der String ist automatisch out of scope. Was passiert da also? Das erste, was passiert, ist, dass der Konstruktor des Strings aufgerufen wird, nämlich dann, wenn ich ihn erstelle. Und anschließend, nachdem diese Chorzelle dann ausgeführt wurde, wird auch der Destruktor des Strings, also Till des String, aufgerufen, denn dieser String ist ja hier nicht mehr im scope und somit auch nicht mehr erreichbar. Also, obwohl man hier weder den Konstruktor Call noch den Destruktor Call wirklich sieht, wären beide Sachen aufgerufen und entsprechend dann ausgeführt. Wenn diese Konstruktoren jetzt aufgerufen werden und ich wieder Vererbung habe, dann analog zu der Frage, die wir vorhin für die Konstruktoren hatten, stellt sich die Frage, in welche Reihenfolge die Destruktoren aufgerufen werden. Und hier ist es im Prinzip genau umgekehrt, wie das, was wir bei den Konstruktoren gesehen haben, nämlich zunächst werden die Destruktoren der Unterklasse aufgerufen und dann die Destruktoren der Oberklasse. Und die Intuition dahinter ist, dass wir analog zu dem, was wir bei den Konstruktoren gesehen haben, zunächst den Zustand der Unterklasse aufräumen und zum Beispiel Speicher wieder freigeben und anschließend dann Kontrolle zurück zur Oberklasse geben, die dann entsprechend ihren Zustand auch aufräumt. Und nachdem ich das jetzt alles erklärt habe, können wir auch nochmal kurz zu dem Beispiel aus dem Quiz von vorhin zurückkommen, denn jetzt sollte auch klarer sein, warum das eben die Reihenfolge ist, in der die Ausgabe hier geschrieben werden. Also die Konstruktoren werden in der Reihenfolge ausgeführt, dass zunächst die Konstruktoren der Superklasse und dann der Subklasse aufgerufen werden. Und weil die Deklaration von A hier vor der Deklaration von B kommt, ist also zuerst A und dann B und anschließend dann der Konstruktor der Klasse, die wir hier eigentlich instanzieren, nämlich C. Und wenn das Objekt dann wieder zerstört wird, weil es hier Autoscope gibt, weil ja das Programm dann auch zu Ende ist, dann werden die Destrukturen aufgerufen, aber eben in der umgekehrten Reihenfolge, also zunächst der von C, dann der von B und schlussendlich der von A. In Sprachen, die keine Destrukturen anbieten, zum Beispiel Java oder C-Sharp, möchte man vielleicht manchmal trotzdem etwas aufräumen, wenn Objekte nicht mehr gebraucht werden und das ist der Grund, warum Finalizes in diese Sprachen eingeführt wurden. Das sind im Prinzip spezielle Operationen, die immer dann aufgerufen werden, wenn ein Objekt vom Garbage-Collector aufgeräumt wird. Das heißt, das Ganze passiert implizit, wenn der Garbage-Collector entscheidet, dass das Objekt nicht mehr benötigt wird und kann nicht explizit vom Programmierer kontrolliert werden. Das heißt, man weiß nie so richtig, wann das eigentlich passiert. Es kann trotzdem ganz nützlich sein, zum Beispiel unbestimmte Ressourcen noch aufzuräumen, zum Beispiel, wenn ich irgendwo ein File-Handel habe, möchte ich das vielleicht wieder freigeben, sodass mein Prozess dann noch genügend andere File-Handels zur Verfügung hat. Das große Problem mit den Finalizes ist, dass die nicht unbedingt aufgerufen werden. Also wenn ich zum Beispiel ein Programm habe, das relativ kurz nur läuft, dann wird der Garbage-Collector vielleicht niemals aktiv und das heißt, mein Finalizer wird nie aufgerufen. Und das ist auch der Grund, warum dieses Feature zumindest für Java schon immer relativ umstritten war und in Java 9 schlussendlich auch deprecated wurde, weil es ein Prinzip für mehr Verwirrung sorgt als Nutzenschaft, ganz einfach aus dem Grund, dass man nie so richtig weiß, wann diese Finalizer denn aufgerufen werden und ob sie überhaupt irgendwann aufgerufen werden. Ein kleines Beispiel, was man mit diesen Finalizers noch so machen kann und das ist jetzt kein Beispiel, was ich empfehle nachzumachen, sondern das ist einfach nur gedacht, um zu zeigen, was das Feature eigentlich so kann, habe ich hier eine Klasse in Java, die sozusagen unsterblich ist und zwar funktioniert das so, dass wir so ein Finalizer angeben, also wir überschreiten die Finalize-Methode, die Java-Lang-Object zur Verfügung stellt und was wir jetzt machen ist, dass wir einmal ausgeben, dass Finalize aufgerufen wird. Okay? Und anschließend fügen wir das aktuelle Objekt, also das, was eigentlich gerade vom Gabis-Collector zerstört wird, in diese Menge der sogenannten Immortals ein und das ist einfach eine Menge, die in einem Static-Field gespeichert ist. Also dieses Static-Field bedeutet, dass das ein Feld ist, was auf Klassenlevel definiert ist. Also auch wenn ich keine Instanz von dieser Klasse mehr habe, wird es diese Menge hier noch geben und indem ich dieses Objekt in diese Menge einfüge, besteht dann plötzlich wieder eine Referenz auf dieses Objekt. Was bedeutet das aus Sicht des Gabis-Collectes, dieses Objekt plötzlich doch nicht mehr aufgeräumt werden kann und dieses Objekt sozusagen damit unsterblich wird. Ich würde es nicht empfehlen, das so zu machen, denn was das ganz einfach nach sich führt, ist, dass diese Art von Objekt nie Gabis-Collected werden kann. Das heißt, wenn ich viele Objekte dieser Art erstelle, dann wird der Speicher einfach immer voller und irgendwann wird mein Programm dann abstürzen, weil es out of memory ist. Aber das ist ein interessantes Feature, was ich sozusagen mit Hilfe dieser Finalizers auch so implementieren kann. Damit sind wir auch schon wieder am Ende dieses dritten Teils. Ich hoffe, Sie wissen jetzt ein bisschen mehr darüber, wie Klassen initialisiert werden, beziehungsweise die Instanzen dieser Klassen initialisiert werden und wie sie dann auch wieder zerstört werden mit Hilfe von Konstrukturen und Destrukturen. Danke fürs Zuhören und bis zum nächsten Mal.