Flujos de Caracteres en Java
Lee y escribe texto en Java con Reader, Writer, FileReader, FileWriter y consideraciones sobre codificación de caracteres.
El capítulo anterior cubrió los flujos de bytes — la capa bruta donde todo es byte. Esa capa es adecuada para datos binarios y errónea para texto. Un carácter UTF-8 puede ocupar uno, dos, tres o cuatro bytes; UTF-16 usa unidades de código de dos bytes con pares sustitutos para todo lo que está fuera del plano multilingüe básico; incluso el texto ASCII requiere en algún lugar la decisión de "esto es ASCII". Llamar a InputStream.read() sobre texto y convertir el resultado a char solo funciona si tienes suerte y el archivo es de un byte por carácter — y en el momento en que alguien escribe "é" o "日" o "🎉", la versión afortunada corrompe los datos.
La jerarquía de flujos de caracteres existe para mantener esa decodificación fuera de tu código. Reader y Writer trabajan con char, no con byte. Las clases puente — InputStreamReader y OutputStreamWriter — reciben un Charset y realizan la conversión. Si el charset es correcto en el puente, todas las capas superiores trabajan con texto decodificado.
El contrato de Reader
Reader es el espejo de InputStream, un par abstracto de métodos (read(char[], int, int) y close()) con conveniencias adicionales:
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 sinkDos diferencias sutiles respecto al lado de los bytes. Primero, la unidad es char (una unidad de código UTF-16 de 16 bits), no byte. Segundo, read() devuelve 0..65535 para una unidad de código y -1 al final del flujo — el mismo truco centinela que InputStream, pero el rango legal es más amplio.
Un char no siempre es un "carácter" — los caracteres fuera del plano multilingüe básico (U+10000 en adelante: la mayoría de los emoji, escrituras antiguas) usan dos unidades de código UTF-16 (un par sustituto). Si divides en límites de char (por ejemplo, leyendo 100 chars a la vez y procesándolos en fragmentos) puedes partir un par sustituto en dos lecturas. Para texto orientado a líneas esto rara vez importa; para procesamiento a nivel de carácter de Unicode arbitrario, trabaja con puntos de código (String.codePoints()).
El contrato de Writer
Writer es el espejo de 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) es la conveniencia que más usarás: la mayor parte de la E/S de texto consiste en un número pequeño de escrituras grandes (un cuerpo JSON, un informe generado) en lugar de salida carácter a carácter.
append existe para la interoperabilidad con CharSequence — StringBuilder implementa CharSequence, por lo que un Writer puede ser el destino de código que escribe en uno u otro dependiendo de una bandera. Es el mismo método append que tiene el propio StringBuilder, mediante interfaz.
Flujos de caracteres concretos
| Clase | Lo que envuelve |
|---|---|
FileReader / FileWriter | Un archivo en disco, decodificado como texto. |
CharArrayReader / CharArrayWriter | Un char[] en memoria. |
StringReader / StringWriter | Un String/StringBuilder en memoria. |
BufferedReader / BufferedWriter | Una vista con búfer de otro Reader/Writer. |
InputStreamReader / OutputStreamWriter | Clases puente: un Reader/Writer sobre un flujo de bytes subyacente, con un Charset. |
PrintWriter | Un decorador de Writer que añade print, println y printf. |
Las clases puente son el punto estructural de toda la jerarquía. Todo flujo de caracteres que se comunica con un archivo, socket o tubería es — por debajo — un flujo de bytes más un charset. FileReader es un envoltorio delgado alrededor de InputStreamReader(new FileInputStream(...)); FileWriter igualmente alrededor de OutputStreamWriter(new FileOutputStream(...)).
La trampa del charset
El error clásico de Java I/O:
// 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")) { ... }Los constructores sin charset usan el charset predeterminado de la JVM, que se determina al inicio a partir de la configuración regional del sistema operativo. En un Mac de desarrollo casi siempre es UTF-8. En un servidor Linux con configuración regional C puede ser US-ASCII. En Windows con una instalación en inglés es Cp1252. El error de "funciona en mi Mac, está roto en el servidor de producción" es exactamente este constructor.
Pasa un charset explícitamente:
// Right
try (FileReader in = new FileReader("data.txt", StandardCharsets.UTF_8)) { ... }
try (FileWriter out = new FileWriter("data.txt", StandardCharsets.UTF_8)) { ... }(Las formas de dos argumentos que reciben un Charset se añadieron en Java 11. Antes de eso, tenías que recurrir a las clases puente — new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8) — y la línea de decoradores encadenados es una de las razones por las que se añadió Files.newBufferedReader(path): usa UTF-8 por defecto desde Java 18 y siempre fue explícito en cuanto al charset antes.)
La API moderna de Files hizo que este valor predeterminado fuera más seguro:
String text = Files.readString(path); // UTF-8 by default (Java 18+)
BufferedReader r = Files.newBufferedReader(path); // UTF-8 by default (always was)Si estás comenzando desde cero, usa las factorías de Files. Si estás modificando código heredado con FileReader/FileWriter, la corrección más económica es añadir el segundo argumento StandardCharsets.UTF_8.
Las clases puente directamente
Necesitas InputStreamReader y OutputStreamWriter cuando la fuente no es un archivo — una ZipEntry, un socket, el cuerpo de una respuesta HTTP, System.in, un flujo envuelto con Inflater — y quieres texto de él:
// 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 forma es siempre la misma: flujo de bytes → InputStreamReader(stream, charset) → BufferedReader opcional → tu código.
Un ejemplo trabajado: texto en tres formas
El programa a continuación escribe un pequeño archivo de texto UTF-8 que contiene ASCII, caracteres acentuados y un emoji multibyte, y luego lo lee de cuatro maneras: como un String, carácter a carácter, línea a línea a través de un BufferedReader, y mediante el constructor heredado FileReader(charset). El ejemplo también muestra la forma de la clase puente funcionando sobre un ByteArrayInputStream para que puedas ver dónde se encuentran Reader e InputStream.
Lo que se puede extraer de la ejecución:
- El archivo en disco (23 bytes) era más grande que
content.length()(20). ElStringtienelength() == 20(contando cada\ny contando el emoji 🎉 como dos unidades de código UTF-16 — eso es lo que mide uncharen Java); UTF-8 codifica el emoji en cuatro bytes yéen dos, por lo que el recuento de bytes es mayor. En puntos de código solo hay 19 — el emoji es un punto de código pero dos chars. El mismo texto lógico es un número en chars, otro en bytes y otro en puntos de código. Saber cuál de ellos se quiere decir es la mitad de los errores de charset. - El bucle char a char reconstruyó exactamente el mismo string. La API de
Readergestionó la decodificación UTF-8 por ti: un único emoji aparece como dos llamadas a(char) read()debido a los sustitutos UTF-16, pero nunca tuviste que pensar en los límites de bytes. BufferedReader.readLine()devolvió tres líneas:hello,café,🎉 party. Ese es el vocabulario orientado a texto — línea a línea, con conciencia del terminador (maneja\n,\ry\r\n), y construido sobre la clase puente. Cada llamada a la API de este capítulo y del siguiente se reduce en última instancia a "decodifica bytes mediante un charset y sirve caracteres."- El bloque directo
InputStreamReader(new ByteArrayInputStream(raw), UTF_8)muestra la forma estructural: fuente de bytes por dentro, charset en el puente, API de caracteres por fuera. CambiaByteArrayInputStreamporsocket.getInputStream()y el resto es idéntico — por eso los clientes HTTP y JDBC convergen en el mismo idioma. - El bloque final decodificó los mismos bytes con el charset incorrecto. La
éacentuada y el emoji salieron como basura — el clásico error mojibake. Los bytes en disco estaban bien; el charset en el puente era incorrecto. Por eso fijar el charset de forma explícita es el hábito más útil en la E/S de texto en Java.
Qué sigue
Tanto los flujos de bytes como los de caracteres realizan por defecto E/S de uno en uno, y en un flujo de archivo bruto cada llamada es una llamada al sistema. El siguiente capítulo, Flujos con Búfer en Java, cubre los decoradores Buffered* — un búfer en memoria entre tu código y el sistema operativo — y la API readLine() que reside allí.