El módulo 6 terminó con una promesa y una carencia. La carencia: nada se guarda al salir. Seis módulos de trabajo —el catálogo, los préstamos, las multas, las reservas, el historial— viven íntegramente en la memoria del proceso, y cuando el proceso termina, la memoria se libera y todo desaparece. La promesa: que este módulo lo resuelve.

Empezamos por la mitad más sencilla de la persistencia, que es leer. Leer es más sencillo que escribir por una razón práctica: si te equivocas leyendo, no destruyes nada. Si te equivocas escribiendo, puedes perder un fichero entero, y eso es exactamente lo que le va a pasar a alguien en la lección siguiente si no lee la tabla del parámetro append.

Esta lección cubre el modelo mental completo de qué es un fichero, cómo se localiza y cómo se lee texto de él con las dos APIs clásicas —FileReader y Scanner—, más el detalle que provoca más incidencias reales de todo el módulo: la codificación de caracteres. Al terminar, BiblioTech arrancará leyendo su catálogo de un fichero de texto, y si ese fichero no existe, arrancará igualmente con el catálogo vacío y dejando constancia en el registro, aplicando la degradación elegante que ya sabes diseñar.

Todo lo que se abra en esta lección se cierra con try-with-resources. Es la construcción de 06-06 y se da por completamente conocida: orden de cierre inverso, excepciones suprimidas, close() idempotente. No se vuelve a explicar; se usa siempre.

Contenido

  1. Por qué un programa necesita persistencia
  2. Qué es un fichero: bytes en disco frente a objetos en memoria
  3. El recorrido de un dato desde la aplicación hasta el disco
  4. Rutas absolutas y relativas, y el directorio de trabajo
  5. Separadores de ruta y portabilidad
  6. La clase File: la API heredada que todavía te encontrarás
  7. Lectura con FileReader: carácter a carácter
  8. Lectura con Scanner sobre un fichero
  9. useDelimiter y el parseo cómodo
  10. Codificación de caracteres: UTF-8 y el desastre de las tildes
  11. FileNotFoundException frente a IOException
  12. Degradación elegante: BiblioTech arranca sin catálogo
  13. Ficheros grandes y por qué no leerlo todo en memoria
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. Por qué un programa necesita persistencia

La memoria de un proceso es volátil: existe mientras el proceso existe. Cuando main retorna, cuando el usuario cierra la ventana o cuando el sistema operativo mata el proceso, la JVM libera su memoria y todo lo que había en ella deja de existir. No hay nada que recuperar.

Esto no es un defecto de Java: es la naturaleza de la RAM. Un programa que solo trabaja en memoria es un programa sin memoria a largo plazo, y eso lo descalifica para casi cualquier uso real:

Necesidad Sin persistencia Con persistencia
Recordar datos entre ejecuciones Imposible Es el caso base
Cargar mil materiales Tecleándolos uno a uno Un fichero de mil líneas
Compartir datos con otro programa Imposible Un fichero que los dos entienden
Auditar qué pasó ayer Imposible Un fichero de registro
Configurar sin recompilar Imposible Un fichero de propiedades
Recuperarse de una caída Se pierde todo Se recupera el último estado guardado

Fíjate en la última fila de la columna izquierda aplicada a BiblioTech: si la aplicación se cae con veinte préstamos activos, esos veinte préstamos no existieron nunca. La biblioteca real seguirá teniendo veinte libros fuera de la estantería, pero el sistema dirá que están todos disponibles. El sistema y el mundo dejan de coincidir, que es la peor cosa que le puede pasar a un sistema de gestión.

Persistir significa escribir el estado en un medio que sobrevive al proceso. En este módulo ese medio es el sistema de ficheros. En un sistema profesional grande suele ser una base de datos —lo verás con Hibernate en 11-03—, pero una base de datos es, por debajo, ficheros con mucha ingeniería encima. Todo empieza aquí.

  1. Qué es un fichero: bytes en disco frente a objetos en memoria

Esta es la idea que hay que interiorizar antes de escribir una línea de código:

Un fichero es una secuencia de bytes con un nombre. Nada más. No tiene tipos, no tiene objetos, no tiene campos. Solo bytes numerados del 0 en adelante.

Compara los dos mundos:

Objeto en memoria Fichero en disco
Naturaleza Estructura con campos tipados Secuencia plana de bytes
Identidad Referencia (dirección) Ruta (nombre)
Vida Mientras dure el proceso Hasta que alguien lo borre
Acceso Directo, nanosegundos A través del sistema operativo, microsegundos o más
Referencias entre datos Punteros nativos Hay que inventárselas (identificadores, offsets)
Tipos El compilador los conoce No existen: todo son bytes
Coste de una operación Barata Cara: hay una llamada al sistema por medio

Cuando tienes en memoria un Libro con título "Java Efectivo", autor "Bloch" e ISBN 978-0000000001, el objeto ocupa una zona de memoria con tres referencias a tres objetos String. En disco no puedes guardar referencias: una dirección de memoria no significa nada mañana ni en otra máquina. Tienes que serializar: convertir esa estructura en una secuencia de bytes que puedas volver a interpretar después. La forma más simple de hacerlo es escribir texto:

978-0000000001;Java Efectivo;Bloch;2018;true

Esa línea es una decisión de diseño completa. Has elegido: un material por línea, campos separados por ;, orden fijo de campos, true/false para la disponibilidad. Cualquier programa que conozca esa convención puede leerla. Es exactamente lo que hace un fichero CSV, y lo formalizarás en 07-07.

La alternativa es dejar que Java haga la conversión por ti, con la serialización nativa de 07-05, que produce bytes ilegibles pero conserva la estructura completa de objetos. Cada opción tiene su precio, y lo verás.

Un matiz importante sobre el vocabulario: se habla de ficheros de texto y ficheros binarios, pero esa distinción no existe en el disco. Todos los ficheros son binarios. Un "fichero de texto" es simplemente un fichero cuyos bytes, interpretados con una determinada codificación, producen caracteres legibles. Si abres un .jpg con un editor de texto, verás basura: no porque el fichero sea distinto, sino porque estás aplicando la interpretación equivocada. Volveremos a esto en el apartado 10 y, a fondo, en 07-03.

  1. El recorrido de un dato desde la aplicación hasta el disco

Entre tu String y el plato del disco hay más capas de las que parece. Conocerlas explica por qué la E/S es lenta, por qué existe el búfer y por qué un close() puede fallar.

flowchart TD
    A["Tu codigo Java<br/>String linea = lector.readLine()"] --> B["Clases java.io<br/>FileReader, Scanner"]
    B --> C["Decodificacion de bytes a caracteres<br/>charset UTF-8"]
    C --> D["Llamada al sistema operativo<br/>read syscall"]
    D --> E["Cache de disco del sistema operativo<br/>page cache en RAM"]
    E --> F["Controlador del dispositivo"]
    F --> G["Disco fisico<br/>SSD o disco duro"]

    style A fill:#e3f2fd
    style D fill:#fff3e0
    style G fill:#f3e5f5

Los puntos que importan de ese recorrido:

  • La frontera cara es la llamada al sistema (recuadro naranja). Cada read obliga a pasar del código de tu aplicación al núcleo del sistema operativo y volver. Ese cambio de contexto cuesta órdenes de magnitud más que ejecutar unas instrucciones en memoria. Si haces una llamada al sistema por cada carácter, el coste es demoledor: es exactamente lo que ocurre con FileReader sin búfer, y lo medirás en el apartado 7.
  • La decodificación es un paso real (recuadro azul claro del medio). Los bytes que llegan del disco no son caracteres. Alguien tiene que decidir qué byte o grupo de bytes forma qué carácter, y ese alguien es el charset. Si nadie lo especifica, se usa uno por defecto, y ahí empiezan los problemas del apartado 10.
  • El sistema operativo ya tiene su propia caché (la page cache). Por eso leer dos veces el mismo fichero pequeño es mucho más rápido la segunda vez: la segunda no llega al disco. Esto también explica por qué medir rendimiento de E/S es traicionero.
  • Escribir no significa "está en el disco". Cuando escribes, el dato pasa a búferes intermedios. Hasta que no se vacían, un corte de corriente lo pierde. Es el tema central de 07-02.

Quédate con la regla que gobierna todo el módulo: cuantas menos llamadas al sistema, mejor. Todo el diseño de la E/S de Java —los búferes, los bloques, transferTo— existe para reducir ese número.

  1. Rutas absolutas y relativas, y el directorio de trabajo

Para leer un fichero hay que localizarlo, y aquí aparece el primer tropiezo clásico: "a mí me funcionaba y en el servidor dice que no encuentra el fichero".

