Módulo 17: Ficheros

Objetivos del módulo

  • Entender qué es la persistencia y distinguir ficheros de texto y binarios
  • Trabajar con rutas absolutas y relativas mediante Path y Path.of
  • Usar la clase Files para crear, leer, escribir, copiar, mover, listar y borrar
  • Leer y escribir texto con BufferedReader y BufferedWriter en UTF-8, y añadir al final de un fichero
  • Leer y escribir ficheros CSV, y cargar objetos desde ellos
  • Leer ficheros con Scanner
  • Escribir y leer ficheros binarios con DataOutputStream y DataInputStream
  • Serializar objetos (Serializable, transient, serialVersionUID) y conocer sus riesgos
  • Gestionar los errores de entrada/salida (IOException, NoSuchFileException…)
  • Conocer la clase heredada File

1. Persistencia: por qué guardar datos en ficheros

Todo lo que has programado hasta ahora vive en la memoria RAM: variables, arrays, objetos, colecciones. Cuando el programa termina (o se va la luz), todo se pierde. Una agenda que olvida los contactos al cerrarse no sirve de mucho.

📘 Concepto: La persistencia es la capacidad de un programa de conservar datos entre ejecuciones, guardándolos en un almacenamiento permanente: un fichero en disco o una base de datos (Curso Avanzado). Un fichero es una secuencia de bytes con un nombre, guardada dentro de una carpeta (directorio).

1.1. Ficheros de texto y ficheros binarios

  Fichero de texto Fichero binario
Contenido Caracteres legibles, organizados en líneas Bytes con cualquier valor: números en formato interno, imágenes, sonido…
¿Se puede abrir con un editor? Sí (Bloc de notas, VS Code) No: verías símbolos sin sentido
Ejemplos .txt, .csv, .json, .html, .java, .log .png, .mp3, .pdf, .class, .dat
Ventajas Legible, portable, fácil de depurar y de editar Compacto y rápido; guarda los tipos exactamente
Clases de Java Reader/Writer, Files.readString… InputStream/OutputStream, DataInputStream…

En un fichero de texto, cada carácter se guarda como uno o varios bytes según una codificación (charset). La más usada hoy es UTF-8, que representa todos los caracteres del mundo (ñ, á, €, emojis…). Desde Java 18, UTF-8 es la codificación por defecto en todas las plataformas, y las clases de java.nio.file.Files siempre la han usado por defecto.

⚠️ Importante: Si un fichero se escribe con una codificación y se lee con otra, las tildes y las eñes se convierten en caracteres raros (canción). Usa UTF-8 siempre y, cuando una clase permita indicarlo, indícalo explícitamente (StandardCharsets.UTF_8).


2. Rutas: Path

2.1. Rutas absolutas y relativas

Para trabajar con un fichero hay que indicar dónde está mediante una ruta:

Árbol de carpetas con la ruta absoluta desde la raíz y la relativa desde el directorio de trabajo Figura 1. Rutas absolutas y relativas.

  • Una ruta absoluta empieza en la raíz del sistema de ficheros: /home/alumno/proyecto/datos/clientes.csv (Linux y macOS) o C:\Users\alumno\proyecto\datos\clientes.csv (Windows).
  • Una ruta relativa se interpreta a partir del directorio de trabajo del programa: datos/clientes.csv. Cuando ejecutas desde IntelliJ IDEA, el directorio de trabajo es, por defecto, la carpeta raíz del proyecto (no la carpeta src); desde la terminal, la carpeta en la que estás.

💡 Consejo: Usa rutas relativas en tus programas (por ejemplo, una carpeta datos dentro del proyecto). Una ruta absoluta como C:\Users\Ana\Escritorio\datos.txt solo funciona en el ordenador de Ana.

2.2. La interfaz Path

Desde Java 7, las rutas se representan con la interfaz java.nio.file.Path. Se crean con Path.of(...) (Java 11), que une las partes con el separador correcto del sistema (/ o \). En código antiguo verás Paths.get(...), que hace exactamente lo mismo.

import java.nio.file.Path;

public class Rutas {
    public static void main(String[] args) {
        System.out.println("Directorio de trabajo: " + System.getProperty("user.dir"));

        Path relativa = Path.of("datos", "clientes.csv");     // une con el separador del sistema
        System.out.println("Ruta relativa: " + relativa);
        System.out.println("  ¿Absoluta? " + relativa.isAbsolute());
        System.out.println("  Absoluta: " + relativa.toAbsolutePath());
        System.out.println("  Nombre del fichero: " + relativa.getFileName());
        System.out.println("  Carpeta padre: " + relativa.getParent());
        System.out.println("  Número de partes: " + relativa.getNameCount());

        Path base = Path.of("informes");
        System.out.println("resolve: " + base.resolve("2026").resolve("enero.txt"));
        System.out.println("resolveSibling: " + relativa.resolveSibling("proveedores.csv"));

        Path rara = Path.of("datos/../informes/./enero.txt");
        System.out.println("Sin normalizar: " + rara);
        System.out.println("Normalizada:    " + rara.normalize());

        Path desde = Path.of("informes", "2026");
        Path hasta = Path.of("datos", "clientes.csv");
        System.out.println("Cómo ir de " + desde + " a " + hasta + ": " + desde.relativize(hasta));
    }
}

Salida (ejecutado en Linux, en la carpeta /home/alumno/proyecto):

Directorio de trabajo: /home/alumno/proyecto
Ruta relativa: datos/clientes.csv
  ¿Absoluta? false
  Absoluta: /home/alumno/proyecto/datos/clientes.csv
  Nombre del fichero: clientes.csv
  Carpeta padre: datos
  Número de partes: 2
resolve: informes/2026/enero.txt
resolveSibling: datos/proveedores.csv
Sin normalizar: datos/../informes/./enero.txt
Normalizada:    informes/enero.txt
Cómo ir de informes/2026 a datos/clientes.csv: ../../datos/clientes.csv

En Windows verías las mismas rutas con \ (datos\clientes.csv, C:\Users\alumno\proyecto\datos\clientes.csv). Crear un Path no crea ni comprueba nada en el disco: es solo una dirección.

Método Qué devuelve
Path.of("a", "b", "c.txt") La ruta a/b/c.txt
getFileName() / getParent() El último elemento / la ruta sin el último elemento
toAbsolutePath() / isAbsolute() La ruta absoluta / si ya lo es
resolve("x") La ruta con x añadido al final (entrar en una subcarpeta o fichero)
resolveSibling("x") Un “hermano”: misma carpeta, otro nombre
normalize() Elimina . y .. innecesarios
relativize(otra) La ruta relativa para ir de esta a otra

3. La clase Files

La clase java.nio.file.Files reúne métodos estáticos para casi todo lo que se hace con ficheros y carpetas. Casi todos lanzan IOException, una excepción comprobada (módulo 15), así que hay que capturarla o declararla con throws. En los primeros ejemplos la declaramos en main para centrarnos en los ficheros; en la sección 4 verás cómo tratarla bien.

import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.stream.Stream;

