MokaByte 96 - Maggio 2005
  MokaByte 96 - Maggio 2005  
di
Andrea
Gini

 

 

 

IO in Java
II Parte: gli stream orientati ai caratteri

Il mese scorso sono stati introdotti gli stream orientati ai byte, gli elementi base della comunicazione tra il computer e i suoi dispositivi. Questo mese verranno analizzati gli stream orientati ai caratteri, che rispetto ai precedenti, risultano più facili da usare se si desidera trasmettere stringhe di caratteri Unicode. Come verrà illustrato, esiste una vasta gerarchia di Reader e Writer, specializzati per vari usi. Infine verrà illustrata la serializzazione, un processo che permette di trasmettere attraverso uno stream il contenuto informativo di un oggetto e ricostruirne una copia in un secondo tempo.

Reader e Writer
A partire dalla versione 1.1 del JSDK sono stati inseriti nuovi stream orientati ai caratteri: i Reader e i Writer. Tecnicamente si tratta di stream a tutti gli effetti, ma non esiste un legame di ereditarietà con le classi InputStream e OutputStream: al contrario essi sono a capo di una gerarchia di Reader e Writer simile a quella di InputStream ed OutputStream. La gerarchia di Reader e Writer verrà approfondita nei prossimi paragrafi: prima verrà illustrata l'interfaccia di programmazione delle classi Reader e Writer, simili per molti versi a quelle di InputStream ed OutputStream ma con alcune sottili differenze. Ecco un elenco dei metodi di Reader:

boolean ready()

Controlla se il Rader è pronto per la lettura.

int read()

Legge un singolo carattere e lo restituisce sotto forma di intero a 16 bit (i 16 bit di ordine superiore vengono scartati). In caso sia stata raggiunta la fine dello stream, viene restituito il valore -1.

int read(char[] cbuf)

Legge una serie di caratteri e li restituisce nell'array passato come parametro. Se non ci sono ulteriori caratteri da leggere, il metodo restituisce il valore -1.

long skip(long n)

Salta i successive n caratteri. Il metodo restituisce il numero di caratteri effettivamente saltati.

abstract void close()

Chiude il Reader e rilascia le risorse ad esso associate.
L'interfaccia di programmazione di Writer, per quanto simile a quella di OutputStream, presenta maggiori differenze, visto che dispone di metodi che permettono di scrivere caratteri o di effettuare una write() utilizzando come parametro una String:

Writer append(char c)

Aggiunge il carattere c in coda al Writer. Il metodo restituisce il Writer stesso.

void write(char[] cbuf)

Scrive un array di char nel Writer.

void write(int c)

Scrive un singolo carattere sotto forma di intero a 16 bit (i 16 bit di ordine superiore vongono ignorati)

void write(String str)

Scrive il contenuto della String passata come parametro.

abstract void flush()

Forza lo svuotamento del Writer.

abstract void close()

Effettua una flush(), quindi chiude il Writer e rilascia le risorse ad esso associate.
I metodi di entrambe le classi possono generare una IOException qualora l'operazione incontri problemi di comunicazione.

 

Gerarchia di Reader e Writer e concatenazione tra Stream
Il package java.io contiene numerose sottoclassi concreti delle classe astratte Reader e Writer: le figure 1 e 2 mostrano una rassegna delle più importanti:


Figura 1
- Una rassegna delle principali sottoclassi di Reader.....

 


Figura 2
- ....ed una di quelle di Writer

Alcune di queste classi sono fatte per essere utilizzate così come sono, altre sono studiate per essere usati in congiunzione con altri Reader o Writer, ai quali possono essere collegati in sequenza mediante concatenazione. E' questo ad esempio il caso di BufferedReader, che tipicamente viene usato per bufferizzare un altro tipo di Reader, come FileReader. Per concatenare due Stream è sufficiente specificare nel costruttore del Reader contenitore il reference allo stream contenuto:

BufferedReader in = new BufferedReader(new FileReader("readme.txt"));


Figura 3 - Un esempio di concatenazione tra stream


Nota: Anche gli InputStream e gli OutputStream offrono la possibilità di concatenare vari Stream tra loro. Il processo segue le stesse identiche regole, e per questo motivo non è stato presentato prima, data la scelta di concentrare la presente trattazione sugli stream orientati ai caratteri. Una semplice consultazione della Javadoc permetterà a qualunque programmatore che abbia compreso il meccanismo di operare senza alcun problema concatenazioni tra Stream.
Nei prossimi paragrafi verranno illustrate le principali sottoclassi di Reader e Writer.

 

InputStreamReader ed OutputStreamWriter
Le classi InputStreamReader e OutputStreamWriter servono a concatenare gli stream orientati ai caratteri con quelli orientati ai byte. Non bisogna infatti dimenticare che, per quanto Java ricorra al sistema di codifica dei caratteri Unicode a 16 bit, le periferiche di Input/Output principali, come il disco, la rete e la stampante, operano ancora su byte. La conversione in byte di caratteri Unicode segue una serie di regole non banali, incorporate in questi stream, che operano una vera e propria traduzione dal mondo dei byte a quello dei char e viceversa. I metodi di questa coppia di stream sono esattamente gli stessi di Reader e Writer; l'unica particolarità da segnalare sono i costruttori, che richiedono il passaggio di un apposito stream verso il quale viene operata la concatenazione:

InputStreamReader(InputStream in)
OutputStreamWriter(OutputStream out)

 

FileReader e FileWriter
Gli stream a caratteri più utilizzati in assoluto sono quelli che permettono di leggere e scrivere su File. Tali stream non hanno metodi diversi da quelli presenti sugli stream di base. I costruttori invece accettano come argomento un file o una stringa che descrive il percorso verso un file:

FileReader(File file)
FileReader(String fileName)

FileWriter(File file)
FileWriter(String fileName)

FileWriter dispone di un'altra coppia di costruttori che accettano un'ulteriore parametro booleano append, che permette di specificare se si desidera che il file venga riaperto e prolungato (true) o sovrascritto (false):

FileWriter(File file, boolean append)
FileWriter(String fileName, boolean append)
BufferedReader e BufferedWriter (LineNumberReader)

Gli stream BufferedReader e BufferedWriter dispongono al loro interno di un buffer, ossia di una piccola memoria cache che in molti casi sveltisce la comunicazione. I costruttori di queste classi richiedono la specifica di uno stream da concatenare. Molto spesso essi vengono usati in abbinamento a FileReader e FileWriter:

BufferedReader(Reader in)
BufferedWriter(Writer out)

Il BufferedReader dispone di un metodo readLine() che restituisce una String corrispondente ad un'intera linea di testo. Similmente il BufferedWriter dispone del metodo newLine() che stampa un carattere di invio (che per quanto sembri banale, è diverso a seconda della piattaforma sottostante). La classe BufferedWriter dispone di un'ulteriore sottoclasse, LineNumberReader, che permette di conoscere il numero di linee lette grazie al metodo getLineNumber().
Grazie a FileReader e BufferedReader è possibile scrivere una versione migliorata del programma di echo presentata nel precedente articolo:

import java.io.*;

public class FileEcho2 {

public static void main(String argv[]) throws Exception {
BufferedReader in = new BufferedReader(new FileReader(argv[0]));

while(true) {
String s = in.readLine();
if(s==null)
break;
else
System.out.println(s);
}
in.close();
}
}

Si noti l'uso concatenato di BufferedReader e FileReader, e l'uso del metodo readLine() che rende molto più pratica la lettura e la stampa dei dati. Infine si noti l'uso del metodo close() alla fine del programma, omesso per semplicità dagli esempi precedenti, che dovrebbe sempre concludere qualunque operazione su stream.

 