Una ruta absoluta parte de la raíz del sistema de ficheros y es autosuficiente:

/home/marta/bibliotech/datos/catalogo.txt        (Linux, macOS)
C:\Users\marta\bibliotech\datos\catalogo.txt     (Windows)

Una ruta relativa no parte de la raíz, sino del directorio de trabajo actual del proceso:

datos/catalogo.txt
./datos/catalogo.txt
../config/bibliotech.properties

La pregunta clave es: ¿relativa a qué? Y la respuesta es la que sorprende a todo el mundo: relativa al directorio desde el que se lanzó la JVM, no al directorio donde está el .class ni al del proyecto. Ese directorio está en la propiedad de sistema user.dir:

public class DondeEstoy {
    public static void main(String[] args) {
        System.out.println("Directorio de trabajo: " + System.getProperty("user.dir"));
        System.out.println("Directorio del usuario: " + System.getProperty("user.home"));
        System.out.println("Directorio temporal:   " + System.getProperty("java.io.tmpdir"));

        java.io.File f = new java.io.File("datos/catalogo.txt");
        System.out.println("Ruta relativa dada:    " + f.getPath());
        System.out.println("Se resuelve como:      " + f.getAbsolutePath());
        System.out.println("¿Existe?               " + f.exists());
    }
}

Ejecuta ese programa desde dos sitios distintos y verás el problema en directo:

# Desde la raiz del proyecto
$ cd /home/marta/bibliotech
$ java -cp target/classes DondeEstoy
Directorio de trabajo: /home/marta/bibliotech
Se resuelve como:      /home/marta/bibliotech/datos/catalogo.txt
¿Existe?               true

# Desde el directorio de clases
$ cd /home/marta/bibliotech/target/classes
$ java DondeEstoy
Directorio de trabajo: /home/marta/bibliotech/target/classes
Se resuelve como:      /home/marta/bibliotech/target/classes/datos/catalogo.txt
¿Existe?               false

El mismo programa, el mismo fichero en disco, dos resultados distintos. Esto es lo que ocurre cuando funciona en el IDE (que lanza desde la raíz del proyecto) y falla al ejecutarlo con un script (que lanza desde otro sitio).

Cómo se trata esto profesionalmente:

Estrategia Cuándo usarla
Ruta por parámetro (args[0] o propiedad -Druta=...) Casi siempre. Quien ejecuta decide dónde están los datos
Ruta relativa a user.home Configuración de usuario: System.getProperty("user.home") + "/.bibliotech/"
Ruta absoluta en fichero de configuración Despliegues en servidor. Lo verás en 07-07
Recurso del classpath (getResourceAsStream) Datos que viajan dentro del .jar y no cambian
Ruta relativa a secas Solo en pruebas y herramientas de línea de comandos, y documentándolo

Y un consejo que ahorra mucho tiempo: cuando un fichero "no existe", imprime siempre la ruta absoluta, nunca la que te pasaron. El mensaje No se encuentra 'datos/catalogo.txt' no te dice nada; No se encuentra '/home/marta/bibliotech/target/classes/datos/catalogo.txt' te resuelve el problema en tres segundos. Esta es una aplicación directa de la lección 06-04: una excepción debe transportar los datos que quien la lee necesita para decidir.

  1. Separadores de ruta y portabilidad

Windows usa \ como separador de directorios; Linux, macOS y prácticamente todo lo demás usan /. Java ofrece dos constantes para no fijarlo en el código:

import java.io.File;

public class Separadores {
    public static void main(String[] args) {
        System.out.println("Separador de rutas:    '" + File.separator + "'");
        System.out.println("Separador de listas:   '" + File.pathSeparator + "'");

        // Construccion portable de una ruta
        String ruta = "datos" + File.separator + "catalogo.txt";
        System.out.println("Ruta portable: " + ruta);

        // Alternativa mucho mejor: el constructor de File con padre e hijo
        File f = new File("datos", "catalogo.txt");
        System.out.println("Con File(padre, hijo): " + f.getPath());
    }
}

Distingue las dos constantes, que se confunden con frecuencia:

Constante Linux/macOS Windows Para qué sirve
File.separator / \ Separar directorios dentro de una ruta
File.pathSeparator : ; Separar rutas dentro de una lista, como el CLASSPATH

Ahora bien, la buena noticia práctica: Windows acepta / en casi todas las APIs de Java. new File("datos/catalogo.txt") funciona en Windows. Por eso, en la práctica:

  • Para literales cortos en el código, escribe / y no te compliques: es legible y funciona en todas partes.
  • Para componer rutas a partir de trozos, no concatenes cadenas: usa new File(padre, hijo) o, mejor todavía, Path.resolve() de NIO.2, que verás en 07-06 y es la forma moderna y correcta.
  • Nunca escribas "C:\\datos\\catalogo.txt" en el código. Además de no ser portable, ese doble backslash es una fuente inagotable de erratas.

  1. La clase File: la API heredada que todavía te encontrarás

java.io.File es de Java 1.0. Representa una ruta, no un fichero abierto: puedes crear un File que apunte a algo inexistente sin que pase nada.

import java.io.File;

public class InspeccionarFichero {

    public static void inspeccionar(String ruta) {
        File f = new File(ruta);

        System.out.println("=== " + ruta + " ===");
        System.out.println("  Ruta absoluta : " + f.getAbsolutePath());
        System.out.println("  Nombre        : " + f.getName());
        System.out.println("  Directorio    : " + f.getParent());
        System.out.println("  ¿Existe?      : " + f.exists());

        if (!f.exists()) {
            System.out.println("  (no hay mas que inspeccionar)");
            return;
        }

        System.out.println("  ¿Es fichero?  : " + f.isFile());
        System.out.println("  ¿Es carpeta?  : " + f.isDirectory());
        System.out.println("  ¿Legible?     : " + f.canRead());
        System.out.println("  ¿Escribible?  : " + f.canWrite());
        System.out.println("  ¿Oculto?      : " + f.isHidden());
        System.out.println("  Tamano        : " + f.length() + " bytes");
        System.out.println("  Modificado    : " + f.lastModified() + " (milisegundos desde 1970)");
    }

    public static void main(String[] args) {
        inspeccionar("datos/catalogo.txt");
        inspeccionar("datos");
        inspeccionar("no-existe-esto.txt");
    }
}

Los métodos que más se usan:

Método Devuelve Nota importante
exists() boolean false también si no tienes permiso para saberlo
isFile() / isDirectory() boolean Ambos false si no existe
canRead() / canWrite() boolean Depende del usuario que ejecuta la JVM
length() long Bytes. 0 si no existe, indistinguible de un fichero vacío
getName() String Solo el nombre, sin directorios
getPath() String La ruta tal cual se dio
getAbsolutePath() String Resuelta contra user.dir, sin normalizar los ..
getCanonicalPath() String Absoluta y normalizada. Lanza IOException
lastModified() long Milisegundos desde 1970. 0 si no existe
listFiles() File[] null si no es un directorio o falla. Trampa clásica
delete() boolean false sin decir por qué
mkdirs() boolean Crea también los directorios intermedios

Y aquí está el gran defecto de esta API, el que motivó su sustitución:

File f = new File("/datos/importante.txt");
boolean borrado = f.delete();

if (!borrado) {
    // ¿Por que? ¿No existia? ¿No tengo permiso? ¿Estaba abierto?
    // ¿Es un directorio no vacio? La API no lo dice. Un false mudo.
    System.out.println("No se pudo borrar");
}

Ese false mudo es exactamente el antipatrón que el módulo 6 dedicó siete lecciones a erradicar: comunicar un fallo sin decir cuál. La API moderna, NIO.2 (java.nio.file.Path y Files, lección 07-06), lanza excepciones específicas —NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException— en lugar de devolver boolean.

Entonces, ¿por qué aprendemos File? Por tres motivos muy prácticos. Primero, porque hay millones de líneas de código escritas con ella y las vas a leer. Segundo, porque muchas APIs todavía piden un File como parámetro. Y tercero, porque la conversión entre ambos mundos es trivial: file.toPath() y path.toFile(). Usa NIO.2 en el código nuevo; entiende File para leer el antiguo.

  1. Lectura con FileReader: carácter a carácter

FileReader es la clase más elemental para leer texto de un fichero. Su método fundamental es read(), y su contrato tiene un detalle que hay que entender bien:

public int read() throws IOException

Devuelve un int, no un char. ¿Por qué? Porque necesita un valor extra que no sea un carácter válido para señalar el fin de fichero, y ese valor es -1. Los caracteres válidos van de 0 a 65535; -1 no colisiona con ninguno. Si el método devolviera char, no habría forma de distinguir "he leído el carácter 0" de "se acabó".