public class FilesBasico {
    public static void main(String[] args) throws IOException {
        Path carpeta = Path.of("datos");
        Files.createDirectories(carpeta);                 // no falla si ya existe
        Path saludo = carpeta.resolve("saludo.txt");

        // Escribir y leer un fichero completo de una vez
        Files.writeString(saludo, "Hola, mundo\nSegunda línea con ñ y tildes: canción\n");
        String contenido = Files.readString(saludo);      // UTF-8 por defecto
        System.out.print("readString:\n" + contenido);

        // Trabajar línea a línea
        List<String> lineas = Files.readAllLines(saludo);
        System.out.println("readAllLines: " + lineas.size() + " líneas -> " + lineas);
        Files.write(carpeta.resolve("compra.txt"), List.of("pan", "leche", "huevos"));

        // Información
        System.out.println("¿Existe? " + Files.exists(saludo)
                + ", ¿es fichero? " + Files.isRegularFile(saludo)
                + ", ¿es carpeta? " + Files.isDirectory(carpeta));
        System.out.println("Tamaño: " + Files.size(saludo) + " bytes");

        // Copiar, mover y borrar
        Files.copy(saludo, carpeta.resolve("copia.txt"), StandardCopyOption.REPLACE_EXISTING);
        Files.move(carpeta.resolve("copia.txt"), carpeta.resolve("renombrado.txt"),
                StandardCopyOption.REPLACE_EXISTING);

        // Listar el contenido de una carpeta
        System.out.println("Contenido de " + carpeta + ":");
        try (DirectoryStream<Path> dir = Files.newDirectoryStream(carpeta)) {
            for (Path p : dir) {
                System.out.println("  " + p.getFileName() + " (" + Files.size(p) + " bytes)");
            }
        }

        // Files.lines y Files.list devuelven un Stream (lectura "bajo demanda"): hay que cerrarlos
        try (Stream<String> lineasStream = Files.lines(saludo);
             Stream<Path> entradas = Files.list(carpeta)) {
            System.out.println("Files.lines: " + lineasStream.count() + " líneas; Files.list: "
                    + entradas.count() + " entradas");
        }

        Files.delete(carpeta.resolve("renombrado.txt"));
        System.out.println("¿Borrado? " + !Files.exists(carpeta.resolve("renombrado.txt")));
        System.out.println("deleteIfExists de algo que no existe: "
                + Files.deleteIfExists(carpeta.resolve("no-existe.txt")));
    }
}

Salida:

readString:
Hola, mundo
Segunda línea con ñ y tildes: canción
readAllLines: 2 líneas -> [Hola, mundo, Segunda línea con ñ y tildes: canción]
¿Existe? true, ¿es fichero? true, ¿es carpeta? true
Tamaño: 53 bytes
Contenido de datos:
  compra.txt (17 bytes)
  renombrado.txt (53 bytes)
  saludo.txt (53 bytes)
Files.lines: 2 líneas; Files.list: 3 entradas
¿Borrado? true
deleteIfExists de algo que no existe: false
Método Qué hace
exists(p), isRegularFile(p), isDirectory(p), size(p) Consultas sobre la ruta
createDirectory(p) / createDirectories(p) Crea una carpeta / la carpeta y todas las intermedias (sin error si ya existe)
writeString(p, texto) / readString(p) Escribe / lee un fichero de texto completo (Java 11)
write(p, lineas) / readAllLines(p) Escribe / lee una lista de líneas
lines(p) Lee las líneas bajo demanda como un Stream (para ficheros enormes; hay que cerrarlo)
newBufferedReader(p) / newBufferedWriter(p) Abre un lector / escritor de texto con buffer (sección 5)
copy(origen, destino, opciones) / move(...) Copia / mueve o renombra (con REPLACE_EXISTING sobrescribe)
delete(p) / deleteIfExists(p) Borra (la primera lanza excepción si no existe)
newDirectoryStream(carpeta) / list(carpeta) Recorre el contenido de una carpeta

⚠️ Importante: readString y readAllLines cargan el fichero entero en memoria. Son perfectos para ficheros pequeños y medianos (configuración, listas, CSV de unos miles de líneas). Para ficheros muy grandes (logs de gigas), lee línea a línea con BufferedReader o Files.lines.


4. Gestión de errores con ficheros

Trabajar con ficheros es trabajar con algo externo al programa: el fichero puede no existir, estar abierto por otro programa, no tener permisos o estar en un disco lleno. Por eso Java obliga a tratar IOException y sus subclases:

Excepción (todas heredan de IOException) Cuándo ocurre
NoSuchFileException El fichero o la carpeta no existen
FileAlreadyExistsException Creas algo que ya existe (createDirectory, copy sin REPLACE_EXISTING)
DirectoryNotEmptyException Borras una carpeta que no está vacía
AccessDeniedException No tienes permisos
EOFException Intentas leer datos binarios más allá del final del fichero
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;

public class ErroresFicheros {
    public static void main(String[] args) throws IOException {
        try {
            String texto = Files.readString(Path.of("no-existe.txt"));
            System.out.println(texto);
        } catch (NoSuchFileException e) {
            System.out.println("No existe el fichero: " + e.getMessage());
        } catch (IOException e) {
            System.out.println("Error de lectura: " + e.getMessage());
        }

        Files.createDirectories(Path.of("carpeta"));
        Files.writeString(Path.of("carpeta", "a.txt"), "algo");
        try {
            Files.createDirectory(Path.of("carpeta"));      // ya existe
        } catch (FileAlreadyExistsException e) {
            System.out.println("Ya existe: " + e.getMessage());
        }
        try {
            Files.delete(Path.of("carpeta"));               // no está vacía
        } catch (DirectoryNotEmptyException e) {
            System.out.println("La carpeta no está vacía: " + e.getMessage());
        }

        // Sin capturar: así se ve la traza
        Files.readAllLines(Path.of("datos", "falta.csv"));
    }
}

Salida:

No existe el fichero: no-existe.txt
Ya existe: carpeta
La carpeta no está vacía: carpeta
Exception in thread "main" java.nio.file.NoSuchFileException: datos/falta.csv
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newFileChannel(UnixFileSystemProvider.java:213)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:244)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:357)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:399)
	at java.base/java.nio.file.spi.FileSystemProvider.newInputStream(FileSystemProvider.java:371)
	at java.base/java.nio.file.Files.newInputStream(Files.java:154)
	at java.base/java.nio.file.Files.newBufferedReader(Files.java:2645)
	at java.base/java.nio.file.Files.readAllLines(Files.java:3085)
	at java.base/java.nio.file.Files.readAllLines(Files.java:3122)
	at ErroresFicheros.main(ErroresFicheros.java:33)

La traza de la última operación es larga porque readAllLines llama a muchos métodos internos (sun.nio.fs...), pero se lee como en el módulo 15: el tipo (NoSuchFileException), el mensaje (la ruta que no existe) y la primera línea de tu código (ErroresFicheros.main(ErroresFicheros.java:33)). En Windows el tipo sería el mismo, aunque las líneas internas cambien (sun.nio.fs.WindowsException...).

💡 Consejo: Captura NoSuchFileException cuando quieras dar un mensaje específico (“no encuentro el fichero”) y IOException para el resto. Recuerda el orden del módulo 15: la más específica primero.


5. Leer y escribir texto con BufferedReader y BufferedWriter

5.1. Flujos (streams) de entrada y salida

Por debajo, Java lee y escribe ficheros mediante flujos (streams): canales por los que los datos circulan en orden, de uno en uno o en bloques. Hay flujos de bytes (InputStream/OutputStream) y de caracteres (Reader/Writer), y se combinan por capas: cada una envuelve a la anterior y le añade una capacidad.

Cadenas de flujos: FileInputStream, BufferedInputStream, DataInputStream; FileReader, BufferedReader; BufferedWriter, FileWriter Figura 2. Los flujos de entrada y salida se apilan en capas.

El buffer es la capa más importante para el rendimiento: en lugar de ir al disco por cada carácter, se leen o escriben bloques de varios miles de bytes de una vez.

⚠️ Importante: No confundas estos streams de entrada/salida (paquete java.io) con los streams de datos de java.util.stream (como el que devuelve Files.lines), que se estudian en el Curso Avanzado. Comparten nombre, pero son conceptos distintos.

