W3docs

Introducción a la E/S de Java

Una visión general de la E/S de Java: flujos de bytes vs. flujos de caracteres, E/S con búfer, java.io vs. java.nio.file.

Introducción a la E/S de Java

La parte 12 terminó con un vocabulario que puede trasladar directamente a esta parte: las lambdas, Consumer<T> y Supplier<T> como las formas que hay detrás de «dame una línea» y «haz algo con esta línea», try-with-resources para cualquier cosa que necesite una limpieza determinista, y la canalización Stream para datos orientados a líneas. Las API de E/S de Java se diseñaron precisamente en torno a esas formas — mucho antes de que existieran las palabras «interfaz funcional», los objetos subyacentes ya tenían un único método cada uno, y la fachada posterior a Java 8 hizo el resto del camino.

Esta parte abarca cuatro conjuntos de herramientas que se solapan:

  1. Los flujos de java.io — la API original de Java 1.0: InputStream/OutputStream para bytes, Reader/Writer para caracteres, y los decoradores Buffered*, Data*, Print* que los envuelven.
  2. java.io.File — la clase heredada del tipo «esta cadena es una ruta». Sigue estando por todas partes en el código antiguo; sustituida por java.nio.file.Path para los nuevos trabajos.
  3. java.nio.file — la API moderna (Java 7+): Path, Files y los métodos auxiliares estáticos (Files.readString, Files.writeString, Files.lines, Files.walk) que convierten la mayoría de las operaciones con archivos en una sola línea.
  4. Serialización — convertir grafos de objetos en bytes y de vuelta con ObjectOutputStream / ObjectInputStream.

Los primeros seis capítulos recorren las operaciones de alto nivel sobre archivos (abrir, crear, leer, escribir, eliminar) usando tanto java.io como java.nio.file, para que pueda ver la misma tarea de dos maneras. Los capítulos intermedios se acercan a las propias clases de flujo — byte vs. carácter, búfer, datos, impresión. Los últimos capítulos cubren la serialización y la API path/walk.

La separación byte/carácter

Cada API de E/S en java.io adopta una de dos formas:

InputStream  /  OutputStream     — orientado a bytes      (bytes en bruto: int read() devuelve 0..255 o -1)
Reader       /  Writer            — orientado a caracteres (texto decodificado: int read() devuelve un carácter o -1)

Esta separación no es cosmética. Los bytes son lo que almacenan los discos y los sockets; los caracteres son lo que leen las personas. Un .png son bytes; un .txt también son bytes en el disco, pero normalmente querrá decodificarlo en caracteres usando un juego de caracteres. Mezclar ambos sin un juego de caracteres es, con diferencia, la causa más común de errores de «caracteres raros» en el código Java antiguo.

Las clases puente — InputStreamReader y OutputStreamWriter — convierten entre ambos y reciben un argumento Charset. Use StandardCharsets.UTF_8, salvo que tenga una razón documentada para usar otra cosa; las formas sin argumento usan el valor predeterminado de la plataforma, que difiere entre sistemas operativos y es la fuente de manual de los errores «funciona en mi Mac, roto en el servidor Linux».

El patrón decorador

java.io está construido sobre el patrón decorador: un pequeño conjunto de flujos en bruto (FileInputStream, FileOutputStream, FileReader, FileWriter) envueltos en funcionalidad por capas (búfer, texto línea por línea, tipos primitivos, salida con formato). Usted compone lo que necesita en el lugar de la llamada:

// Leer un archivo de texto UTF-8 línea por línea:
try (BufferedReader in = new BufferedReader(
        new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8))) {
  String line;
  while ((line = in.readLine()) != null) {
    System.out.println(line);
  }
}

Tres capas, de abajo arriba: FileInputStream lee bytes en bruto; InputStreamReader los decodifica como caracteres UTF-8; BufferedReader añade un búfer en memoria y el método readLine(). Cada capa es una clase separada con un único trabajo. Java 11 redujo exactamente este patrón a una línea — Files.newBufferedReader(path) —, pero la decoración sigue siendo lo que ocurre por debajo.

try-with-resources es la regla

Cada flujo, reader, writer y canal en java.io y java.nio implementa AutoCloseable. Cerrar importa: un FileOutputStream sin cerrar puede perder su último búfer; un socket sin cerrar filtra un descriptor de archivo; un reader sin cerrar en Windows mantiene un bloqueo que el sistema operativo no liberará. La construcción try-with-resources (Java 7+) garantiza que close() se invoque en cada ruta exitosa y en cada ruta excepcional:

try (BufferedReader in = Files.newBufferedReader(path)) {
  return in.readLine();
}                                  // close() se ejecuta aquí, incluso si readLine() lanza una excepción

Puede declarar más de un recurso en el mismo try; se cierran en orden inverso. El código antiguo try/finally que llama a close() a mano es incorrecto casi siempre — la excepción interna se traga la excepción de cierre, o el propio cierre se olvida en la ruta de error. Use try-with-resources para cualquier cosa que abra un handle.

java.io frente a java.nio.file