import java.io.FileReader;
import java.io.IOException;

public class LecturaCaracterACaracter {

    public static void mostrar(String ruta) throws IOException {
        try (FileReader lector = new FileReader(ruta)) {
            int codigo;                       // int, NO char
            while ((codigo = lector.read()) != -1) {
                char c = (char) codigo;       // conversion explicita
                System.out.print(c);
            }
        }
        // El try-with-resources ya lo ha cerrado (06-06)
    }

    public static void main(String[] args) throws IOException {
        mostrar("datos/catalogo.txt");
    }
}

Desmenucemos el bucle, porque su forma es idiomática y la verás por todas partes:

while ((codigo = lector.read()) != -1) { ... }
  1. lector.read() lee el siguiente carácter y avanza la posición.
  2. codigo = ... guarda el resultado en la variable. En Java, una asignación es una expresión cuyo valor es el asignado; por eso puede usarse dentro de la condición.
  3. Los paréntesis externos son obligatorios: sin ellos, codigo = lector.read() != -1 intentaría asignar un boolean a un int y no compilaría.
  4. La comparación decide si seguir.

Y ahora, el problema. Este código es correcto pero lento, y conviene entender exactamente cuánto:

import java.io.FileReader;
import java.io.IOException;

public class MedirLecturaLenta {

    public static long contarCaracteres(String ruta) throws IOException {
        long total = 0;
        try (FileReader lector = new FileReader(ruta)) {
            while (lector.read() != -1) {
                total++;
            }
        }
        return total;
    }

    public static void main(String[] args) throws IOException {
        long inicio = System.nanoTime();
        long n = contarCaracteres("datos/catalogo-grande.txt");
        long ms = (System.nanoTime() - inicio) / 1_000_000;

        System.out.printf("%d caracteres leidos en %d ms%n", n, ms);
    }
}

Con un fichero de 5 MB, en una máquina de escritorio corriente, los órdenes de magnitud son estos (cifras ilustrativas, no una medición formal):

Técnica Llamadas al sistema aproximadas Tiempo orientativo
FileReader.read() carácter a carácter ~5 000 000 ~4 500 ms
FileReader.read(char[8192]) por bloques ~640 ~90 ms
BufferedReader.readLine() (lección 07-04) ~640 ~60 ms

Dos órdenes de magnitud de diferencia, y la causa es exactamente la del apartado 3: cada read() sin búfer se traduce en una petición al sistema operativo. Cinco millones de cruces de frontera para leer cinco megabytes.

La solución intermedia, sin salir todavía de FileReader, es leer por bloques con la sobrecarga que acepta un array:

import java.io.FileReader;
import java.io.IOException;

public class LecturaPorBloques {

    public static String leerTodo(String ruta) throws IOException {
        StringBuilder contenido = new StringBuilder();

        try (FileReader lector = new FileReader(ruta)) {
            char[] bloque = new char[8192];         // 8 KB, tamano habitual
            int leidos;

            // read(char[]) devuelve CUANTOS caracteres ha puesto en el array,
            // o -1 si no habia nada que leer. Puede devolver MENOS de 8192
            // aunque queden datos: nunca supongas que llena el array.
            while ((leidos = lector.read(bloque)) != -1) {
                contenido.append(bloque, 0, leidos);   // solo la parte util
            }
        }
        return contenido.toString();
    }
}

Dos detalles críticos de ese código, y los dos son fuente de errores reales:

  • read(char[]) devuelve el número de elementos leídos, que puede ser menor que el tamaño del array aunque el fichero no se haya terminado. Nunca supongas que llena el búfer.
  • Hay que usar append(bloque, 0, leidos), no append(bloque). Si usas la versión sin límites, en la última vuelta añadirás la basura que quedó en el array de la vuelta anterior. Es un fallo que solo se manifiesta al final del fichero y con contenido aparentemente aleatorio.

La forma definitiva de resolver esto —envolver el FileReader en un BufferedReader que gestiona el bloque por ti— es el tema de 07-04. Aquí queda claro el porqué; allí verás el cómo con todo el detalle.

  1. Lectura con Scanner sobre un fichero

Ya conoces Scanner del módulo 1, leyendo de System.in. La misma clase lee de un fichero: solo cambia lo que le pasas al constructor.

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class LecturaConScanner {

    public static void mostrarLineas(String ruta) throws FileNotFoundException {
        // Scanner es Closeable: try-with-resources obligatorio (06-06).
        // Cerrarlo cierra tambien el fichero subyacente.
        try (Scanner sc = new Scanner(new File(ruta))) {
            int numero = 1;
            while (sc.hasNextLine()) {
                String linea = sc.nextLine();
                System.out.printf("%3d | %s%n", numero++, linea);
            }
        }
    }
}

El par hasNextLine() / nextLine() es el patrón canónico y merece un aviso:

Comprueba siempre con hasNextLine() antes de llamar a nextLine(). Si llamas a nextLine() sin quedar líneas, lanza NoSuchElementException, que es no comprobada: el compilador no te avisa y explota en ejecución. Es el mismo contrato de Iterator que viste en 05-02.

Scanner no se queda en líneas: sabe parsear tipos, y ahí está su comodidad. Supón este fichero de préstamos de BiblioTech:

PR-0001 978-0000000001 E-001 12 0.25
PR-0002 978-0000000002 E-002 30 0.25
PR-0003 978-0000000003 E-003 5 0.10

Se lee así, campo a campo, sin partir cadenas a mano:

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class LeerPrestamos {

    public static void leer(String ruta) throws FileNotFoundException {
        try (Scanner sc = new Scanner(new File(ruta))) {
            while (sc.hasNext()) {
                String referencia = sc.next();      // PR-0001
                String isbn       = sc.next();      // 978-0000000001
                String empleado   = sc.next();      // E-001
                int    dias       = sc.nextInt();   // 12
                double tarifa     = sc.nextDouble();// 0.25

                System.out.printf("%s: %s a %s, %d dias, %.2f EUR/dia%n",
                        referencia, isbn, empleado, dias, tarifa);
            }
        }
    }
}

Cuidado con dos comportamientos de Scanner que causan sorpresas:

  • nextDouble() depende de la configuración regional. Con el idioma español, el separador decimal es la coma, así que 0.25 puede fallar con InputMismatchException mientras que 0,25 funciona. Es un fallo que aparece solo en algunas máquinas y desconcierta. La solución es fijar la configuración explícitamente:
import java.util.Locale;

Scanner sc = new Scanner(new File(ruta));
sc.useLocale(Locale.ROOT);      // punto decimal, siempre, en cualquier maquina

Este mismo problema, aplicado a los ficheros CSV que se abren en una hoja de cálculo española, reaparece en 07-07.

  • nextInt() no consume el salto de línea. Si mezclas nextInt() con nextLine(), el nextLine() devolverá la cadena vacía que queda hasta el final de la línea actual. Es la trampa que ya viste con la consola en el módulo 1, y aquí es idéntica.

Comparación honesta de las dos APIs vistas hasta ahora:

FileReader a pelo Scanner sobre fichero
Unidad de lectura Carácter o bloque de caracteres Línea, palabra, int, double, expresión regular
Parseo A mano Incluido
Velocidad Lenta sin búfer Lenta: usa expresiones regulares por dentro
Errores de formato No los detecta InputMismatchException
Excepción del constructor FileNotFoundException FileNotFoundException
Codificación Segundo parámetro Charset useLocale para números, Charset en el constructor
Bueno para Copiar, procesar sin estructura Ficheros pequeños con formato tabulado
Malo para Cualquier cosa con estructura Ficheros grandes: es el más lento de los tres

Para un fichero de configuración de veinte líneas, Scanner es cómodo y su lentitud es irrelevante. Para el fichero de catálogo de diez mil materiales, la respuesta correcta es BufferedReader, y la verás en 07-04.

  1. useDelimiter y el parseo cómodo

Por defecto, Scanner separa por espacios en blanco. useDelimiter cambia ese criterio a cualquier expresión regular, lo que permite leer ficheros con separadores propios sin partir cadenas a mano.

Con este fichero de catálogo:

978-0000000001;Java Efectivo;Bloch;2018
978-0000000002;Patrones de Diseno;Gamma;1994
978-0000000003;Refactorizacion;Fowler;1999