5.2. Ejemplo completo

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class TextoBuffered {
    public static void main(String[] args) {
        Path fichero = Path.of("poema.txt");

        // Escribir con BufferedWriter (UTF-8 por defecto)
        try (BufferedWriter out = Files.newBufferedWriter(fichero)) {
            out.write("Caminante, son tus huellas");
            out.newLine();                                  // salto de línea del sistema
            out.write("el camino y nada más;");
            out.newLine();
            out.write("caminante, no hay camino,");
            out.newLine();
            out.write("se hace camino al andar.");
            out.newLine();
        } catch (IOException e) {
            System.out.println("No se pudo escribir: " + e.getMessage());
            return;
        }

        // Leer línea a línea con BufferedReader
        try (BufferedReader in = Files.newBufferedReader(fichero)) {
            String linea;
            int numero = 1;
            while ((linea = in.readLine()) != null) {       // null = fin del fichero
                System.out.printf("%2d: %s%n", numero++, linea);
            }
        } catch (IOException e) {
            System.out.println("No se pudo leer: " + e.getMessage());
        }

        // La forma "clásica" con FileWriter/FileReader (indicando siempre la codificación)
        try (BufferedWriter out = new BufferedWriter(new FileWriter("clasico.txt", StandardCharsets.UTF_8));
             BufferedReader in = new BufferedReader(new FileReader("poema.txt", StandardCharsets.UTF_8))) {
            String linea;
            while ((linea = in.readLine()) != null) {
                out.write(linea.toUpperCase());
                out.newLine();
            }
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
        try {
            System.out.println("Primera línea de clasico.txt: " + Files.readAllLines(Path.of("clasico.txt")).get(0));
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Salida:

 1: Caminante, son tus huellas
 2: el camino y nada más;
 3: caminante, no hay camino,
 4: se hace camino al andar.
Primera línea de clasico.txt: CAMINANTE, SON TUS HUELLAS

Claves del ejemplo:

  • Siempre con try-with-resources (módulo 15): el fichero se cierra solo, haya error o no. Un BufferedWriter que no se cierra puede no llegar a escribir lo que tenía en su buffer.
  • readLine() devuelve la siguiente línea sin el salto de línea, o null al llegar al final. El bucle while ((linea = in.readLine()) != null) es el patrón clásico.
  • newLine() escribe el salto de línea del sistema (\n en Linux y macOS, \r\n en Windows).
  • Files.newBufferedReader/Writer usan UTF-8. Si usas FileReader/FileWriter, indica la codificación con el constructor que recibe un Charset (Java 11).

5.3. Añadir al final de un fichero: APPEND

Por defecto, escribir en un fichero borra lo que tenía. Para añadir al final, usa las opciones StandardOpenOption.CREATE (créalo si no existe) y StandardOpenOption.APPEND:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public class Append {
    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("visitas.txt");

        Files.writeString(fichero, "visita 1\n");                     // crea o SOBRESCRIBE
        Files.writeString(fichero, "visita 2\n");                     // ¡ha borrado la 1!
        System.out.print("Sin APPEND:\n" + Files.readString(fichero));

        Files.writeString(fichero, "visita 3\n",
                StandardOpenOption.CREATE, StandardOpenOption.APPEND);  // añade al final
        Files.writeString(fichero, "visita 4\n",
                StandardOpenOption.CREATE, StandardOpenOption.APPEND);
        System.out.print("Con APPEND:\n" + Files.readString(fichero));
    }
}

Salida:

Sin APPEND:
visita 2
Con APPEND:
visita 2
visita 3
visita 4

Las mismas opciones sirven en Files.newBufferedWriter(ruta, CREATE, APPEND) y en Files.write. Con las clases clásicas, el equivalente es new FileWriter("fichero.txt", StandardCharsets.UTF_8, true): el último parámetro true significa “añadir”.

5.4. Ejemplo: un registro de log

Un log es un fichero donde un programa va anotando lo que hace (inicio, avisos, errores) para poder revisarlo después. Es el uso más típico de APPEND:

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

class Log {
    enum Nivel { INFO, AVISO, ERROR }

    private static final DateTimeFormatter FORMATO = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private final Path fichero;

    Log(Path fichero) {
        this.fichero = fichero;
    }

    void escribir(Nivel nivel, String mensaje) {
        String linea = LocalDateTime.now().format(FORMATO) + " [" + nivel + "] " + mensaje;
        try (BufferedWriter out = Files.newBufferedWriter(fichero,
                StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
            out.write(linea);
            out.newLine();
        } catch (IOException e) {
            // Si ni siquiera podemos escribir el log, avisamos por la salida de error
            System.err.println("No se pudo escribir en el log: " + e.getMessage());
        }
    }

    List<String> leer(Nivel nivel) throws IOException {
        List<String> resultado = new ArrayList<>();
        for (String linea : Files.readAllLines(fichero)) {
            if (linea.contains("[" + nivel + "]")) {
                resultado.add(linea);
            }
        }
        return resultado;
    }
}

public class RegistroLog {
    public static void main(String[] args) throws IOException {
        Log log = new Log(Path.of("aplicacion.log"));
        log.escribir(Log.Nivel.INFO, "Aplicación iniciada");
        log.escribir(Log.Nivel.AVISO, "Fichero de configuración no encontrado: se usan valores por defecto");
        try {
            Integer.parseInt("abc");
        } catch (NumberFormatException e) {
            log.escribir(Log.Nivel.ERROR, "Dato no válido: " + e.getMessage());
        }
        log.escribir(Log.Nivel.INFO, "Aplicación finalizada");

        System.out.print(Files.readString(Path.of("aplicacion.log")));
        System.out.println("Solo errores: " + log.leer(Log.Nivel.ERROR).size());
    }
}

Salida (las horas son las de esa ejecución):

2026-10-04 12:27:03 [INFO] Aplicación iniciada
2026-10-04 12:27:04 [AVISO] Fichero de configuración no encontrado: se usan valores por defecto
2026-10-04 12:27:04 [ERROR] Dato no válido: For input string: "abc"
2026-10-04 12:27:04 [INFO] Aplicación finalizada
Solo errores: 1

Cada llamada a escribir abre el fichero en modo APPEND, añade una línea y lo cierra, así que el log sobrevive entre ejecuciones: si vuelves a ejecutar el programa, las nuevas líneas se añaden debajo. En aplicaciones profesionales se usan bibliotecas de log (java.util.logging, SLF4J con Logback), que verás en el Curso Avanzado; la idea es la misma.


6. Ficheros CSV

6.1. El formato CSV

Un fichero CSV (Comma-Separated Values) guarda una tabla en texto: una línea por fila y los campos separados por un carácter. Lo abren Excel, LibreOffice Calc y casi cualquier programa de datos.

nombre;edad;nota
Lucía Pérez;19;8.5
Mario Gil;20;6.25
  • La primera línea suele ser una cabecera con los nombres de las columnas.
  • En España se usa a menudo ; como separador, porque la coma es el separador decimal. Ojo: Double.parseDouble solo entiende el punto (8.5), no la coma.
  • Para leer una línea, se divide con split(";"); para escribirla, se unen los campos con String.join(";", ...) o concatenando.

6.2. Guardar y cargar objetos en CSV

La técnica habitual es que la propia clase sepa convertirse en una línea (aCsv()) y crearse a partir de una (desdeCsv()):

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

record Alumno(String nombre, int edad, double nota) {

    // Convierte el objeto en una línea CSV
    String aCsv() {
        return nombre + ";" + edad + ";" + nota;
    }

    // Crea un objeto a partir de una línea CSV (o lanza una excepción si está mal)
    static Alumno desdeCsv(String linea) {
        String[] campos = linea.split(";");
        if (campos.length != 3) {
            throw new IllegalArgumentException("se esperaban 3 campos y hay " + campos.length);
        }
        return new Alumno(campos[0].strip(), Integer.parseInt(campos[1].strip()),
                Double.parseDouble(campos[2].strip()));
    }
}

public class CsvAlumnos {

    static void guardar(List<Alumno> alumnos, Path fichero) throws IOException {
        try (BufferedWriter out = Files.newBufferedWriter(fichero)) {
            out.write("nombre;edad;nota");                  // cabecera
            out.newLine();
            for (Alumno a : alumnos) {
                out.write(a.aCsv());
                out.newLine();
            }
        }
    }

    static List<Alumno> cargar(Path fichero) throws IOException {
        List<Alumno> alumnos = new ArrayList<>();
        try (BufferedReader in = Files.newBufferedReader(fichero)) {
            String linea = in.readLine();                   // saltamos la cabecera
            int numLinea = 1;
            while ((linea = in.readLine()) != null) {
                numLinea++;
                if (linea.isBlank()) {
                    continue;
                }
                try {
                    alumnos.add(Alumno.desdeCsv(linea));
                } catch (IllegalArgumentException e) {      // incluye NumberFormatException
                    System.out.println("  Línea " + numLinea + " ignorada (" + e.getMessage() + "): " + linea);
                }
            }
        }
        return alumnos;
    }

    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("alumnos.csv");
        guardar(List.of(new Alumno("Lucía Pérez", 19, 8.5), new Alumno("Mario Gil", 20, 6.25),
                new Alumno("Sofía Ríos", 18, 9.0)), fichero);

        // Simulamos que alguien edita el fichero a mano y comete errores
        Files.writeString(fichero, "Pablo Sanz;veinte;7\nIrene Mora;21\n\nHugo León; 22 ; 4.75\n",
                java.nio.file.StandardOpenOption.APPEND);

        System.out.print("Contenido de " + fichero + ":\n" + Files.readString(fichero));
        System.out.println("Cargando...");
        List<Alumno> alumnos = cargar(fichero);
        double suma = 0;
        for (Alumno a : alumnos) {
            System.out.println("  " + a);
            suma += a.nota();
        }
        System.out.printf("%d alumnos cargados, nota media %.2f%n", alumnos.size(), suma / alumnos.size());
    }
}

Salida:

Contenido de alumnos.csv:
nombre;edad;nota
Lucía Pérez;19;8.5
Mario Gil;20;6.25
Sofía Ríos;18;9.0
Pablo Sanz;veinte;7
Irene Mora;21

Hugo León; 22 ; 4.75
Cargando...
  Línea 5 ignorada (For input string: "veinte"): Pablo Sanz;veinte;7
  Línea 6 ignorada (se esperaban 3 campos y hay 2): Irene Mora;21
  Alumno[nombre=Lucía Pérez, edad=19, nota=8.5]
  Alumno[nombre=Mario Gil, edad=20, nota=6.25]
  Alumno[nombre=Sofía Ríos, edad=18, nota=9.0]
  Alumno[nombre=Hugo León, edad=22, nota=4.75]
4 alumnos cargados, nota media 7.13

Observa cómo se trata cada problema línea a línea: una línea errónea se avisa y se salta, pero no impide cargar las demás (try/catch dentro del bucle, como en el ejercicio 2 del módulo 15). NumberFormatException hereda de IllegalArgumentException, así que un solo catch recoge los dos tipos de error. Las líneas en blanco se ignoran y strip() elimina los espacios sobrantes (" 22 ").

⚠️ Importante: split tiene dos trampas. Por defecto elimina los campos vacíos del final: "Ana;611333444;".split(";") da 2 campos, no 3; usa split(";", -1) para conservarlos. Y no sabe nada de comillas: si un campo puede contener el separador ("García; Ana"), necesitas un formato con comillas y una biblioteca como OpenCSV o Apache Commons CSV.

6.3. Ejemplo: agenda que persiste en CSV

Ahora sí, una agenda que recuerda sus contactos. Al crearla, carga el fichero si existe; guardar() lo reescribe con el contenido actual. Para simular dos ejecuciones del programa, el main crea dos agendas sobre el mismo fichero, una después de otra:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

record Contacto(String nombre, String telefono, String email) {
    String aCsv() {
        return String.join(";", nombre, telefono, email);
    }

    static Contacto desdeCsv(String linea) {
        String[] c = linea.split(";", -1);     // -1: conserva campos vacíos al final
        if (c.length != 3) {
            throw new IllegalArgumentException("línea mal formada: " + linea);
        }
        return new Contacto(c[0], c[1], c[2]);
    }
}

class Agenda {
    private final Path fichero;
    private final Map<String, Contacto> contactos = new TreeMap<>();

    Agenda(Path fichero) throws IOException {
        this.fichero = fichero;
        cargar();
    }

    private void cargar() throws IOException {
        if (!Files.exists(fichero)) {
            System.out.println("(No hay agenda guardada: se empieza vacía)");
            return;
        }
        for (String linea : Files.readAllLines(fichero)) {
            if (!linea.isBlank()) {
                Contacto c = Contacto.desdeCsv(linea);
                contactos.put(c.nombre().toLowerCase(), c);
            }
        }
        System.out.println("(Cargados " + contactos.size() + " contactos de " + fichero + ")");
    }

    void guardar() throws IOException {
        List<String> lineas = new ArrayList<>();
        for (Contacto c : contactos.values()) {
            lineas.add(c.aCsv());
        }
        Path temporal = fichero.resolveSibling(fichero.getFileName() + ".tmp");
        Files.write(temporal, lineas);                       // primero a un temporal...
        Files.move(temporal, fichero, java.nio.file.StandardCopyOption.REPLACE_EXISTING); // ...y luego se sustituye
    }

    void anadir(Contacto c) {
        contactos.put(c.nombre().toLowerCase(), c);
    }

    boolean borrar(String nombre) {
        return contactos.remove(nombre.toLowerCase()) != null;
    }

    int tamano() {
        return contactos.size();
    }
}

public class AgendaCsv {
    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("agenda.csv");

        System.out.println("== Primera ejecución ==");
        Agenda agenda = new Agenda(fichero);
        agenda.anadir(new Contacto("Pedro Gil", "600111222", "pedro@ejemplo.es"));
        agenda.anadir(new Contacto("Ana Ruiz", "611333444", ""));
        agenda.guardar();

        System.out.println("== Segunda ejecución (otro día) ==");
        Agenda otra = new Agenda(fichero);                   // lee lo guardado
        otra.anadir(new Contacto("Eva Martín", "622555666", "eva@ejemplo.es"));
        otra.borrar("pedro gil");
        otra.guardar();
        System.out.println("Contactos: " + otra.tamano());
        System.out.print("Fichero " + fichero + ":\n" + Files.readString(fichero));
    }
}

Salida:

== Primera ejecución ==
(No hay agenda guardada: se empieza vacía)
== Segunda ejecución (otro día) ==
(Cargados 2 contactos de agenda.csv)
Contactos: 2
Fichero agenda.csv:
Ana Ruiz;611333444;
Eva Martín;622555666;eva@ejemplo.es

Los objetos Contacto en memoria se guardan como líneas de texto en agenda.csv y se reconstruyen al cargar Figura 3. El ciclo de guardar y cargar objetos en un fichero.

Dos detalles profesionales:

  • split(";", -1) conserva el email vacío de Ana.
  • guardar() escribe primero en un fichero temporal y después lo mueve sobre el definitivo. Si el programa fallara a mitad de la escritura, el fichero original seguiría intacto: nunca te quedas con una agenda medio escrita.

7. Leer ficheros con Scanner

El Scanner del módulo 04 también puede leer de un fichero: basta con pasarle un Path en lugar de System.in. Es cómodo para leer números o palabras separados por espacios:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Scanner;

public class ScannerFichero {
    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("medidas.txt");
        Files.writeString(fichero, "12 7 19\n4 x 30\n\n8\n");

        int suma = 0, cuantos = 0, ignorados = 0;
        try (Scanner sc = new Scanner(fichero)) {      // Scanner sobre un fichero: sí se cierra
            while (sc.hasNext()) {
                if (sc.hasNextInt()) {
                    suma += sc.nextInt();
                    cuantos++;
                } else {
                    System.out.println("Ignorado: " + sc.next());
                    ignorados++;
                }
            }
        }
        System.out.println(cuantos + " números, suma " + suma + ", " + ignorados + " ignorados");
    }
}

Salida:

Ignorado: x
6 números, suma 80, 1 ignorados

A diferencia del Scanner(System.in), un Scanner sobre un fichero sí debe cerrarse (con try-with-resources). hasNextInt() permite comprobar el tipo antes de leer y evitar la InputMismatchException. Para leer líneas de texto, en cambio, BufferedReader es más rápido y predecible.


8. Ficheros binarios: DataOutputStream y DataInputStream

En un fichero binario, los datos se guardan en su formato interno: un int ocupa siempre 4 bytes y un double, 8, tengan el valor que tengan. Las clases DataOutputStream y DataInputStream escriben y leen tipos primitivos y String en ese formato:

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Binario {
    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("productos.dat");
        String[] nombres = {"Teclado", "Ratón", "Monitor"};
        int[] stock = {12, 40, 3};
        double[] precios = {25.5, 9.99, 189.0};

        // Escribir: cada dato en su formato binario
        try (DataOutputStream out = new DataOutputStream(
                new BufferedOutputStream(Files.newOutputStream(fichero)))) {
            for (int i = 0; i < nombres.length; i++) {
                out.writeUTF(nombres[i]);      // 2 bytes de longitud + el texto
                out.writeInt(stock[i]);        // 4 bytes
                out.writeDouble(precios[i]);   // 8 bytes
            }
        }
        System.out.println("Tamaño de " + fichero + ": " + Files.size(fichero) + " bytes");

        // Leer: EN EL MISMO ORDEN y con los mismos tipos
        try (DataInputStream in = new DataInputStream(
                new BufferedInputStream(Files.newInputStream(fichero)))) {
            while (true) {
                String nombre = in.readUTF();
                int unidades = in.readInt();
                double precio = in.readDouble();
                System.out.printf("  %-8s %3d uds  %7.2f €%n", nombre, unidades, precio);
            }
        } catch (EOFException e) {
            System.out.println("(fin del fichero)");
        }

        // ¿Qué hay realmente dentro? Los primeros bytes en hexadecimal
        byte[] bytes = Files.readAllBytes(fichero);
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 21; i++) {
            sb.append(String.format("%02X ", bytes[i]));
        }
        System.out.println("Primeros 21 bytes: " + sb);
    }
}