StringReader e StringWriter
Lo stream StringReader permette di leggere una stringa attraverso i metodi caratteristici di un Reader:

StringReader(String s)

In modo simile, StringWriter permette di scrivere su uno StringBuffer con i metodi caratteristici della classe Writer. La classe StringWriter dispone di due metodi caratteristici:

StringBuffer getBuffer()
String toString()

Il primo restituisce lo StringBuffer che viene utilizzato come contenitore di dati; il secondo fornisce una rappresentazione in formato String dello StringBuffer stesso.

 

PrintWriter
Il PrintWriter è uno stream ottimizzato per scrivere dati eterogenei su un dispositivo. Esso dispone infatti di una serie di metodi print() che permettono di scrivere su stream qualunque tipo di dato primitivo, oltre alle String. I costruttori permettono di creare PrintWriter che scrivono su un File, descritto attraverso un apposito oggetto File o da un percorso in forma di String, oppure su un OutputStream o da un Writer:

PrintWriter(File file)
PrintWriter(String fileName)
PrintWriter(OutputStream out)
PrintWriter(Writer out)

Una serie di metodi print() permettono di stampare qualsiasi dato di tipo primitivo, o in alternativa un vettore di char, un oggetto String o un generico Object, nel qual caso viene invocato automaticamente il metodo toString();

void print(boolean b)
void print(char c)
void print(double d)
void print(float f)
void print(int i)
void print(long l)
void print(char[] s)
void print(String s)
void print(Object obj)

In secondo luogo viene fornita una serie di metodi println() che stampano sullo stream l'argomento del metodo, seguito da un carattere "a capo":

void println()
void println(boolean x)
void println(char x)
void println(double x)
void println(float x)
void println(int x)
void println(long x)
void println(char[] x)
void println(String x)
void println(Object x)

A partire dalla release 1.5, la classe PrintWriter dispone anche di un metodo printf() simile a quello presente nel C, che permette di stampare un output formattato:

PrintWriter printf(String format, Object... args)

Si noti che il secondo parametro utilizza la nuova sintassi "..." per parametri multipli (cfr [1]). Il metodo richiede come parametro una String che contiene sia il testo da stampare che una serie di direttive, precedute dal carattere %, che servono a specificare il formato in cui devono essere stampati gli oggetti passati nei parametri successivi. Si veda un esempio informale:

out.printf("Il conto corrente numero %d del Signor %s ha un saldo di %f Euro", cc.getNumber(), cc.getOwner(),
cc.getValue());

In questo caso, al momento della stampa, le direttive %d, %s e %f verranno sostituite dai valori dei parametri successivi, sotto forma di numero decimale, stringa e valore floating point decimale con la virgola:

Il conto corrente numero 567042 del Signor Mario Rossi ha un saldo di 2542000,00 Euro

Nelle righe seguenti vengono elencate le direttive permesse dal comando e il relative effetto;

  • '%b' o '%B': l'argomento corrispondente deve essere di tipo boolean o Boolean, il valore stampato pertanto sarà true o false, a seconda del valore dell'oggetto.
  • '%h' o '%H': l'argomento può essere un generico Object, che viene riportato sotto forma di numero esadecimale chiamando il metodo Integer.toHexString(arg.hashCode()).
  • '%s' o '%S': l'argomento corrispondente può essere un oggetto di qualunque tipo: se implementa l'interfaccia java.util.Formattable, allora viene invocato il metodo formatTo(), altrimenti il risultato viene ottenuto dal classico metodo toString().
  • '%c' o '%C': l'argomento deve essere un char o un Character. Il risultato è la stampa del carattere stesso.
  • '%d': il valore deve essere di tipo int, long, Integer o Long, che viene riportato sotto forma di numero decimale.
  • '%o' il valore deve essere di tipo int, long, Integer o Long, che viene riportato come numero in base otto.
  • '%x' o '%X': il valore deve essere di tipo int, long, Integer o Long, che viene formattato come intero esadecimale.
  • '%e' o '%E': l'argomento deve essere di tipo float, double, Float o Double. Il risultato è un numero decimale formattato secondo la notazione scientifica computerizzata (ad esempio "1.020030e+06")
  • '%f': l'argomento deve essere di tipo float, double, Float o Double, che viene trascritto in forma decimale con la virgola (ad esempio "19342.3234").
  • '%g' o '%G': l'argomento deve essere di tipo float, double, Float o Double; il risultato è un numero floating point formattato usando la notazione scientifica o quella decimale a seconda della precisione e del valore dopo l'arrotondamento.
  • '%a' o '%A': l'argomento deve essere di tipo float, double, Float o Double, il risultato viene riportato come numero floating point esadecimale.
  • '%t' o '%T': questa direttiva deve essere seguita da un apposito carattere di formattazione scelto tra quelli specificati nella classe java.util.Formatter. Il parametro deve essere di tipo java.util.Date, e il risultato dipende dal carattere specificato assieme alla direttiva (ad esempio: minuti, secondi, giorno della settimana, mese solare, anno e così via).
  • '%%': questa direttiva non richiede un argomento; il suo scopo è quello di stampare il carattere % stesso.
  • '%n': anche in questo caso non è richiesto un argomento; al suo posto viene stampato un carattere di "a capo"

 

Serializzazione
La serializzazione è un processo che permette di inviare attraverso uno stream un oggetto con tutto il suo contenuto informativo, e di ripristinarlo successivamente. Per trasmettere o ricevere oggetti si usano gli appositi stream ObjectInputStream e ObjectOutputStream, che dispongono rispettivamente di un metodo readObject() e writeObject(Object o).


Figura 4 - Una rappresentazione pittorica del concetto di serializzazione

Esistono due sistemi per rendere serializzabile un oggetto. Il più semplice è quello di dichiarare una classe che implementi l'interfaccia Serializable, un'interfaccia priva di metodi che funziona solo da flag per il compilatore: la serializzazione vera e propria viene effettuata dallo stream, che trasforma il valore di ciascuno dei campi in formato binario e lo scrive sul canale di output. L'unica condizione necessaria a rendere serializzabile una classe è che i suoi attributi siano di tipo primitivo o oggetti a loro volta serializzabili. Molte classi presenti nel JSDK sono serializzabili, e questo rende abbastanza semplice ricorrere a questo sistema. L'unico difetto di questo meccanismo di serializzazione è che il formato binario in cui i dati vengono rappresentati può risultare incompatibile tra una versione e l'altra del JSDK, o addirittura della classe stessa, qualora vengano aggiunti o tolti attributi.

Nota: la serializzazione opera unicamente sugli attributi di un oggetto, non sui metodi. Questo fatto ha molta importanza nella trasmissione di oggetti attraverso la rete: l'host ricevente deve infatti possedere una copia della classe per poter operare correttamente la deserializzazione.

Alcuni oggetti non possono essere serializzati in questo modo. Ad esempio, una connessione ad un data base non può essere resa permanente solamente salvando il suo stato corrente: per ripristinarla è necessario effettuare nuovamente la connessione. In caso come questo, in cui l'interfaccia Serializable, non è applicabile, si può ricorrere all'interfaccia Externalizable, caratterizzata dai seguenti metodi:

void writeExternal(ObjectOutput out)

Questo metodo deve contenere le direttive per trasferire il contenuto informativo dell'oggetto nell'apposito oggetto ObjectOutput passato come parametro, che prevede una serie di metodi per scrivere su stream valori primitivi ed oggetti. Il metodo può generare una IOException qualora si verifichi qualche problema durante la scrittura.

void readExternal(ObjectInput in)