Se lee así:

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class LeerConDelimitador {

    public static void leer(String ruta) throws FileNotFoundException {
        try (Scanner sc = new Scanner(new File(ruta))) {

            // Delimitador: un punto y coma O un salto de linea (en cualquiera
            // de sus tres formas). \\R representa cualquier terminador de linea.
            sc.useDelimiter(";|\\R");

            while (sc.hasNext()) {
                String isbn   = sc.next();
                String titulo = sc.next();
                String autor  = sc.next();
                int    anio   = sc.nextInt();

                System.out.printf("[%s] %-25s %-10s %d%n", isbn, titulo, autor, anio);
            }
        }
    }
}

Delimitadores útiles:

Patrón Significado
";" Solo punto y coma
";|\\R" Punto y coma o cualquier salto de línea
"\\s*,\\s*" Coma con espacios opcionales alrededor
"\\R" Solo saltos de línea: equivale a leer línea a línea
"\\Z" Fin de entrada: lee el fichero entero como un solo token

Ese último truco tiene su gracia para ficheros muy pequeños:

try (Scanner sc = new Scanner(new File("nota.txt"))) {
    sc.useDelimiter("\\Z");
    String todo = sc.hasNext() ? sc.next() : "";
    System.out.println(todo);
}

Funciona, pero es un apaño. Desde Java 11 existe Files.readString(Path), que hace lo mismo de forma explícita y correcta, y lo verás en 07-06. Reconoce el truco cuando lo leas en código ajeno; no lo escribas tú.

Y una advertencia que hay que dar ya, aunque se desarrolle en 07-07: partir un CSV por ; o por , es correcto solo mientras ningún campo contenga el separador. En cuanto aparezca un título como "Java: el lenguaje, la máquina y el ecosistema" dentro de comillas, este código produce campos mal alineados en silencio. La lección 07-07 implementa un lector CSV que sí lo hace bien.

  1. Codificación de caracteres: UTF-8 y el desastre de las tildes

Este apartado es el más importante de la lección. La mayoría de los fallos de E/S que verás en tu carrera profesional no son fallos de lógica: son fallos de codificación.

El problema de fondo

Un fichero contiene bytes. Un String contiene caracteres. Convertir de uno a otro requiere una tabla de correspondencias, y esa tabla es el charset o codificación.

  • ASCII (1963): 128 caracteres, un byte cada uno. Sin ñ, sin á, sin .
  • ISO-8859-1 (Latin-1): 256 caracteres, un byte cada uno. Tiene ñ y vocales acentuadas. No tiene ni nada no europeo occidental.
  • Windows-1252: variante de la anterior, la de Windows en Europa occidental. Casi igual pero no exactamente, lo que genera fallos sutiles.
  • UTF-8 (1993): cubre todo Unicode con longitud variable de 1 a 4 bytes. ASCII es un subconjunto exacto suyo. Es el estándar de facto de la web y del intercambio de datos.

Cuánto ocupa cada carácter en UTF-8:

Carácter Bytes en UTF-8 Bytes en ISO-8859-1
a 1 (0x61) 1 (0x61)
ñ 2 (0xC3 0xB1) 1 (0xF1)
3 (0xE2 0x82 0xAC) No representable
Un emoji 4 No representable

Consecuencia directa que sorprende a mucha gente: en UTF-8, el número de caracteres de un texto no es el número de bytes del fichero. "Diseño" son 6 caracteres y 7 bytes. Por eso File.length() (bytes) no te dice cuántos caracteres hay.

Qué pasa exactamente si te equivocas

Escribes "Patrones de Diseño" en UTF-8 y lo lees como ISO-8859-1. Los dos bytes de la ñ (0xC3 0xB1) se interpretan como dos caracteres independientes:

Escrito en UTF-8:      Patrones de Diseño
Leido como ISO-8859-1: Patrones de Diseño

Al revés, escribes en ISO-8859-1 y lees en UTF-8. El byte 0xF1 de la ñ no forma una secuencia UTF-8 válida, y el decodificador lo sustituye por el carácter de reemplazo:

Escrito en ISO-8859-1: Patrones de Diseño
Leido como UTF-8:      Patrones de Dise�o

Ese ñ y ese son los dos síntomas que hay que aprender a reconocer al instante. Cuando los veas, no busques el fallo en tu lógica: es codificación.

Y hay algo peor que la fealdad. Fíjate en este caso:

// Fichero escrito en UTF-8, leido como ISO-8859-1
String titulo = "Patrones de Diseño";       // lo que se ha cargado en memoria

// La busqueda del usuario, escrita correctamente, no encuentra nada:
if (titulo.equals("Patrones de Diseño")) {   // false
    // nunca entra
}
System.out.println(titulo.length());          // 19, no 18

Los datos siguen mal después de leerlos. Se guardarán mal, se compararán mal y se mostrarán mal. La corrupción se propaga.

La codificación por defecto y por qué no debes confiar en ella

Hasta Java 17 incluido, cuando no especificas charset, FileReader y FileWriter usan la codificación por defecto de la plataforma, que depende del sistema operativo y de la configuración regional:

Entorno Codificación por defecto histórica
Linux moderno UTF-8
macOS UTF-8
Windows en España Windows-1252
Windows en Japón Shift_JIS
Contenedor Docker minimalista Con frecuencia US-ASCII

Esto significa que el mismo programa leyendo el mismo fichero produce resultados distintos en máquinas distintas. Es la causa del clásico "en mi portátil se ve bien y en el servidor sale con interrogantes".

Java 18 (JEP 400) cambió el valor por defecto de file.encoding a UTF-8 en todas las plataformas, lo cual es una gran mejora. Pero:

  • Si trabajas con Java 17 —la versión de referencia de este curso y la más usada en producción— sigues teniendo el comportamiento antiguo.
  • Aunque tu proyecto sea Java 21, el código que especifica el charset es autoexplicativo y no depende de una propiedad que alguien pueda cambiar con -Dfile.encoding.

Regla profesional, sin excepciones: especifica siempre el charset explícitamente en toda operación de texto. No cuesta nada y elimina una categoría entera de fallos.

Cómo hacerlo bien

Desde Java 11, FileReader acepta un Charset en el constructor:

import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class LecturaConCharset {

    public static void leer(String ruta) throws IOException {
        // Java 11+: charset explicito en el constructor
        try (FileReader lector = new FileReader(ruta, StandardCharsets.UTF_8)) {
            int c;
            while ((c = lector.read()) != -1) {
                System.out.print((char) c);
            }
        }
    }
}

En versiones anteriores —y sigue siendo la forma más general, la que verás en 07-03— se usa el puente InputStreamReader:

import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

try (InputStreamReader lector =
             new InputStreamReader(new FileInputStream(ruta), StandardCharsets.UTF_8)) {
    // ...
}

Y Scanner también lo acepta:

try (Scanner sc = new Scanner(new File(ruta), StandardCharsets.UTF_8)) {
    // ...
}

Las constantes disponibles en java.nio.charset.StandardCharsets, garantizadas en toda JVM:

Constante Nombre
StandardCharsets.UTF_8 UTF-8. Tu opción por defecto siempre
StandardCharsets.ISO_8859_1 Latin-1. Solo para leer ficheros antiguos
StandardCharsets.US_ASCII ASCII de 7 bits
StandardCharsets.UTF_16 UTF-16 con marca de orden de bytes

Usa las constantes, no las cadenas. new FileReader(ruta, StandardCharsets.UTF_8) no puede fallar; la versión que acepta el nombre como cadena lanza UnsupportedEncodingException si escribes "UTF8", "utf-8 " con un espacio o cualquier otra errata. Es un error de compilación convertido en error de ejecución sin ninguna ventaja.

  1. FileNotFoundException frente a IOException

La E/S es el territorio natural de las excepciones comprobadas del módulo 6, y por un motivo que ahora resulta evidente: el fichero es del mundo exterior. Puede no existir, puede haber cambiado de permisos, el disco puede llenarse, alguien puede desconectar la unidad de red. Nada de eso es un fallo de tu programa, y todo es previsible.

La jerarquía relevante:

flowchart TD
    T["Throwable"] --> E["Exception"]
    E --> IOE["IOException<br/>(comprobada)"]
    IOE --> FNF["FileNotFoundException"]
    IOE --> EOF["EOFException"]
    IOE --> UEE["UnsupportedEncodingException"]
    IOE --> NSF["NoSuchFileException<br/>(NIO.2, 07-06)"]
    IOE --> ADE["AccessDeniedException<br/>(NIO.2, 07-06)"]
    IOE --> MFE["MalformedInputException<br/>(charset invalido)"]

    style IOE fill:#ffe0b2
    style FNF fill:#e3f2fd

Qué significa cada una en la práctica:

Excepción Cuándo se lanza Qué suele hacerse
FileNotFoundException El fichero no existe, es un directorio, o no hay permiso de lectura Degradar: usar un valor por defecto, o pedir la ruta correcta
IOException Fallo genérico al leer: disco, red, dispositivo Registrar y propagar. Rara vez recuperable
EOFException Fin inesperado leyendo datos binarios estructurados (07-03) Fichero truncado o corrupto: abortar la carga
MalformedInputException Bytes que no forman caracteres válidos en el charset dado Charset equivocado. Revisar el apartado 10

Nota engañosa sobre el nombre: FileNotFoundException no significa solo "no existe". También se lanza si la ruta apunta a un directorio o si el fichero existe pero no tienes permiso de lectura. Por eso el mensaje del sistema operativo es tan valioso y nunca debes descartarlo.

El orden de los catch sigue la regla de 06-02, de lo específico a lo general:

import java.io.FileNotFoundException;
import java.io.IOException;

public void cargar(String ruta) {
    try (Scanner sc = new Scanner(new File(ruta), StandardCharsets.UTF_8)) {
        // ... leer ...

    } catch (FileNotFoundException e) {
        // Lo especifico primero. Este caso se puede tratar de forma distinta.
        LOG.log(Level.WARNING, "No se encontro el fichero: " + ruta, e);

    } catch (IOException e) {
        // Lo general despues. Al reves NO COMPILA: codigo inalcanzable.
        LOG.log(Level.SEVERE, "Fallo de E/S leyendo " + ruta, e);
    }
}

  1. Degradación elegante: BiblioTech arranca sin catálogo

Vamos a juntarlo todo con la primera pieza real del módulo. CargadorCatalogo lee un fichero de texto y construye materiales, y —esto es lo importante— si el fichero no existe, la aplicación arranca igualmente con el catálogo vacío.

Este es exactamente el criterio de 06-07 para distinguir lo recuperable de lo irrecuperable: degrada cuando el servicio reducido siga siendo correcto. Una biblioteca sin materiales es una biblioteca vacía, que es un estado perfectamente válido y en el que se pueden dar de alta materiales. No hay nada incorrecto en ello. Muy distinto sería arrancar sin conocer la tarifa de las multas: ahí no se puede degradar, porque cobraríamos importes falsos.

El formato del fichero:

# Catalogo de BiblioTech - Nexus Software
# tipo;referencia;titulo;autor;anio
LIBRO;978-0000000001;Java Efectivo;Joshua Bloch;2018
LIBRO;978-0000000002;Patrones de Diseno;Erich Gamma;1994
LIBRO;978-0000000003;Refactorizacion;Martin Fowler;1999

Y el cargador:

package com.nexussoftware.bibliotech.servicio;

import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.ReferenciaDuplicadaException;

/**
 * Carga el catalogo de BiblioTech desde un fichero de texto.
 *
 * Politica de errores (06-07):
 *   - Fichero ausente        -> DEGRADAR: catalogo vacio y aviso en el log.
 *   - Linea con formato malo -> DESCARTAR esa linea y seguir con las demas.
 *   - Fallo de E/S grave     -> propagar como CatalogoNoAccesibleException.
 *
 * Esta version usa Scanner por claridad. En 07-04 se sustituye por
 * BufferedReader, que es lo correcto para ficheros de miles de lineas.
 */
public class CargadorCatalogo {

    private static final Logger LOG = Logger.getLogger(CargadorCatalogo.class.getName());

    private static final String COMENTARIO = "#";
    private static final String SEPARADOR  = ";";
    private static final int    CAMPOS_LIBRO = 5;

    private final String ruta;

    private int lineasLeidas     = 0;
    private int materialesAltas  = 0;
    private int lineasDescartadas = 0;

    public CargadorCatalogo(String ruta) {
        this.ruta = java.util.Objects.requireNonNull(ruta, "La ruta no puede ser nula");
    }

    /**
     * Carga el catalogo. NUNCA lanza por fichero ausente: degrada.
     *
     * @return un Catalogo, posiblemente vacio, nunca null
     */
    public Catalogo cargar() {
        Catalogo catalogo = new Catalogo();
        File fichero = new File(ruta);

        // Aviso util: registrar SIEMPRE la ruta absoluta, no la relativa (apartado 4)
        LOG.config(() -> "Cargando catalogo desde " + fichero.getAbsolutePath());

        try (Scanner sc = new Scanner(fichero, StandardCharsets.UTF_8)) {

            while (sc.hasNextLine()) {
                String linea = sc.nextLine();
                lineasLeidas++;
                procesarLinea(catalogo, linea, lineasLeidas);
            }

            LOG.info(String.format(
                    "Catalogo cargado: %d materiales de %d lineas (%d descartadas)",
                    materialesAltas, lineasLeidas, lineasDescartadas));

        } catch (FileNotFoundException e) {
            // DEGRADACION ELEGANTE. No es un fallo de la aplicacion: la primera
            // vez que se ejecuta BiblioTech, este fichero no existe todavia.
            // Nivel WARNING, no SEVERE: cuando todo es SEVERE nadie mira los
            // errores de verdad (06-07).
            LOG.log(Level.WARNING,
                    "No hay fichero de catalogo en " + fichero.getAbsolutePath()
                            + "; se arranca con el catalogo vacio", e);
        }

        return catalogo;
    }

    /** Procesa una linea. Los fallos de formato descartan la linea, no abortan la carga. */
    private void procesarLinea(Catalogo catalogo, String linea, int numero) {
        String limpia = linea.trim();

        // Lineas vacias y comentarios: se ignoran en silencio, son normales
        if (limpia.isEmpty() || limpia.startsWith(COMENTARIO)) {
            return;
        }

        String[] campos = limpia.split(SEPARADOR);

        if (campos.length != CAMPOS_LIBRO) {
            descartar(numero, "se esperaban " + CAMPOS_LIBRO
                    + " campos y hay " + campos.length);
            return;
        }
        if (!"LIBRO".equalsIgnoreCase(campos[0].trim())) {
            descartar(numero, "tipo desconocido '" + campos[0].trim() + "'");
            return;
        }

        try {
            String isbn   = campos[1].trim();
            String titulo = campos[2].trim();
            String autor  = campos[3].trim();
            int    anio   = Integer.parseInt(campos[4].trim());

            catalogo.registrar(new Libro(titulo, autor, isbn, anio));
            materialesAltas++;

        } catch (NumberFormatException e) {
            // El anio no era un numero. Se traduce a un descarte con contexto (06-03).
            descartar(numero, "el anio '" + campos[4].trim() + "' no es un numero");

        } catch (ReferenciaDuplicadaException e) {
            // Excepcion propia del modulo 6: el ISBN ya estaba. Se descarta la linea.
            descartar(numero, e.getMessage());
        }
    }

    private void descartar(int numero, String motivo) {
        lineasDescartadas++;
        LOG.warning(() -> "Linea " + numero + " descartada: " + motivo);
    }

    public int getLineasLeidas()      { return lineasLeidas; }
    public int getMaterialesAltas()   { return materialesAltas; }
    public int getLineasDescartadas() { return lineasDescartadas; }
}

Y el arranque de la aplicación queda así:

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.servicio.CargadorCatalogo;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.infraestructura.ConfiguracionLog;

public class BiblioTechApp {

    /** Ruta por defecto; se puede cambiar por argumento de linea de comandos. */
    private static final String RUTA_CATALOGO_DEFECTO = "datos/catalogo.txt";

    public static void main(String[] args) {
        ConfiguracionLog.inicializar();          // 06-07

        String ruta = (args.length > 0) ? args[0] : RUTA_CATALOGO_DEFECTO;

        Catalogo catalogo = new CargadorCatalogo(ruta).cargar();

        System.out.println("BiblioTech - Nexus Software");
        System.out.println("Materiales en catalogo: " + catalogo.tamano());

        new MenuBiblioTech(catalogo).ejecutar();
    }
}

Las tres decisiones de diseño que conviene señalar:

  1. La ruta llega por argumento, con un valor por defecto. Es la estrategia recomendada del apartado 4: quien ejecuta decide dónde están los datos.
  2. cargar() nunca devuelve null. Devuelve un Catalogo vacío si no hay fichero. Es la regla que 06-07 estableció: el null no es una forma de comunicar nada.
  3. Una línea mala no aborta el fichero. Se descarta, se cuenta y se registra. Que un catálogo de diez mil líneas no se cargue porque la línea 4 200 tiene un año mal escrito sería una política pésima. Esto es el "objeto resultado" de 06-07 aplicado a la importación, y en 07-04 se convertirá en un informe completo.

  1. Ficheros grandes y por qué no leerlo todo en memoria

Cerramos con una advertencia de dimensionamiento. Es tentador escribir esto:

// PELIGROSO con ficheros grandes
String todo = leerFicheroEnteroComoCadena("datos/historico-prestamos.txt");

Y para un fichero de configuración de 2 KB es perfectamente razonable. Pero para el histórico de préstamos de tres años, no:

Tamaño del fichero Memoria aproximada del String Viable con heap de 512 MB
10 KB ~20 KB Sí, sin pensarlo
5 MB ~10 MB
200 MB ~400 MB Al límite, con riesgo
2 GB ~4 GB OutOfMemoryError

Por qué el factor de dos: internamente un String de Java 9 en adelante guarda un byte por carácter cuando todo el texto es Latin-1 y dos bytes por carácter en cuanto aparece un carácter fuera de ese rango. Un solo emoji o un carácter cirílico en un fichero de 2 GB duplica la memoria del String completo. Y además necesitas el StringBuilder intermedio mientras lo construyes, así que en el pico llegas a tener el contenido dos veces.

Un OutOfMemoryError es un Error, no una Exception: no se captura, no se recupera y tumba el proceso, con la salvedad de la frontera de main que viste en 06-07.

La alternativa correcta es procesar en flujo (streaming): leer una línea, procesarla, olvidarla, leer la siguiente. La memoria usada es la de una línea, no la del fichero, y el consumo es constante aunque el fichero tenga 50 GB:

// Esquema de procesamiento en flujo. La version completa, en 07-04.
try (BufferedReader lector = new BufferedReader(
        new FileReader(ruta, StandardCharsets.UTF_8))) {

    String linea;
    while ((linea = lector.readLine()) != null) {
        procesar(linea);          // se usa y se descarta
    }
}

La regla práctica:

Carga el fichero entero en memoria solo si conoces su tamaño máximo y es pequeño. Configuración, plantillas, ficheros de pocos KB: adelante. Datos, registros, exportaciones, cualquier cosa que crezca con el uso: procesa en flujo.

Esa es exactamente la razón de ser de BufferedReader, que es la lección 07-04.

Errores Comunes y Consejos

  • Usar rutas relativas y no saber respecto a qué. El clásico "funciona en el IDE y falla en producción". Imprime System.getProperty("user.dir") en cuanto tengas dudas y pasa las rutas por parámetro.
  • Informar del error con la ruta relativa. No se encuentra 'datos/catalogo.txt' no sirve para nada. Registra siempre getAbsolutePath().
  • No especificar el charset. El error más caro del módulo a largo plazo, porque no falla: corrompe en silencio. StandardCharsets.UTF_8, siempre, en toda operación de texto.
  • Confundir ñ con ?. ñ significa que escribiste UTF-8 y leíste Latin-1. ? o significa lo contrario. Reconocer el síntoma te ahorra media hora cada vez.
  • Usar cadenas en lugar de constantes de charset. "UTF8" compila y explota en ejecución. StandardCharsets.UTF_8 no puede escribirse mal.
  • Declarar char c = lector.read(). No compila, y por un buen motivo: hace falta el -1 para el fin de fichero, que no cabe en un char.
  • Olvidar los paréntesis en while ((c = lector.read()) != -1). Sin ellos no compila. Con ellos, es el bucle idiomático que verás siempre.
  • Usar append(bloque) en lugar de append(bloque, 0, leidos). Añade basura de la vuelta anterior en la última lectura. Falla solo al final del fichero, que es lo que lo hace difícil de diagnosticar.
  • Llamar a nextLine() sin comprobar hasNextLine(). NoSuchElementException, no comprobada, en ejecución.
  • Mezclar nextInt() y nextLine() sin consumir el salto de línea. La misma trampa del módulo 1, idéntica sobre ficheros.
  • Confiar en nextDouble() sin fijar el Locale. En una máquina con configuración española, 0.25 puede fallar. sc.useLocale(Locale.ROOT).
  • Leer carácter a carácter un fichero grande. Correcto y cien veces más lento. Bloques o BufferedReader (07-04).
  • Cargar en un String un fichero de tamaño desconocido. OutOfMemoryError, que no se captura. Procesa en flujo.
  • Interpretar length() == 0 como "no existe". Un fichero vacío da lo mismo. Usa exists().
  • Ignorar que listFiles() puede devolver null. NullPointerException en el for. NIO.2 lo resuelve (07-06).
  • Consejo: escribe una única clase que sepa leer cada tipo de fichero. Si tienes new FileReader(...) repartido por veinte clases, cambiar el charset o el formato será un rastreo. Concentra la E/S en la capa que le corresponde, como hace CargadorCatalogo.
  • Consejo: prueba siempre con un fichero que contenga ñ, á y . Un fichero de prueba solo con ASCII te ocultará todos los fallos de codificación hasta que llegue a producción.
  • Consejo: prueba también con el fichero ausente y con el fichero vacío. Son los dos casos que más se olvidan y los dos que más ocurren, sobre todo el primer día de vida del sistema.

Ejercicios

Ejercicio 1: diagnóstico de rutas

Escribe una clase DiagnosticoRuta con un método public static void diagnosticar(String ruta) que imprima un informe completo y útil sobre una ruta, pensado para que alguien pueda resolver un problema con él:

  1. La ruta tal como se dio y su forma absoluta.
  2. El directorio de trabajo actual.
  3. Si existe; y si no existe, si existe su directorio padre (para distinguir "el fichero falta" de "el directorio no es ese").
  4. Si existe: si es fichero o directorio, si es legible, su tamaño y el número de líneas.
  5. Un diagnóstico final en una frase con la causa más probable.

Pruébalo con una ruta correcta, una inexistente y un directorio.

Ejercicio 2: detector de codificación

Escribe DetectorCodificacion con un método public static void comparar(String ruta) que lea el mismo fichero tres veces —como UTF-8, como ISO-8859-1 y como US-ASCII— y muestre las tres primeras líneas de cada lectura, para ver el efecto sobre las tildes y las eñes.

Después, añade public static boolean pareceUtf8(String ruta), que devuelva true si el fichero se decodifica sin ningún carácter de reemplazo (\uFFFD) al leerlo como UTF-8. Explica en un comentario por qué esa comprobación detecta "esto no es UTF-8" pero no puede detectar "esto es Latin-1 leído como UTF-8 al revés".

Prepara un fichero de prueba con Patrones de Diseño, Refactorización y Año 2018.

Ejercicio 3: cargador de empleados con informe

Amplía la idea de CargadorCatalogo a los empleados. Dado un fichero como este:

# identificador;nombre;prestamos_activos
E-001;Marta Ruiz;2
E-002;Diego Alonso;0
E-003;Nuria Vidal;1

Escribe CargadorEmpleados con:

  1. public ResultadoCarga cargar(), que devuelva un objeto resultado (idea de 06-07) con la lista de empleados cargados y la lista de errores, en lugar de lanzar en el primer fallo.
  2. Validación de cada línea: exactamente 3 campos, identificador con formato E-NNN, nombre no vacío y número de préstamos entre 0 y Empleado.MAX_PRESTAMOS_SIMULTANEOS.
  3. Degradación si el fichero no existe: resultado vacío con un aviso, sin excepción.
  4. Charset explícito y try-with-resources.
  5. Un main de demostración que imprima el informe con printf.

Añade al fichero de prueba una línea con dos campos, una con un identificador X-999 y una con 9 préstamos, para comprobar que las tres se descartan con su motivo y las buenas se cargan.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.util;

import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

/**
 * Informe de diagnostico de una ruta.
 *
 * El objetivo NO es saber si el fichero existe, sino poder explicar POR QUE
 * no existe, que es lo que de verdad hace falta para arreglarlo (06-04).
 */
public class DiagnosticoRuta {

    public static void diagnosticar(String ruta) {
        File f = new File(ruta);

        System.out.println("========================================");
        System.out.println("DIAGNOSTICO DE: " + ruta);
        System.out.println("========================================");

        // 1. Ruta dada y ruta resuelta
        System.out.println("  Ruta dada        : " + f.getPath());
        System.out.println("  Ruta absoluta    : " + f.getAbsolutePath());
        System.out.println("  ¿Es absoluta?    : " + f.isAbsolute());

        // 2. Contexto de ejecucion: la pieza que casi siempre falta
        System.out.println("  Directorio actual: " + System.getProperty("user.dir"));

        // 3. Existencia, distinguiendo fichero de directorio padre
        boolean existe = f.exists();
        System.out.println("  ¿Existe?         : " + existe);

        File padre = f.getAbsoluteFile().getParentFile();
        boolean existePadre = (padre != null && padre.exists());
        System.out.println("  Directorio padre : "
                + (padre == null ? "(ninguno)" : padre.getAbsolutePath())
                + (existePadre ? "  [existe]" : "  [NO existe]"));

        // 4. Detalles solo si existe
        if (existe) {
            System.out.println("  ¿Es fichero?     : " + f.isFile());
            System.out.println("  ¿Es directorio?  : " + f.isDirectory());
            System.out.println("  ¿Legible?        : " + f.canRead());
            System.out.println("  ¿Escribible?     : " + f.canWrite());
            System.out.println("  Tamano           : " + f.length() + " bytes");

            if (f.isFile() && f.canRead()) {
                System.out.println("  Lineas           : " + contarLineas(f));
            }
        }

        // 5. Conclusion en una frase
        System.out.println("  ----");
        System.out.println("  CONCLUSION: " + concluir(f, existe, existePadre));
        System.out.println();
    }