Salida:

Tamaño de productos.dat: 62 bytes
  Teclado   12 uds    25.50 €
  Ratón     40 uds     9.99 €
  Monitor    3 uds   189.00 €
(fin del fichero)
Primeros 21 bytes: 00 07 54 65 63 6C 61 64 6F 00 00 00 0C 40 39 80 00 00 00 00 00 

Los primeros 21 bytes son el primer producto: 00 07 (la longitud de “Teclado”), 54 65 63 6C 61 64 6F (las letras T, e, c, l, a, d, o), 00 00 00 0C (el int 12) y 40 39 80 00 00 00 00 00 (el double 25.5). “Ratón” ocupa un byte más que sus 5 letras porque la ó necesita dos bytes.

Escribir Leer Bytes
writeInt(n) readInt() 4
writeLong(n) readLong() 8
writeDouble(d) readDouble() 8
writeBoolean(b) readBoolean() 1
writeUTF(s) readUTF() 2 + longitud del texto

⚠️ Importante: Un fichero binario no tiene estructura visible: hay que leerlo exactamente en el mismo orden y con los mismos tipos con que se escribió. Si lees un double donde había un int, no hay error: obtienes un número sin sentido. Cuando se intenta leer más allá del final, se lanza EOFException, que puede usarse para detectar el fin, como en el ejemplo, o evitarse guardando al principio cuántos registros hay (ejercicio 6).