Questo metodo deve contenere una serie di direttive speculari a quelle presenti nel metodo precedente, che permettano di ripristinare lo stato preesistente dell'oggetto serializzato a partire dall'oggetto ObjectInput presente come parametro della chiamata, che a sua volta dispone di una serie di metodi per leggere valori primitivi o oggetti. Il metodo può generare sia una IOException che una ClassNotFoundException, nel caso una delle classi da ripristinare non venga trovata.

 

Esempio di serializzazione e deserializzazione su file
Per illustrare il funzionamento pratico della serializzazione, è utile introdurre un esempio funzionante. Il primo file di questo esempio è una semplice classe dati che implementa l'interfaccia Serializable. I tre campi che costituiscono il contenuto informativo di questa classe sono un int, un float e una String, tutti attributi a loro volta serializzabili:

import java.io.*;

public class SerializableObject implements Serializable {

private int i;
private float f;
private String s;

public SerializableObject(int i,float f,String s) {
this.i = i;
this.f = f;
this.s = s;
}
public int getI() {
return i;
}
public float getF() {
return f;
}
public String getS() {
return s;
}
}

La seconda classe dell'esempio crea un oggetto SerializableObject, ne stampa a schermo il contenuto, apre un ObjectOutputStream concatenato con un FileOutputStream e serializza sul file object.ser l'oggetto:

import java.io.*;

public class Serializer {

public static void main(String argv[]) throws Exception {
SerializableObject o = new SerializableObject(10,2.253E12F,"Stringa");
System.out.printf("L'oggetto da serializzare contiene i seguenti valori: i=%d, f=%e, s=%s",o.getI(),
o.getF(),o.getS());

ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("object.ser"));
out.writeObject(o);
out.close();
}
}

Il file object.ser è in formato binario: se si prova ad aprirlo con un programma tipo wordpad il risultato è qualcosa del genere:

í sr SerializableObject"
;Õ6Ù÷D F fI iL st Ljava/lang/String;xpT $Q
t Stringa

Come si vede si tratta di un formato illeggibile per un essere umano. L'ultima classe dell'esempio è speculare alla precedente: dapprima viene aperto un ObjectoInputStream concatenato ad un FileInputStream che legge il file object.ser, quindi viene letto dallo stream un oggetto di tipo SerializableObject (si noti la necessità di ricorrere al casting), infine si stampa a schermo il contenuto dell'oggetto appena deserializzato:

import java.io.*;

public class Deserializer {

public static void main(String argv[]) throws Exception {
ObjectInputStream in = new ObjectInputStream(new FileInputStream("object.ser"));

SerializableObject o = (SerializableObject)in.readObject();
System.out.printf("L'oggetto serializzato contiene i seguenti valori: i=%d, f=%e, s=%s",o.getI(),o.getF(),o.getS());
in.close();
}
}

L'output del programma dimostra che l'oggetto è stato serializzato e deserializzato correttamente:

L'oggetto serializzato contiene i seguenti valori: i=10, f=2.253000e+12, s=Stringa

Come si può vedere l'uso della serializzazione non presenta particolari complicazioni, e per questa ragione può essere utilizzata ogni qual volta possa tornare utile. L'unica accortezza è quella di leggere gli oggetti serializzati nello stesso ordine in cui sono stati scritti, pena una ClassCastException e la conseguente terminazione del programma.

 

Conclusioni
Questo è stata illustrato l'Input/Output attraverso gli stream orientati ai caratteri. Dopo una prima introduzione delle classi Reader e Writer, sono stati illustrati in profondità le sottoclassi di questi stream. Quindi è stata illustrata la serializzazione, un processo attraverso il quale è possibile inviare in uno stream il contenuto di un intero oggetto. Il mese prossimo verrà introdotta la programmazione multi-threading.

 

Bibliografia
[1] Andrea Gini, "Java2 Standard Edition 1.5 beta 1", Mokabyte 3/2004
http://www.mokabyte.it/2004/03/jdk1_5.htm

 

Esempi
Scarica qui gli esempi