    /** Cuenta lineas de forma segura: si falla, lo dice en lugar de propagarlo. */
    private static String contarLineas(File f) {
        try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
            int n = 0;
            while (sc.hasNextLine()) {
                sc.nextLine();
                n++;
            }
            return String.valueOf(n);
        } catch (FileNotFoundException e) {
            // Puede pasar entre el exists() y el open(): es una condicion de
            // carrera TOCTOU, exactamente la advertida en 06-07.
            return "(no se pudo leer: " + e.getMessage() + ")";
        }
    }

    private static String concluir(File f, boolean existe, boolean existePadre) {
        if (existe && f.isFile() && f.canRead()) {
            return "El fichero existe y se puede leer. Todo correcto.";
        }
        if (existe && f.isDirectory()) {
            return "La ruta apunta a un DIRECTORIO, no a un fichero. "
                    + "Al abrirlo se lanzaria FileNotFoundException, "
                    + "pese a lo que sugiere su nombre.";
        }
        if (existe && !f.canRead()) {
            return "El fichero existe pero el usuario que ejecuta la JVM "
                    + "no tiene permiso de lectura. Revisa los permisos.";
        }
        if (!existe && !existePadre) {
            return "No existe ni el fichero ni su directorio. Lo mas probable "
                    + "es que la ruta relativa se este resolviendo desde otro "
                    + "directorio de trabajo del esperado.";
        }
        return "El directorio existe pero el fichero no. Comprueba el nombre "
                + "exacto, incluidas mayusculas: en Linux distinguen.";
    }

    public static void main(String[] args) {
        diagnosticar("datos/catalogo.txt");     // correcta
        diagnosticar("datos");                  // un directorio
        diagnosticar("datos/no-existe.txt");    // falta el fichero
        diagnosticar("otra/ruta/mala.txt");     // falta el directorio
    }
}

Lo que enseña este ejercicio: la diferencia entre detectar un fallo y explicarlo. Los cinco casos de concluir corresponden a cinco causas distintas que la API resume todas en un exists() a false. Distinguirlas es lo que convierte un mensaje de error en una solución. Fíjate también en el comentario sobre TOCTOU: entre comprobar exists() y abrir el fichero puede pasar cualquier cosa, así que la comprobación previa no sustituye al catch.

Solución 2

package com.nexussoftware.bibliotech.util;

import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
 * Muestra el efecto de leer el mismo fichero con distintas codificaciones.
 */
public class DetectorCodificacion {

    /** Carácter de reemplazo que inserta el decodificador ante bytes invalidos. */
    private static final char REEMPLAZO = '\uFFFD';

    public static void comparar(String ruta) {
        Charset[] candidatos = {
                StandardCharsets.UTF_8,
                StandardCharsets.ISO_8859_1,
                StandardCharsets.US_ASCII
        };

        for (Charset cs : candidatos) {
            System.out.println("--- Leido como " + cs.name() + " ---");
            try {
                String[] lineas = primerasLineas(ruta, cs, 3);
                for (String l : lineas) {
                    System.out.println("    " + l);
                }
            } catch (IOException e) {
                System.out.println("    ERROR: " + e.getMessage());
            }
            System.out.println();
        }
    }

    /** Lee las n primeras lineas con el charset dado. */
    private static String[] primerasLineas(String ruta, Charset cs, int n) throws IOException {
        String[] resultado = new String[n];
        int indice = 0;
        StringBuilder actual = new StringBuilder();

        try (FileReader lector = new FileReader(ruta, cs)) {
            int c;
            while (indice < n && (c = lector.read()) != -1) {
                if (c == '\n') {
                    resultado[indice++] = actual.toString();
                    actual.setLength(0);
                } else if (c != '\r') {
                    actual.append((char) c);
                }
            }
        }
        // Ultima linea sin salto final
        if (indice < n && actual.length() > 0) {
            resultado[indice++] = actual.toString();
        }
        for (int i = indice; i < n; i++) {
            resultado[i] = "(no hay mas lineas)";
        }
        return resultado;
    }

    /**
     * ¿Se decodifica el fichero como UTF-8 sin caracteres de reemplazo?
     *
     * POR QUE FUNCIONA EN UN SENTIDO: UTF-8 tiene una gramatica estricta.
     * El byte 0xF1 (la 'ñ' de Latin-1) anuncia una secuencia de 4 bytes que
     * en un fichero Latin-1 no vendra bien formada, asi que el decodificador
     * inserta \uFFFD. Detectamos "esto NO es UTF-8".
     *
     * POR QUE NO FUNCIONA EN EL OTRO: un fichero UTF-8 leido como Latin-1
     * NUNCA produce reemplazos, porque en Latin-1 los 256 bytes posibles son
     * caracteres validos. Simplemente salen los caracteres equivocados
     * ("Diseño"). Un decodificador no puede saber que eso esta mal: para el
     * es texto perfectamente legal. Por eso NO EXISTE deteccion fiable de
     * codificacion, y por eso hay que ESPECIFICARLA siempre.
     */
    public static boolean pareceUtf8(String ruta) throws IOException {
        try (FileReader lector = new FileReader(ruta, StandardCharsets.UTF_8)) {
            int c;
            while ((c = lector.read()) != -1) {
                if (c == REEMPLAZO) {
                    return false;
                }
            }
        }
        return true;
    }

    public static void main(String[] args) throws IOException {
        String ruta = "datos/prueba-acentos.txt";
        comparar(ruta);
        System.out.println("¿Parece UTF-8? " + pareceUtf8(ruta));
    }
}

Con un fichero de prueba guardado en UTF-8 que contenga Patrones de Diseño, Refactorización y Año 2018, la salida es:

--- Leido como UTF-8 ---
    Patrones de Diseño
    Refactorización
    Año 2018

--- Leido como ISO-8859-1 ---
    Patrones de Diseño
    Refactorización
    Año 2018

--- Leido como US-ASCII ---
    Patrones de Dise??o
    Refactorizaci??n
    A??o 2018

¿Parece UTF-8? true

La conclusión práctica es la del comentario: la detección de codificación es fundamentalmente imposible en el caso general. Los editores de texto la adivinan con heurísticas y a veces se equivocan. Por eso ninguna cantidad de listeza en el código sustituye a la regla simple: especifica siempre el charset, al leer y al escribir.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.Empleado;

/**
 * Carga empleados desde un fichero de texto acumulando errores en lugar
 * de abortar en el primero (objeto resultado, 06-07).
 */
public class CargadorEmpleados {

    private static final Logger LOG = Logger.getLogger(CargadorEmpleados.class.getName());

    private static final String SEPARADOR   = ";";
    private static final String COMENTARIO  = "#";
    private static final int    NUM_CAMPOS  = 3;
    private static final String PATRON_ID   = "E-\\d{3}";

    private final String ruta;

    public CargadorEmpleados(String ruta) {
        this.ruta = Objects.requireNonNull(ruta, "La ruta no puede ser nula");
    }

    /** Resultado de la carga: lo que salio bien y lo que salio mal, junto. */
    public static class ResultadoCarga {
        private final List<Empleado> empleados = new ArrayList<>();
        private final List<String>   errores   = new ArrayList<>();
        private boolean ficheroEncontrado = true;

        void anadir(Empleado e)          { empleados.add(e); }
        void error(int linea, String m)  { errores.add("Linea " + linea + ": " + m); }
        void marcarFicheroAusente()      { ficheroEncontrado = false; }

        public List<Empleado> getEmpleados()  { return List.copyOf(empleados); }
        public List<String>   getErrores()    { return List.copyOf(errores); }
        public boolean hayErrores()           { return !errores.isEmpty(); }
        public boolean ficheroEncontrado()    { return ficheroEncontrado; }
        public int total()                    { return empleados.size() + errores.size(); }
    }

