Jetzt komme ich hier zum zweiten, etwas praktischeren Teil der Vorlesung. Das erste Thema hier war die Speicherung von Zuständen. Hier fand ich das Beispiel, mit dem das Thema begonnen wurde ziemlich hilfreich. Und zwar wurde ein Spiel als Beispiel genommen, da hier ja andauernd Spielfiguren stärker werden, Waffen dazu bekommen, oder verlieren und dieser Zustand ja irgendwie gespeichert werden muss, denn eigentlich möchte man ja nicht jedes Mal von Neuem beginnen. Kurzer Fakt hierzu, an den ich denken musste: in manchen Spielen kann man absichtlich einstellen, dass das Spiel extrem selten gespeichert wird, um es schwerer zu machen (zum Beispiel bei Resident Evil im schwersten Modus). Nun zurück zum eigentlichen Thema. Wie ich eigentlich schon weiß, haben Objekte einen Zustand und ein Verhalten. Das Verhalten ist innerhalb der Klasse, aber der Zustand steckt in jedem einzelnen Objekt. Wenn man also ein Spiel programmiert, braucht man eine Speicher und Wiederherstellen-Funktion. Bei sämtlichen Spielen merkt man das auch als Spieler ziemlich gut. Möchte man ein Spiel speichern, muss man an einen gewissen Ort laufen, oder einen bestimmten Knopf drücken und die Konsole fängt an den Spielstand zu speichern. Dies nimmt meist auch ein wenig Zeit in Anspruch. Möchte man wieder spielen, kann man auch häufig zwischen Spielstand wiederherstellen, oder beginne ein neues Spiel wählen. Ehrlich gesagt, hatte ich mir noch nie Gedanken darüber gemacht, wie das Ganze funktioniert und gerade mit dem Vergleich zu Spielen, hat mich das dann ziemlich aufmerksam zuhören lassen. Die eine Möglichkeit zur Spielspeicherung (die Version, auf die man ohne weiteres Wissen kommen kann) ist, dass man jedes Objekt abfragt und dann den Wert jeder Instanzvariable in eine Datei schreibt mit einem selbst definierten Format. Könnte aber ziemlich aufwändig werden. Deshalb gibt es auch die einfachere, objektorientierte Weise, bei der man nur das Objekt quasi flachdrückt bzw. speichert und dann wieder aufbläst bzw. wiederherstellt, wenn man es zurückhaben möchte. Leider muss man es doch ab und an komplizierter machen, wenn die von einem Programm gespeicherte Datei durch irgendein Nicht-Java-Programm gelesen werden muss.
Es gibt viele Möglichkeiten den Zustand eines Java-Programms zu speichern. Wie immer hängt es meist davon ab, wie man den gespeicherten Zustand verwenden möchte.
Die Option, die wir auch heute während der Vorlesung praktisch verwenden mussten, war die Serialisierung. Diese wird verwendet, wenn die Daten ausschließlich durch das Java-Programm verwendet werden.
Wenn die Datei von anderen Programmen verwendet werden soll, schreibt man eine einfache Textdatei.
Natürlich gibt es noch andere Möglichkeiten, aber das technische Grundprinzip ist für Eingabe/Ausgabe immer ähnlich: Man schreibt irgendwelche Daten in irgendwas hinein. Dieses irgendwas ist meist eine Datei auf dem Speichermedium, oder ein Strom aus einer Netzwerkverbindung. Beim Lesen der Daten passiert meist das Gleiche, nur andersherum. Irgendwelche Daten werden entweder aus einer Datei auf einem Speichermedium, oder aus einer Netzwerkverbindung gelesen.
! ganz wichtig, denn das hatte ich auch nicht in der Vorlesung mitbekommen, sondern erst wieder beim Durchlesen der Folie: das gilt alles nur, wenn man NICHT mit einer Datenbank arbeitet!
Innerhalb der Vorlesung zog sich das Beispiel mit der Wiederherstellung und Speicherung von Spielen durch. Also, spielen wir auch hier jetzt einmal das Szenario durch. Wenn ich ein Spiel entwickeln würde, was wahrscheinlich eine gute Story hätte (hoffe ich doch mal), würde ich mir Etwas überlegen müssen, um dem Spieler die Möglichkeit zu geben seinen Spielstand zu speichern, sonst wäre er nach dem x-ten Mal anfangen extremst genervt, oder er würde das Spiel durchspielen, aber eher gezwungenermaßen. Deshalb, gibt es zum einen die Möglichkeit serialisierte Spielfigur-Objekte in eine Datei , oder einfach eine einfache Textdatei zu schreiben.
Und wie schreibt man jetzt eine serialisiertes Objekt in eine Datei?
Das hört sich jetzt wirklich komplizierter an, als es ist. Da es mir gerade beim Thema Programmierung sehr schwer fällt erst einmal die Theorie lange zu hören und dann erst Aufgaben zu machen (ich brauche das learning by doing hier einfach), musste ich mir nach der Vorlesung das Ganze noch einmal aufmerksam durchlesen und es ist wirklich logisch was wir hier machen 🙂
Hier sind die Schritte für eine Serialisierung:
- Einen FileOutputStream erzeugen:
FileOutputStream fileStream = new FileOutputStream („MeinSpiel.ser“);
durch new wird ein neues FileOutputStream – Objekt erzeugt. Der FileOutputStream weiß, wie man sich mit einer Datei verbindet. Wenn die Datei MeinSpiel nicht existieren sollte, wird sie automatisch erzeugt
2. Einen ObjectOutputStream erzeugen:
ObjectOutputStream os = new ObjectOutputStream(fileStream);
Mit dem ObjectOutputStream kann man Objekte schreiben, aber dieser kann sie nicht direkt mit einer Datei verbinden. Deshalb braucht er einen Helfer, den wir ihm übergeben. Das Ganze bezeichnet man auch als eine „Verkettung“ eines Stroms mit einem anderen.
3. Das Objekt schreiben (nehmen wir mal an, wir haben 3 Spielfiguren):
os.writeObject(figur1);
os.writeObject(figur2);
os.writeObject(figur3);
Die von figur1, figur2 und figur3 referenzierten Objekte werden serialisiert und in die Datei „MeinSpiel“ geschrieben.
Ab hier fand ich es ein wenig komplizierter mitzudenken, da Referenzen, schreiben und serialisieren in einem Satz vorkamen 😀 Das hatte ich auch erst beim Durchlesen in Ruhe verstanden.
4. Den ObjectOutputStream schließen:
os.close();
Wenn man Etwas öffnet, muss man es auch wieder schließen, das Gleiche gilt für den ObjectOutputStream. Wenn man den obersten Strom (bzw. Stream, das wird hier nämlich gemeint) schließt, werden auch die darunter liegenden Ströme geschlossen. Der FileOutputStream und die Datei schließen sich daher automatisch.
In der Java E/A-API gibt es Anschlusströme, die Verbindungen zu Zielorten und Quellen (z.B. Dateien) repräsentieren und Verkettungsströme die nur funktionieren, wenn sie mit anderen Strömen verkettet sind.
Um es kurz zu fassen:
Anschlussströme: repräsentieren eine Verbindung zu einer Quelle oder einem Zielort
Verkettungsströme: können selbst keine Verbindung herstellen und müssen daher mit einem Anschlussstrom verkettet werden.
Meist braucht man mindestens zwei aneinander gehängte Ströme: einen für den Anschluss und einen zweiten auf dem die Methode aufgerufen werden kann. Nachdem ich mir die Folien in Ruhe noch einmal durchgelesen hatte, weiß ich jetzt auch, was mit low-level gemeint ist und warum man mindestens zwei Ströme braucht. So besitzt ein FileOutputStream , der ein Anschlussstrom ist, eine Methode um Bytes zu schreiben, was wir eigentlich gar nicht brauchen, deshalb braucht man einen Verkettungsstrom, der auf einer höheren Stufe steht. Warum nicht alles in allem? Weil objektorientiert dafür steht, dass jede Klasse genau eine Sache gut kann. Deshalb erzeugt man einen FileOutputStream, mit dem man eine Datei schreiben kann und an desse Ende hängt man einen ObjectOutputStream (=Verkettungsstrom). wenn man dann writeObject() auf dem ObjectOutputStream aufruft, wird das Objekt in den Strom gepumpt und gelangt von dort in den FileOutputStream, wo es dann schließlich in Form von Bytes in eine Datei geschrieben wird. Ich merke es immer wieder, wie logisch programmieren ist, wenn man es wirklich versucht zu verstehen und sich Zeit nimmt. Und das Wort Zeit ist hier wirklich wichtig. Dadurch, dass man Anschluss- und Verkettungsströme so bilden kann, wie man möchte, sind hier der Kreativität keine Grenzen gesetzt 😀
Hier noch einmal zur Hilfe die bildliche Abfolge der Serialisierung:

Doch was geschieht wirklich?
Objekte auf dem Heap haben einen Zustand, nämlich den Wert der Instanzvariablen in diesem Objekt. Wird ein Objekt serialisiert, speichert dieses Objekt die Werte der Instanzvariablen, so dass eine identische Instanz/Objekt in das Leben auf dem Heap zurückgerufen werden kann. Wie, wenn man einem aufblasbaren Schwimmring die Luft wegnimmt (=Serialisierung) und ihn dann wieder aufbläst (=Wiederherstellung).
Hier ist auch noch einmal eine Darstellung aus den Folien mit Text, die zeigt, was dann auch mit den Instanzvariablen passiert:

Wichtig zu wissen ist auch noch, dass bei der Serialisierung der vollständige Objektgraph gespeichert wird. Zuerst das serialisierende Objekt und ausgehend davon alle über Instanzvariablen referenzierten Objekte. Das hatten wir auch in der Aufgabe, die wir am Ende der Stunde machen wollten gesehen, aber darauf gehe ich später noch einmal ein.
Wie so häufig, muss man, wenn man möchte, dass die Klasse serialisierbar ist, Serializable implementieren. Dieses Interface dient nur dazu die Klasse, von der es implementiert wird, als serialisierbar zu kennzeichnen (es enthält keine zu implementierenden Methoden). Alle Objekte dieses Typs können dann über den Serialisierungsmechanismus gespeichert werden. Wenn die Superklasse einer Unterklasse serialisierbar ist, dann ist sie es auch.
Außerdem noch ganz wichtig: Serializable gehört zum E/A Paket java.io, deshalb muss man erst dieses Paket implementieren.
Damit nicht ein Hund ohne Gewicht, oder ohne Ohren zurück kommt, heißt Serialisierung alles oder nichts. Entweder wird der gesamte Objektgraph korrekt serialisiert, oder die Serialisierung schlägt fehl.
Natürlich kann es aber sein, dass man aus verschiedenen Gründen nicht möchte, dass eine Instanzvariable serialisiert wird. Dann kennzeichnet man sie mit transient. Ein Grund hierfür kann sein, dass die Instanzvariable laufzeitspezifisch ist und diese Informationen kann man nicht speichern.
Ein weiterer Punkt, den man nicht übersehen sollte ist, dass E/A-Operationen Exceptions auslösen können, zum Beispiel , wenn eine Datei bzw. ein Objekt nicht serialisierbar sein sollte. Deshalb sollte man immer noch einen catch hinzufügen, damit dieser dann die exception, falle es eine geben sollte, fängt.
Ein Beispiel, wie man eine E/A-Operation in ein try und catch einbetten kann:

Außerdem gut zu wissen, ist dass, wenn die Superklasse nicht final und nicht serialisierbar ist, man aufgrund der Polymorphie die Superklasse durch die Unterklasse erweitern kann.
Wir übergehen in der Vorlesung in diesem Semester die „Es gibt keine dummen Fragen“, aber wir sollen sie uns dennoch daheim durchlesen, ob wir sie verstanden haben. Hier fand ich nur eine Frage interessant und das war, was passiert wenn man eine Variable transient macht, da man diese ja auch braucht, wenn man das Objekt wieder zum Leben erwecken möchte. Das wird so geregelt, dass wenn man ein Objekt serialisiert haben möchte, bekommt man eine transient-Variable als null zurück (nicht als 0, sondern null). Das heißt, dass der gesamte Objektgraph, der an diese Instanzvariable gebunden ist, nicht gespeichert wird. Man braucht ja aber meistens einen einen anderen Wert als null deshalb macht man entweder:
a) die null-Instanzvariable reinitialisieren mit einem Standardzustand. Das geht, wenn das deserialisierte Objekt nicht von einem bestimmten Wert für die transient Variable abhängt.
b) Wenn der Wert einer transient-Variable doch eine Rolle spielt, speichert man die charakteristischen Werte speichern und beim Wiederherstellen wird dann eine neue, aber identische Instanzvariable erzeugt
Wenn es eine Serialisierung gibt, muss es zum Wiederbeleben des Objekts auch eine Deserialisierung geben.
Das funktioniert wie folgt:


Es handelt sich also eigentlich einfach um die umgekehrte Serialisierung. Anstatt einen Output, gibt es hier einen Input-Stream zum Wiederherstellen und die Objekte müssen gecastet werden, damit sie in ihren eigentlichen Typen umgewandelt werden sollen. Desto häufiger ich mir das hie rgerade durchlese, desto logischer erscheint es mir 😀 (nur so als Anmerkung am Rande).
Was passiert hier genau im Hintergrund?
Bei der Deserialisierung versucht die JVM das Objekt wiederherzustellen, indem sie ein neues Objekt auf dem Heap erzeugt, das den gleichen Zustand hat, wie das serialisierte Objekt zum Zeitpunkt seiner Serialisierung.
Hier noch die bildliche Darstellung aus der Vorlesung:

Falls die Klasse von der JVM nicht gefunden werden kann, wird eine Exception ausgelöst.
Dem neuen Objekt wird dann ein Platz auf dem Heap zugeteilt, aber der Konstruktor des serialisierten Objekts wird nicht ausgeführt, sonst würde ja der ursprüngliche new Zustand des Objekts wiederhergestellt werden.
Am Ende der Vorlesung bekamen wir dann zur Übung eine Aufgabe die wie folgt lautete:

Nun hatte ich leider folgendes Problem: ich habe einen neuen Laptop, ohne Java und ohne Eclipse, oder Netbeans (nur Visual Studio, oder Atom)…Also durfte ich mir erst wieder das JDK mit Entwciklungsumgebung herunterladen…Nächstes Problem: Ich bevorzuge Netbeans und nicht Eclipse und leider fand Netbeans nach dem Download mein JDK nicht…Da ich nicht noch mehr Zeit verschwenden wollte, habe ich dann doch Eclipse heruntergeladen…Nächstes Problem: zu langsames Internet… Am Ende der Vorlesung war dann gerade einmal so Eclipse heruntergeladen und ich hatte nicht einmal angefangen mit der Aufgabe.. Da war ich dann ein wenig aufgelöst.
Einen Tag später, setzte ich mich also an die Aufgabe mit der Panik es nicht hinzubekommen… Zum Glück, hatte ich die Aufgab ein 5 Minuten gelöst gehabt, nachdem ich mir das Skript noch einmal durchgelesen hatten.
Hier mein Code:


Die Ausgabe des Codes sind dann die Typen der 3 Spielfiguren
So das war dann auch schon die erste Vorlesung, bei der wir im Thema Streams bis Folie 38 gekommen sind