8.1. Ejemplo: copia de ficheros

Para copiar cualquier fichero (una foto, un PDF), se trabaja con bytes: un InputStream lee bloques en un byte[] y un OutputStream los escribe. Así funciona por dentro Files.copy:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;

public class CopiaFicheros {

    // Copia "a mano" con un buffer de bytes: funciona con cualquier tipo de fichero
    static long copiar(Path origen, Path destino) throws IOException {
        long total = 0;
        try (InputStream in = Files.newInputStream(origen);
             OutputStream out = Files.newOutputStream(destino)) {
            byte[] buffer = new byte[8192];
            int leidos;
            while ((leidos = in.read(buffer)) != -1) {   // -1 = fin del fichero
                out.write(buffer, 0, leidos);
                total += leidos;
            }
        }
        return total;
    }

    public static void main(String[] args) throws IOException {
        Path original = Path.of("foto.bin");
        byte[] datos = new byte[100_000];
        for (int i = 0; i < datos.length; i++) {
            datos[i] = (byte) (i * 31);                  // contenido "binario" de prueba
        }
        Files.write(original, datos);

        long bytes = copiar(original, Path.of("copia1.bin"));
        System.out.println("Copia manual: " + bytes + " bytes");

        Files.copy(original, Path.of("copia2.bin"), StandardCopyOption.REPLACE_EXISTING);
        System.out.println("Files.copy:   " + Files.size(Path.of("copia2.bin")) + " bytes");

        // Comprobar que las copias son idénticas (Java 12+)
        System.out.println("¿Idénticas? " + (Files.mismatch(original, Path.of("copia1.bin")) == -1)
                + " y " + (Files.mismatch(original, Path.of("copia2.bin")) == -1));
    }
}

Salida:

Copia manual: 100000 bytes
Files.copy:   100000 bytes
¿Idénticas? true y true

in.read(buffer) rellena el array y devuelve cuántos bytes ha leído (puede ser menos que el tamaño del array en el último bloque) o -1 al final. Por eso se escribe out.write(buffer, 0, leidos) y no el array entero. Files.mismatch (Java 12) compara dos ficheros y devuelve -1 si son idénticos.


9. Serialización de objetos

9.1. Guardar objetos “tal cual”

La serialización convierte un objeto (con todos los objetos a los que referencia) en una secuencia de bytes que se puede guardar en un fichero y, más tarde, deserializar para reconstruir el objeto. Para que una clase se pueda serializar, debe implementar la interfaz Serializable, que no tiene métodos: es solo una “marca”.

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

class Usuario implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;   // versión de la "forma" de la clase

    private final String login;
    private final int nivel;
    private transient String contrasena;              // transient: NO se guarda

    Usuario(String login, int nivel, String contrasena) {
        this.login = login;
        this.nivel = nivel;
        this.contrasena = contrasena;
    }

    @Override
    public String toString() {
        return login + " (nivel " + nivel + ", contraseña " + contrasena + ")";
    }
}

public class Serializacion {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Path fichero = Path.of("usuarios.ser");
        List<Usuario> usuarios = new ArrayList<>(List.of(
                new Usuario("admin", 9, "s3cr3t0"), new Usuario("invitado", 1, "1234")));
        System.out.println("Antes de guardar: " + usuarios);

        try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(fichero))) {
            out.writeObject(usuarios);                // guarda la lista y todo lo que contiene
        }
        System.out.println("Guardados en " + fichero + " (" + Files.size(fichero) + " bytes)");

        try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(fichero))) {
            @SuppressWarnings("unchecked")
            List<Usuario> leidos = (List<Usuario>) in.readObject();
            System.out.println("Leídos:           " + leidos);
        }
    }
}

Salida:

Antes de guardar: [admin (nivel 9, contraseña s3cr3t0), invitado (nivel 1, contraseña 1234)]
Guardados en usuarios.ser (152 bytes)
Leídos:           [admin (nivel 9, contraseña null), invitado (nivel 1, contraseña null)]
Elemento Para qué sirve
implements Serializable Permite serializar la clase. Todos sus atributos deben ser también serializables (String, Integer, ArrayList… lo son)
transient Marca un atributo que no se guarda. Al leer, queda con su valor por defecto (null, 0, false). Úsalo para contraseñas, cachés o datos que se pueden recalcular
serialVersionUID Número de versión de la clase. Si al leer no coincide con el de la clase actual, se lanza InvalidClassException
@Serial (Java 14) Anotación opcional que pide al compilador comprobar que el campo está bien declarado
writeObject / readObject Guardan y leen un objeto completo; readObject devuelve Object, así que hay que hacer un cast