java.io.File (1996) modelaba una ruta como un String y ofrecía un puñado de operaciones (exists, isFile, delete, listFiles). La clase sigue estando por todas partes en el código heredado, y muchas API todavía devuelven o aceptan File. Pero tiene límites que el JDK ya no finge ocultar:

  • No hay forma de preguntar por qué falló una operación — file.delete() devuelve false para «el archivo no existe», «permiso denegado» y «el archivo está abierto». No puede saber cuál es.
  • Sin soporte para enlaces simbólicos, atributos de archivo, permisos u operaciones atómicas.
  • Sin forma de recorrer un árbol de directorios sin escribir la recursión usted mismo.

java.nio.file (Java 7) la reemplaza. Path es el nuevo tipo «esto es una ruta», y Files es una clase de utilidad static con unos 80 métodos para todo lo que querría hacer con una ruta:

Path p = Path.of("data", "users.txt");                       // ruta independiente de la plataforma
String text = Files.readString(p, StandardCharsets.UTF_8);   // archivo completo, una llamada
List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8);
Files.writeString(p, "hello\n", StandardCharsets.UTF_8);
try (Stream<String> s = Files.lines(p, StandardCharsets.UTF_8)) {
  s.filter(l -> !l.isBlank()).forEach(System.out::println);
}

Dos cosas a tener en cuenta. Primero, Files.lines(path) devuelve un Stream<String> — la canalización de flujos que aprendió en la parte 12 lee archivos directamente. Segundo, el flujo es propietario de un handle de archivo abierto, de modo que la envoltura try-with-resources es obligatoria — sin ella, el archivo permanece abierto hasta la siguiente recolección de basura.

A lo largo de toda la parte 13 mostraremos ambas API una al lado de la otra. El código nuevo debería recurrir primero a java.nio.file; los capítulos sobre el código heredado existen porque se encontrará con las formas más antiguas en cualquier base de código anterior a Java 11.

Hacia dónde va esta parte

  • El siguiente capítulo, Clase File de Java, recorre la API heredada java.io.File — sus métodos de consulta, el listado y los límites que motivaron java.nio.file.
  • Los cuatro capítulos posteriores (Crear archivos, Leer archivos, Escribir archivos, Eliminar archivos) cubren las operaciones de alto nivel de «hacer una cosa a un archivo» usando ambas API.
  • Capítulos sobre flujos de bytes, de caracteres y con búfer se acercan luego a la pila de decoradores java.io subyacente.
  • La serialización, y luego Path, Files y la API de recorrido de directorios cierran la parte.

Un ejemplo trabajado: la misma tarea, de cuatro maneras

El programa siguiente escribe un breve archivo de texto de cuatro maneras — una vez con el moderno Files.writeString, una vez con el clásico FileWriter + try-with-resources, una vez decorado con BufferedWriter y una vez con PrintWriter para salida con formato. Después vuelve a leer el archivo dos veces — una vez con Files.readString (archivo completo, una llamada) y una vez con Files.lines como un Stream<String> filtrado con un Predicate<String>. El ejemplo usa un archivo temporal del sistema para que funcione en cualquier entorno aislado.

java— editable, runs on the server

Qué llevarse de la ejecución:

  • El mismo archivo se escribió de cuatro maneras distintas. Files.writeString es el camino más corto para «deja esta cadena en este archivo»; FileWriter es el writer en bruto clásico; BufferedWriter añade un búfer en memoria (barato cuando escribe muchos fragmentos pequeños); PrintWriter añade printf. Cada uno sobrescribió el contenido anterior porque el modo de apertura predeterminado es «truncar y luego escribir» — debe pasar StandardOpenOption.APPEND (tratado en el capítulo sobre escritura) para añadir a un archivo.
  • Cada writer se ejecutó dentro de try-with-resources. Omitirlo en un writer con búfer es el error en el que los últimos caracteres nunca llegan al disco — close() es lo que vacía el último búfer.
  • Files.readString devolvió el archivo completo como una sola String — bien para archivos pequeños, mala elección para un registro de 4 GB. Files.lines devolvió un Stream<String> que puede canalizar a través de filter, map y count sin mantener el archivo entero en memoria. El try-with-resources obligatorio en el flujo se debe a que el flujo es propietario de un handle de archivo abierto.
  • La línea Predicate<String> nameLine = l -> l.startsWith("name") es el mismo vocabulario de la parte 12 — un valor Predicate, pasado a Stream.filter. Files.lines es donde la API de flujos se encuentra con la API de E/S.
  • Files.deleteIfExists es la eliminación sin excepción: devuelve true si eliminó el archivo, false si no estaba. El antiguo File.delete() devuelve un boolean tanto para «eliminado» como para «no se pudo eliminar» — Files distingue ambos lanzando una excepción.

Qué sigue

Antes de que la moderna API java.nio.file se haga cargo del resto de la parte, el siguiente capítulo cubre la clase con la que se topará primero en cualquier base de código antigua: Clase File de Java. Es el tipo heredado para ruta y metadatos — limitado, pero omnipresente —, y ver lo que no puede hacer es lo que justifica el caso de Path y Files.

Práctica

Práctica

¿Por qué `java.io` separa `InputStream`/`OutputStream` de `Reader`/`Writer`?