1. Ein-/Ausgabeströme
Im vorherigen Kapitel, »Dateien und wahlfreier Zugriff auf Dateiinhalte«, sowie Kapitel »Exceptions«, haben wir uns schon mit dem grundlegenden Lesen und Schreiben von Dateiinhalten beschäftigt. Diese Kapitel legt den Fokus auf die Datenströme, also den kontinuierlichen Fluss von Daten; sie können in ein Ziel geschrieben oder aus einer Quelle gelesen werden und dabei durch mehrere Filter laufen. Die Verschachtelung von Javas Ein-/Ausgabeströmen ist ein gutes Beispiel für eine Abstraktion und Flexibilität, das auch bei der Modellierung von eigenen Filtern sehr hilft.
Voraussetzungen
Typhierarchie von Ein-/Ausgabeklassen kennen
zeichenorientierte und byteorientierte Klassen unterscheiden können
Dekoration von Strömen verstehen
Stromdaten durch Filter schicken können
Daten komprimieren können
Verwendete Datentypen in diesem Kapitel:
Noch mehr Aufgaben findest du im Buch: ›Captain CiaoCiao erobert Java: Das Trainingsbuch für besseres Java. 300 Java-Workshops, Aufgaben und Übungen mit kommentierten Lösungen‹
1.1. Direkte Datenströme
In Java werden vier Typen unterschieden: InputStream
und OutputStream
(byteorientiertes Lesen und Schreiben) sowie Reader
und Writer
(zeichenorientiertes Lesen und Schreiben). Wir beginnen mit den Aufgaben zunächst mit genau solchen Strömen, die direkt Daten in eine Ressource schreiben oder direkt aus einer Ressource lesen.
1.1.1. Anzahl unterschiedlicher Stellen ermitteln (Dateien lesen) ⭐
Captain CiaoCiao bekommt zwei Textdateien, und die sehen auf den ersten Blick sehr gleich aus. Aber er möchte genau wissen, ob die beiden Dateien exakt übereinstimmen oder ob es Unterschiede gibt.
Aufgabe:
Schreibe eine Methode
long distance(Path file1, Path file2)
, die die Anzahl unterschiedlicher Stellen zurückgeliefert. In der Informatik wird das Hamming-Abstand (engl. Hamming distance) genannt.Es ist davon auszugehen, dass die beiden Dateien exakt gleich lang sind.
Beispiel: Eine Datei enthält den String
To Err is Human. To Arr is Pirate.
und eine andere Datei den String
To Arr is Human. To Err is Pirate!
Die Distanz ist 3, weil 3 Symbole nicht übereinstimmen.
1.1.2. Python-Programm in Java konvertieren (Datei schreiben) ⭐
In Kapitel »Imperative Sprachkonzepte« haben wir verschiedene Aufgaben gelöst, die SVG-Ausgaben auf den Bildschirm schreiben — nun wollen wir diese Ausgaben direkt in HTML-Dateien schreiben. Zur Erinnerung: Folgendes HTML enthält eine SVG mit einem Rechteck mit einer Höhe und Breite von 1 und den x-/y-Koordinaten 10/10:
<!DOCTYPE html>
<html><body>
<svg width="256" height="256">
<rect x="10" y="10" width="1" height="1" style="fill:rgb(0,29,0);" />
</svg>
</body></html>
In einem Buch über computergenerierte Kunst findet Captain CiaoCiao auf den ersten Seiten eine Abbildung. Das Muster wird von einem Python-Programm erzeugt:
import Image, ImageDraw
image = Image.new("RGB", (256, 256))
drawingTool = ImageDraw.Draw(image)
for x in range(256):
for y in range(256):
drawingTool.point((x, y), (0, x^y, 0))
del drawingTool
image.save("xorpic.png", "PNG")
Die Python-Funktion point(…)
bekommt die x-y-Koordinate und RGB-Farbinformationen, wobei die drei Argumente 0
, x^y
, 0
für die Anteile Rot, Grün, Blau stehen.
Aufgabe:
Da Captain CiaoCiao keine Schlangen mag, muss das Python-Programm in ein Java-Programm konvertiert werden.
Statt einer PNG-Datei soll am Ende eine HTML-Datei mit einem SVG-Block entstehen, in der jedes Pixel ein 1 × 1 großes SVG-Rechteck ist.
Bonus: Öffne am Ende die HTML-Datei mit dem Browser — hier hilft die Desktop
-Klasse.
1.1.3. Zielcode generieren (Datei schreiben) ⭐
Von der Post bekommt Captain CiaoCiao immer öfter Briefe mit rosafarbenem Strichcode. Er denkt erst an kodierte Liebesbotschaften von Bonny Brain, muss dann aber feststellen, dass auf dem Briefumschlag ein sogenannter Zielcode steht, der die Postleitzahl kodiert.
Die Kodierung der Zahlen in Strichen ist wie folgt, wobei der Unterstrich _
den Abstand durch ein Leerzeichen symbolisiert:
Wert | Kodierung |
---|---|
0 |
|
1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
Aufgabe:
Schreibe eine statische Methode
writeZielcode(String, Writer)
, die einen String mit Ziffern in der genannten Kodierung in einenWriter
schreibt.Zwischen den vier Symbolen für eine Ziffer sollen zwei Leerzeichen stehen.
Beispiel:
Der String
"023"
wird als|||| || | ||
in die Datei geschrieben.
Besorge von |
Für ergänzende Informationen zur Kodierung siehe auch https://de.wikipedia.org/wiki/Zielcode.
1.1.4. Dateiinhalt in Kleinbuchstaben konvertieren (Datei lesen und schreiben) ⭐
Textkonvertierungen von einem Format in ein anderes sind übliche Vorgänge.
Aufgabe:
Öffne eine Textdatei, lies jedes Zeichen ein, konvertiere es in Kleinbuchstaben, und schreibe es in eine neue Datei. Schreibe eine Methode, die das erledigt, und nenne sie
convertFileToLowercase(Path inPath, Path outPath)
.
1.1.5. PPM-Grafikformat in ASCII-Graustufen anzeigen ⭐⭐⭐
Pixelgrafiken zu generieren ist wegen der verschiedenen Formate immer etwas aufwendiger. Es gibt allerdings mit PPM (Portable Pixel Map) ein sehr einfaches ASCII-basiertes Dateiformat. Die Spezifikation (http://netpbm.sourceforge.net/doc/ppm.html) ist einfach und ein Java-Programm kann PPM-Bilder leicht generieren. Ein Nachteil unter Windows ist jedoch, dass für die Darstellung Drittanbieterprogramme nötig sind, etwa die freie Software GIMP (https://www.gimp.org/).
Das folgende Beispiel zeigt den Grundaufbau einer PPM-Datei:
P3 3 2 255 255 0 0 0 255 0 0 0 255 255 255 0 255 255 255 0 0 0
Es gibt diverse Tokens, die durch Weißraum getrennt sind. Wir stellen folgende Regeln auf:
Das erste Token ist die Kennung
P3
.Es folgen die Breite und Höhe des Bildes.
Es folgt der maximale Farbwert, wir nehmen immer 255 an.
Es folgen Rot-, Grün-, Blauwerte für alle Pixel von oben links nach unten rechts.
Höhe und Breite und die Farbwerte sind immer positiv.
Aufgabe:
Lies eine PPM-Datei ein, und extrahiere alle Farbwerte.
Übertrage jeden Farbwert auf einen Graustufenwert.
Jeder Punkt der Grafik soll ein ASCII-Zeichen werden. Konvertiere jeden Graustufenwert von 0 bis 255 in ein ASCII-Zeichen.
Erlaube im Programm die Parametrisierung der Umrechnung der RGB-Werte in den Graustufenwert, damit der Algorithmus auswechselbar ist.
Erlaube die Parametrisierung der Umwandlung vom Graustufenwert in das ASCII-Zeichen.
Zur Konvertierung in einen Graustufenwert lässt sich auf folgende Schnittstelle und Konstante zurückgreifen:
public interface RgbToGray {
RgbToGray DEFAULT = (r, g, b) -> (r + g + b) / 3;
int toGray( int r, int g, int b );
}
Java bietet mit dem IntBinaryOperator
eine Abbildung von (int
, int
) auf ein int
, aber keine funktionale Schnittstelle mit drei Parametern.
Die Durchschnittsmethode ist performant, aber entspricht nicht der menschlichen Wahrnehmung. Realistischere Abbildungen berücksichtigen, dass Durchschnittsmenschen Farben unterschiedlich stark wahrnehmen. Die bekannte »Leuchtkraft-Methode« ist: 0.21 R + 0.72 G + 0.07 B.
Die Schnittstelle IntUnaryOperator
kann gut für die Abbildung eines Graustufenwerts (int
) auf ein ASCII-Zeichen (char
, erweitert auf int
) herangezogen werden. Ein Default-Konverter kann so aussehen:
public enum GrayToAscii implements IntUnaryOperator {
DEFAULT;
private final char[] ASCII_FOR_SHADE_OF_GRAY =
// black = 0, white = 255
"@MBENRWDFQASUbehGmLOYkqgnsozCuJcry1v7lit{}?j|()=~!-/<>\"^_';,:`. ".toCharArray();
private final int CHARS_PER_RGB = 256 / ASCII_FOR_SHADE_OF_GRAY.length;
@Override public int applyAsInt( int gray ) {
return ASCII_FOR_SHADE_OF_GRAY[ gray / CHARS_PER_RGB ];
}
}
Der vorgegebene String[1] ist 64 Zeichen lang. Im Prinzip heißt das: Schwarz wird @
, und Weiß wird ein Leerzeichen.
Beispiel:
Das Ergebnis für oberes PPM ist:
kkk ? @
1.1.6. Dateien portionieren (Dateien lesen und schreiben) ⭐⭐
Auf dem Anaa-Atoll läuft die Hafensoftware seit rund 40 Jahren auf einem Commodore PC-30. Bonny Brain hat den Rechner erfolgreich manipuliert, doch jetzt benötigt die Software ein Update, das über Disketten eingespielt werden muss. 3,5-Zoll-HD-Disketten können standardmäßig 1.474.560 Byte (1440 KiB) speichern. Das Software-Update passt nicht auf eine Diskette, daher ist eine Software nötig, die eine große Datei »diskettenkompatibel« in mehrere kleine Dateien zerlegt.
Aufgabe:
Schreibe ein Programm, das auf der Kommandozeile einen Dateinamen übergeben bekommt und dann diese Datei in mehrere kleinere Teile aufspaltet.
Beispiel:
Der Aufruf sieht folgendermaßen aus:
$ java com.tutego.exercise.io.FileSplitter Hanjaab.bin
Ist die Datei Hanjaab.bin 2440 KiB groß, dann wird das Java-Programm daraus die Dateien Hanjaab.bin.1 und Hanjaab.bin.2 mit den Größen 1440 KiB und 1000 KiB machen.
1.2. Ströme verschachteln
Ströme können wie russische Puppen verschachtelt werden; ein Strom ist im Kern die eigentliche Ressource, und andere Ströme sind wie eine Hülle darumgelegt. Die Operationen über die Hüllen gehen letztendlich in den Kern.
1.2.1. Zahlenfolgen mit dem GZIPOutputStream komprimieren ⭐
java.util.zip.GZIPOutputStream
ist ein besonderer Ausgabestrom, der Daten verlustfrei komprimiert.
Aufgabe:
Erstelle eine komprimierte Datei mit Zahlen von
0
bis < N, die mitwriteLong(…)
in einenGZIPOutputStream
geschrieben werden.Vergleiche die Dateigrößen für unterschiedliche N.
Ab welchem N lohnt sich eine Kompression?
1.2.2. Ausgaben aufspreizen mit einer Klasse TeeOutputStream ⭐⭐
Java bietet nur Klassen, die einen Ausgabestrom in genau einen anderen Ausgabestrom schreiben.
Aufgabe:
Schreibe eine neu Klasse
TeeOutputStream
, die gleichzeitig in zwei Ausgabeströme schreiben kann.
Beispiel:
Ausgaben sollen in zwei
ByteArrayOutputStream
geschrieben werden:ByteArrayOutputStream baos1 = new ByteArrayOutputStream(); ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); try ( OutputStream tos = new TeeOutputStream( baos1, baos2 ); Writer ost = new OutputStreamWriter( tos, StandardCharsets.UTF_8 ); PrintWriter pw = new PrintWriter( ost ) ) { pw.print( "Hey" ); pw.write( '\n' ); pw.printf( "%d times %d equals %d", 2, 3, 4 ); } System.out.println( baos1.toString( StandardCharsets.UTF_8 ) ); System.out.println( baos2.toString( StandardCharsets.UTF_8 ) );
Hinweis: Berücksichtige, dass bei Ausnahmen auf dem einen Kanal die Operationen immer noch in den anderen Kanal geschrieben werden.
1.2.3. PrintWriter und weitere Dekoratoren zusammenbringen ⭐
In dieser Aufgabe soll eine ganze Kette an Strömen zusammengebracht werden, der → gibt an, wer wohin schreibt:
PrintWriter
→ OutputStreamWriter
→ BufferedOutputStream
→ CheckedOutputStream
→ Datei-OutputStream
Bei Verkettungen dieser Art fängt man immer bei der eigentlichen Ressource an.
Aufgabe:
Lege eine Datei mit einem
OutputStream
an, der vonFiles.new*(...)
kommt.Nutze einen
CheckedOutputStream
, um mit einer Adler32-Checksum-Implementierung eine Prüfsumme berechnen zu können.Setze um den
CheckedOutputStream
einenjava.io.BufferedOutputStream
zur Pufferung.Um von den Bytes eines
OutputStream
in die Unicode-Welt zu kommen wollen wir eineOutputStreamWriter
einsetzen. Konfiguriere ihn mit UTF-8.Erzeuge anschließend einen
java.io.PrintWriter
und schreibe einige Zeilen Text hinein.Am Ende soll die Prüfsumme ausgegeben werden.
Achte darauf die Ressourcen mit try
-mit-Ressourcen zu schließen.
1.2.4. Häufigkeiten mit einem FilterReader mitzählen ⭐⭐
Aus der Häufigkeit der Buchstaben lässt sich oft die Sprache ableiten. Gesucht ist ein Java-Filter, der sich als Writer
zwischen andere Writer
hängen lässt und intern die Anzahl unterschiedlichen Zeichen mitzählt.
Aufgabe:
Nenne die Klasse
CharacterFrequencyReader
, leite sie vonjava.io.FilterReader
ab.Die Methode
toString()
soll eine Tabelle mit Buchstabenhäufigkeiten ausgeben.
Beispiel: Gegeben ist die Zeichenfolge in einer Ressource.
3.14% of sailors are Pi Rates.
Wenn alle Zeichen durch den CharacterFrequencyReader
strömen, und das Programm später toString()
aufruft, sollte ein String mit folgendem Ergebnis auf dem Bildschirm erscheinen:
(0A) | 1 | 3,23 | 5 | 16,13 % | 1 | 3,23 . | 2 | 6,45 1 | 1 | 3,23 3 | 1 | 3,23 4 | 1 | 3,23 P | 1 | 3,23 R | 1 | 3,23 a | 3 | 9,68 e | 2 | 6,45 f | 1 | 3,23 i | 2 | 6,45 l | 1 | 3,23 o | 2 | 6,45 r | 2 | 6,45 s | 3 | 9,68 t | 1 | 3,23
Kann dadurch auf die Sprache des Textes geschlossen werden?
1.3. Serialisierung
Java ermöglicht mit der Serialisierung, Objektzustände in einen Datenstrom zu schreiben und dann später das Objekt aus einem Datenstrom wiederzubelegen; diesen Vorgang nennt man Deserialisierung.
Zur Umwandlung von Java-Objekten in einen Binärstrom und umgekehrt werden die Klassen ObjectOutputStream
und ObjectInputStream
verwendet; alle zu serialisierenden Objekttypen müssen Serializable
sein. Wir wollen die Typen in den nächsten Aufgaben einsetzen und praktische Beispiele für die Serialisierung kennenlernen.
Beide Klassen sind typische Dekoratoren: Beim Serialisieren ermittelt der ObjectOutputStream
die Daten und schreibt die seriell anfallenden Bytefolgen in den OutputStream
, der im Konstruktor übergeben wurde — beim Einlesen ist es umgekehrt, hier liest der ObjectInputStream
aus einem übergeben InputStream
.
1.3.1. Daten für den Chat (de)serialisieren und in Text umwandeln ⭐⭐
Ein Chat-Programm soll zur Übermittlung von Java-Objekten genutzt werden. Das Chat-Programm kann allerdings nur ASCII-Zeichen übermitteln. Daher müssen die Objekte nicht nur (de)serialisiert werden, sondern auch in das bzw. aus dem Textformat konvertiert werden.
Aufgabe:
Schreibe eine Methode
String objectToBase64(Object)
, die ein Objekt serialisiert, anschließend mit einemDeflaterOutputStream
komprimiert und dann Base64 kodiert zurückliefert.Schreibe eine Methode
deserializeObjectFromBase64(String)
, die einen Base64-kodierten String in einen Bytestrom bringt, diesen mit demInflaterInputStream
entpackt und als Quelle für die Deserialisierung nutzt.
Zur Konvertierung von Binärdaten in einen String und umgekehrt helfen der |
1.3.2. Quiz: Voraussetzung für das Serialisieren ⭐
Wenn wir von der folgenden Klasse Inputs
ein Objekt bilden, können wir es dann über den ObjectOutputStream
serialisieren? Oder welche Voraussetzungen sind vielleicht nicht gegeben?
class Inputs {
public static class Input {
String input;
}
public List<Input> inputs = new ArrayList<>();
}
1.3.3. Letzte Eingaben sichern ⭐⭐
Bonny Brain benutzt regelmäßig die STRING2UPPERCASE-Anwendung, die im Kern so aussieht:
for ( String line; (line = new Scanner( System.in ).nextLine()) != null; )
System.out.println( line.toUpperCase() );
Doch jetzt soll jede Benutzereingabe im Dateisystem gespeichert werden, sodass die Anwendung beim Start die gemachten Eingaben anzeigt.
Aufgabe:
Setze den folgenden Container für alle Eingaben in das Projekt:
class Inputs implements Serializable { public static class Input implements Serializable { String input; } public List<Input> inputs = new ArrayList<>(); }
Immer dann, wenn eine Benutzereingabe gemacht wurde, soll sie in ein
Inputs
-Objekt aufgenommen werden.Nach jeder Eingabe soll
Inputs
in eine Datei serialisiert werden.Wenn die Anwendung neu startet, sollen alle serialisierten Werte am Anfang auf dem Bildschirm angezeigt werden. Ausnahmen durch nichtexistierende Dateien oder falsche Dateiformate können geloggt, sollen aber ignoriert werden.
Ändere in
Input
den DatentypString
der Objektvariableninput
in den DatentypCharSequence
. Starte das Programm neu. Was passiert bei der Deserialisierung vonInputs
? Gibt es Probleme?Setze in
Inputs
und inInput
die Zeile@Serial private static final long serialVersionUID = 1;
Starte das Programm erneut, und serialisiere neue Daten.
Ergänze in
Input
die ZeileLocalDateTime localDateTime = LocalDateTime.now();
für eine zusätzliche Objektvariable. Starte das Programm neu: was passiert bzw. passiert nicht?
Noch mehr Aufgaben findest du im Buch: ›Captain CiaoCiao erobert Java: Das Trainingsbuch für besseres Java. 300 Java-Workshops, Aufgaben und Übungen mit kommentierten Lösungen‹