9.2. Riesgos de la serialización

La serialización parece la solución perfecta (¡una línea para guardar toda la agenda!), pero tiene inconvenientes serios:

  • Fragilidad: si cambias la clase (añades o quitas un atributo, la renombras, la mueves de paquete), los ficheros antiguos pueden dejar de leerse.
  • Formato cerrado: el fichero solo lo entiende un programa Java con esas mismas clases. No lo puedes abrir con un editor, con Excel ni desde otro lenguaje.
  • Seguridad: deserializar datos de origen no confiable (un fichero descargado, datos que llegan por la red) ha sido la causa de graves vulnerabilidades en aplicaciones Java, porque se pueden construir bytes que, al deserializarse, ejecutan código no deseado.

💡 Consejo: Para guardar los datos de tus aplicaciones, prefiere formatos de texto (CSV, o JSON en el Curso Avanzado) o una base de datos. Usa la serialización solo para datos propios y temporales, y nunca deserialices datos que no hayas generado tú.


10. La clase File (heredada)

Antes de Java 7, las rutas y las operaciones sobre ficheros se hacían con la clase java.io.File. Sigue existiendo y la verás en código antiguo y en muchas bibliotecas:

import java.io.File;
import java.nio.file.Path;

public class FileHeredada {
    public static void main(String[] args) throws Exception {
        File carpeta = new File("docs");
        carpeta.mkdirs();
        File f = new File(carpeta, "nota.txt");
        System.out.println("createNewFile: " + f.createNewFile());
        System.out.println(f.getName() + " existe=" + f.exists() + " tamaño=" + f.length()
                + " ruta=" + f.getPath());
        Path p = f.toPath();                       // de File a Path...
        File otra = p.toFile();                    // ...y de Path a File
        System.out.println("Convertido: " + p + " / " + otra);
        System.out.println("delete: " + f.delete() + " (devuelve false si falla, sin excepción)");
        System.out.println("delete otra vez: " + f.delete());
    }
}

Salida:

createNewFile: true
nota.txt existe=true tamaño=0 ruta=docs/nota.txt
Convertido: docs/nota.txt / docs/nota.txt
delete: true (devuelve false si falla, sin excepción)
delete otra vez: false
  java.io.File (antigua) java.nio.file.Path + Files (moderna)
Errores Muchos métodos devuelven false sin decir por qué Lanzan excepciones con información (NoSuchFileException…)
Funcionalidad Básica Copiar, mover, atributos, enlaces, codificación…
Conversión file.toPath() path.toFile()

💡 Consejo: En código nuevo, usa Path y Files. Si una biblioteca te pide un File, conviértelo con path.toFile().


11. Ejemplo completo: estadísticas de un fichero de texto

Este programa analiza un fichero línea a línea y combina casi todo lo visto en el curso: ficheros, cadenas, expresiones regulares, mapas y bucles:

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;

public class Estadisticas {
    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("quijote.txt");
        Files.writeString(fichero, """
                En un lugar de la Mancha, de cuyo nombre no quiero acordarme,
                no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero,
                adarga antigua, rocín flaco y galgo corredor.

                Una olla de algo más vaca que carnero, salpicón las más noches,
                duelos y quebrantos los sábados, lentejas los viernes,
                algún palomino de añadidura los domingos, consumían las tres partes de su hacienda.
                """);

        int lineas = 0, vacias = 0, palabras = 0, caracteres = 0;
        String masLarga = "";
        Map<String, Integer> frecuencia = new HashMap<>();

        try (BufferedReader in = Files.newBufferedReader(fichero)) {
            String linea;
            while ((linea = in.readLine()) != null) {
                lineas++;
                caracteres += linea.length();
                if (linea.isBlank()) {
                    vacias++;
                    continue;
                }
                if (linea.length() > masLarga.length()) {
                    masLarga = linea;
                }
                for (String p : linea.toLowerCase().split("[^\\p{L}]+")) {
                    if (!p.isEmpty()) {
                        palabras++;
                        frecuencia.put(p, frecuencia.getOrDefault(p, 0) + 1);
                    }
                }
            }
        }

        String top = null;
        for (Map.Entry<String, Integer> e : frecuencia.entrySet()) {
            if (top == null || e.getValue() > frecuencia.get(top)
                    || (e.getValue().equals(frecuencia.get(top)) && e.getKey().compareTo(top) < 0)) {
                top = e.getKey();
            }
        }

        System.out.println("Fichero: " + fichero + " (" + Files.size(fichero) + " bytes)");
        System.out.println("Líneas: " + lineas + " (" + vacias + " vacías)");
        System.out.println("Palabras: " + palabras + " (" + frecuencia.size() + " distintas)");
        System.out.println("Caracteres (sin saltos de línea): " + caracteres);
        System.out.println("Línea más larga (" + masLarga.length() + "): " + masLarga);
        System.out.println("Palabra más frecuente: \"" + top + "\" (" + frecuencia.get(top) + " veces)");
    }
}

Salida:

Fichero: quijote.txt (391 bytes)
Líneas: 7 (1 vacías)
Palabras: 66 (50 distintas)
Caracteres (sin saltos de línea): 375
Línea más larga (83): algún palomino de añadidura los domingos, consumían las tres partes de su hacienda.
Palabra más frecuente: "de" (7 veces)

12. Buenas prácticas y errores comunes

Error Consecuencia Solución
No cerrar el fichero Datos sin escribir, fichero bloqueado Try-with-resources siempre
Rutas absolutas “a fuego” (C:\Users\Ana\...) El programa no funciona en otro equipo Rutas relativas (o configurables) con Path.of
Concatenar rutas con "/" o "\\" Falla en otro sistema operativo Path.of("a", "b") o resolve
Leer con una codificación distinta de la usada al escribir Tildes y eñes rotas UTF-8 en todo, indicado explícitamente
Olvidar APPEND Se borra el contenido anterior StandardOpenOption.CREATE, APPEND
Un error en una línea del CSV detiene toda la carga Se pierden todos los datos try/catch dentro del bucle
split(";") con el último campo vacío Faltan campos split(";", -1)
Leer un binario en otro orden o con otros tipos Datos absurdos, sin error Mismo orden y mismos tipos que al escribir
catch (IOException e) { } vacío El usuario no sabe que no se ha guardado nada Informa del error
Deserializar ficheros de origen desconocido Riesgo de seguridad Usa formatos de texto; deserializa solo lo tuyo

13. Ejercicios

Ejercicio 1: Diario personal

Escribe un programa con dos métodos: anotar(Path diario, String texto), que añada una entrada al final del fichero (creándolo si no existe), y mostrar(Path diario), que muestre todas las entradas numeradas o “(diario vacío)” si el fichero no existe todavía.

Ver solución
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;

public class E1Diario {