    public ResultadoCarga cargar() {
        ResultadoCarga resultado = new ResultadoCarga();
        File fichero = new File(ruta);

        try (Scanner sc = new Scanner(fichero, StandardCharsets.UTF_8)) {
            int numero = 0;
            while (sc.hasNextLine()) {
                numero++;
                procesar(sc.nextLine(), numero, resultado);
            }

        } catch (FileNotFoundException e) {
            // DEGRADACION: resultado vacio, aviso, y sin excepcion hacia arriba
            resultado.marcarFicheroAusente();
            LOG.log(Level.WARNING, "No hay fichero de empleados en "
                    + fichero.getAbsolutePath() + "; se arranca sin empleados", e);
        }
        return resultado;
    }

    private void procesar(String linea, int numero, ResultadoCarga r) {
        String limpia = linea.trim();
        if (limpia.isEmpty() || limpia.startsWith(COMENTARIO)) {
            return;                                   // normal, no es un error
        }

        // -1 en split conserva los campos vacios del final: "E-001;;" da 3 campos.
        // Sin el -1 daria 1, y el mensaje de error seria enganoso.
        String[] campos = limpia.split(SEPARADOR, -1);

        if (campos.length != NUM_CAMPOS) {
            r.error(numero, "se esperaban " + NUM_CAMPOS + " campos y hay " + campos.length);
            return;
        }

        String id     = campos[0].trim();
        String nombre = campos[1].trim();
        String activos = campos[2].trim();

        if (!id.matches(PATRON_ID)) {
            r.error(numero, "identificador '" + id + "' no tiene el formato E-NNN");
            return;
        }
        if (nombre.isEmpty()) {
            r.error(numero, "el nombre esta vacio");
            return;
        }

        int prestamos;
        try {
            prestamos = Integer.parseInt(activos);
        } catch (NumberFormatException e) {
            r.error(numero, "'" + activos + "' no es un numero de prestamos valido");
            return;
        }
        if (prestamos < 0 || prestamos > Empleado.MAX_PRESTAMOS_SIMULTANEOS) {
            r.error(numero, "prestamos activos fuera de rango (0.."
                    + Empleado.MAX_PRESTAMOS_SIMULTANEOS + "): " + prestamos);
            return;
        }

        Empleado empleado = new Empleado(nombre, id);
        for (int i = 0; i < prestamos; i++) {
            empleado.registrarPrestamo();             // restaura el estado guardado
        }
        r.anadir(empleado);
    }

    public static void main(String[] args) {
        ResultadoCarga r = new CargadorEmpleados("datos/empleados.txt").cargar();

        System.out.println("=== CARGA DE EMPLEADOS ===");
        if (!r.ficheroEncontrado()) {
            System.out.println("  (no habia fichero: se arranca sin empleados)");
            return;
        }

        System.out.printf("  Lineas procesadas : %d%n", r.total());
        System.out.printf("  Empleados cargados: %d%n", r.getEmpleados().size());
        System.out.printf("  Lineas con error  : %d%n", r.getErrores().size());

        if (!r.getEmpleados().isEmpty()) {
            System.out.println("  --- Cargados ---");
            for (Empleado e : r.getEmpleados()) {
                System.out.printf("    %-8s %s%n", e.getIdentificador(), e.getNombre());
            }
        }
        if (r.hayErrores()) {
            System.out.println("  --- Descartados ---");
            for (String error : r.getErrores()) {
                System.out.println("    " + error);
            }
        }
    }
}

Con este fichero de prueba:

# identificador;nombre;prestamos_activos
E-001;Marta Ruiz;2
E-002;Diego Alonso;0
E-003;Nuria Vidal;1
E-004;Solo dos campos
X-999;Formato malo;1
E-005;Demasiados;9

La salida es:

=== CARGA DE EMPLEADOS ===
  Lineas procesadas : 6
  Empleados cargados: 3
  Lineas con error  : 3
  --- Cargados ---
    E-001    Marta Ruiz
    E-002    Diego Alonso
    E-003    Nuria Vidal
  --- Descartados ---
    Linea 5: se esperaban 3 campos y hay 2
    Linea 6: identificador 'X-999' no tiene el formato E-NNN
    Linea 7: prestamos activos fuera de rango (0..3): 9

Las tres decisiones que merece la pena señalar:

  1. El objeto resultado en lugar de una excepción por línea. Quien importa un fichero quiere ver todos los problemas de una vez para corregirlos y volver a intentarlo, no descubrirlos de uno en uno. Es exactamente el caso de uso que 06-07 planteó para el objeto resultado.
  2. split(SEPARADOR, -1). Sin ese -1, split descarta los campos vacíos finales, así que E-001;; daría 1 campo en vez de 3 y el mensaje de error sería incorrecto. Es una trampa clásica que reaparecerá en 07-07.
  3. El fichero ausente no es un error de línea. Se marca aparte, porque son dos situaciones distintas: "no hay datos" y "hay datos mal escritos". Confundirlas produce informes inútiles.

Conclusión

Has puesto los cimientos de la persistencia de BiblioTech.

Entiendes qué es un fichero de verdad: una secuencia de bytes con un nombre, sin tipos ni estructura, frente a un objeto en memoria con campos y referencias. Sabes que pasar de uno a otro exige una decisión de formato explícita —un material por línea, campos separados por ;— y que esa decisión es el germen del CSV de 07-07. Y conoces el recorrido completo del dato hasta el disco, con la conclusión que gobierna todo el módulo: la llamada al sistema es la operación cara, y todo el diseño de la E/S existe para reducir su número.

Sabes localizar un fichero: rutas absolutas y relativas, el papel de user.dir como origen de las relativas, y por qué el mismo programa "no encuentra" el mismo fichero según desde dónde se lance. Tienes las cinco estrategias profesionales para no depender de eso, con la recomendación de pasar la ruta por parámetro, y la regla de registrar siempre la ruta absoluta en los mensajes de error. Conoces File.separator frente a File.pathSeparator y por qué es mejor componer rutas que concatenar cadenas.

Conoces java.io.Fileexists, isFile, canRead, length, getAbsolutePath— y, sobre todo, el defecto que la condenó: los boolean mudos que dicen que algo falló sin decir por qué, exactamente el antipatrón que el módulo 6 erradicó. Sabes que 07-06 la sustituye por Path y Files, y que la conversión entre ambas es un toPath().

Sabes leer con FileReader carácter a carácter, con el int de retorno y el -1 de fin de fichero, y por qué ese bucle es dos órdenes de magnitud más lento de lo necesario. Conoces la lectura por bloques con read(char[]) y sus dos trampas: el valor de retorno que puede ser menor que el array, y el append(bloque, 0, leidos) que evita arrastrar basura. Y sabes leer con Scanner sobre fichero, con hasNextLine/nextLine, el parseo de tipos, useDelimiter con expresiones regulares, y sus dos sorpresas: el Locale que cambia el separador decimal y el nextInt() que no consume el salto de línea.

Y tienes el apartado que más incidencias reales evita: la codificación. Sabes qué es UTF-8, por qué ñ ocupa dos bytes, qué significa exactamente ver ñ y qué significa ver , por qué la codificación por defecto de la plataforma es una bomba de relojería entre máquinas, qué cambió Java 18 y por qué eso no te exime de especificarla. Y sabes que detectar la codificación es imposible en el caso general, lo que convierte la regla en absoluta: StandardCharsets.UTF_8, explícito, siempre.

BiblioTech ya lee. CargadorCatalogo construye el catálogo desde un fichero de texto, descarta las líneas mal formadas contándolas y registrándolas en lugar de abortar la carga entera, traduce NumberFormatException y ReferenciaDuplicadaException a descartes con contexto, y —lo más importante— degrada elegantemente: si no hay fichero de catálogo, arranca con el catálogo vacío y un WARNING, porque una biblioteca vacía es un estado correcto. Es la política de errores de 06-07 aplicada a la E/S desde la primera línea de código.

Queda la mitad que falta, y es la que da miedo. Leer es seguro; escribir destruye. En la lección 07-02, Escritura de Archivos, verás FileWriter y el parámetro append que decide entre añadir al final y borrar el fichero entero, que es el error más caro de todo el módulo; PrintWriter con el printf que ya usas desde el módulo 1; el búfer y el vaciado, y por qué lo que has escrito puede no estar en el disco hasta que cierres; el patrón de escritura atómica —escribir a un temporal y renombrar— que impide dejar un fichero a medias cuando algo falla a mitad, hermano directo de la compensación en finally que ya sabes escribir; y el separador de línea del sistema, con el motivo por el que \n no siempre basta. Al terminarla, ExportadorCatalogo dejará de ser una clase que sabe cerrarse pero no escribir, y BiblioTech guardará por primera vez lo que ha hecho.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados