19
von
Charles L. Perkins
Heute lernen Sie alles über Java-Datenstreams mit folgenden Schwerpunkten:
Sie lernen auch zwei Datenstreamschnittstellen, die das Lesen und Schreiben getippter Datenstreams vereinfachen. Ferner lernen Sie, wie ganze Objekte gelesen und geschrieben werden. Und Sie lernen mehrere Utility-Klassen kennen, die benutzt werden, um auf das Dateisystem zuzugreifen. Wir beginnen mit einer kurzen Geschichte über Datenstreams.
Eine der ersten Versionen des UNIX-Betriebssystems war die Pipe. Eine Pipe ist ein nichtinterpretierter Byte-Stream, der zur Kommunikation zwischen Programmen (bzw. »gegabelten« Kopien eines Programms) oder zum Lesen und Schreiben von verschiedenen Peripheriegeräten und Dateien benutzt wird. Durch Vereinheitlichung aller möglichen Kommunikationsarten in einer einzigen Metapher ebnete UNIX den Weg für eine ganze Reihe ähnlicher Neuerungen, die schließlich in der Abstraktion namens Streams oder Datenstreams gipfelten.
Dieser Informationsblock, d.h. ein nichtinterpretierter Byte-Stream, kann von jeder »Pipe-Quelle«, dem Rechnerspeicher oder auch vom Internet kommen. Quelle und Ziel eines Datenstreams sind willkürliche Erzeuger bzw. Verbraucher von Bytes. Darin liegt die Leistung dieser Abstraktion. Sie müssen beim Lesen nichts über die Quelle und beim Schreiben nichts über das Ziel des Datenstreams wissen.
Allgemeine Methoden, die von jeder beliebigen Quelle lesen können, akzeptieren ein Streamargument, das die Quelle bezeichnet. Allgemeine Methoden zum Schreiben akzeptieren einen Stream, um das Ziel zu bestimmen. Arbiträre Prozessoren (oder Filter) haben zwei Streamargumente. Sie lesen vom ersten, verarbeiten die Daten und schreiben die Ergebnisse in den zweiten. Diese Prozessoren kennen weder Quelle noch Ziel der Daten, die sie verarbeiten. Quelle und Ziel können sehr unterschiedlich sein: von zwei Speicherpuffern auf dem gleichen lokalen Rechner über ELF-Übertragungen von und zu einer Unterwasserstation bis zu Echtzeit-Datenstreams einer NASA-Sonde im Weltraum.
Durch Abkoppeln des Verbrauchs, der Verarbeitung und der Produktion der Daten von Quelle und Ziel dieser Daten können Sie jede beliebige Kombination mischen, während Sie Ihr Programm schreiben. Künftig, wenn neue, bisher nicht bekannte Formen von Quelle oder Ziel (oder Verbraucher, Verarbeitung und Erzeuger) erscheinen, können sie im gleichen Rahmen ohne Änderung von Klassen benutzt werden. Neue Streamabstraktionen, die höhere Interpretationsebenen »oberhalb« der Bytes unterstützen, können völlig unabhängig von den zugrundeliegenden Mechanismen für den Transport der Bytes geschrieben werden.
Die Bais dieses Streamgerüsts bilden die zwei abstrakten Klassen InputStream und OutputStream. Wenn Sie sich das Diagramm von java.io in Anhang B ansehen, erkennen Sie, daß unter diesen Klassen eine virtuelle Fülle kategorisierter Klassen steht, die den breiten Bereich von Datenstreams im System, aber auch eine äußerst gut ausgelegte Hierarchie von Beziehungen zwischen diesen Datenstreams aufzeigt. Ein ähnlicher Baum ist im Diagramm von java.io-rw vorhanden, der in den abstrakten Eltern Reader und Writer seine Wurzeln hat. Wir beginnen mit diesen Elternklassen und arbeiten uns durch diesen buschigen Baum.
Die Grundlagen für alle Eingabeoperationen von Java bilden die zwei in den nächsten Unterabschnitten beschriebenen Klassen. Nach deren Definition ergeben sich die analogen Klassen, die aus den hier dargestellten stammen, weil diese Klassenpaare fast identische Methodenschnittstellen haben und auf die gleiche Weise benutzt werden.
InputStream ist eine abstrakte Klasse, die die Grundlagen für das Lesen eines Byte-Streams durch den Verbraucher (das Ziel) von einer Quelle definiert. Die Identität der Quelle und die Art, wie die Bytes erstellt und befördert werden, ist nicht relevant. Bei der Verwendung eines Eingabestreams sind Sie das Ziel dieser Bytes. Das ist alles, was Sie wissen müssen.
Reader ist eine abstrakte Klasse, die die Grundlagen definiert, wie ein Ziel (Verbraucher) einen aus Zeichen bestehenden und von irgendeiner Quelle kommenden Datenstream liest. Der Leser (Reader) und alle seine Unterklassen sind analog der Klasse InputStream und allen ihren Unterklassen, ausgenommen, daß sie als zugrundeliegende Informationseinheiten Zeichen anstelle von Bytes benutzen.
Die wichtigste Methode für den Verbraucher eines Eingabestreams (oder Lesers) ist die, die die Bytes (Zeichen) von der Quelle liest. Diese Methode ist read(). Sie existiert in vielen Varianten, von denen in der heutigen Lektion je ein Beispiel aufgezeigt wird.
Jede dieser read()-Methoden ist so definiert, daß sie warten muß, bis alle angeforderten Eingaben verfügbar sind. Sorgen Sie sich nicht wegen dieser Einschränkung. Dank Multithreading können Sie viele andere Dinge realisieren, während ein Thread auf eine Eingabe wartet. Üblicherweise wird ein Thread je einem Eingabestream (und je einem Ausgabestream) zugewiesen, der allein für das Lesen vom (oder Schreiben zum) jeweiligen Stream zuständig ist. Die Eingabe-Threads können dann die Informationen zur Verarbeitung an andere Threads abgeben. Dadurch überlappt natürlich die I/O-Zeit Ihres Programms mit seiner Berechnungszeit.
Hier die erste Form von read():
InputStream s = getAnInputStreamFromSomewhere();
Reader r = getAReaderFromSomewhere();
byte[] bbuffer = new byte[1024]; // Kann beliebige Größe sein
char[] cbuffer = new char[1024];
if (s.read(bbuffer) != bbuf.length || r.read(cbuffer) != cbuf.length)
System.out.println("Ich habe weniger erhalten als erwartet.");
Diese Form von read() versucht, den gesamten zugeteilten Puffer zu füllen. Gelingt es ihr nicht (normalerweise, weil das Ende des Eingabestreams vorher erreicht wird), gibt sie die tatsächliche Anzahl von Bytes aus, die in den Puffer eingelesen wurden. Danach gibt ein eventueller weiterer Aufruf von read() den Wert -1 zurück, was anzeigt, daß das Ende des Datenstreams erreicht ist. Die if-Anweisung funktioniert auch, wenn der Datenstream leer ist, weil -1 nie der Pufferlänge entspricht.
Sie können auch in einen Bereich Ihres Puffers einlesen, indem Sie den Versatz (Offset) und die gewünschte Länge als Argumente in der zweiten Form von read() angeben:
s.read(bbuffer, 100, 300);
r.read(cbuffer, 100, 300);
Bei diesem Beispiel werden Bytes (Zeichen) 100 bis 399 aufgefüllt. Andernfalls verhält es sich genauso wie mit der vorherigen read()-Methode. In der aktuellen Version benutzt die Standardimplementierung der ersten Form von read() die zweite Alternative:
public int read(byte[] b) throws IOException { /* Von InputStream.java */
return read(b, 0, b.length);
}
public int read(char[] cbuf) throws IOException { /* Von Reader.java */
return read(cbuf, 0, cbuf.length);
}
In der dritten Form können die Bytes (Zeichen) auch einzeln eingelesen werden:
InputStream s = getAnInputStreamFromSomewhere();
InputStream r = getAReaderFromSomewhere();
byte b;
char c;
int byteOrMinus1, charOrMinus1;
while ((byteOrMinus1 = s.read()) != -1 && (charOrMinus1 = r.read()) != -1) {
b = (byte) byteOrMinus1; c = (char) charOrMinus1;
. . . // Verarbeite Byte b (oder Zeichen c)
}
. . . // Datenstreamende erreicht
Für den Fall, daß Sie einige Bytes in einem Datenstream überspringen oder von einer anderen Stelle mit dem Lesen des Datenstreams beginnen wollen, gibt es eine mit read() vergleichbare Methode:
if (s.skip(1024) != 1024 || r.skip(1024) != 1024)
System.out.println("Ich habe weniger übersprungen als erwartet.");
Dadurch werden die nächsten 1024 Byte des Eingabestreams übersprungen. skip() nimmt und gibt eine lange Ganzzahl zurück, weil Datenstreams nicht auf eine bestimmte Größe begrenzt werden müssen. Die Standardimplementierung von skip() in InputStream benutzt im Release 1.1 einfach read():
public long skip(long n) throws IOException { /* Von InputStream.java */
byte[] data = new byte[(int) n & 0xEFFFFFFF];
return read(data);
}
Wenn Sie wissen wollen, wie viele Bytes ein Datenstream momentan umfaßt (oder ob beim Leser weitere Zeichen auf Sie warten), können Sie so fragen:
if (s.available() < 1024)
System.out.println("Momentan ist zu wenig verfügbar.");
if (r.ready() != true)
System.out.println("Momentan sind keine Zeichen verfügbar.");
Dadurch wird Ihnen die Anzahl von Bytes mitgeteilt, die ohne Blockierung gelesen werden können (oder ob Sie irgendwelche Zeichen lesen können). Aufgrund der abstrakten Natur der Quelle dieser Bytes sind Datenstreams eventuell nicht in der Lage, Ihnen auf diese Frage eine Antwort zu geben. Einige Datenstreams geben beispielsweise immer 0 (oder false) zurück. Dieser Wert ist der Rückgabewert der Standardimplementierung von available() (oder ready()).
Sofern Sie keine spezifischen Unterklassen von InputStream verwenden, die Ihnen eine vernünftige Antwort auf diese Frage geben, sollten Sie sich nicht auf diese Methode verlassen. Multithreading schließt ohnehin viele Probleme in Verbindung mit der Blockierung während der Wartezeit auf einen Datenstream aus. Damit schwindet einer der vorrangigen Nutzen von available() (oder ready()) dahin.
Einige Datenstreams unterstützen die Markierung einer Position im Datenstream und das spätere Zurücksetzen des Datenstreams auf diese Position, um die Bytes (Zeichen) ab dieser Stelle erneut zu lesen. Der Datenstream müßte sich dabei an alle Bytes (Zeichen) »erinnern«, deshalb gibt es eine Einschränkung, in welchem Abstand in einem Datenstream markiert und zurückgesetzt werden kann. Ferner gibt es eine Methode, die fragt, ob der Datenstream dies überhaupt unterstützt. Hier ein Beispiel:
InputStream s = getAnInputStreamFromSomewhere();
Reader r = getAReaderFromSomewhere();
if (s.markSupported() && r.markSupported()) { // Unterstützt die
// Markierung?
. . . // Datenstream eine Weile
// lesen
s.mark(1024); r.mark(1024);
. . . // Weniger als 1024 weitere
// Bytes (Zeichen)
// lesen
s.reset(); r.reset();
. . . // Wir können diese Bytes
// (Zeichen) jetzt erneut
// lesen
} else {
. . . // Nein, führe irgendeine
// Alternative aus
}
Durch Markieren eines Datenstreams wird die Höchstzahl der Bytes (Zeichen) bestimmt, die vor dem Zurücksetzen weitergegeben werden soll. Dadurch kann der Datenstream den Umfang seines »Speichers« eingrenzen. Läuft diese Zahl durch, ohne daß ein reset() erfolgt, wird die Markierung ungültig und der Versuch zurückzusetzen erzeugt eine Ausnahme.
Markieren und Zurücksetzen eines Datenstreams ist nützlich, wenn der Streamtyp (oder der nächste Streamteil) identifiziert werden soll. Hierfür verbrauchen Sie aber einen beträchtlichen Anteil davon im Prozeß. Oft liegt das daran, daß man mehrere Parser hat, denen man den Datenstream übergeben kann. Sie verbrauchen aber eine (Ihnen unbekannte) Zahl an Bytes (Zeichen), bevor sie sich entscheiden, ob der Datenstream ihr Typ ist. Setzen Sie eine große Größe als Lesegrenze, und lassen Sie jeden Parser ablaufen, bis er entweder einen Fehler ausgibt oder die Syntaxanalyse erfolgreich beendet. Wird ein Fehler ausgegeben, setzen Sie ihn zurück, und versuchen Sie es mit dem nächsten Parser.
Da Sie nicht wissen, welche Ressourcen ein offener Datenstream darstellt und wie diese Ressourcen zu behandeln sind, nachdem der Datenstream gelesen wurde, müssen Sie einen Datenstream normalerweise explizit schließen, damit er diese Ressourcen freigeben kann. Selbstverständlich können das der Garbage-Collector und eine Methode finalize() ebenfalls erledigen. Es könnte aber sein, daß Sie den Datenstream erneut öffnen müssen, bevor die Ressourcen dieses asynchronen Prozesses freigegeben werden. Bestenfalls ist das ärgerlich oder verwirrend. Im schlechtesten Fall entsteht ein unerwarteter, schwer auszumachender Fehler. Da Sie hierbei mit der Außenwelt, d.h. mit externen Ressourcen, zu tun haben, ist es ratsam, genau anzugeben, wann deren Benutzung enden soll:
InputStream s = alwaysMakesANewInputStream();
Reader r = alwaysMakesANewReader();
try {
. . . // Benutze s (oder r) nach Herzenslust
} finally {
s.close(); r.close();
}
Gewöhnen Sie sich an die Verwendung von finally(). Sie stellen damit sicher, daß Aktionen (z.B. das Schließen eines Datenstreams) auf jeden Fall ausgeführt werden. Selbstverständlich gehen Sie davon aus, daß der Datenstream immer erfolgreich erzeugt wird. Ist das nicht stets der Fall und wird zuweilen Null zurückgegeben, gehen Sie auf Nummer Sicher:
InputStream s = tryToMakeANewInputStream();
Reader r = tryToMakeAReader();
if (s != null && r != null) {
try {
. . .
} finally {
s.close(); r.close();
}
}
Alle Eingabestreams stammen von der abstrakten Klasse InputStream, und alle Leser stammen von Reader ab. Alle haben die bisher beschriebenen Methoden. Somit könnte Datenstream s (oder Leser r) im vorherigen Beispiel auch einen der komplexeren Eingabestreams haben, die in den nächsten Abschnitten beschrieben werden.
Durch »Umkehr« einiger der vorherigen Beispiele mit read() würde man einen Eingabestream (oder Leser) aus einem Byte- oder Zeichenarray erstellen. Genau das besorgt ByteArrayInputStream (bzw. CharArrayReader):
byte[] bbuffer = new byte[1024];
char[] cbuffer = new char[1024];
fillWithUsefulData(bbuffer); fillWithUsefulData(cbuffer);
InputStream s = new ByteArrayInputStream(bbuffer);
Reader r = new CharArrayReader(cbuffer);
Reader des neuen Datenstream s (r) sehen einen Datenstream mit einer Länge von 1024 Byte (Zeichen), d.h. den Inhalt des Arrays bbuffer (cbuffer). Der Konstruktor dieser Klasse hat wie read() einen Versatz (Offset) und eine Länge:
InputStream s = new ByteArrayInputStream(bbuffer, 100, 300);
Reader r = new CharArrayReader(cbuffer, 100, 300);
Hier ist der Datenstream 300 Byte (Zeichen) lang und enthält Bytes 100 399 aus dem Array bbuffer (cbuffer).
ByteArrayInputStream (CharArrayReader) implementiert lediglich die Standardmethoden wie alle Eingabestreams. Hier hat die Methode available() (ready()) aber eine ganz bestimmte Aufgabe: Sie gibt 1024 bzw. 300 (true und true) für die zwei Instanzen von ByteArrayInputStream (CharArrayReader) zurück, die Sie zuvor erstellt haben, weil sie genau weiß, wie viele Bytes (Zeichen) verfügbar sind. Auch markSupported() gibt true zurück. Schließlich wird der Datenstream durch Aktivieren von reset() ohne ein vorangehendes mark() an den Anfang seines Puffers zurückgesetzt.
Eine der häufigsten Verwendungen von Datenstreams und historisch die älteste ist das Anhängen von Datenstreams an Dateien im Dateisystem. Hier wird beispielsweise ein solcher Eingabestream (oder Leser) auf einem UNIX-System erstellt:
InputStream s = new FileInputStream("/Irgendein/Pfad/und/Dateiname");
Reader r = new FileReader("/Irgendein/Pfad/und/Dateiname.utf8");
Sie können Datenstreams auch aus einem zuvor aktivierten FileDescriptor oder einer Datei (File) erstellen:
InputStream s = new FileInputStream(FileDescriptor.in); /* Standardeingabe */
Reader r = new FileReader(FileDescriptor.in);
InputStream s = new FileInputStream(new File("/Irgendein/Pfad/und/Dateiname"));
Reader r = new FileReader(new File("/Irgendein/Pfad/und/Dateiname.utf8"));
Da dies auf einer tatsächlichen Datei mit einer bestimmten Länge basiert, kann der erzeugte Eingabestream (Reader) in allen drei Fällen problemlos available() (ready()) und skip() implementieren (wie übrigens auch ByteArrayInputStream und CharArrayReader).
FileReader ist eigentlich eine triviale Unterklasse der weiteren Reader-Klasse InputStreamReader, die jeden beliebigen Eingabestream (InputStream) kapseln und ihn in einen Zeichenleser umwandeln kann. Somit besteht die Implementierung von FileReader lediglich aus der Aufforderung von InputStreamReader, (selbst) einen FileInputStream zu kapseln:
public class FileReader extends InputStreamReader { /* Von FileReader.java */
public FileReader(String fileName) throws FileNotFoundException {
super(new FileInputStream(fileName))
}
public FileReader(File file) throws FileNotFoundException {
super(new FileInputStream(file))
}
public FileReader(FileDescriptor fd) throws FileNotFoundException {
super(new FileInputStream(fd))
}
}
FileInputStream (nicht aber FileReader) kennt darüber hinaus noch ein paar Tricks:
FileInputStream aFIS = new FileInputStream("EinDateiname");
FileDescriptor myFD = aFIS.getFD();
/* aFIS.finalize(); */ // Aktiviert close(), wenn GC automatisch aufgerufen wird
Ein Aspekt ist ganz klar: getFD() gibt den Bezeichner der Datei zurück, auf der der Datenstream basiert. Der zweite Aspekt ist eine interessante Kurzform, mit der Sie beliebige FileInputStream erstellen können, ohne sich um deren spätere Schließung Gedanken machen zu müssen. Die Implementierung von finalize() (einer geschützten Methode) durch FileInputStream schließt den Datenstream. Im Gegensatz zum vorherigen Beispiel sollten Sie eine finalize()-Methode nie direkt aufrufen. Der Garbage-Collector ruft sie auf, nachdem er festgestellt hat, daß der Datenstream nicht mehr gebraucht wird. Das System schließt den Datenstream (irgendwann).
Sie können sich diese Lässigkeit leisten, weil Datenstreams, die auf Dateien basieren, nur wenige Ressourcen binden. Diese Ressourcen können nicht versehentlich vor ihrer Beseitigung durch den Garbage-Collector (entgegen den vorherigen Beispielen mit finalize() und close()) wiederverwendet werden. Selbstverständlich müssen Sie sorgfältiger vorgehen, wenn Sie auch in die Datei schreiben wollen. Durch zu frühes erneutes Öffnen der Datei nach dem Schreiben kann sich ein inkonsistenter Zustand ergeben, weil finalize() und damit close() noch nicht ausgeführt wurden. Wenn Sie den Typ von InputStream nicht genau kennen, rufen Sie am besten close() selbst auf.
Diese »abstrakten« Klassen (in Wirklichkeit ist nur FilterReader abstrakt) bieten einen »Durchlauf« für alle Standardmethoden von InputStream (oder Reader). Sie selbst enthalten einen anderen Datenstream weiter unten in der Filterkette, an die sie alle Methodenaufrufe abgeben. Sie implementieren nichts Neues, gestatten aber ihr eigenes Verschachteln:
InputStream s = getAnInputStreamFromSomewhere();
FilterInputStream s1 = new FilterInputStream(s);
FilterInputStream s2 = new FilterInputStream(s1);
FilterInputStream s3 = new FilterInputStream(s2);
... s3.read() ...
Wenn eine Leseoperation auf den gefilterten Datenstream s3 ausgeführt wird, wird die Anfrage s2 übergeben. Dann macht s2 genau das gleiche wie s1, und schließlich wird s aufgefordert, die Bytes bereitzustellen. Unterklassen von FilterInputStream führen eine gewisse Verarbeitung der durchfließenden Bytes aus. Diese im obigen Beispiel eher umständliche »Verkettung« kann eleganter geschrieben werden:
s3 = new FilterInputStream(new FilterInputStream(new FilterInputStream(s)));
Sie sollten diese Form soweit möglich immer in Ihrem Code verwenden. Sie drückt die Verschachtelung verketteter Filter deutlich aus. Außerdem kann sie leicht analysiert und »laut gelesen« werden, indem man ab dem innersten Datenstream s liest, bis man den äußersten Datenstream s3 erreicht.
Im nächsten Abschnitt betrachten wir die Unterklassen von FilterInputStream und die jeweiligen Gegenstücke von Reader.
Das sind zwei der nützlichsten Datenstreams. Sie implementieren die vollen Fähigkeiten der Methoden von InputStream und Reader, jedoch durch Verwendung eines gepufferten Byte- bzw. Zeichenarrays, der sich als Cache für weitere Leseoperationen verhält. Dadurch werden die gelesenen »Stückchen« von den größeren Blöcken, in denen Datenstreams am effizientesten gelesen werden (z.B. von Peripheriegeräten, Dateien im Dateisystem oder im Netz), abgekoppelt. Ferner ermöglicht es den Datenstreams, Daten vorauszulesen.
Da das Puffern von BufferedInputStream (BufferedReader) so hilfreich ist, und das auch die einzigen Klassen sind, die mark() und reset() richtig abarbeiten, möchte man sich wünschen, daß jeder Eingabestream (Reader) diese wertvollen Fähigkeiten irgendwie nutzt. Normalerweise hat man kein Glück, weil diese Datenstreamklassen sie nicht implementieren. Sie haben aber bereits eine Möglichkeit gesehen, durch die sich Filterstreams um andere Datenstreams »herumwickeln« können. Wenn ein gepufferter FileInputStream (FileReader) korrekt markieren und zurücksetzen soll, schreiben Sie folgendes:
InputStream s = new BufferedInputStream(new FileInputStream("foo"));
Reader r = new BufferedReader(new FileReader("foo"));
Damit haben Sie einen gepufferten Eingabestream auf der Grundlage der Datei »foo«, die mark() und reset() unterstützt.
Darüber hinaus hat BufferedReader eine spezielle Methode, um eine Zeichenzeile (die mit '\r', '\n' oder '\r\n' endet) zu lesen:
BufferedReader r = new BufferedReader(new FileReader("foo"));
String line = r.readLine(); // Nächste Eingabezeile lesen
Jetzt wird die Leistung verschachtelter Datenstreams langsam klar. Jede von einem gefilterten Datenstream bereitgestellte Fähigkeit kann durch Verschachtelung von einem anderen Datenstream genutzt werden. Selbstverständlich ist durch Verschachtelung der Filterstreams jede Kombination dieser Fähigkeiten in jeder beliebigen Reihenfolge möglich.
Alle Methoden dieser Klasse sind in einer separaten Schnittstelle definiert, die von DataInputStream und RandomAccessFile (einer weiteren Klasse in java.io) implementiert wird. Diese Schnittstelle ist allgemein, so daß Sie sie in Ihren eigenen Klassen benutzen können. Sie heißt DataInput.
Wenn Sie häufigen Gebrauch von Datenstreams machen, werden Sie bald feststellen, daß Byte-Streams kein Format bieten, in das alle Daten eingezwängt werden können. Vor allem die primitiven Typen der Java-Sprache können in den bisher behandelten Datenstreams nicht gelesen werden. Die DataInput-Schnittstelle spezifiziert höhere Methoden zum Lesen und Schreiben, die komplexere Datenstreams unterstützen. Diese Schnittstelle definiert folgende Methoden:
void readFully(byte[] bbuffer) throws IOException;
void readFully(byte[] bbuffer, int offset, int length) throws IOException;
int skipBytes(int n) throws IOException;
boolean readBoolean() throws IOException;
byte readByte() throws IOException;
int readUnsignedByte() throws IOException;
short readShort() throws IOException;
int readUnsignedShort() throws IOException;
char readChar() throws IOException;
int readInt() throws IOException;
long readLong() throws IOException;
float readFloat() throws IOException;
double readDouble() throws IOException;
String readLine() throws IOException;
String readUTF() throws IOException;
Die ersten drei Methoden sind lediglich neue Bezeichnungen für skip() und die zwei vorher behandelten Formen von read(). Die nächsten zehn Methoden lesen einen Primitivtyp bzw. dessen vorzeichenloses Gegenstück (nützlich für die effiziente Verwendung aller Bits in einem Binärstream). Diese Methoden müssen eine Ganzzahl mit einer breiteren Größe ausgeben. Da Ganzzahlen in Java Vorzeichen haben, passen die vorzeichenlosen Werte nicht in etwas, das kleiner ist. Die letzten zwei Methoden lesen eine neue Zeile ('\r', '\n' oder '\r\n') aus dem Datenstream beendete Zeichenketten (die erste in ASCII und die zweite in Unicode UTF-8).
Da Sie nun wissen, wie die von DataInputStream implementierte Schnittstelle aussieht, betrachten wir sie in Aktion:
DataInputStream s = new DataInputStream(getNumericInputStream());
long size = s.readLong(); // Anzahl Elemente im Datenstream
while (size-- > 0) {
if (s.readBoolean()) { // Soll ich dieses Element verarbeiten?
int anInteger = s.readInt();
int magicBitFlags = s.readUnsignedShort();
double aDouble = s.readDouble();
if ((magicBitFlags & 0100000) != 0) {
. . . // Hohe Bitmenge, etwas Besonderes damit anfangen
}
. . . // Verarbeite anInteger und aDouble
}
}
Die Klasse implementiert eine Schnittstelle für alle ihre Methoden, deshalb können Sie auch folgende Schnittstelle verwenden:
DataInput d = new DataInputStream(new FileInputStream("Irgendwas"));
String line;
while ((line = d.readLine()) != null) {
. . . // Verarbeite die Zeile
}
Auf die meisten Methoden von DataInputStream trifft folgendes zu: Wird das Ende des Datenstreams erreicht, werfen sie EOFException aus. Das ist sehr hilfreich, denn es ermöglicht Ihnen, alle Verwendungen von -1 in den bisherigen Beispielen besser zu schreiben:
DataInputStream s = new DataInputStream(getAnInputStreamFromSomewhere());
try {
while (true) {
byte b = (byte) s.readByte();
. . . // Verarbeite Byte b
}
} catch (EOFException e) {
. . . // Datenstreamende erreicht
}
Das funktioniert bei allen read-Methoden von DataInputStream außer den letzten zwei.
In einem Editor oder Debugger ist die Zeilennumerierung wichtig. Um diese Fähigkeit in Ihrem Programm anzuwenden, benutzen Sie den Filterstream LineNumberInputStream (oder LineNumberReader). Er kann sich sogar eine Zeilennummer merken und später in mark() und reset() zurückschreiben. Sie können diese Klasse wie folgt verwenden:
LineNumberReader r = new LineNumberReader(new FileReader("source.utf8"));
LineNumberInputStream aLNIS;
aLNIS = new LineNumberInputStream(new FileInputStream("source"));
DataInputStream s = new DataInputStream(aLNIS);
String bline, cline;
while ((bline = s.readLine()) != null && (cline = r.readLine()) != null) {
. . . // Verarbeite die Zeilen
System.out.println("Did byte line number: " + aLNIS.getLineNumber());
System.out.println("Did char line number: " + r.getLineNumber());
}
Hier werden zwei Filtereingabestreams in FileInputStream verschachtelt. Der erste liest die Zeilen nacheinander, und der zweite verfolgt die Nummern dieser Zeilen. Der innere Filter aLNIS muß explizit benannt werden. Andernfalls können Sie später getLineNumber() nicht aufrufen. Wenn Sie die Reihenfolge der verschachtelten Datenstreams umkehren, kann LineNumberInputStream beim Lesen von DataInputStream die Zeilen nicht »sehen«.
Filterstreams müssen quasi als »Überwacher« in der Mitte der Kette stehen und die Daten aus dem äußersten Filterstream herausziehen, damit die Daten alle Überwacher nacheinander durchlaufen. Auch das Puffern sollte möglichst im Zentrum der Kette stattfinden, es sei denn, die meisten Datenstreams, die gepuffert werden müssen, stehen weiter hinten im Fluß. Im folgenden Beispiel sehen Sie eine unsinnige Reihenfolge:
new BufferedInputStream(new LineNumberInputStream(
new DataInputStream(new FileInputStream("foo"));
Die Reihenfolge im nächsten Beispiel ist viel besser:
new DataInputStream(new LineNumberInputStream(
new BufferedInputStream(new FileInputStream("foo"));
LineNumberInputStream (und LineNumberReader) kann auch angewiesen werden, setLineNumber() für die seltenen Fälle zu setzen, wenn Sie mehr darüber wissen als Ihr Code.
Die Filterstreamklasse PushbackInputStream (bzw. PushbackReader) wird üblicherweise in Parsern benutzt, um ein einzelnes Byte (Zeichen) der Eingabe (nach dem Lesen) »zurückzuschieben«, während versucht wird, die nächste Aktion zu ermitteln. Das ist eine vereinfachte Version von mark() und reset(). Sie erweitert die InputStream-Standardmethoden um unread(). Wie Sie sich denken können, gibt diese Methode vor, das in ihr durchgereichte Byte (Zeichen) nie gelesen zu haben. Dann übergibt sie dieses Byte (Zeichen) dem nächsten read() als Ausgabewert. In Version 1.1 gibt es neue Methoden zum »Entlesen« (unread) eines ganzen Puffers und eines Teilbereichs. Das bedeutet, daß es jetzt drei Formen von unread() gibt, die den drei Standardformen von read() entsprechen.
Das folgende Beispiel ist eine einfache Implementierung von readLine() anhand dieser Klasse (eine Anpassung der Implementierung aus DataInputStream.java):
public class SimpleLineReader {
private FilterInputStream s;
public SimpleLineReader(InputStream anIS) {
s = new DataInputStream(anIS);
}
. . . // Weitere read()-Methoden mit Datenstream s
public String readLine() throws IOException {
char[] buffer = new char[100];
int offset = 0;
byte thisByte;
try {
loop: while (offset < buffer.length) {
switch (thisByte = (byte) s.read()) {
case '\n':
break loop;
case '\r':
byte nextByte = (byte) s.read();
if (nextByte != '\n') {
if (!(s instanceof PushbackInputStream)) {
s = new PushbackInputStream(s);
}
((PushbackInputStream) s).unread(nextByte);
}
break loop;
default:
buffer[offset++] = (char) thisByte;
break;
}
}
} catch (EOFException e) {
if (offset == 0)
return null;
}
return String.copyValueOf(buffer, 0, offset);
}
}
Das zeigt verschiedene Dinge auf. In diesem Beispiel ist readLine() auf das Lesen der ersten 100 Zeichen der Zeilen begrenzt. (In dieser Hinsicht wird aufgezeigt, wie ein allgemeiner Zeilenverarbeiter nicht geschrieben werden sollte wir wollen ja Zeilen jeder Größe lesen.) Außerdem werden wir daran erinnert, daß wir mit break aus einer äußeren Schleife ausbrechen können und wie eine Zeichenkette (string) von einem Zeichenarray erzeugt wird (in diesem Fall aus einer »Scheibe« des Zeichenarrays). In diesem Beispiel wird auch die Standardverwendung von read() in InputStream zum Lesen der einzelnen Bytes aufgezeigt. Das Stream-Ende wird durch Einbinden in DataInputStream und catch EOFException festgelegt.
Ein ungewöhnlicher Aspekt ist bei diesem Beispiel die Art, wie PushbackInputStream verwendet wird. Um sicher zu sein, daß '\n' nach '\r' ignoriert wird, muß ein Zeichen vorausgelesen werden. Ist dieses Zeichen kein '\n', muß es zurückgeschoben werden. Wir betrachten die Quellzeilen, beginnend mit if (...instanceof...), als ob wir nichts über den Datenstream s wüßten. Die allgemein angewandte Technik ist lehrreich. Erstens sehen wir, ob s bereits eine Instanz (instanceof) der einen oder anderen Art von PushbackInputStream ist. Trifft das zu, können wir ihn direkt verwenden. Andernfalls wird der aktuelle Datenstream (egal welcher) in einen neuen PusbackInputStream gesetzt, und dieser neue Datenstream wird verwendet.
Die nächste Zeile möchte die Methode unread() aufrufen. FilterInputStream von s ist aber ein Kompilierzeittyp und versteht somit diese Methode nicht. Die zwei vorherigen Zeilen gewährleisten jedoch, daß PushbackInputStream der Laufzeittyp des Datenstreams in s ist, so daß Sie ihn problemlos in diesen Typ umwandeln und unread() aufrufen können.
Bisher wurden noch nicht alle Unterklassen von FilterInputStream beschrieben. Nun ist es an der Zeit, zu den direkten Unterklassen von InputStream zurückzukehren.
Nachdem Sie ein komplexes Geflecht aus untereinander verbundenen Objekten erstellt haben, ist oft die Möglichkeit nützlich, den Zustand all dieser Objekte gleichzeitig »speichern« zu können. Das vereinfacht das Kopieren zu Zwecken wie Backup oder Rückgängigmachen und Wiederherstellen. Außerdem bleiben die Objekte im Dateisystem erhalten und können später wieder »zum Leben erweckt« werden. Darüber hinaus können Objekte im Internet gemeinsam »auf die Reise gehen« und sicher am anderen Ende ankommen. (Sie können bereits ganze Klassen auf diese Weise senden und somit neue Instanzen am anderen Ende erstellen. Möchten Sie aber den Inhalt eines lokalen Objekts übertragen, brauchen Sie etwas Neues.)
JDK 1.1 führt das neue Konzept der Serilisation ein. Das bedeutet im wesentlichen, daß ein Objekt leicht und sicher in einen Datenstream und wieder zurück verwandelt werden kann. In Verbindung mit seiner »Bruderklasse« ObjectOutputStream bewirkt ObjectInputStream genau das. Aus Sicherheitsgründen werden zur Serialisation nur Objekte zugelassen, die zum Austausch zwischen Systemen als »sicher« deklariert wurden. Solche Objekte sind Instanzen von Klassen, die die neue Schnittstelle Serializable implementieren. Die meisten internen Systemklassen implementieren Serializable nicht, wohl aber viele der mehr »informativen« Klassen. In Anhang B sind diese Klassen durch eine gestrichelte Linie gekennzeichnet, was bedeutet, daß sie mit dieser neuen Schnittstelle verknüpft sind.
Alle Methoden, die Instanzen dieser Klasse verstehen, sind in der getrennten Schnittstelle ObjectInput definiert, die ObjectInputStream implementiert.
Die Schnittstelle ObjectInput erweitert die Schnittstelle DataInput, wobei sie alle ihre Methoden erbt und darüber hinaus eine neue Methode der oberen Ebene bereitstellt, die einen komplexen Typenstream serialisierter Objektdaten unterstützt:
Object readObject() throws ClassNotFoundException, IOException;
Im folgenden einfachen Beispiel wird ein solcher Datenstream gelesen, der im »Bruderbeispiel« (ObjectOutputStream) in einem späteren Beispiel der heutigen Lektion produziert wird:
FileInputStream s = new FileInputStream("objectFileName");
ObjectInputStream ois = new ObjectInputStream(s);
int i = ois.readInt(); // Benutzt die DataInput-Methode
String today = (String) ois.readObject();
Date date = (Date) ois.readObject();
s.close();
Diese Klassen und ihre Schwestern PipedOutputStream und PipedReader werden später in der heutigen Lektion behandelt. Vorläufig genügt zu wissen, daß sie zusammen eine einfache zweiwegige Kommunikation zwischen Threads ermöglichen.
Soll aus zwei Datenstreams ein zusammengesetzter Datenstream gebildet werden, wird SequenceInputStream verwendet:
InputStream s1 = new FileInputStream("theFirstPart");
InputStream s2 = new FileInputStream("theRest");
InputStream s = new SequenceInputStream(s1, s2);
... s.read() ... // Liest nacheinander aus jedem Datenstream
Wir hätten das gleiche Ergebnis durch abwechselndes Lesen der Dateien auch »simulieren« können. Was aber, wenn wir den zusammengesetzten Datenstream s einer andere Methode übergeben wollen, die nur einen InputStream erwartet? Hier ein Beispiel (mit s), bei dem die Zeilen der zwei vorherigen Dateien durch ein übliches Numerierungsschema numeriert werden:
LineNumberInputStream aLNIS = new LineNumberInputStream(s);
... aLNIS.getLineNumber() ...
Wenn Sie mehr als zwei Datenstreams verketten wollen, versuchen Sie es so:
Vector v = new Vector();
. . . // Setze alle Datenstreams und füge jeden einzelnen zum Vektor hinzu
InputStream s1 = new SequenceInputStream(v.elementAt(0), v.elementAt(1));
InputStream s2 = new SequenceInputStream(s1, v.elementAt(2));
InputStream s3 = new SequenceInputStream(s2, v.elementAt(3));
. . .
Viel einfacher ist aber die Verwendung eines anderen Konstruktors, den SequenceInputStream bietet:
InputStream s = new SequenceInputStream(v.elements());
Hierfür ist eine Aufzählung aller Datenstreams erforderlich, die kombiniert werden sollen. Im Anschluß wird ein einzelner Datenstream zurückgegeben, der die Daten nacheinander liest.
StringBufferInputStream (StringReader) ist genau wie ByteArrayInputStream (CharArrayReader), basiert aber nicht auf einem Byte- bzw. Zeichenarray, sondern auf einer Zeichenkette:
String buffer = "Now is the time for all good men to come...";
InputStream s = new StringBufferInputStream(buffer);
Reader r = new StringReader(buffer);
Alle Kommentare zu ByteArrayInputStream (CharArrayReader) treffen auch hier zu (siehe ersten Abschnitt über diese Klassen).
Ausgabedatenstreams und Writer werden fast ausnahmslos mit einem brüderlichen InputStream (bzw. Reader) gepaart. Führt ein InputStream (Reader) eine bestimmte Operation aus, wird die umgekehrte Operation vom OutputStream (Writer) ausgeführt. Was das bedeuten soll, sehen Sie in Kürze.
OutputStream ist die abstrakte Klasse, die die grundlegenden Arten definiert, in der eine Quelle (Erzeuger) einen Bytestream in ein Ziel schreiben kann. Die Identität des Ziels und die Art der Beförderung und Speicherung der Bytes sind nicht relevant. Bei der Verwendung eines Ausgabestreams sind Sie die Quelle der Bytes. Das ist alles, was Sie wissen müssen.
Writer ist eine abstrakte Klasse, die die grundlegenden Arten definiert, in der eine Quelle (Erzeuger) einen Bytestream in ein Ziel schreiben kann. Sie und alle ihre Unterklassen entsprechen OutputStream und ihren Unterklassen, ausgenommen, daß sie als Grundeinheiten nicht Bytes, sondern Zeichen verwenden.
Die wichtigste Methode für den Erzeuger eines Ausgabestreams (oder Writers) ist diejenige, die Bytes (Zeichen) in das Ziel schreibt. Diese Methode ist write(), die es in verschiedenen Varianten gibt, wie Sie in den folgenden Beispielen sehen werden.
OutputStream s = getAnOutputStreamFromSomewhere();
Writer w = getAWriterFromSomewhere();
byte[] bbuffer = new byte[1024]; // Größe kann beliebig sein
char[] cbuffer = new byte[1024];
fillInData(bbuffer); fillInData(cbuffer); // Die Daten, die wir ausgeben
// wollen
s.write(bbuffer);
w.write(cbuffer);
Sie können auch ein »Scheibchen« Ihres Puffers schreiben, indem Sie den Versatz und die gewünschte Länge als Argumente für write() angeben:
s.write(bbuffer, 100, 300);
w.write(cbuffer, 100, 300);
Dadurch werden die Bytes (Zeichen) 100 bis 399 ausgegeben. Ansonsten ist das Verhalten genauso wie bei der vorherigen write()-Methode. Im derzeitigen Release benutzt die Standardimplementierung der ersten Form von write() die zweite Alternative:
public void write(byte[] b) throws IOException { /* Von OutputStream.java */
write(b, 0, b.length);
}
public void write(char[] cbuf) throws IOException { /* Von Writer.java */
write(cbuf, 0, cbuf.length);
}
Letztlich können Sie Bytes einzeln ausgeben:
while (thereAreMoreBytesToOutput() && thereAreMoreCharsToOutput()) {
byte b = getNextByteForOutput();
char c = getNextCharForOutput();
s.write(b);
w.write(c);
}
Da wir nicht wissen, mit was ein Ausgabestream (Writer) verbunden ist, können wir mit flush() die Leerung der Ausgabe durch einen gepufferten Cache anfordern, um sie (zeitgerecht oder überhaupt) zu erhalten. Die OutputStream-Version dieser Methode bewirkt nichts. Von ihr wird lediglich erwartet, diese Version durch Unterklassen, die flush voraussetzen (z.B. BufferedOutputStream und PrintStream) mit nichttrivialen Aktionen zu überschreiben.
Wie bei InputStream (oder Reader) sollte ein OutputStream normalerweise explizit geschlossen werden, damit die von ihm beanspruchten Ressourcen freigegeben werden. Im übrigen trifft alles zu, was über close() in Zusammenhang mit InputStream gesagt wurde.
Alle Ausgabestreams stammen von der abstrakten Klasse OutputStream, und alle Writer stammen von Writer ab und haben die oben beschriebenen Methoden.
Das Gegenstück von ByteArrayInputStream (CharArrayReader), das einen Eingabestream für ein Byte- bzw. Zeichenarray erzeugt, ist ByteArrayOutputStream (CharArrayWriter), der einen Ausgabestream an ein Byte- oder Zeichenarray übergibt:
OutputStream s = new ByteArrayOutputStream();
Writer w = new CharArrayWriter();
s.write(123);
w.write('\n');
. . .
Die Größe eines internen Byte- bzw. Zeichenarrays wächst nach Bedarf, um einen Datenstream jeder beliebigen Länge zu speichern. Sie können auf Wunsch eine Anfangskapazität als Hilfe für die Klasse festlegen:
OutputStream s = new ByteArrayOutputStream(1024 * 1024); // 1 Megabyte
Writer w = new CharArrayWriter(1024 * 1024);
Nachdem ByteArrayOutputStream s (bzw. CharArrayWriter w) gefüllt wurde, kann er Daten an einen anderen Ausgabestream (oder Schreiber) ausgeben:
OutputStream anotherOutputStream = getTheOtherOutputStream();
Writer anotherWriter = getTheOtherWriter();
ByteArrayOutputStream s = new ByteArrayOutputStream();
CharArrayWriter w = new CharArrayWriter();
fillWithUsefulData(s); fillWithUsefulData(w);
s.writeTo(anotherOutputStream);
w.writeTo(anotherWriter);
Außerdem kann er als Byte- oder Zeichenarray herausgezogen oder in eine Zeichenkette (string) konvertiert werden:
byte[] bbuffer = s.toByteArray();
char[] cbuffer = w.toCharArray();
String streamString = s.toString();
String writerString = w.toString();
String streamUnicodeString = s.toString(upperByteValue);
ByteArrayOutputStream (und CharArrayWriter) haben zwei Utility-Methoden: Eine gibt die aktuelle Anzahl der im internen Byte- bzw. Zeichenarray gespeicherten Bytes (Zeichen) aus, die andere setzt das Array zurück, so daß der Datenstream von Anfang an erneut geschrieben werden kann:
int sizeOfMyByteArray = s.size();
int sizeOfMyCharArray = w.size();
s.reset(); // s.size() würde jetzt 0 zurückgeben
w.reset(); // w.size() würde jetzt 0 zurückgeben
s.write(123);
w.write('\n');
. . .
Eine der häufigsten Verwendungen von Datenstreams und historisch die älteste ist das Anhängen von Datenstreams an Dateien im Dateisystem. Hier wird beispielsweise ein solcher Ausgabestream (oder Writer) auf einem UNIX-System erstellt:
OutputStream s = new FileOutputStream("/Irgendein/Pfad/und/Dateiname");
Writer w = new FileWriter("/Irgendein/Pfad/und/Dateiname.utf8");
Sie können Datenstreams auch aus einem zuvor aktivierten FileDescriptor oder eine Datei (File) erstellen:
OutputStream s = new FileOutputStream(FileDescriptor.out); /* Standardeingabe */
Writer w = new FileWriter(FileDescriptor.err); /* Standardfehler */
OutputStream s = new FileOutputStream(new File("/Irgendein/Pfad/und/Dateiname"));
Writer w = new FileWriter(new File("/Irgendein/Pfad/und/Dateiname.utf8"));
FileWriter ist eigentlich eine triviale Subklasse der weiteren writer-Klasse OutputStreamWriter, die jeden beliebigen OutputStream kapseln und ihn in einem Writer vom Typ char umwandeln kann. Somit besteht die Implementierung von FileWriter lediglich aus der Aufforderung von OutputStreamWriter, (selbst) einen FileOutputStream zu kapseln:
FileOutputStream aFOS = new FileOutputStream("aFileName");
FileDescriptor myFD = aFOS.getFD();
/* aFOS.finalize(); */ // Aktiviere close() bei automatischem Aufruf des Garbage-
//Collectors
Ein Aspekt ist ganz klar: getFD() gibt die Dateiunterschrift (FileDescriptor) zurück, auf der der Datenstream basiert. Der zweite Aspekt ist ein Kommentar, der Sie daran erinnern soll, daß Sie sich um das Schließen dieses Datenstreamtyps keine Gedanken machen müssen. Die Implementierung von finalize() besorgt das automatisch. (Siehe Erläuterungen unter FileInputStream und FileReader.)
Diese »abstrakten« Klassen (in Wirklichkeit ist nur FilterWriter abstrakt) bieten einen »Durchlauf« für alle Standardmethoden von OutputStream (oder Writer). Sie selbst enthalten einen anderen Datenstream weiter unten in der Filterkette, an die sie alle Methodenaufrufe abgeben. Sie implementieren nichts Neues, gestatten aber ihr eigenes Verschachteln:
OutputStream s = getAnOutputStreamFromSomewhere();
FilterOutputStream s1 = new FilterOutputStream(s);
FilterOutputStream s2 = new FilterOutputStream(s1);
FilterOutputStream s3 = new FilterOutputStream(s2);
... s3.write(123) ...
Wenn eine Schreiboperation auf den gefilterten Datenstream s3 ausgeführt wird, wird die Anfrage s2 übergeben. Dann macht s2 genau das gleiche wie s1, und schließlich wird s aufgefordert, die Bytes bereitzustellen. Unterklassen von FilterOutputStream führen eine gewisse Verarbeitung der durchfließenden Bytes aus. Diese im obigen Beispiel eher umständliche »Verkettung« kann eleganter geschrieben werden. Wie das gemacht wird, finden Sie unter der »Bruderklasse« FilterInputStream.
Im nächsten Abschnitt betrachten wir die Unterklassen von FilterOutputStream.
Das sind zwei der nützlichsten Datenstreams. Sie implementieren die vollen Fähigkeiten der Methoden von OutputStream und Writer, jedoch durch Verwendung eines gepufferten Byte- bzw. Zeichenarrays, der sich als Cache für weitere Schreiboperationen verhält. Dadurch werden die geschriebenen »Stückchen« von den größeren Blöcken, in denen Datenstreams am effizientesten geschrieben werden (z.B. in Peripheriegeräten, Dateien im Dateisystem oder im Netz), abgekoppelt.
BufferedOutputStream (BufferedWriter) ist eine der wenigen Klassen der Java-Bibliothek, die eine nichttriviale Version von flush() implementieren. Sie bewirkt, daß die geschriebenen Bytes (Zeichen) durch den Puffer geschoben und auf der anderen Seite ausgegeben werden. Da das Puffern von BufferedOutputStream (BufferedWriter) so hilfreich ist, möchte man sich wünschen, daß jeder Ausgabestream (Writer) diese wertvollen Fähigkeiten irgendwie nutzt. Zum Glück können Sie jeden Ausgabestream (oder Writer) umgehen, um genau das zu erreichen:
OutputStream s = new BufferedOutputStream(new FileOutputStream("foo"));
Writer w = new BufferedWriter (new FileWriter("foo.utf8"));
Damit haben Sie einen gepufferten Ausgabestream (Writer) auf der Grundlage der Datei »foo«, die flush() unterstützt.
Jede von einem gefilterten Ausgabedatenstream (oder Schreiber) bereitgestellte Fähigkeit kann durch Verschachtelung von einem anderen Datenstream genutzt werden. Selbstverständlich ist durch Verschachtelung der Filterstreams jede Kombination dieser Fähigkeiten in jeder beliebigen Reihenfolge möglich.
Alle Methoden dieser Klasse sind in einer separaten Schnittstelle definiert, die von DataOutputStream und RandomAccessFile implementiert wird. Diese Schnittstelle ist allgemein, so daß Sie sie in Ihren eigenen Klassen benutzen können. Sie heißt DataOutput.
In Zusammenhang mit dem Gegenstück DataInput bietet DataOutput höhere Methoden zum Lesen und Schreiben von Daten. Anstatt sich mit Bytes zu befassen, schreibt diese Schnittstelle die primitiven Typen von Java direkt:
void write(int i) throws IOException;
void write(byte[] buffer) throws IOException;
void write(byte[] buffer, int offset, int length) throws IOException;
void writeBoolean(boolean b) throws IOException;
void writeByte(int i) throws IOException;
void writeShort(int i) throws IOException;
void writeChar(int i) throws IOException;
void writeInt(int i) throws IOException;
void writeLong(long l) throws IOException;
void writeFloat(float f) throws IOException;
void writeDouble(double d) throws IOException;
void writeBytes(String s) throws IOException;
void writeChars(String s) throws IOException;
void writeUTF(String s) throws IOException;
Zu den meisten dieser Methoden gibt es DataInput-Gegenstücke.
Die ersten drei Methoden spiegeln lediglich die drei Formen von write() wider, die Sie bereits kennengelernt haben. Die nächsten acht Methoden schreiben jeweils einen primitiven Typ. Die letzten drei Methoden schreiben eine aus Bytes oder Zeichen bestehende Zeichenkette in den Datenstream: die erste als 8-Bit-Bytes, die zweite als 16-Bit-Zeichen im binären Unicode und die dritte als speziellen Unicode-Datenstream (UTF-8) (der von readUTF() in DataInput gelesen werden kann).
Da Sie nun wissen, wie die von DataOutputStream implementierte Schnittstelle aussieht, betrachten wir sie in Aktion:
DataOutputStream s = new DataOutputStream(getNumericOutputStream());
long size = getNumberOfItemsInNumericStream();
s.writeLong(size);
for (int i = 0; i < size; ++i) {
if (shouldProcessNumber(i)) {
s.writeBoolean(true); // Sollte dieses Element verarbeiten
s.writeInt(theIntegerForItemNumber(i));
s.writeShort(theMagicBitFlagsForItemNumber(i));
s.writeDouble(theDoubleForItemNumber(i));
} else
s.writeBoolean(false);
}
Das ist das genaue Gegenstück des mit DataInput aufgeführten Beispiels. Zusammen bilden sie ein Paar, das ein bestimmtes strukturiertes Primitivtypen-Array über jeden Datenstream (bzw. die Transportschicht) austauschen kann. Verwenden Sie dieses Paar als Sprungbrett für ähnliche Aktionen.
Zusätzlich zur obigen Schnittstelle implementiert die Klasse eine (selbsterklärende) Utility-Methode:
int theNumberOfBytesWrittenSoFar = s.size();
Zu den häufigsten Ein- und Ausgabeoperationen zählen das Öffnen einer Datei, das zeilenweise Lesen und Verarbeiten und das Ausgeben dieser Daten in eine andere Datei. Das folgende Beispiel ist ein Prototyp dessen, wie dies in Java realisiert wird:
DataInput aDI = new DataInputStream(new FileInputStream("source"));
DataOutput aDO = new DataOutputStream(new FileOutputStream("dest"));
String line;
while ((line = aDI.readLine()) != null) {
StringBuffer modifiedLine = new StringBuffer(line);
. . . // Verarbeite modifiedLine
aDO.writeBytes(modifiedLine.toString());
}
aDI.close();
aDO.close();
Möchten Sie das byteweise verarbeiten, schreiben Sie folgendes:
try {
while (true) {
byte b = (byte) aDI.readByte();
. . . // Verarbeite b
aDO.writeByte(b);
}
} finally {
aDI.close();
aDO.close();
}
Der folgende nette Zweizeiler kopiert die Datei:
try { while (true) aDO.writeByte(aDI.readByte()); }
finally { aDI.close(); aDO.close(); }
Ohne sich möglicherweise dessen bewußt zu sein, sind Sie bereits mit den zwei Methoden der PrintStream-Klasse vertraut. Wenn Sie die Methodenaufrufe
System.out.print(. . .)
System.out.println(. . .)
verwenden, benutzen Sie eigentlich eine Instanz von PrintStream, die sich in der Variablen out der Klasse System befindet, um die Ausgabe auszuführen. System.err gehört ebenfalls zu PrintStream, und System.in ist ein InputStream.
PrintStream ist ein Ausgabestream ohne brüderliches Gegenstück. PrintWriter ist einer von nur zwei Schreibern mit der gleichen Eigenschaft. Da sie normalerweise mit einer Bildschirmausgabe zusammenhängen, implementieren sie flush(). Ferner bieten sie die bekannten Methoden close() und write() sowie eine Fülle von Möglichkeiten zur Ausgabe der primitiven Typen und Zeichenketten von Java:
public void write(int byteOrChar); // byte (PrintStream), char (PrintWriter)
public void write(byte[] buffer, int offset, int length); // PrintStream
public void write(char[] buffer, int offset, int length); // PrintWriter
public void write(String string); // Die nächsten zwei Methoden nur in
// PrintWriter
public void write(String string, int offset, int length); // PrintWriter
public void flush(); // (Alles ab hier ist in beiden Klassen)
public void close();
public void print(Object o);
public void print(String s);
public void print(char[] buffer);
public void print(char c);
public void print(int i);
public void print(long l);
public void print(float f);
public void print(double d);
public void print(boolean b);
public void println(Object o);
public void println(String s);
public void println(char[] buffer);
public void println(char c);
public void println(int i);
public void println(long l);
public void println(float f);
public void println(double d);
public void println(boolean b);
public void println(); // Leerzeile ausgeben
PrintStream (PrintWriter) kann auch benutzt werden, um einen Ausgabestream zu umwickeln wie eine Filterklasse (trotz der Tatsache, daß PrintWriter keine Unterklasse von FilterWriter ist, läßt sie sich wie eine verschachteln):
PrintStream s = new PrintStream(new FileOutputStream("foo"));
PrintWriter w = new PrintWriter(new FileWriter("foo.utf8"));
s.println("Das ist die erste Textzeile der Datei foo.");
w.println("Das ist die erste Textzeile der Datei foo.utf8.");
Ein zweites Argument für den Konstruktor von PrintStream (oder PrintWriter) ist boolesch und bestimmt, ob der Datenstream automatisch »flushen« soll. Im Fall von true (wahr) wird nach jedem Zeichen, das eine neue Zeile setzt ('\n'), ein flush() gesendet. Bei der Form von write() mit drei Argumenten wird nach jeder Zeichengruppe ein flush() gesendet. PrintWriter handhabt das automatische Flush ein wenig anders flush() wird nur nach dem Aufruf einer der Methoden println(...) gesetzt.
Das folgende kleine Programm arbeitet wie der UNIX-Befehl cat. Es nimmt die Standardeingabe zeilenweise entgegen und gibt sie auf der Standardausgabe aus:
import java.io.*; // Das schreiben wir heute nur hier
public class Cat {
public static void main(String argv[]) {
DataInput d = new DataInputStream(System.in);
String line;
try { while ((line = d.readLine()) != null)
System.out.println(line);
} catch (IOException ignored) { }
}
}
Damit wurden nun alle Unterklassen von FilterOutputStream beschrieben. Wir wenden uns jetzt den direkten Unterklassen von OutputStream zu.
Diese und die Bruderklasse ObjectInputStream unterstützen die Serialisation von Objekten (weitere Einzelheiten hierzu finden Sie unter ObjectInputStream). Alle Methoden, die Instanzen dieser Klasse verstehen, sind in der getrennten Schnittstelle ObjectOutput definiert, die ObjectOutputStream implementiert.
Diese Schnittstelle erweitert die Schnittstelle DataOutput, wobei sie alle ihre Methoden erbt und darüber hinaus eine neue Methode der oberen Ebene bereitstellt, die einen komplexen Typenstream serialisierter Objektdaten unterstützt:
void writeObject(Object obj) throws IOException;
Im folgenden einfachen Beispiel wird ein Datenstream geschrieben, der im »Bruderbeispiel« (ObjectInputStream) in einem früheren Beispiel der heutigen Lektion gelesen wurde:
FileOutputStream s = new FileOutputStream("objectFileName");
ObjectOutputStream oos = new ObjectOutputStream(s);
oos.writeInt(12345); // Benutzt die DataOutput-Methode
oos.writeObject("Today");
oos.writeObject(new Date());
oos.flush();
s.close();
Diese und die Klassen PipedInputStream (PipedReader) bilden zusammen die Paare, die eine UNIX-artige Pipe-Verbindung zwischen zwei Threads herstellen und sorgfältig die gesamte Synchronisation implementieren, die eine sichere Operation dieser Art von gemeinsamer Warteschlange ermöglicht. Die Verbindung wird so eingerichtet:
PipedInputStream sIn = new PipedInputStream();
PipedOutputStream sOut = new PipedOutputStream(sIn);
PipedReader wIn = new PipedReader();
PipedWriter wOut = new PipedWriter(wIn);
Ein Thread schreibt sOut (wOut), und der andere liest von sIn (wIn). Durch Einrichten solcher Paare können die Threads in beiden Richtungen problemlos kommunizieren.
Die übrigen Klassen und Schnittstellen in java.io ergänzen die Datenstreams, so daß ein komplettes Ein-/Ausgabesystem bereitgestellt wird. Drei davon werden im folgenden beschrieben.
Die Klasse File abstrahiert eine Datei auf plattformunabhängige Weise. Mit einem Dateinamen kann sie auf Anfragen über Typ, Status und Eigenschaften einer Datei oder eines Verzeichnisses im Dateisystem reagieren.
Anhand einer Datei, eines Dateinamens oder eines Zugriffsmodus (»r« oder »rw«) wird eine RandomAccessFile erzeugt. Sie umfaßt Implementierungen von DataInput und DataOutput in einer Klasse, jeweils auf »Zufallszugriff« auf eine Datei im Dateisystem abgestimmt. Zusätzlich zu diesen Schnittstellen bietet RandomAccessFile bestimmte herkömmliche Einrichtungen nach UNIX-Art, z.B. seek() zum Suchen eines Zufallspunkts in einer Datei.
Die Klasse StreamTokenizer greift einen Eingabestream (oder Leser) heraus und erzeugt daraus eine Folge von Token. Durch Überladen verschiedener darin enthaltener Methoden in Ihren Unterklassen können Sie starke lexikale Parser erstellen.
In der API-Beschreibung Ihres Java-Release finden Sie (online) weitere Informationen über diese Klassen.
Heute haben Sie das allgemeine Konzept von Datenstreams gelernt und Beispiele mit Eingabestreams und Lesern auf der Grundlage von Byte-Arrays, Dateien, Pipes, anderen Datenstreamfolgen und Zeichenkettenpuffern sowie Eingabefiltern, Dateneingaben, Zeilennumerierung und Zurückschieben von Zeichen durchgearbeitet.
Sie haben auch die Gegenstücke dazu die Ausgabestreams und Schreiber für Bytearrays, Dateien, Pipes und Ausgabefilter zum Schreiben typisierter Daten und Ausgabefilter kennengelernt.
Sie haben sich in dieser Lektion Kenntnisse über die grundlegenden Methoden aller Datenstreams (z.B. read() und write()) und einige spezielle Methoden angeeignet. Sie haben das Auffangen (catch()) von Ausnahmen, insbesondere EOFException, gelernt.
Sie haben gelernt, mit den doppelt nützlichen Schnittstellen DataInput und DataOutput umzugehen, die den Kern von RandomAccessFile bilden.
Java-Datenstreams bieten eine starke Grundlage, auf der Sie Multithreading-/Streaming-Schnittstellen der komplexesten Art entwickeln können, die in Browsern (z.B. Microsoft Internet Explorer oder Netscape Navigator) interpretiert werden. Die höheren Internet-Protokolle und -Dienste, für die Sie künftig Ihre Applets schreiben können, sind im Prinzip nur durch Ihre Vorstellungskraft beschränkt.
F In einem früheren read()-Beispiel haben Sie meiner Meinung nach mit der Variablen byteOrMinus1 etwas Plumpes angestellt. Gibt es dafür keine bessere Art? Und falls nicht, warum haben Sie in einem späteren Abschnitt die Umwandlung empfohlen?
A Stimmt, diese Anweisungen haben wirklich etwas Schwerfälliges an sich. Man ist versucht, statt dessen etwa folgenden Code zu schreiben:
while ((b = (byte) s.read()) != -1) {
. . . // Verarbeite Byte b
}
Das Problem bei dieser Kurzform entsteht, wenn read() den Wert 0xFF (0377) zurückgibt. Da dieser Wert vor der Ausgabe ein verlängertes Vorzeichen erhält, erscheint er genauso wie der ganzzahlige Wert -1, der das Datenstreamende bezeichnet. Nur durch Speichern dieses Wertes in einer getrennten ganzzahligen Variablen und späteres Umwandeln erreicht man das gewünschte Ergebnis. Ich habe die Umwandlung in byte aus Konsistenzgründen empfohlen. Das Speichern ganzzahliger Werte in Variablen mit korrekter Größe entspricht immer einem guten Stil (abgesehen davon sollte read() hier eine byte-Größe zurückgeben und für das Datenstreamende eine Ausnahme auswerfen).
F Welche Eingabedatenstreams in java.io implementieren nun eigentlich mark(), reset() und markSupported()?
A InputStream und seine Standardimplementierungen geben für markSupported() false zurück, machen bei mark() nichts und werfen durch reset() eine Ausnahme aus. Der einzige Eingabedatenstream, der in der derzeitigen Version die Kennzeichnung korrekt unterstützt, ist BufferedInputStream, der diese Vorgaben übergeht. LineNumberInputStream implementiert mark() und reset(), jedoch erfolgt auch in Version 1.1 auf markSupported() keine richtige Antwort. Die neuen Reader-Klassen in Version 1.1 handhaben markSupported() in allen Fällen korrekt, und weitere Klassen unterstützen nichttriviales mark() und reset().
F Wozu soll available() nützlich sein, wenn es manchmal die falsche Antwort ausgibt?
A Erstens muß man zugeben, daß es bei vielen Datenstreams richtig reagiert. Zweitens kann seine Implementierung bei manchen Netzdatenstreams eine spezielle Anfrage senden, um bestimmte Informationen aufzudecken, die Sie andernfalls nicht einholen können (z.B. die Größe einer über ftp übertragenen Datei). Würden Sie einen Verlaufsbalken für das Downloading oder die Übertragung von Dateien anzeigen, gäbe available() beispielsweise die Gesamtgröße der Übertragung zurück bzw. andernfalls 0, was für Sie und Ihre Benutzer sichtbar wäre.
F Können Sie mir ein gutes Beispiel für die Verwendung des Schnittstellenpaares DataInput/DataOutput geben?
A Eine übliche Verwendung dieses Schnittstellenpaars ist, wenn sich Objekte selbst zum Speichern oder Befördern über das Netz vorbereiten. Jedes Objekt implementiert Lese- und Schreibmethoden anhand dieser Schnittstellen, so daß sie sich selbst effektiv in einen Datenstream umwandeln, der später am anderen Ende als Kopie des Originalobjekts wiederhergestellt werden kann. Dieser Prozeß kann ab Version 1.1 über die neuen Ein- und Ausgabedatenstreams für Objekte automatisiert werden.
(c) 1997 SAMS