Stream di caratteri in Java
Leggi e scrivi testo in Java con Reader, Writer, FileReader, FileWriter e la gestione della codifica dei caratteri.
Il capitolo precedente trattava i byte stream — il livello grezzo in cui tutto è byte. Quel livello è adatto per i dati binari e inadatto per il testo. Un carattere UTF-8 può occupare uno, due, tre o quattro byte; UTF-16 usa unità di codice a due byte con coppie surrogate per tutto ciò che supera il piano multilingue base; persino il testo ASCII richiede da qualche parte una decisione del tipo "questo è ASCII". Chiamare InputStream.read() su testo e convertire il risultato in char funziona solo per fortuna, se il file ha un byte per carattere — e nel momento in cui qualcuno scrive "é", "日" o "🎉", la versione fortunata corrompe i dati.
La gerarchia dei character stream esiste per tenere quella decodifica fuori dal tuo codice. Reader e Writer lavorano con char, non con byte. Le classi ponte — InputStreamReader e OutputStreamWriter — accettano un Charset ed eseguono la conversione. Se il charset è corretto al livello ponte, ogni strato superiore lavora con testo decodificato.
Il contratto di Reader
Reader è il corrispettivo di InputStream, con un'astrazione su una coppia di metodi (read(char[], int, int) e close()) e alcune comodità in cima:
int read(); // next char as int 0..65535, or -1 at end
int read(char[] buf); // read up to buf.length chars; return count or -1
int read(char[] buf, int off, int len); // into a slice
String readLine(); // only on BufferedReader — not on Reader itself
long transferTo(Writer out); // Java 10+: pipe straight to a sinkDue differenze sottili rispetto al lato byte. Prima: l'unità è char (un'unità di codice UTF-16 a 16 bit), non byte. Seconda: read() restituisce 0..65535 per un'unità di codice e -1 alla fine dello stream — la stessa tecnica sentinella di InputStream, ma con un intervallo legale più ampio.
Un char non è sempre un "carattere" — i caratteri al di fuori del piano multilingue base (U+10000 e superiori: la maggior parte delle emoji, scritture antiche) usano due unità di codice UTF-16 (una coppia surrogate). Se si divide per confini di char (ad esempio si leggono 100 char alla volta e si elaborano a blocchi) si può spezzare una coppia surrogate tra due letture. Per il testo orientato alle righe questo accade raramente; per l'elaborazione a livello di carattere di Unicode arbitrario, lavora con i code point (String.codePoints()).
Il contratto di Writer
Writer è il corrispettivo di OutputStream:
void write(int c); // low 16 bits
void write(char[] buf);
void write(char[] buf, int off, int len);
void write(String s); // convenience — encodes a whole String
void write(String s, int off, int len);
Writer append(CharSequence csq); // chainable: w.append("a").append("b")
void flush();
void close(); // calls flush() firstwrite(String) è la comodità che userai di più: la maggior parte dell'I/O testuale consiste in un piccolo numero di scritture grandi (un corpo JSON, un report generato) piuttosto che in un output carattere per carattere.
append esiste per l'interoperabilità con CharSequence — StringBuilder implementa CharSequence, quindi un Writer può essere la destinazione di codice che scrive in uno o nell'altro a seconda di un flag. È lo stesso metodo append che ha StringBuilder stesso, tramite interfaccia.
Stream di caratteri concreti
| Classe | Cosa racchiude |
|---|---|
FileReader / FileWriter | Un file su disco, decodificato come testo. |
CharArrayReader / CharArrayWriter | Un char[] in memoria. |
StringReader / StringWriter | Una String/StringBuilder in memoria. |
BufferedReader / BufferedWriter | Una vista bufferizzata di un altro Reader/Writer. |
InputStreamReader / OutputStreamWriter | Classi ponte: un Reader/Writer sopra un byte stream sottostante, con un Charset. |
PrintWriter | Un decoratore di Writer che aggiunge print, println e printf. |
Le classi ponte sono il punto strutturale dell'intera gerarchia. Ogni character stream che comunica con un file, un socket o una pipe è — sotto — un byte stream più un charset. FileReader è un sottile wrapper attorno a InputStreamReader(new FileInputStream(...)); FileWriter analogamente attorno a OutputStreamWriter(new FileOutputStream(...)).
La trappola del charset
Il classico bug dell'I/O in Java:
// WRONG in any code that might run on more than one machine
try (FileReader in = new FileReader("data.txt")) { ... }
try (FileWriter out = new FileWriter("data.txt")) { ... }I costruttori senza charset usano il charset predefinito della JVM, determinato all'avvio dalla locale del sistema operativo. Su un Mac per sviluppatori è quasi sempre UTF-8. Su un server Linux con locale C può essere US-ASCII. Su Windows con un'installazione inglese è Cp1252. Il bug "funziona sul mio Mac, rotto sul server di produzione" è esattamente questo costruttore.
Passa esplicitamente un charset:
// Right
try (FileReader in = new FileReader("data.txt", StandardCharsets.UTF_8)) { ... }
try (FileWriter out = new FileWriter("data.txt", StandardCharsets.UTF_8)) { ... }(Le forme a due argomenti che accettano un Charset sono state aggiunte in Java 11. Prima bisognava ricorrere alle classi ponte — new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8) — e la riga con i decoratori annidati è uno dei motivi per cui è stato aggiunto Files.newBufferedReader(path): usa UTF-8 come default da Java 18 e prima era sempre esplicito sul charset.)
La moderna API Files ha reso questo default più sicuro:
String text = Files.readString(path); // UTF-8 by default (Java 18+)
BufferedReader r = Files.newBufferedReader(path); // UTF-8 by default (always was)Se stai iniziando da zero, usa le factory di Files. Se stai modificando codice legacy con FileReader/FileWriter, il fix più economico è aggiungere il secondo argomento StandardCharsets.UTF_8.
Le classi ponte direttamente
Hai bisogno di InputStreamReader e OutputStreamWriter ogni volta che la sorgente non è un file — una ZipEntry, un socket, il corpo di una risposta HTTP, System.in, uno stream avvolto da Inflater — e vuoi testo in uscita:
// Read text from System.in as UTF-8
try (BufferedReader stdin = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
String line = stdin.readLine();
}
// Write the response of an HttpURLConnection as text
try (BufferedReader resp = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
resp.lines().forEach(System.out::println);
}La struttura è sempre la stessa: byte stream → InputStreamReader(stream, charset) → BufferedReader opzionale → il tuo codice.
Un esempio pratico: testo in tre forme
Il programma seguente scrive un piccolo file di testo UTF-8 contenente ASCII, caratteri accentati e un'emoji multi-byte, poi lo rilegge in quattro modi: come String, carattere per carattere, riga per riga tramite un BufferedReader, e tramite il costruttore legacy FileReader(charset). L'esempio mostra anche la struttura con le classi ponte che operano su un ByteArrayInputStream, così puoi vedere dove Reader e InputStream si incontrano.
Cosa trarre dall'esecuzione:
- Il file su disco (23 byte) era più grande di
content.length()(20). LaStringhalength() == 20(contando ogni\ne contando l'emoji 🎉 come due unità di codice UTF-16 — questo è ciò che misura uncharin Java); UTF-8 codifica l'emoji come quattro byte edécome due, quindi il conteggio dei byte è maggiore. In code point ce ne sono solo 19 — l'emoji è un code point ma due char. Lo stesso testo logico è un numero in char, un altro in byte, un altro in code point. Sapere quale si intende è metà dei bug di charset. - Il ciclo carattere per carattere ha riassemblato la stessa identica stringa. L'API
Readerha gestito per te la decodifica UTF-8: una singola emoji compare come due chiamate a(char) read()a causa dei surrogati UTF-16, ma non hai mai dovuto pensare ai confini dei byte. BufferedReader.readLine()ha restituito tre righe:hello,café,🎉 party. Questo è il vocabolario orientato al testo — riga per riga, consapevole dei terminatori (gestisce\n,\re\r\n), e costruito sopra la classe ponte. Ogni chiamata API in questo e nel prossimo capitolo si riduce in ultima analisi a "decodifica i byte attraverso un charset e fornisce caratteri."- Il blocco diretto
InputStreamReader(new ByteArrayInputStream(raw), UTF_8)mostra la struttura: la sorgente byte all'interno, il charset al ponte, l'API dei caratteri all'esterno. SostituisciByteArrayInputStreamconsocket.getInputStream()e il resto è identico — ecco perché i client HTTP e JDBC convergono tutti sullo stesso idioma. - Il blocco finale ha decodificato gli stessi byte con il charset sbagliato. L'
éaccentata e l'emoji sono entrambe uscite come spazzatura — il classico bug mojibake. I byte su disco erano corretti; il charset al ponte era sbagliato. Ecco perché specificare il charset esplicitamente è la singola abitudine più utile nell'I/O di testo in Java.
Cosa viene dopo
Sia i byte stream che i character stream operano di default un elemento alla volta, e su un file stream grezzo ogni chiamata è una syscall. Il prossimo capitolo, Java Buffered Streams, tratta i decoratori Buffered* — un buffer in memoria tra il tuo codice e il sistema operativo — e l'API readLine() che vi risiede.