Nachdem wir uns im Kapitel zur semantischen Analyse stark mit Techniken beschäftigt haben, welche in einem Übersetzer zum Einsatz kommen, wollen wir uns in diesem Kapitel wieder den Konzepten von Programmiersprachen widmen. Genauer gesagt wollen wir betrachten, was eigentlich ein Objekt ausmacht und welchen Wandel ein einzelnes Objekt im Verlauf eines Programms durchmacht. Dabei werde ich ein viel breiteres und gleichzeitig viel engeres Verständnis von "Objekt" annehmen, als man dies in den sogenannten objektorientierten Programmiersprachen verwendet. Breiter deshalb, weil ich jedes Datum und jede Ansammlung von Daten als Objekt betrachte, und enger, weil ich dabei nur auf die passiven Informationen und nicht auf die damit verbundenen (aktiven) Operationen eingehe. Diese werde ich erst im nächsten Kapitel genauer diskutieren.
Es lohnt sich jedoch, Objekte einmal in Isolation, losgelöst von ihren Operationen, zu betrachten. Objekte sind jene Entitäten mit deren Hilfe wir Informationen im Programmverlauf hin und her werfen. Daher ist es für den effektiven Programmierer essenziell zu verstehen, wie Objekte entstehen, wie wir sie verwenden und wie sie zerstört werden. Um diesen Wandel einzelner Objekte zu verdeutlichen, werde ich für diese Vorlesung die Metapher vom Lebenszyklus eines Objekts verwenden, welche sich in vielen etablierten programmiersprachlichen Begriffen, wie der Lebenszeit eines Objekts, wiederfindet. Der effiziente Programmierer lernt in dieser Vorlesung, welche Kosten bei unterschiedlichen Typen von Objekten und Zugriffen entstehen und was es bedeutet, wenn eine Sprache automatisches Speichermanagement (Garbage Collection) durchführt.
Zunächst müssen wir aber definieren, was wir, für diese Vorlesung, unter einem "Objekt" überhaupt verstehen. Dazu möchte ich noch einmal das Maschinenmodell aus der ersten Vorlesung in Erinnerung rufen: Die virtuelle Maschine der Sprache definiert, wie wir Informationen ablegen (Objekte im Speicher) und wie wir diese Daten verarbeiten können (Operationen). Schauen wir mit dieser Brille auf einen real existierenden Prozessor, so kennt dessen Maschinenmodell nur Zahlen als Objekte, welche in Registern bzw. dem Hauptspeicher abgelegt werden können. Weiterhin ist jede Zahl ein Verbund von 8, 16, 32 oder 64 einzelnen Bits, welche die grundlegendste informationstragende Entität darstellen.
Verallgemeinern wir dies, so könnten wir sagen, dass ein Objekt ein Verbund von Informationen ist, der existenziell voneinander abhängig ist und logisch zusammengehört. Logisch-zusammengehörig deswegen, weil die Definition unabhängig davon ist, an welchen Orten die Informationen gespeichert sind. So sind die einzelnen Bits eines Maschinenworts in unterschiedlichen Flip-Flops bzw. DRAM-ZellenIm Fall von DRAM können die einzelnen Bits einer Speicherzelle sogar auf mehrere Chips verteilt sein. verteilt. Dies hält uns aber nicht davon ab, die logisch zusammengehörigen Bits als eine Zahl zu sehen. Löschen wir die Zahl, zum Beispiel durch überschreiben der Speicherzelle, so gehen auch die Wahrheitswerte der einzelnen Bits verloren. Die Bits sind also in ihrer Existenz abhängig von der Zahl.
Betten wir ein Objekt ein, so entsteht ein neues, größeres Objekt und die Einbettung wird existenziell abhängig: Das Array von Zahlen ist ein Verbund einzelner Zahlen, löschen wir das Array von Zahlen, so löschen wir auch die enthaltenen Zahlen. Dabei ist egal, dass die Zahlen weiterhin im Speicher an der Stelle stehen, wo früher das Objekt stand. Für die Programmiersprache sind die Zahlobjekte verloren.
Auf Ebene der Programmiersprache haben wir eine deutlich größere Auswahl an verschiedenartigen und strukturierten Objekten als einfach nur Zahlen. Einen Einblick in solch strukturierte Daten haben wir bereits in der Typen-Vorlesung kennengelerntDamals haben wir Typen, unter anderem, denotationell definiert: Typen sind die Menge von Objekten, die sie beschreiben. Die Objekte dieser Typen sind es, mit denen wir uns nun beschäftigen werden.: Zahlen, Arrays, Records, Referenzen, etc. Diese Objekte können alleine stehen (die Zahl) oder als Einbettung eines größeren Objekts auftreten (Array von Zahlen). Auf den Folien ist jeder umrandete Kasten ein einzelnes Objekt.
Anders ist es, wenn wir eine Referenz auf ein anderes Objekt einbetten. In diesem Fall haben wir zwei unterschiedliche Objekte, da das referenzierte Objekt unabhängig vom referenzierenden Objekt existieren könnte. Das Löschen des einen Objekts führt nicht zwingend zur Löschung des anderen Objekts.
Deutlich spannender als die Frage, welche Art von Objekten es gibt, ist der Lebenszyklus von Objekten, denn viele Sprachkonstrukte drehen sich um das Management dieses Lebenszyklus: Durch Allokation und Initialisierung wird ein Objekt geboren. Während des Lebens hat es einen oder mehrere Besitzer, die es auslesen, verändern und an Dritte weitergegeben. Und mit der Deinitialisierung und der Freigabe des Speichers verlässt es uns wieder.
Während der gesamten Lebenszeit ist ein Objekt nur deswegen Objekt, weil es die Regeln seines Typen, die Typinvarianten, einhält. Diese Invarianten werden bei der Objektgeburt durch die passende Belegung des Speichers etabliert und müssen während der gesamten Lebenszeit aufrecht erhalten werden. Verletzen wir diese Invarianten, zum Beispiel indem wir das 0-Byte am Ende eines C-Strings überschreiben, so verletzen wir das Typsystem und es kommt zu einem Bug. Erst beim Tod des Objekts dürfen die Typinvarianten wieder verletzt werden. Die Dauer dieses Lebenszyklus ist die Lebenszeit eines Objekts.
Mit diesen drei Phasen des Lebenszyklus (Geburt, Leben und Tod) werden wir uns in dieser Vorlesung beschäftigen.
Um den Lebenszyklus eines Objekts zu verdeutlichen, schauen wir uns einen Logger in C an. Hier werden die drei Phasen besonders deutlich, da nichts vom Lebenszyklus durch Sprachkonstrukte vor unserem Auge versteckt bzw. abstrahiert wird.
Die log_t
-Objekte sind durch eine C-Struct Deklaration beschrieben und bestehen aus einem Log-Level (level_t
) und einem Unix-Dateideskriptor. Beide Unterobjekte sind als Einbettung in der Struktur existenziell abhängig von den log_t
-ObjektenAllerdings ist der Dateideskriptor, ohne dass wir das auf Sprachebene sehen, eine Referenz auf ein Objekt im Betriebssystem. Aber das ist Thema von Grundlagen der Betriebssysteme..
Die Geburt eines log_t
-Objekts findet in der log_init()
-Funktion statt. Dort wird der nötige Speicher allokiert und die beiden Felder so initialisiert, dass sie die Invarianten des Objekts erfüllen. Leider sind diese Invarianten an dieser Stelle nicht gut sichtbar, weswegen ich sie Ihnen verrate: (1) level
ist ein valides Loglevel. (2) fd
ist ein offener Dateideskriptor. Die anderen Funktionen (log()
, log_deinit()
) verlassen sich auf diese Invarianten, um nicht jedes Mal prüfen zu müssen, ob das übergebene Argument wirklich ein valides log_t
-Objekt ist.
Die log_init()
-Funktion gibt eine Referenz auf das Objekt zurück, womit die main()
-Funktion der Besitzer des Objekts wird. Als Besitzer kann main()
das Objekt verändern, indem es zum Beispiel das Loglevel neu setzt oder das Objekt per Parameterübergabe weitergibt.
Das Ende unseres Objekts findet in log_deinit()
statt: Dort wird der bei der Konstruktion angeforderte Dateideskriptor geschlossen und der angeforderte Speicher zurückgegeben. Nach diesem Zeitpunkt existiert das Objekt nicht mehr.
Auch sichtbar an diesem Beispiel wird, dass uns C nicht sehr dabei hilft, sorgsam mit Objekten umzugehen: So erlaubt es uns C nicht, die Typinvarianten zu beschreiben und kann uns daher nicht daran hindern diese zu verletzen. Auch wäre es möglich den Speicher, an dem einst das Objekt gelebt hat, weiterzuverwenden und einen use-after-free-Bug zu provozieren, da die Referenz in L
nicht invalidiert wird. Außerdem wäre es ein Leichtes gewesen, die Freigabe des Objekts zu vergessen, womit ein Speicherleck (Memory Leak) entstanden wäre. In anderen Sprachen gibt es Konstrukte und Regeln, die es uns schwer oder vollständig unmöglich machen, solche Probleme und Bugs zu provozieren. Alle hängen damit zusammen, wie uns die Sprache erlaubt, Objekte zu erzeugen und mit ihnen umzugehen.
Der erste Schritt bei der Geburt eines Objekts ist die Allokation von Speicher. Das Ziel der Allokation ist es, eine Menge von Speicherzellen für dieses Objekt zu reservieren. Diese Speicherzellen sind dann ausschließlich für dieses Objekt reserviert. Dabei können wir meist davon ausgehen, dass der Speicherbereich für das Objekt zusammenhängend ist; die Speicherzellen also ab einer Startadresse fortlaufend für das Objekt reserviert werdenWie alles in der Informatik, ist auch dies kein Naturgesetz, sondern eine Entscheidung des Menschen. Wenn man sehr viele gleichartige Objekte hat, kann man die Objekte an den Attributen aufteilen und spaltenweise speichern. Dazu gibt es einen sehr guten Talk von Mike Acton auf der CppCon2014: Data-Oriented Design and C++.. Um genügend Speicher zu reservieren, brauchen wir die Größe des Objekts, eine Information, die uns normalerweise das Typsystem liefert und rufen damit eine Allokationsfunktion auf.
Die Ihnen wahrscheinlich bekannteste Allokationsfunktion ist malloc()
. Allerdings gibt es noch weitere Arten, Speicher für Objekte zu reservieren, die nicht zwingend mit dem Aufruf einer Funktion verbunden sind.
Bei der statischen Allokation übernimmt der Übersetzer, zusammen mit dem Linker und Loader, die Allokation des Speicherbereichs. Dabei wird, von vornherein, ein Ort für das Objekt in der Binärdatei vorgemerkt, sodass die Adresse des Objekts bereits vor der Laufzeit feststeht. Die Lebenszeit dieser statisch allokierten Objekte ist normalerweise gleich mit der Laufzeit des Programms; sie werden beim Start des ProgrammsOft vor der main()
-Funktion. initialisiert und erst direkt vor dem Beenden des Prozesses werden sie wieder zerstört. Das klassische Beispiel für statische Allokation ist die Definition einer globalen Variable. Aber auch konstante Literale, wie "foobar"
oder die übersetzten FunktionenIn der Binärdatei wird jede Funktion zu einer Sequenz von Maschineninstruktionen. Auch diese benötigen Platz und haben eine Startadresse. werden vom Übersetzer statisch allokiert. Die statische Allokation ist die unflexibelste Art Speicher zu reservieren, da die maximale Anzahl der Objekte bereits vor der Laufzeit feststehen muss. Allerdings ist sie auch die effizienteste, da keinerlei Kosten zur Laufzeit entstehen.
Bei der Stackallokation, wird der Laufzeit- bzw. Aufrufstapel verwendet, um Speicher für Objekte zu allokieren. Diesen Mechanismus haben wir bereits im Kapitel über NamensauflösungSiehe Function-Call Frames. kennengelernt, als wir uns mit Funktions-lokalen Variablen beschäftigt haben. Objekte, die mittels Stackallokation allokiert werden, sind in Function-Call Frames eingebettet und leben daher maximal so lange wie dieser existiert. Normalerweise endet die Lebenszeit der stackallokierten Objekte also mit der Rückkehr aus der FunktionRufen Sie sich in Erinnerung, wann dies nicht der Fall ist.. Verglichen mit der statischen Allokation, ist die Stackallokation deutlich flexibler, da wir nun dynamisch Objekte allokieren können. Allerdings ist die Lebenszeit dieser Objekte strikt an die Aufrufhierarchie gebunden und wir können nicht einfach die Adresse eines stackallokierten Objekts zurückgeben. Da die Allokation nur aus der Subtraktion der Objektgröße auf den Stackzeiger besteht, bringt die Stackallokation nur wenig Laufzeitkosten.
Mit der Heapallokation (malloc()
) überwinden wir die Einschränkung der statischen und der Stackallokation, handeln uns aber höhere Verwaltungskosten ein. Durch den Aufruf von malloc()
reservieren wir einen Speicherbereich, der völlig unabhängig von der Programmstruktur ist; die darin abgelegten Objekte können eine beliebige Lebenszeit haben. Die so allokierten Speicherbereiche müssen allerdings, da ihre Lebenszeit nicht am Programmablauf hängt, explizit freigegeben werden (free()
). Dies kann entweder manuell geschehen oder automatisch durch einen Garbage Collector (dt. Speicherbereinigung). Später in dieser Vorlesung werden wir uns noch Strategien zur (semi-)automatischen Speicherbereinigung anschauen.
In allen Fällen ist das Ergebnis der Speicherallokation die Startadresse des reservierten Speicherbereichs. Ab dieser Adresse aufsteigend (zu den hohen Adressen) wird unser Neugeborenes Objekt leben.
Nun, da wir genügend Speicher haben, der allerdings noch und oft von undefiniertem Inhalt istDen Speicher immer zu nullen, würde unnötige Kosten erzeugen., können wir damit anfangen, unser Objekt zu initialisieren. Das Ziel der Initialisierung ist es, den Speicher mit Werten zu füllen, damit er wie ein Objekt vom passenden Typen aussieht. Diese Bedingung, was ein Objekt ausmacht, zerfällt in zwei separate Teilaspekte: den sprachspezifischen und den nutzerspezifischen Aspekt.
Der sprachabhängige Teil der Initialisierung ist vom Sprachprozessor (Übersetzer bzw. Interpreter) und der Laufzeitumgebung abhängig. So müssen wir alle Informationen, die wir zur Laufzeit benötigen, um die Features der Sprache zu implementieren, mit in das Objekt schreiben. Unterstützt unsere Sprache zum Beispiel dynamische Typen, so müssen wir die Typinformationen, in Form eines Typ-Tags der eines vtable
-Zeigers, in den Speicherbereich des Objekts schreiben. FIXME: Typ-Tag oder vtable? vtable Implementationsdetail? Andernfalls hätten wir später keine Möglichkeit mehr den dynamischen Typen des Objekts herauszufinden.
Eine andere Sorte der sprachabhängigen Initialisierung ist die Registrierung des Objekts beim Garbage Collector (GC). Wie wir später sehen werden, benötigt der GC eine globale Sicht auf alle existierenden Objekte. Eine Möglichkeit dies zu schaffen ist es, jedes Objekt bei seiner Erstellung in eine verkettete Liste einzufügen.
Zum Vergleich: Bei der Sprache C gibt es keine sprachabhängige Initialisierung von Objekten. Jedes Stück Speicher sieht für ein C Programm erst mal wie ein valides Objekt aus. Sie können sich denken, welche Probleme, aber auch welche Effizienz, wir durch diese Entscheidung bekommen.
Bei der benutzerdefinierten Initialisierung darf der Benutzer Code ausführen, der die Felder des Objekts nach seinem Geschmack richtig belegt. Diesen Code nennen wir Konstruktor und es ist die Aufgabe des Konstruktors die semantischen Invarianten des Typs zu etablieren. Außerdem können Konstruktoren noch weitere Ressourcen (wie Dateideskriptoren oder andere Objekte) anfordern. Nach der Ausführung des Konstruktors ist das Objekt nicht nur aus Sicht der Laufzeitumgebung, sondern auch aus Sicht des Programmierers ein vollständiges, ordentliches, einsatzfähiges Objekt; es ist ausgewachsen!
Wie wir bereits am Beispiel des C-Loggers gesehen haben, hat nicht jede Sprache eingebaute Unterstützung für Konstruktoren. In solchen Fällen, verwenden wir normale Funktionen, um das Konstrukt "Konstruktor" abzubilden. Jedoch bleibt die Idee die Gleiche: Ein Konstruktor bekommt einen Speicherbereich übergeben, in den es ein Objekt vom entsprechenden Typen hinein-initialisiert.
In Sprachen, die Konstruktoren als Konzept bieten, werden diese meist mit dem Typen zusammen definiert. Um die Initialisierung von Objekten flexibel zu gestalten, sind Konstruktoren normalerweise parametrisierbar und teilen sich ansonsten die meiste Syntax mit Funktionen. Definieren wir keine eigenen Konstruktoren, so erstellt der Übersetzer einen Default-Konstruktor für uns.
Wie Funktionen lassen sich Konstruktoren in C++ und Java überladen, besitzen aber keinen Rückgabewert, da das Ergebnis des Konstruktors klar ist: ein fertig initialisiertes Objekt. Wie bei allen Methoden, wird das zu bearbeitende Objekt als nullter Parameter (this
-Zeiger) übergeben. Außerdem haben Konstruktoren auch eine Sichtbarkeit.
Gehen Sie einen Moment zurück… was soll das heißen, Konstruktoren haben eine Sichtbarkeit?! Wie sollten wir denn jemals einen Konstruktor aufrufen der private
ist, wenn dies nur durch Methoden geschehen kann die innerhalb der Klasse definiert sind? Und wozu wäre dies Überhaupt gut? Die Antwort darauf sind statische Methoden
, die auch ohne ein existierendes Objekt aufgerufen werden können. Ein Beispiel, wo ein privater Konstruktor Sinn macht, ist folgendes Stück Code:
class Object() { private: Object() {} public: static Object * Create() { return new Object(); } };
Die einzige Art diesen Typen zu instantiieren ist es, die Create()
-Methode aufzurufen, welche immer eine Heapallokation mittels new
durchführt. Es kann niemals geschehen, dass ein Benutzer ein solches Objekt aus Versehen auf dem Stack anlegt. Grund für eine solche Beschränkung kann sein, dass das Objekt sehr groß ist und daher leicht einen Stacküberlauf provozieren könnte. Weitere Gründe für einen privaten Konstruktor finden sich in dieser Diskussion auf Stackoverflow.
Generell werden Konstruktoren automatisch vom Sprachprozessor bzw. der Laufzeitumgebung aufgerufen. Dadurch kann es niemals sein, dass es ein Objekt vom passenden Typen gibt, für das kein Konstruktor gelaufen ist.
Bei Sprachen mit Vererbung muss der Sprachprozessor allerdings eine gewisse Sorgfalt walten lassen, in welcher Reihenfolge die Konstruktoren von Eltern- bzw. Kindsklassen aufgerufen werden. Generell gilt: Die Konstruktoren der Elternklasse werden von den Konstruktoren der Kindsklasse aufgerufen. Auf diese Weise findet ein Konstruktor der Kindsklasse bereits ein vollständig konstruiertes und valides Eltern-Objekt als this
-Zeiger vor.
Wir werfen nun einen genaueren Blick auf Konstruktoren in Java. Zum Ersten fällt auf, dass in Java Konstruktoren so wie ihre Klasse heißen müssen und überladen sein können. Im Fall von new Derived("123")
startet die benutzerdefinierte Initialisierung beim Konstruktor Derived(String)
. Hier parsen wir den String zur Ganzzahl 123 und rufen den anderen Konstruktor (Derived(int)
) in gleichen Klasse mittels this()
auf. Die Delegation zwischen Konstruktoren erlaubt es uns weniger Code zu duplizieren.
Im Derived(int)
Konstruktor rufen wir explizit einen gewissen Konstruktor (Counter(int)
) der Elternklasse mittels super()
auf. Hätten wir keinen expliziten super()
-Aufruf platziert, so hätte Java automatisch den "no-arg"-Konstruktor Counter()
aufgerufen.
Schließlich angekommen in Counter(int)
initialisieren wir das Feld start
. Da dieses Feld final
ist, zwingt uns Java dieses Feld in einem Konstruktor zu initialisieren; sein Wert kann später nicht mehr geändert werden. Der Sprachstandard von Java sieht weiterhin vor, dass das Feld next
automatisch auf 0
initialisiert wird. Auf diese Weise verhindert Java, dass es uninitialisierte Felder gibt.
Auf den Folien habe ich außerdem noch ein Stück Pseudo-C Code aufgeschrieben, welches eine ähnliche Initialisierung durchführen würde, wie die Java Laufzeitumgebung. Besonders interessant ist hier das memset(..., 0, ...)
, welches die automatische Nullung durchführt und das Setzen des vtable
-Zeigers, welches für virtuelle Methodenaufrufe nötig ist.
Ich habe bereits angeschnitten, dass eine Aufgabe von Konstruktoren ist, die semantischen Invarianten des Typs zu etablieren. Da dies ein wichtiger Punkt ist, komme ich noch einmal darauf zurück. Klar ist, dass dieser Punkt nichts mit den Sprachregeln zu tun hat, sondern eher eine sinnvolle Konvention darstellt. Niemand hält Sie davon ab, im Konstruktor eines Objekts dumme Dinge zu tun. Sie sollten allerdings zu Ihrem und zum Wohle der Welt davon absehen.
Invarianten sind Annahmen, die über die gesamte Lebenszeit eines Objektes, vom Konstruktor bis zum Tod des Objekts gelten. Alle Operationen/Methodenaufrufe, die auf diesem Objekt arbeiten, können sich auf diese Invarianten verlassen. Wenn sie das Objekt allerdings verändern, müssen sie dafür Sorgen, dass die Invarianten nach Beendigung der Operation wieder gelten. Während die Operation ausgeführt wird, dürfen die Invarianten kurzzeitig verletzt werdenDenken Sie an dieser Stelle darüber nach, wie Nebenläufigkeit und semantische Invarianten zusammenspielen und alles kompliziert machen. Überlegen Sie sich auch wie synchronized
das Problem auf Java-Sprachebene löst..
Das Beispiel auf den Folien zeigt eine Art Array, für welches der Konstruktor genügend Speicher allokiert, die Länge des Arrays abspeichert und ptr
auf das erste Array-Element zeigen lässt. Diese drei Operationen im Konstruktor etablieren die aufgelisteten Invarianten, auf diese sich dann auch die Funktion void set(uint8_t)
verlässt, wenn sie blind den Zeigern ptr
dereferenziert und den übergebenen Parameter in das entsprechende Array-Element speichert.
Wenn Sie selbst eine Klasse definieren, überlegen Sie sich, welche Invarianten gelten sollten. All ihre Konstruktoren sollten das Objekt so hinterlassen, dass diese Invarianten erfüllt sind. Jede Methode darf sich dann auf diese Invarianten verlassen, muss aber das Objekt so valide hinterlassen, wie es vorgefunden wurde.
Besonders gut ist Ihre Datenabstraktion, wenn es dem externen Benutzer unmöglich ist, das Objekt in einen Zustand zu bringen, in dem seine Invarianten verletzt sind. Besonders öffentlich schreibbare Felder, bei denen nicht jede Belegung die Invarianten erfüllt, sind ein häufiges Problem, was diesem Ziel entgegensteht.
Nachdem wir nun wissen, wie Objekte ins Leben kommen, stellt sich die Frage, was wir zu ihren Lebzeiten damit machen können. Wir müssen diese Objekte im Programmtext, genauer gesagt mittels Variablen, ablegen, wiederfinden und weitergeben können. Außerdem wollen wir die Informationen des Objekts auslesen und gegebenenfalls verändern können. Dabei sollten wir stets im Hinterkopf behalten, dass eine Sprache es dem Entwickler leicht machen sollte, die Objekt-Invarianten aufrechtzuerhalten.
Die grundlegendste Frage für die Verbindung von Programmtext und Objekten ist, wie diese abgelegt und weitergereicht werden. Also wie die Verbindung von Namen und Objekten genau aussieht und wie ein Teil des Programms ein Objekt an einen anderen Teil des Programms weitergeben kann. Dazu erinnern wir uns an die Vorlesung zur NamensauflösungSiehe Binding Time.: Zum Bindezeitpunkt wird ein konkretes Objekt an einen Namen gebunden, sodass das Objekt über diesen Namen wieder aufgefunden werden kann. Damals haben wir auch gesagt, dass das Ergebnis der vollständigen Namensauflösung die Startadresse des Objekts ist. Es schließt sich hier also der Kreis: Die Startadresse, an der wir das Objekt initialisiert haben, wird zum Bindezeitpunkt an einen Namen gebunden. Ein solcher Name kann eine Variable sein, wie wir das in den Folien bei var_foo
beispielhaft sehen.
Allerdings stellt sich eine wichtige, zunächst aber philosophisch anmutende, Frage: Beinhaltet eine gebundene Variable das Objekt oder beinhaltet sie nur eine Referenz auf das Objekt. Die Antwort auf diese Frage hat weitreichende Folgen: Wenn eine Variable das Objekt beinhaltet, was wir das Wertemodell für Variablen nennen, so kann keine andere Variable das selbe Objekt beinhalten. Dies hat zur Folge, dass jedes Objekt in maximal einer Variable gespeichert ist. Wollen wir ein Objekt mit zwei Namen in Verbindung bringen, so müssen wir beim Wertemodell ein explizites Zwischenobjekt, eine Referenz bzw. einen Pointer, anlegen und abspeichern. Wertemodell bedeutet also, dass es separate Referenzobjekte gibt.
Dass ein Objekt in maximal einer Variable abgelegt werden kann, führt auch dazu, dass Zuweisungen zwischen Variablen zwangsläufig eine Objektkopie erfordern. Um ein Objekt zu kopieren, allokieren wir Speicher für ein zweites Objekt und kopieren die enthaltenen Informationen in den neuen Speicher. Das kopieren der Informationen kann entweder über bitweises Kopieren des Speichers geschehen (memcpy()
) oder über einen nutzerdefinierten Copy-Konstruktor (C++). In beiden Fällen läuft beim Kopieren der normale Konstruktor nicht.
Klassische Vertreter für das Wertemodell sind C, C++ oder, in modernen Zeiten, Rust. Überlegen Sie sich für das folgende Stück C-Code, wie viele und welche Objekte es gibt und wann es zum Kopieren eines Objekts kommt:
typedef struct { int x; } object_t; object_t a; object_t b = a; object_t *c = &b; object_t *d = c;
Der Gegenentwurf zum Wertemodell ist das Referenzmodell für Variablen, bei dem Variablen und Objekte unabhängig voneinander existieren und die Variable nur eine Referenz auf ein Objekt speichert. Die Variable steht aber weiterhin für das Objekt und es bedarf keiner manuellen Dereferenzierung des Zeigers durch den Entwickler; die Sprache dereferenziert automatisch und unbedingt beim Zugriff auf das Objekt. Da jede Variable nur Referenzen enthält, führen Zuweisungen zu keinen Objektkopien, sondern es wird nur die Referenz von der einen Variable in die andere kopiert. Am Ende stehen zwei Variablen für dasselbe Objekt. Da Variablen alle Objekten nur referenzieren, benötigt eine Sprache, die nach dem Referenzmodell arbeitet, keine separaten Referenz-Objekte.
Die meisten Skriptsprachen verwenden das Referenzmodell. Auch Java arbeitet hauptsächlich mit dem Referenzmodell, macht aber bei primitiven Datentypen (int
, float
) eine Ausnahme und wendet für diese das Wertemodell an. Etwas, was jedem Java-Lernenden zuerst komisch vorkommen muss.
Sowohl das Wertemodell als auch das Referenzmodell haben ihre Vor- und Nachteile. Wenn wir versuchen die Brille des effektiven Programmierers aufzusetzen, so ist ein reines Referenzmodell einfacher zu erlernen, da Objekte unabhängig von Variablen existieren und wir uns keine Gedanken darüber machen müssen, ob wir eine Kopie oder eine Referenz weiterreichen wollen. Außerdem werden Objekte beim Referenzmodell niemals implizit durch Kopieren angelegt, sondern immer explizit und mit Hilfe der Konstruktoren. Diese Reduktion der Variablen-Objekt-Abhängigkeit ist auch der Grund, wieso Java und die meisten Skriptsprachen das Referenzmodell bevorzugen; es ist einfacher zu handhaben.
Die Komplexität des Wertemodells können wir aber auch als eine Stärke auffassen: Da alle Referenzen sichtbar und explizit sind, erlaubt uns das Wertemodell feingranular zu steuern, wann wir ein Objekt referenzieren und wann es in einer Variable lebt. Dies erlaubt uns effizientere Programme zu schreiben: Beim Referenzmodell tragen wir immer die Kosten für die Indirektion über die Referenz. Beim Wertemodell müssen wir explizit danach verlangen eine Indirektion zu bekommen. So kann es effizienter sein, ein Objekt als Kopie an eine Funktion zu übergeben, als eine Referenz zu übergeben, die dann dereferenziert wird. Dazu kommt, dass das Referenzmodell eigentlich nur mit Heap-allokierten Objekten richtig gut funktioniert, was zusätzliche Kosten erzeugt. Beim Wertemodell können wir Objekte viel einfacher auf dem Stack allokieren. Aber Vorsicht, die starke Kopplung von Variablen und Objekten und die implizierten Kopien können beim Wertemodell zu Overheads führen, die auf den ersten Blick nicht sichtbar sind. So allokiert das folgende Stück C-Code 8192 Bytes vom Stack und kopiert 4096 Bytes in einer unscheinbaren Zuweisung:
typedef struct { char stack[4096]; } thread_t; void foo() { thread_t user_stack; thread_t tmp = user_stack; }
Ein weiteres Problem des Wertemodells tritt bei Vererbung auf. Wenn abgeleitete Klassen mehr Felder haben ihre Elternklassen, ist das Kindobjekt größer als das Elternobjekt. Daher passt es, vom benötigten Speicherplatz her, nicht mehr in Variablen vom Elterntyp. Dies spiegelt sich in C++ im Object SlicingWikipedia: Objectslicing und in der Beschränkung, dass dynamische Typumwandlung nur mit Zeigern funktioniert, wieder.
Der andere Aspekt des Wertemodells, was sowohl Segen als auch Fluch ist, ist eine Folge der starken Bindung der Lebenszeit eines Objekts an die Gültigkeit einer Variable. Ein Objekt, welches in einer Variable residiert, muss zwangsläufig sterben, wenn die Variable ihre Gültigkeit verliert. So sterben Objekte in lokalen Variablen, wenn die Funktion beendet wird. Aber dazu später bei der Deinitialisierung mehr.
Zusammenfassend ist zu sagen: Sowohl mit dem Referenzmodell als auch mit dem Wertemodell lässt sich gut Arbeiten und beide Modelle haben ihre Vor- und Nachteile. Ich persönlich finde das Wertemodell angenehmer, weil ich darin expliziter Ausdrücken kann, was ich haben möchte.
Nun, da wir Objekte herumreichen, und mittels Variablen in unserem Code verankern können, wollen wir natürlich auch auf diese zugreifen, Informationen auslesen und Informationen verändern. Dazu müssen wir zunächst die Akteure eines Zugriffs identifizieren: Ganz klar und naheliegend ist, dass das Objekt auch das Objekt des ZugriffsIn einem grammatikalischen Sinne von Subjekt - Verb - Objekt. ist.
Aber welche Entitäten können Subjekt, also Handelnder, des Zugriffs sein? Hier müssen wir uns anschauen, was die aktiven Teile unseres Programms sind, all diese können wir als Benutzer eines Objekts bezeichnen. Da haben wir zum einen das Stück Code, von dem aus der Zugriff erfolgt; meist ist dies eine zugreifende Funktion. Aber auch der Thread, von dem aus der Zugriff geschieht, kann als Benutzer auftreten. Wichtig wird diese Festlegung des Benutzers, wie wir noch diskutieren werden, wenn wir den Benutzerkreis eines Objekts einschränken wollen.
Der zweite Teil eines Zugriffs besteht aus der Frage: Mit welchem Recht greifen wir zu? Dabei ist der Besitz einer Referenz auf das Objekt die Bedingung, um einen Zugriff durchzuführen. Aber nicht jede Referenz muss gleich mächtig sein, und es kann eingeschränkte Referenzen (wie read-only Referenzen) geben, über die nicht jeder Zugriff möglich ist. Daher können wir Referenzen auch als Capabilities bzw. BefähigungenWikipedia: Capability-based security zum Objektzugriff ansehen.
Die letzte Frage ist, welche Arten von Zugriffen es gibt. Hier fallen einem sofort lesen und schreiben ein. Aber eigentlich ist auch das Erzeugen einer neuen Referenz ein Zugriff auf das Objekt, da wir mit einer Referenz Macht über ein Objekt weitergeben können. Wir wollen uns hier mit expliziten Setter- und Getter-Methoden auseinandersetzen, die den Zugriff auf das Objekt filtern können.
Zusammenfassen kann man die Frage des Objektzugriffs mit der Frage: Wer greift wie und mit welchem Recht auf ein Objekt zu?
Beginnen wir also bei den Benutzern eines Objekts und beschränken uns darauf, von welcher Codestelle ein Zugriff erfolgt. Die Idee einen Zugriff anhand des Zugreifenden einzuschränken, haben wir bereits mit der Sichtbarkeit von Feldern in Klassen kennengelernt. Dort unterscheiden wir zwischen Zugriffen, die von innerhalb der Klasse kommen und solchen, die von außerhalb kommen. Private Felder dürfen nur von innen, öffentliche Felder von überall her zugegriffen werden.
Generell müssen wir bei der benutzerabhängigen Zugriffseinschränkung die einzelnen Nutzergruppen definieren und ihnen gewisse Rechte zuschreiben. Bei der Sichtbarkeit von Felder geschieht diese Unterscheidung daran, ob eine Funktion innerhalb der Klasse als Methode definiert wurde, oder außerhalb. Interne Benutzer bekommen bei dieser Einschränkung weitere Rechte.
Der große Vorteil einer solchen Einschränkung der externen Nutzer ist, dass wir sicher sein können, dass es in der gesamten Code-Basis keinen Zugriff auf die privaten Felder gibt, der nicht von einer unserer Methoden ausgeht. Alle Zugriffe auf private Felder laufen über Methoden. Auf diese Weise können wir viel einfacher sicherstellen, dass die Invarianten des Objekts nicht verletzt werden. Außerdem können wir das Innere der Klasse beliebig verändern, da kein externer Benutzer Abhängigkeiten entwickeln konnte. Dies ermöglicht es uns, Implementierung und Daten zu kapseln.
Allerdings wird eine solche Kapselung löchrig, wenn wir in einer Methode eine Referenz auf ein eingebettetes Objekt an einen externen Benutzer herausgeben. Mit solchen flüchtenden Referenzen verlieren wir die Kontrolle über unser Objekt, unsere Invarianten und überhaupt alles an unserem Objekt. Überlegen Sie sich demnach immer gut, ob Sie Code der Form inner_t* Object::method() {return &(this->inner);}
schreiben sollten.
Die zweite Frage, die wir betrachten wollen ist die der eingeschränkten Referenzen. Wie bereits gesagt, sind Referenz (bzw. Zeiger) Befähigungen über ein Objekt: Wer eine Referenz hat, hat Macht über ein Objekt. Da nicht jede Nutzung eines Objekts volle Rechte braucht, erlauben es viele Sprachen Referenzen zu erzeugen, die nicht allmächtig sind und nicht jede Art von Zugriff erlauben. Der klassische Vertreter der referenzabhängigen Zugriffseinschränkung ist die read-only-Referenz.
Sehen wir eine eingeschränkte Referenz, so können wir uns sicher sein, dass der Nutzer nur eine eingeschränkte Menge von Zugriffsarten durchführen wird. So kann der Besitzer einer read-only-Referenz das Objekt nur auslesen, nicht aber verändern. Wichtig ist, dass Referenzen nur weiter eingeschränkt werden dürfen. So sollte es dem Nutzer einer read-only-Referenz (eigentlich) nicht möglich sein, wieder eine read-write-Referenz abzuleiten.
Implementiert sind solche referenzabhängigen Zugriffseinschränkungen in Programmiersprachen normalerweise mittels unterschiedlicher Referenztypen. Dort gibt es dann nicht nur den normalen Typkonstruktor für eine Referenz (pointer(T)
), sondern noch weitere Referenztypen, die beispielsweise nur Lesezugriff erlauben (const_pointer(T)
). Die Propagierung der Zugriffseinschränkung und ihre Durchsetzung geschieht dann im Typ-spezifischen Teil der semantischen Analyse.
Bemerkenswert ist auch, wie unterschiedliche Sprachen den read-only- oder den read-write-Fall zum Standardverhalten machen. Bei C++ ist der normale Zeiger eine allmächtige Referenz auf ein Objekt und der Entwickler muss explizit mittels const
angeben, wenn eine eingeschränkte Referenz vorliegt. Bei Rust ist es genau umgekehrt. Dort sind Referenzen per Default schreibgeschützt und wir müssen explizit veränderbare Referenzen erzeugen (&mut
), für die dann noch weitere Einschränkungen gelten. Java hat diese Art der eingeschränkten Referenzen nicht implementiert, da es das Erlernen der Sprache schwieriger macht. Ein Stichwort, mit dem man C++-Entwickler an dieser Stelle gut reizen kann ist Const Correctness.
Der letzte Teil unserer Zugriffsbetrachtungen ist das "Wie?": Wie greifen wir auf ein Objekt zu? Der einfachste Fall, der am wenigsten Kosten verursacht und auf den am Ende alle Zugriffe abgebildet werden müssen, ist der direkte Speicherzugriff. Bei diesem leiten wir aus der Objektadresse eine Zugriffsadresse ab (z.B. auf ein Feld) und lesen bzw. schreiben die Informationen des Objekts. Wie bereits mehrfach erwähnt, kann es problematisch sein, jedem direkten Speicherzugriff auf unser Objekt zu geben, da wir dadurch die Kontrolle über die Zugriffe verlieren und Invarianten verletzt werden könnten.
Gut wäre es, wenn wir bei Objektzugriffen aktiven Code dazwischen schalten könnten. Genau dies wird bei ZugriffsmethodenWikipedia: Mutatormethod gemacht. Bei diesem Entwicklungsmuster verbietet man den Zugriff von Außen mittels Sichtbarkeit und legt Zugriffsmethoden mit kanonischen Namen an (getLength(), ~setLength()
). Auf diese Weise können wir Zugriffe auf das Objekt filtern und sicherstellen, dass der (externe) Nutzer des Objekts die Invarianten nicht verletzt.
Allerdings, Sie sehen das Problem bereits, das ganze artet, wenn man es von Anfang an konsequent umsetzen will, in eine richtige Boilerplateschlacht aus. Und man muss es von Anfang an durchführen, weil ansonsten das hinzufügen eines Zugriffsfilters Änderung an allen Benutzer des Objekts nach sich ziehen würde. Jeder direkte Zugriff müsste dann, in der gesamten Codebasis, bei allen Kunden und deren Enkeln, zu einem Methodenaufruf umgebogen werden.
Daher bieten manche Sprachen es an, Getter- und Setter-Methoden zu definieren, die beim Benutzer syntaktisch genauso aussehen, wie ein direkter Speicherzugriff. Ein Beispiel davon für C# sehen Sie auf den Folien. Dort kann der Benutzer weiterhin obj.Name = "Max Mustermensch"
schreiben und es wird dennoch die passende Setter-Funktion aufgerufen. Diese syntaktische Gleichheit erlaubt es auch, solche Zugriffsmethoden im Nachhinein hinzuzufügen. In Python können Sie sich den @property
-Dekorator anschauen.
Wir wollen uns nun eine Sorte von Objekten ansehen, bei denen mehrere Aspekte des Objektzugriffs zusammenkommen: unveränderliche Objekte. Diese Objekte werden nur während der Initialisierungsphase verändert und in ihrem gesamten weiteren Leben gibt es keine Schreibzugriffe mehr. Dies ist gleichbedeutend damit, dass es im gesamten Programm keine Referenzen mit Schreibrechten gibt.
Diese unveränderlichen Objekte haben einige spannende Eigenschaften: Da sie nie verändert werden, gibt es keinerlei Probleme mit konkurrierenden Zugriffen in nebenläufigen Programmen; es gibt keine read-write- oder write-write-Wettlaufsituationen (race conditions), wenn es kein "write" gibt.
Außerdem ist das Werte- und das Referenzmodell zu weiten Teilen bei unveränderlichen Objekten gleich. Da diese Objekte niemals verändert werden, kann ich sie zwar, wie vom Wertemodell gefordert, bei einer Zuweisung kopieren, aber der Übersetzer kann auch einfach eine Referenz weitergeben, falls dies effizienter ist.
Weiterhin können wir unveränderliche Objekte auch einfach deduplizieren, wenn ihr Konstruktor nur von den Parametern abhängt. Deduplizieren bedeutet, dass wir die Daten mehrerer gleicher Objekte nur einmal speichern und die gleiche Referenz mehrfach herausgeben. Ein klassisches Beispiel für diese Deduplizierung ist die Internalisierung von StringsIn Python: https://medium.com/@bdov_/https-medium-com-bdov-python-objects-part-iii-string-interning-625d3c7319de:
Intern_Str2Value = {} Intern_Value2Str = {} def Intern(value): global Intern_Str2Value global Intern_Value2Str if value not in Intern_Str2Value: ret = len(Intern_Str2Value) # Bi-Directional Mapping Intern_Str2Value[value] = ret Intern_Value2Str[ret] = value else: ret = Intern_Str2Value[value] return ret def InternString(value): global Intern_Value2Str return Intern_Value2Str[value] x = Intern("foo") y = Intern("bar") z = Intern("foo") print x,y,z print InternString(x), InternString(y), InternString(z) print Intern_Str2Value print Intern_Value2Str
In diesem Beispiel übersetzen wir die Argumente von Intern()
in eine Ganzzahl und tragen diese in zwei Tabellen ein, welche wir für die bidirektionale Übersetzung verwenden können. Die Rückgabewerte von Intern()
sind Referenzen auf die tatsächlichen Strings, können aber deutlich schneller herumgereicht und auf Gleichheit geprüft werden.
Was in diesem Beispiel sehr hemdsärmelig und nur skizzenhaft gezeigt ist, ist eine Technik des Übersetzerbaus, die bei der Implementierung der Symboltabelle zum Einsatz kommt. Dort müssen Bezeichner in der Symboltabelle schnell wiedergefunden werden, was viele Vergleiche von Strings erfordert. Da die Anzahl der Bezeichner in einem Programm allerdings meist begrenzt ist, kann die Internalisierung von Strings signifikant Laufzeit sparen.
Es gibt auch Objekte, die klassischerweise unveränderlich sind: Dazu gehören normalerweise Zahlen und Literale. So macht es keinen Sinn die Zahl 5 an sich zu verändern. Sicherlich möchten wird nicht, dass a = 5; a++
dazu führt, dass alle Fünfen im gesamten Programm zu einer Sechs werden. Ebenso wenig wäre es hilfreich, wenn die Modifikation eines String-Literals (a = "abc"; a[1] = 'x';
) dazu führt, dass alle literal notierten Strings "abc"
plötzlich den Wert "axc"
hätten. Daher sind diese Objekte in jeder vernünftigen Sprache unveränderlich. Zu diesem Thema lasse ich Ihnen als Rätsel das folgende Stück Code da. Die Ausgabe ist Six times Seven = 43
. Zur Hilfe lasse ich Ihnen noch die Stichwörter Autoboxing und Integer-Cache da:
import java.lang.reflect.*; class IntReflection { public static void main(String[] args) throws Exception { Field value = Integer.class.getDeclaredField("value"); value.setAccessible(true); value.set(42, 43); System.out.printf("Six times Seven = %d%n", 6 * 7); } }
Das andere Thema, auf das ich beim Komplex "Objekte in Programmiersprachen" noch unbedingt eingehen möchte, ist das Konzept des Besitzers eines Objekts. In den meisten Programmiersprachen, mit Rust als erste wirkliche Ausnahme, ist der Besitz eines Objekts ein informelles und intentionales Konzept. Entweder der Entwickler denkt darüber nach, wem ein gewisses Objekt gehört, oder er begibt sich in die Gefahr, Bugs am laufenden Band zu produzieren.
Denn was bedeutet Besitz bei Objekten: Besitzer eines Objekts bedeutet mehr als eine Referenz auf ein Objekt in Händen zu halten. Besitz bedeutet Verantwortung. Wer ein Objekt besitzt, der ist für den Lebenszyklus des Objekts verantwortlich. Insbesondere bedeutet dies, dass der letzte Besitzer eines Objekts die Deinitialisierung einleiten muss. Hat das Objekt zum Beispiel Ressourcen im Konstruktor nachgefordert, so müssen diese geordnet freigegeben werden.
Als Beispiel sei hier ein Netzwerksocket genannt. Im Konstruktor bzw. zu Beginn seines Lebens wurde der Netzwerksocket geöffnet. Bevor nun der letzte Besitzer seinen Anspruch an dem Socket abgibt, sollte dieser den Socket schließen, da sonst irgendwann die Zahl der gleichzeitig offenen Sockets (ein Limit des Betriebssystems) erreicht wird und keine weiteren Sockets geöffnet werden können.
Besonders schwierig wird das Konzept von Besitz, wenn es mehrere unabhängige Besitzer zum gleichen Zeitpunkt geben kann. Dies kann zum Beispiel auftreten, wenn mehrere Threads sich den Besitz eines Objekts teilen und diesen in unbestimmter Reihenfolge aufgeben können. In diesem Fall ist es, aufgrund der Nebenläufigkeit, besonders schwierig den letzten Besitzer ausfindig zu machen.
Das andere Problem, welches mit Besitz zu tun hat, ist das dangling-reference problem, wie es auf den Folien zu sehen ist. In diesem Beispiel gibt es unterschiedliche Auffassungen über den Besitz des Objekts obj
: Das Proxyobjekt p
hält eine Referenz auf obj
, was erst mal keinen Besitzanspruch impliziert. Allerdings geht der Code von Proxy davon aus (Invariante!), dass es Besitzer des referenzierten Objekts ist und dieses nicht einfach verschwindet. Der umgebende Code gibt das Objekt allerdings einfach frei (delete obj
), weil er davon ausgeht, dass er der letzte Besitzer des Objekts ist. Der nachfolgende Aufruf p.call()
führt nun dazu, dass der Referenz p.ref
gefolgt wird, obwohl das referenzierte Objekt längst freigegeben wurde; das sogenannte dangling-reference problem. Denn die Freigabe des Objekts führt nicht dazu, dass alle Referenzen auf das Objekt im gesamten Programm gelöscht werden, da dies viel zu aufwendig wäre.
Da Besitz von Objekten schwierig ist, gibt es moderne Sprachkonzepte, wie den std::unique_ptr<>
von C++ und den Borrowchecker von RustSiehe: Rust Manual, Ownership, die es einfacher machen, beim Besitzen von Objekten weniger falsch zu machen. Sollten Sie in einem Projekt keinen Zugriff auf solche modernen Methoden haben, so lohnt es sich dennoch immer zu überlegen: Wer ist aktuell der Besitzer dieses Objekts? und An welchen Stellen übergebe, oder gar teile, ich den Besitz dieses Objekts.
Kommen wir nun zur letzten Phase im Lebenszyklus eines einzelnen Objekts: seinem Tod und der Freigabe all seiner angeforderten Ressourcen. Dabei gibt es zwei grundlegende Fragen: "Wann endet die Lebenszeit eines Objekts?" und "Wann und wie wird das Objekt freigegeben?". Diesen beiden Fragen werden wir nun nachgehen.
Ganz generell können wir sagen, dass die Freigabe eines Objekts erst dann erfolgen darf, wenn seine Lebenszeit geendet hat. Eine Verletzung dieses Prinzips haben wir gerade eben, beim dangling-reference problem, gesehen. Aber wann genau endet denn die Lebenszeit eines Objekts?
Mit der Definition auf den Folien verknüpfen wir die Lebenszeit eines Objekts mit seiner Nutzung; wird ein Objekt in der Zukunft nicht mehr verwendet werden, so endet seine tatsächliche Lebenszeit nach dem letzten Zugriff. Wie wir schon sehen, ist die genaue Feststellung der Lebenszeit schwierig, da wir im Vorhinein nicht wissen, ob später noch einmal ein Zugriff auf das Objekt stattfinden wird.
Allerdings gibt es einige Indikatoren, die das Ende der Lebenszeit eines Objekts anzeigen können. So ist es notwendig, dass kein Teil des Programms noch einen Besitzanspruch auf das Objekt erhebt. Als eine Überabschätzung können wir diesen Zustand technisch feststellen, indem wir feststellen, ob es noch gültige Referenzen auf das Objekt gibt. Denn wo es keine Referenzen mehr gibt, da kann es auch keine Zugriffe mehr geben. Diese Bedingung ist daher sogar hinreichend.
Umgekehrt gilt dies übrigens nicht: Nur weil es keinen Besitzer gibt, der sich verantwortlich fühlt, kann es dennoch sein, dass noch eine Referenz existiert. Der letzte Besitzer könnte schlicht und ergreifend vergessen haben die Referenz zu löschen.
Die einfachste Lösung, jedenfalls aus Sicht der Programmiersprache, ist die explizite Freigabe. Dort gibt der Programmierer ein Objekt ganz explizit und selbständig frei und er tut damit kund: "Dieses Objekt hat seine Lebenszeit überschritten, wir können es freigeben!" Damit zieht sich die Programmiersprache ganz fein aus der Affäre und überlässt die Verantwortung über die Lebenszeit ganz und gar dem Entwickler… ausgefuchst, diese faulen Sprachentwickler… Im Code sieht die explizite Freigabe dann so aus, dass es dedizierte Aufrufe zu Freigabefunktionen (log_deinit()
) bzw. dedizierte Schlüsselwörter zur Freigabe (delete
) gibt.
Da explizite Freigabe von Objekten, und dem für sie reservierten Speicher, mühsam und fehleranfällig istMal gibt man zu früh (dangling-reference problem), mal zu spät (Speicherleck) frei., haben die Designer von Programmiersprachen Mechanismen zur automatischen Freigabe, auch automatische Speicherbereinigung genannt, entwickelt. Diese lassen sich in zwei große Bereiche einteilen: Referenzzähler und die Nutzung eines Garbage Collectors. Beide beruhen auf der hinreichende Bedingung für den Todeszeitpunkt (keine gültige Referenz mehr), funktionieren aber gänzlich unterschiedlich.
Die erste Variante der automatischen Speicherbereinigung, die wir uns genauer anschauen werden, ist die Referenzzählung. Die Idee ist, dass wir zählen, wie viele Referenzen es auf ein Objekt gibt. Fällt der Referenzzähler auf 0
, so geben wir das Objekt frei, da wir wissen, dass niemand es in der Zukunft verwenden kann. Diese Referenzzählung kann entweder manuell erfolgen oder durch programmiersprachliche Abstraktionen.
Generell assoziieren wir mit jedem Objekt, welches wir per Referenzzählung verwalten wollen, einen Referenzzähler. Dieser Zähler kann beispielsweise als vorzeichenlose Zahl in dem Objekt selbst angelegt werden. Die Invariante für diesen Zähler ist, dass er immer die aktuelle Anzahl der gültigen Referenzen oder die aktuelle Anzahl der verantwortlichen Besitzer speichertÜberlegen Sie sich, welchen Unterschied dies macht. Auf jeden Fall muss man sich auf eine Semantik festlegen, sonst kommt alles massiv durcheinander.. Um diese Invariante zu etablieren, setzt der Konstruktor den Zähler auf den Wert 1
; zum Zeitpunkt der Konstruktion gibt es immer genau einen Besitzer bzw. genau eine Referenz.
Bei der manuellen Referenzzählung bietet das Objekt zusätzlich eine API an, um den Referenzzähler zu erhöhen (claim()
), bzw. zu verringern (release()
). Fällt der Zähler beim verringern auf 0
Hinweis: In einem mehrfädigen Prozess ist das dekrementieren und prüfen eine heikle Stelle, bei der es zu Wettlaufsituationen kommen kann. Dies löst man normalerweise mit einer atomaren decrement_and_fetch()
-Operation, so löscht sich das Objekt mittels expliziter Freigabe selbst.
Wenn wir jetzt unser heikles Beispiel von gerade eben mit dieser API absichern wollen würden, dann sähe unser Proxy jetzt so aus:
class Proxy { obj_t* ref; public: void set(obj_t* o) { o->claim(); ref = o; } void call() { ref->call(); } }; Proxy p; p.set(obj); obj->release(); // war: delete obj; p.call();
Wie Sie sehen, löschen wir das Objekt nicht mehr explizit, sondern der erste Besitzer des Objekts zeigt nur an, dass er dieses Objekt nicht mehr besitzt. Das Objekt obj
wird jetzt allerdings vom Proxyobjekt p
besessen, sodass es noch zu keiner Freigabe kommt.
Ein Beispiel, wo solche manuelle Referenzzählung zum Einsatz kommt, ist der Linux-Kern. Dort wird der Referenzzähler durch den Typen struct kref
abstrahiert. Mittels Einbettung werden verschiedenste Objekte im Kern durch Referenzzählung verwaltet. Die API-Funktionen heißen dort kref_get()
(= claim()
) und kref_put()
(= release()
). Den Source-Code finden Sie in der Datei include/linux/kref.h.
Ein generelles Problem der Referenzzählung sind zyklische Abhängigkeiten. Referenzieren bzw. besitzen sich Objekte gegenseitig. Wird die letzte Referenz, die von außen kommt, auf den Zyklus weggenommen, so bleibt bei jedem Mitglied des Objektzyklus der Referenzzähler mindestens bei 1
und die Objekte halten sich gegenseitig im Speicher; ein Speicherleck ist entstanden! Dieses Problem ist mit Referenzzählung niemals zu lösen, da das Problem nur mit einer globalen Sicht auf den Referenzgraphen gelöst werden kann, die Referenzzählung aber nur lokal an einem Objekt stattfindet. Ist man sich dieses Problems allerdings bewusst ist, so ist Referenzzählung ein recht effizienter Mechanismus, um automatische Speicherverwaltung durchzuführenEffizient wird sie auch dadurch, dass die Modifikation eines eingebetteten Referenzzählers in den selben Cachezeilen stattfindet, in denen auch das Objekt liegt..
Eine weitere Verbesserung des Referenzzähler-Konzepts ist der Smart Pointer
. Im Hintergrund werkelt dabei die gleiche Maschinerie wie bei der manuellen Referenzzählung, allerdings wird das in/dekrementieren des Zählers automatisch durchgeführt. Dazu wird ein Datentyp eingeführt, der als Ersatz für die normalen Referenzen dient und sich nach außen hin genauso verhält wie ein normaler Zeiger. Um dies am Beispiel zu verdeutlichen: Der Typausdruck für einen Ganzzahlzeiger in C++ ist pointer(int)
, der Typ eines Smart-Pointers auf einen Int (=Typ der Variable A
und B
) ist shared_ptr(int)
Rufen Sie sich aus der Vorlesung über Typen in Erinnerung, dass generische Typen dem Benutzer die Möglichkeit geben, eigene Typkonstruktoren anzulegen..
Gekoppelt mit dem Wertemodell für Variablen kann so eine automatische Referenzzählung durchgeführt werden: Bei jeder Kopie eines shared_ptr<T>
-Referenzobjekts wird claim()
im Kopier-Konstruktor ausgeführt. Wird ein Referenzobjekts gelöscht, weil zum Beispiel die lokale Variable, in der es residiert, ungültig wird, so ruft der Destruktor release()
auf.
Die zweite Art der automatischen Speicherveraltung ist die Verwendung eines Garbage Collectors. Dieser findet tote Objekte ebenfalls mittels der hinreichenden Bedingung, dass jedes Objekt, welches nicht mehr referenziert wird, auf jeden Fall tot ist. Der Kernbegriff für den Garbage Collector ist die Erreichbarkeit eines Objekts im Referenzgraphen.
Der Referenzgraph ist ein gerichteter Graph, bei dem jedes existierende Objekt ein Knoten ist. Für jede Referenz, die von einem Objekt ausgeht, gibt es im Referenzgraphen eine ausgehende Kante auf das referenzierte Objekt. Stellen Sie sich aber nicht vor, dass dabei eine separate Datenstruktur referencegrapht aufgebaut wird. Der Referenzgraph ist nur implizit über die Belegung der Felder gegeben.
Um in diesem Geflecht aus sich gegenseitig referenzierenden Objekten die toten Objekte zu finden, drehen wir die hinreichende Bedingung auf den Kopf und finden all jene Objekte, die vielleicht noch nicht tot sind. Bewaffnet mit einem sogenannten "root set" finden wird alle Objekte, die transitiv über die Kanten des Referenzgraphen erreichbar sind. Alle Objekte, die nicht erreichbar sind, sind auf jeden Fall tot und können freigegeben werden.
Das root set beinhaltet alle Entitäten, über die ein Zugriff auf ein Objekt starten kann. Dies beinhaltet die lokalen Variablen, alle globalen Variablen, aber auch den aktuellen Inhalt der Prozessorregister. Zu einem späteren Zeitpunkt könnte nämlich das Programm ein Element aus dem root sets herausnehmen, mehreren Referenzen folgen und auf ein Objekt tief im Referenzgraphen zugreifen. Daher muss jedes Objekt, welches potentiell noch verwendet werden könnte, erhalten bleiben.
Für die Verwendung eines Garbage Collectors gibt es allerdings einige Vorbedingungen (siehe Folien), die alle damit zusammenhängen, dass wir das existierende Geflecht der Referenzen korrekt erfassen. Wir dürfen auf keinen Fall eine Referenz übersehen, sonst löschen wir vielleicht Objekte, die noch gebraucht werden. Um dies präzise durchzuführen, braucht es daher eine typsichere Sprache, bei der der Benutzer keine beliebigen Referenzen aus dem Nichts erzeugen kann und bei der für jedes Objekt klar ist, welche anderen Objekte referenziert werden. Dies ist auch der Grund, wieso Sprachen wie C und C++ keinen Garbage Collector anbieten.
Ein anderer Nachteil bei der Verwendung eines Garbage Collectors ist, dass der Todeszeitpunkt und der Moment der Löschung eines Objekts voneinander entkoppelt sind. Der Garbage Collector läuft in Intervallen, da das Iterieren über alle existierende Objekte eine teure Operation ist. Daher kann es beliebig langeEine Strategie den Garbage Collector zu starten ist: Wir allokieren solange aus einem Speicherpool, bis dieser leer ist. Finden wir für eine angeforderte Allokation keinen Platz im Pool, so starten wir den Garbage Collector. Ist danach immer noch kein Platz, vergrößern wir den Pool. dauern, bis ein totes Objekt freigegeben wird. Daher ist es bei der Verwendung des Garbage Collectors schwierig, die Freigabe von nachgeforderten Ressourcen (z.B. offener Socket) an die Löschung des Objekts zu koppeln. Daher muss es in Java mehr deinit()
-Funktionen als in C++ geben.
Eine konkrete Technik für Garbage Collection ist der Mark-and-Sweep Garbage Collector, für den Sie auf den Folien eine Animation seiner Funktionsweise finden. Dieser Garbage Collector arbeitet mit einer Markierung an jedem Objekt. Nachdem die Welt angehalten wurde, wird das root-set bestimmt und die Marke an allen Objekten zurückgesetzt. Mittels Tiefensuche im Graph markiert der Mark-and-Sweep GC alle erreichbaren Objekte (die Mark Phase). Dabei starten wir die Suche von jedem Element des root sets aus, aber brechen die Suche immer dann ab, wenn wir auf eine Positiv-Marke treffen; ab dort haben wir bereits alles Erreichbare markiert. In der Sweep Phase iterieren wir über alle existierenden Objekte und geben jene Objekte frei, die in der Mark Phase nicht markiert wurden. Fertig. Ein Mark-and-Sweep GC als Python-Pseudocode sähe in etwa so aus:
def prepare(): for obj in objects: obj.mark = False def mark(obj): obj.mark = True for ref in obj.references(): if ref.mark: continue mark(ref) def sweep(): for obj in objects: if obj.mark is False: free(obj) def GC(): prepare() for obj in root_set: mark(obj) sweep()
In realen Laufzeitumgebungen werden weitere Verfeinerungen vorgenommen, um den Garbage-Collection-Prozess effizienter zu machen. So können die Objekte in mehrere Generationen eingeteilt werden, je nachdem wie viele GC Läufe sie bereits überlebt haben. Es hat sich nämlich heraus gestellt, dass die meisten Objekte nicht besonders lange leben, sondern nur für kurze Zeit in Verwendung sind.
CPython3 ist die Standardimplementierung von Python 3 (geschrieben in der Programmiersprache C). Dieser GC verwendet eine Kombination von Referenzzählung und gelegentlicher Garbage Collection mit einem Mark-and-Sweep GC mit drei Generationen. Diese Kombination ist deswegen möglich, da ein Referenzzähler von 0
bereits eine hinreichende Bedingung für ein totes Objekt ist. Nur für die zyklische Referenzkringel brauchen wir eine globale Sicht auf den Referenzgraphen.
Das schöne bei diesem GC ist, dass wir auf ihn aus der Sprache heraus mit dem Modul gc
zugreifen können. So können wir uns mit gc.get_objects()
die Liste aller existierenden Objekte geben lassen. Außerdem können wir uns die Vorgänger und die Nachfolger eines Objekts im Referenzgraphen anzeigen lassen. Das Objekt, welches von der Variable x
referenziert wird Python folgt dem Referenzmodell! (eine Liste), referenziert sein einziges Element und selbst wird es vom Wurzelnamensraum referenziert. Daher hat diese Liste eine eingehende und eine ausgehende Kante im Referenzgraphen.
Zusätzlich können wir uns den Referenzzähler der Liste mittels sys.getrefcount()
anzeigen lassen. Hier ist besonders, dass nicht nur die eine eingehende Kante vom Wurzelnamensraum gezählt wird, sondern auch die Referenz, die durch die Übergabe an getrefcount()
entsteht, mitgezählt wird. Daher ist der ausgegebene Referenzzähler 2
.
Nachdem wir die toten Objekte gefunden haben, entweder durch explizites Freigeben, durch Referenzzählung, oder durch einen Garbage Collector, müssen wir sie nur noch wirklich Freigeben. Dazu ruft die Laufzeitumgebung zuerst eine benutzerdefinierte Destruktormethode auf, bevor der Speicher an die Speicherverwaltung zurückgegeben wird.
Die Aufgabe des Destruktors ist es, die Effekte des Konstruktors rückgängig zu machen. Dies bezieht sich insbesondere auf die Rückgabe von nachgeforderten Ressourcen. Auch muss das Objekt jedweden Besitz an anderen Objekten aufgebenFast schon philosophisch an dieser Stelle: Mit dem Tod gibt ein Objekt all seinen Besitz auf., was zu weiteren Freigaben führen kann. Bei expliziter Freigabe muss das Objekt auch noch dafür sorgen, dass es nicht mehr bei anderen Objekten registriert ist.
Bei Vererbung wird immer zuerst der Destruktor des Kindes und dann der der Elternklasse aufgerufen. Also in der umgekehrten Reihenfolge wie die Konstruktoren. Hintergrund ist, das jeder Destruktor ein valides Objekt seines Typs vorfinden muss.
Mit den Destruktoren haben wir alles beisammen, um ein zentrales Konzept des C++-Ökosystems kennenzulernen: Resource Aquisition is Initialization oder kurz RAII. Im Grunde habe ich Ihnen dies die ganze Zeit bereits heimlich untergeschoben, als ich meinte, das Konstruktoren weitere Ressourcen nachfordern können. Aber hinter RAII steht noch viel mehr.
C++ folgt dem Wertemodell für Variablen. Dies bedeutet, dass die Lebenszeit von Objekten, die in lokalen Variablen leben, mit dem Ende des aktuellen Scopes endet. Für diese Objekte garantiert C++, dass der Destruktor in jedem Fall aufgerufen wird, wenn der Scope verlassen wird. Dies ist insbesondere auch dann der Fall, wenn der Scope durch eine Exception verlassen wird! Wirft also irgendeine Unterfunktion eine Exception, die erst irgendwo ganz oben im Programm gefangen wird, so werden alle Destruktoren aller lokalen Variablen, die auf dem Weg sind, ausgeführt.
Diese Garantie erlaubt es uns, dass wir den Besitz von nachgeforderten Ressourcen in stackallokierten lokalen Objekten bündeln können. Die Funktion besitzt die lokale Variable samt Objekt und das Objekt besitzt alle nachgeforderten Ressourcen. Endet die Funktion, so gibt sie den Besitz an der lokalen Variable auf und der gesamte Untergraph im Referenzgraphen wird freigegeben. Dabei kommt es zu keinen Problemen mit nicht aufgeräumten Ressourcen, falls uns eine Exception um die Ohren fliegt.
Das RAII-Konzept lässt sich auch auf Ressourcen anwenden, die sich nicht in einer Referenz manifestieren. So kann man mit einem Lock-Guard Objekt (std::lock_guard
) den Besitz eines Mutexes koppeln. Mit der Definition des Lock-Guards (X
) nehmen wir uns das Lock, mit dem Ende des Scopes wird es automatisch wieder freigegeben.
Im Fall von DRAM können die einzelnen Bits einer Speicherzelle sogar auf mehrere Chips verteilt sein.
Damals haben wir Typen, unter anderem, denotationell definiert: Typen sind die Menge von Objekten, die sie beschreiben. Die Objekte dieser Typen sind es, mit denen wir uns nun beschäftigen werden.
Allerdings ist der Dateideskriptor, ohne dass wir das auf Sprachebene sehen, eine Referenz auf ein Objekt im Betriebssystem. Aber das ist Thema von Grundlagen der Betriebssysteme.
Wie alles in der Informatik, ist auch dies kein Naturgesetz, sondern eine Entscheidung des Menschen. Wenn man sehr viele gleichartige Objekte hat, kann man die Objekte an den Attributen aufteilen und spaltenweise speichern. Dazu gibt es einen sehr guten Talk von Mike Acton auf der CppCon2014: Data-Oriented Design and C++.
Oft vor der main()
-Funktion.
In der Binärdatei wird jede Funktion zu einer Sequenz von Maschineninstruktionen. Auch diese benötigen Platz und haben eine Startadresse.
Siehe Function-Call Frames.
Rufen Sie sich in Erinnerung, wann dies nicht der Fall ist.
Den Speicher immer zu nullen, würde unnötige Kosten erzeugen.
Denken Sie an dieser Stelle darüber nach, wie Nebenläufigkeit und semantische Invarianten zusammenspielen und alles kompliziert machen. Überlegen Sie sich auch wie synchronized
das Problem auf Java-Sprachebene löst.
Siehe Binding Time.
Wikipedia: Objectslicing
In einem grammatikalischen Sinne von Subjekt - Verb - Objekt.
Wikipedia: Capability-based security
Wikipedia: Mutatormethod
Mal gibt man zu früh (dangling-reference problem), mal zu spät (Speicherleck) frei.
Überlegen Sie sich, welchen Unterschied dies macht. Auf jeden Fall muss man sich auf eine Semantik festlegen, sonst kommt alles massiv durcheinander.
Hinweis: In einem mehrfädigen Prozess ist das dekrementieren und prüfen eine heikle Stelle, bei der es zu Wettlaufsituationen kommen kann. Dies löst man normalerweise mit einer atomaren decrement_and_fetch()
-Operation
Effizient wird sie auch dadurch, dass die Modifikation eines eingebetteten Referenzzählers in den selben Cachezeilen stattfindet, in denen auch das Objekt liegt.
Rufen Sie sich aus der Vorlesung über Typen in Erinnerung, dass generische Typen dem Benutzer die Möglichkeit geben, eigene Typkonstruktoren anzulegen.
Eine Strategie den Garbage Collector zu starten ist: Wir allokieren solange aus einem Speicherpool, bis dieser leer ist. Finden wir für eine angeforderte Allokation keinen Platz im Pool, so starten wir den Garbage Collector. Ist danach immer noch kein Platz, vergrößern wir den Pool.
Python folgt dem Referenzmodell!
Fast schon philosophisch an dieser Stelle: Mit dem Tod gibt ein Objekt all seinen Besitz auf.
Technische Universität Braunschweig
Universitätsplatz 2
38106 Braunschweig
Postfach: 38092 Braunschweig
Telefon: +49 (0) 531 391-0