    static void anotar(Path diario, String texto) throws IOException {
        String linea = "- " + texto + System.lineSeparator();
        Files.writeString(diario, linea, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }

    static void mostrar(Path diario) throws IOException {
        if (!Files.exists(diario)) {
            System.out.println("(diario vacío)");
            return;
        }
        List<String> lineas = Files.readAllLines(diario);
        System.out.println("Diario (" + lineas.size() + " entradas):");
        for (int i = 0; i < lineas.size(); i++) {
            System.out.println("  " + (i + 1) + " " + lineas.get(i));
        }
    }

    public static void main(String[] args) throws IOException {
        Path diario = Path.of("diario.txt");
        mostrar(diario);
        anotar(diario, "Hoy he aprendido a leer ficheros");
        anotar(diario, "Mañana, CSV y serialización");
        mostrar(diario);
        anotar(diario, "Repaso de excepciones");
        mostrar(diario);
    }
}

Salida (en la primera ejecución, con el fichero todavía sin crear):

(diario vacío)
Diario (2 entradas):
  1 - Hoy he aprendido a leer ficheros
  2 - Mañana, CSV y serialización
Diario (3 entradas):
  1 - Hoy he aprendido a leer ficheros
  2 - Mañana, CSV y serialización
  3 - Repaso de excepciones

System.lineSeparator() devuelve el salto de línea del sistema operativo. Si ejecutas el programa una segunda vez, el diario empezará con 3 entradas y terminará con 6, porque APPEND conserva lo anterior: eso es persistencia.

Ejercicio 2: Buscar en un fichero

Escribe int buscar(Path origen, String palabra, Path resultado) que copie en resultado las líneas de origen que contengan la palabra (sin distinguir mayúsculas), precedidas por su número de línea, y devuelva cuántas ha encontrado. Comprueba qué ocurre si el fichero de origen no existe.

Ver solución
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class E2Buscar {

    static int buscar(Path origen, String palabra, Path resultado) throws IOException {
        int encontradas = 0;
        try (BufferedReader in = Files.newBufferedReader(origen);
             BufferedWriter out = Files.newBufferedWriter(resultado)) {
            String linea;
            int numero = 0;
            while ((linea = in.readLine()) != null) {
                numero++;
                if (linea.toLowerCase().contains(palabra.toLowerCase())) {
                    out.write(numero + ": " + linea);
                    out.newLine();
                    encontradas++;
                }
            }
        }
        return encontradas;
    }

    public static void main(String[] args) throws IOException {
        Path origen = Path.of("refranes.txt");
        Files.writeString(origen, """
                A quien madruga, Dios le ayuda.
                No por mucho madrugar amanece más temprano.
                Más vale pájaro en mano que ciento volando.
                Camarón que se duerme se lo lleva la corriente.
                """);
        Path resultado = Path.of("resultado.txt");
        int n = buscar(origen, "MADRUG", resultado);
        System.out.println(n + " líneas encontradas. Contenido de " + resultado + ":");
        System.out.print(Files.readString(resultado));
        try {
            buscar(Path.of("no-existe.txt"), "x", resultado);
        } catch (IOException e) {
            System.out.println("Error: " + e.getClass().getSimpleName() + " " + e.getMessage());
        }
    }
}

Salida:

2 líneas encontradas. Contenido de resultado.txt:
1: A quien madruga, Dios le ayuda.
2: No por mucho madrugar amanece más temprano.
Error: NoSuchFileException no-existe.txt

Un único try-with-resources abre los dos ficheros y garantiza que ambos se cierran. El método no captura la IOException: la declara con throws y deja que decida quien lo llama (aquí, main informa del error). Es lo correcto según el módulo 15: buscar no sabe si el usuario quiere reintentar, elegir otro fichero o cancelar.

Ejercicio 3: Informe de notas desde CSV

Lee un fichero notas.csv con el formato alumno;t1;t2;t3, calcula la media de cada alumno y genera informe.csv con las columnas alumno;media;resultado (APROBADO si la media es al menos 5). Las líneas con errores deben avisarse y no aparecer en el informe.

Ver solución
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;

public class E3Informe {
    public static void main(String[] args) throws IOException {
        Path notas = Path.of("notas.csv");
        Files.writeString(notas, """
                alumno;t1;t2;t3
                Laura;7.5;8;9
                Óscar;4;5.5;3
                Nerea;6;x;7
                Iker;9;9.5
                Carmen;5;5;5
                """);

        List<String> lineas = Files.readAllLines(notas);
        int aprobados = 0, procesados = 0;
        try (BufferedWriter out = Files.newBufferedWriter(Path.of("informe.csv"))) {
            out.write("alumno;media;resultado");
            out.newLine();
            for (int i = 1; i < lineas.size(); i++) {           // i = 1: saltar cabecera
                String[] c = lineas.get(i).split(";");
                try {
                    if (c.length != 4) {
                        throw new IllegalArgumentException(c.length + " campos");
                    }
                    double media = (Double.parseDouble(c[1]) + Double.parseDouble(c[2])
                            + Double.parseDouble(c[3])) / 3;
                    String resultado = media >= 5 ? "APROBADO" : "SUSPENSO";
                    out.write(String.format(Locale.ROOT, "%s;%.2f;%s", c[0], media, resultado));
                    out.newLine();
                    procesados++;
                    if (media >= 5) {
                        aprobados++;
                    }
                } catch (IllegalArgumentException e) {
                    System.out.println("Línea " + (i + 1) + " con errores (" + e.getMessage() + "): " + lineas.get(i));
                }
            }
        }
        System.out.println(procesados + " alumnos procesados, " + aprobados + " aprobados");
        System.out.print(Files.readString(Path.of("informe.csv")));
    }
}

Salida:

Línea 4 con errores (For input string: "x"): Nerea;6;x;7
Línea 5 con errores (3 campos): Iker;9;9.5
3 alumnos procesados, 2 aprobados
alumno;media;resultado
Laura;8.17;APROBADO
Óscar;4.17;SUSPENSO
Carmen;5.00;APROBADO

String.format(Locale.ROOT, "%.2f", media) garantiza el punto decimal (8.17): con la configuración española de printf saldría 8,17, y al volver a leer el fichero con Double.parseDouble fallaría. Una línea con menos campos se detecta comprobando c.length y lanzando una IllegalArgumentException que recoge el mismo catch que los errores de conversión.

Ejercicio 4: Fichero de configuración

Muchos programas guardan su configuración en ficheros de texto con líneas clave = valor. Escribe Map<String, String> leerConfig(Path) que ignore las líneas vacías y los comentarios (que empiezan por #), avise de las líneas mal formadas y elimine los espacios alrededor de claves y valores. Después, lee valores con un valor por defecto si no existen.

Ver solución
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;

public class E4Config {

    static Map<String, String> leerConfig(Path fichero) throws IOException {
        Map<String, String> config = new LinkedHashMap<>();
        int n = 0;
        for (String linea : Files.readAllLines(fichero)) {
            n++;
            linea = linea.strip();
            if (linea.isEmpty() || linea.startsWith("#")) {
                continue;                                   // vacía o comentario
            }
            int igual = linea.indexOf('=');
            if (igual <= 0) {
                System.out.println("Aviso: línea " + n + " ignorada: " + linea);
                continue;
            }
            config.put(linea.substring(0, igual).strip(), linea.substring(igual + 1).strip());
        }
        return config;
    }

    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("app.conf");
        Files.writeString(fichero, """
                # Configuración de la aplicación
                idioma = es
                tema=oscuro

                tamano.letra = 14
                esto no es válido
                ruta.datos = /home/alumno/datos
                """);
        Map<String, String> config = leerConfig(fichero);
        System.out.println(config);
        int tamano = Integer.parseInt(config.getOrDefault("tamano.letra", "12"));
        String sonido = config.getOrDefault("sonido", "activado");
        System.out.println("Tamaño de letra: " + tamano + ", sonido: " + sonido);
    }
}

Salida:

Aviso: línea 6 ignorada: esto no es válido
{idioma=es, tema=oscuro, tamano.letra=14, ruta.datos=/home/alumno/datos}
Tamaño de letra: 14, sonido: activado

Se usa indexOf('=') en lugar de split("=") para que un valor pueda contener = (por ejemplo, formula = a=b). LinkedHashMap conserva el orden del fichero. Java incluye la clase java.util.Properties, que hace algo muy parecido con ficheros .properties; aquí lo programamos a mano para practicar.

Ejercicio 5: Copia de seguridad

Escribe copiaDeSeguridad(Path origen, Path destino, String patron) que copie a la carpeta destino (creándola si hace falta) todos los ficheros de origen que cumplan un patrón como *.txt, sobrescribiendo las copias anteriores, y muestre cuántos ficheros y bytes ha copiado.

Ver solución
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;

public class E5Backup {

    static int copiaDeSeguridad(Path origen, Path destino, String patron) throws IOException {
        Files.createDirectories(destino);
        int copiados = 0;
        long bytes = 0;
        try (DirectoryStream<Path> ficheros = Files.newDirectoryStream(origen, patron)) {
            for (Path f : ficheros) {
                if (Files.isRegularFile(f)) {
                    Path copia = destino.resolve(f.getFileName());
                    Files.copy(f, copia, StandardCopyOption.REPLACE_EXISTING);
                    copiados++;
                    bytes += Files.size(copia);
                    System.out.println("  copiado " + f.getFileName());
                }
            }
        }
        System.out.println(copiados + " ficheros, " + bytes + " bytes");
        return copiados;
    }

    public static void main(String[] args) throws IOException {
        Path docs = Path.of("documentos");
        Files.createDirectories(docs.resolve("subcarpeta"));
        Files.writeString(docs.resolve("notas.txt"), "Apuntes de Java");
        Files.writeString(docs.resolve("tareas.txt"), "1. Estudiar ficheros\n2. Hacer ejercicios\n");
        Files.writeString(docs.resolve("foto.png"), "no es una foto de verdad");

        System.out.println("Copia de *.txt:");
        copiaDeSeguridad(docs, Path.of("backup"), "*.txt");
        System.out.println("Segunda copia (sobrescribe):");
        copiaDeSeguridad(docs, Path.of("backup"), "*.txt");
    }
}

Salida:

Copia de *.txt:
  copiado notas.txt
  copiado tareas.txt
2 ficheros, 56 bytes
Segunda copia (sobrescribe):
  copiado notas.txt
  copiado tareas.txt
2 ficheros, 56 bytes

Files.newDirectoryStream(carpeta, "*.txt") filtra por un patrón glob (* = cualquier texto), así que foto.png no se copia. isRegularFile descarta las subcarpetas. REPLACE_EXISTING permite repetir la copia; sin esa opción, la segunda ejecución lanzaría FileAlreadyExistsException.

Ejercicio 6: Temperaturas en binario

Guarda las temperaturas de una semana en un fichero binario: primero un int con cuántas hay y después cada temperatura como double. Escribe el método que lo carga en un array, muestra la mínima, la máxima y la media, y comprueba qué ocurre al leer un fichero truncado.

Ver solución
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class E6Temperaturas {

    static void guardar(Path fichero, double[] temperaturas) throws IOException {
        try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(fichero))) {
            out.writeInt(temperaturas.length);          // primero, cuántas hay
            for (double t : temperaturas) {
                out.writeDouble(t);
            }
        }
    }

    static double[] cargar(Path fichero) throws IOException {
        try (DataInputStream in = new DataInputStream(Files.newInputStream(fichero))) {
            int n = in.readInt();
            double[] datos = new double[n];
            for (int i = 0; i < n; i++) {
                datos[i] = in.readDouble();
            }
            return datos;
        }
    }

    public static void main(String[] args) throws IOException {
        Path fichero = Path.of("semana.dat");
        guardar(fichero, new double[] {14.5, 16.0, 18.25, 17.5, 12.0, 11.75, 15.0});
        System.out.println("Tamaño: " + Files.size(fichero) + " bytes (4 + 7 × 8)");

        double[] t = cargar(fichero);
        double min = t[0], max = t[0], suma = 0;
        for (double v : t) {
            min = Math.min(min, v);
            max = Math.max(max, v);
            suma += v;
        }
        System.out.printf("Días: %d, mínima %.2f, máxima %.2f, media %.2f%n", t.length, min, max, suma / t.length);

        // Un fichero truncado provoca EOFException
        byte[] bytes = Files.readAllBytes(fichero);
        Files.write(Path.of("roto.dat"), java.util.Arrays.copyOf(bytes, 20));
        try {
            cargar(Path.of("roto.dat"));
        } catch (EOFException e) {
            System.out.println("Fichero incompleto: " + e);
        }
    }
}

Salida:

Tamaño: 60 bytes (4 + 7 × 8)
Días: 7, mínima 11.75, máxima 18.25, media 15.00
Fichero incompleto: java.io.EOFException

Guardar primero el número de elementos permite crear el array del tamaño exacto y leer con un for, sin depender de EOFException. El tamaño del fichero es predecible: 4 bytes del int más 7 × 8 de los double. Al cortar el fichero a 20 bytes, solo caben el int y dos temperaturas, y la tercera lectura lanza EOFException.

Ejercicios propuestos

✏️ Ejercicio 7: Agenda con menú. Convierte la agenda de la sección 6.3 en un programa de consola con un menú (añadir, buscar, borrar, listar, salir) que cargue agenda.csv al arrancar y lo guarde al salir. Valida la entrada con lo aprendido en el módulo 15.

✏️ Ejercicio 8: Contador de visitas. Escribe un programa que, cada vez que se ejecute, lea un número de un fichero contador.txt (0 si no existe), lo incremente, lo guarde y muestre “Esta es la ejecución número N”.

✏️ Ejercicio 9: Ficheros más grandes. Escribe un programa que reciba una carpeta y muestre sus ficheros ordenados de mayor a menor tamaño (usa newDirectoryStream, una lista y un Comparator), con el tamaño en KB y el total al final.

✏️ Ejercicio 10: De CSV a informe con formato. Lee un CSV de productos (codigo;nombre;precio;unidades) y genera un fichero de texto con una tabla alineada con printf/String.format, una línea de total y el producto con más valor en stock.

✏️ Ejercicio 11: Inventario serializado. Crea una clase Producto implements Serializable con un atributo transient para la fecha de la última consulta. Guarda una ArrayList<Producto> con ObjectOutputStream, léela de nuevo y comprueba qué valor tiene el atributo transient. Después, añade un atributo nuevo a la clase sin cambiar el serialVersionUID e intenta leer el fichero antiguo: ¿qué ocurre? ¿Y si cambias el serialVersionUID?

✏️ Ejercicio 12: Comparar ficheros. Escribe un programa que compare dos ficheros de texto línea a línea y muestre el número y el contenido de las líneas que difieren, y las líneas que sobran en el más largo.


Resumen

Concepto Sintaxis / ejemplo Idea clave
Ruta Path p = Path.of("datos", "a.txt"); Relativa al directorio de trabajo; no toca el disco
Operaciones con rutas resolve, getFileName, getParent, toAbsolutePath, normalize Construir y analizar rutas
Leer/escribir todo Files.readString(p), Files.writeString(p, s) Ficheros pequeños, UTF-8 por defecto
Líneas Files.readAllLines(p), Files.write(p, lista) Lista de String
Añadir al final StandardOpenOption.CREATE, StandardOpenOption.APPEND Sin APPEND, se sobrescribe
Gestión exists, createDirectories, copy, move, delete, newDirectoryStream Casi todo lanza IOException
Texto con buffer Files.newBufferedReader/Writer(p), readLine(), newLine() Siempre con try-with-resources
CSV linea.split(";", -1), String.join(";", ...) aCsv() / desdeCsv(); errores línea a línea
Scanner sobre fichero new Scanner(path), hasNextInt() Cerrarlo (a diferencia de System.in)
Binario DataOutputStream.writeInt/writeDouble/writeUTF Leer en el mismo orden y tipos; EOFException
Bytes InputStream.read(buffer), OutputStream.write(buffer, 0, n) Copiar cualquier fichero
Serialización implements Serializable, transient, serialVersionUID Cómoda pero frágil e insegura con datos ajenos
Clase antigua java.io.File, file.toPath() Usa Path y Files en código nuevo
Errores NoSuchFileException, FileAlreadyExistsException, EOFException Todas son IOException (comprobadas)