El mòdul 6 va acabar amb una promesa i una mancança. La mancança: res no es guarda en sortir. Sis mòduls de feina —el catàleg, els préstecs, les multes, les reserves, l'historial— viuen íntegrament a la memòria del procés, i quan el procés acaba, la memòria s'allibera i tot desapareix. No hi ha res per recuperar. La promesa: que aquest mòdul ho resol.

Comencem per la meitat més senzilla de la persistència, que és llegir. Llegir és més senzill que escriure per una raó pràctica: si t'equivoques llegint, no destrueixes res. Si t'equivoques escrivint, pots perdre un fitxer sencer, i això és exactament el que li passarà a algú a la lliçó següent si no llegeix la taula del paràmetre append.

Aquesta lliçó cobreix el model mental complet de què és un fitxer, com es localitza i com se'n llegeix text amb les dues API clàssiques —FileReader i Scanner—, més el detall que provoca més incidències reals de tot el mòdul: la codificació de caràcters. En acabar, BiblioTech arrencarà llegint el seu catàleg d'un fitxer de text, i si aquest fitxer no existeix, arrencarà igualment amb el catàleg buit i deixant-ne constància al registre, aplicant la degradació elegant que ja saps dissenyar.

Tot el que s'obri en aquesta lliçó es tanca amb try-with-resources. És la construcció de 06-06 i es dona per completament coneguda: ordre de tancament invers, excepcions suprimides, close() idempotent. No es torna a explicar; es fa servir sempre.

Contingut

  1. Per què un programa necessita persistència
  2. Què és un fitxer: bytes al disc davant d'objectes en memòria
  3. El recorregut d'una dada des de l'aplicació fins al disc
  4. Camins absoluts i relatius, i el directori de treball
  5. Separadors de camí i portabilitat
  6. La classe File: l'API heretada que encara et trobaràs
  7. Lectura amb FileReader: caràcter a caràcter
  8. Lectura amb Scanner sobre un fitxer
  9. useDelimiter i l'anàlisi còmoda
  10. Codificació de caràcters: UTF-8 i el desastre dels accents
  11. FileNotFoundException davant d'IOException
  12. Degradació elegant: BiblioTech arrenca sense catàleg
  13. Fitxers grans i per què no llegir-ho tot en memòria
  14. Errors Comuns i Consells
  15. Exercicis

  1. Per què un programa necessita persistència

La memòria d'un procés és volàtil: existeix mentre el procés existeix. Quan main retorna, quan l'usuari tanca la finestra o quan el sistema operatiu mata el procés, la JVM allibera la seva memòria i tot el que hi havia deixa d'existir. No hi ha res per recuperar.

Això no és un defecte de Java: és la naturalesa de la RAM. Un programa que només treballa en memòria és un programa sense memòria a llarg termini, i això el desqualifica per a gairebé qualsevol ús real:

Necessitat Sense persistència Amb persistència
Recordar dades entre execucions Impossible És el cas base
Carregar mil materials Teclejant-los un a un Un fitxer de mil línies
Compartir dades amb un altre programa Impossible Un fitxer que tots dos entenen
Auditar què va passar ahir Impossible Un fitxer de registre
Configurar sense recompilar Impossible Un fitxer de propietats
Recuperar-se d'una caiguda Es perd tot Es recupera l'últim estat desat

Fixa't en l'última fila de la columna esquerra aplicada a BiblioTech: si l'aplicació cau amb vint préstecs actius, aquests vint préstecs no van existir mai. La biblioteca real continuarà tenint vint llibres fora del prestatge, però el sistema dirà que estan tots disponibles. El sistema i el món deixen de coincidir, que és la pitjor cosa que li pot passar a un sistema de gestió.

Persistir significa escriure l'estat en un mitjà que sobreviu al procés. En aquest mòdul aquest mitjà és el sistema de fitxers. En un sistema professional gran acostuma a ser una base de dades —ho veuràs amb Hibernate a 11-03—, però una base de dades és, per sota, fitxers amb molta enginyeria a sobre. Tot comença aquí.

  1. Què és un fitxer: bytes al disc davant d'objectes en memòria

Aquesta és la idea que cal interioritzar abans d'escriure una línia de codi:

Un fitxer és una seqüència de bytes amb un nom. Res més. No té tipus, no té objectes, no té camps. Només bytes numerats del 0 endavant.

Compara els dos mons:

Objecte en memòria Fitxer al disc
Naturalesa Estructura amb camps tipats Seqüència plana de bytes
Identitat Referència (adreça) Camí (nom)
Vida Mentre duri el procés Fins que algú l'esborri
Accés Directe, nanosegons A través del sistema operatiu, microsegons o més
Referències entre dades Punters natius Cal inventar-se-les (identificadors, desplaçaments)
Tipus El compilador els coneix No existeixen: tot són bytes
Cost d'una operació Barata Cara: hi ha una crida al sistema pel mig

Quan tens en memòria un Llibre amb títol "Java Eficaç", autor "Bloch" i ISBN 978-0000000001, l'objecte ocupa una zona de memòria amb tres referències a tres objectes String. Al disc no pots desar referències: una adreça de memòria no significa res demà ni en una altra màquina. Has de serialitzar: convertir aquesta estructura en una seqüència de bytes que puguis tornar a interpretar després. La forma més simple de fer-ho és escriure text:

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

Aquesta línia és una decisió de disseny completa. Has triat: un material per línia, camps separats per ;, ordre fix de camps, true/false per a la disponibilitat. Qualsevol programa que conegui aquesta convenció pot llegir-la. És exactament el que fa un fitxer CSV, i el formalitzaràs a 07-07.

L'alternativa és deixar que Java faci la conversió per tu, amb la serialització nativa de 07-05, que produeix bytes il·legibles però conserva l'estructura completa d'objectes. Cada opció té el seu preu, i el veuràs.

Un matís important sobre el vocabulari: es parla de fitxers de text i fitxers binaris, però aquesta distinció no existeix al disc. Tots els fitxers són binaris. Un "fitxer de text" és simplement un fitxer els bytes del qual, interpretats amb una determinada codificació, produeixen caràcters llegibles. Si obres un .jpg amb un editor de text, hi veuràs brossa: no perquè el fitxer sigui diferent, sinó perquè hi estàs aplicant la interpretació equivocada. Hi tornarem a l'apartat 10 i, a fons, a 07-03.

  1. El recorregut d'una dada des de l'aplicació fins al disc

Entre el teu String i el plat del disc hi ha més capes de les que sembla. Conèixer-les explica per què l'E/S és lenta, per què existeix la memòria intermèdia i per què un close() pot fallar.

flowchart TD
    A["El teu codi Java<br/>String linia = lector.readLine()"] --> B["Classes java.io<br/>FileReader, Scanner"]
    B --> C["Descodificacio de bytes a caracters<br/>charset UTF-8"]
    C --> D["Crida al sistema operatiu<br/>read syscall"]
    D --> E["Cache de disc del sistema operatiu<br/>page cache a la RAM"]
    E --> F["Controlador del dispositiu"]
    F --> G["Disc fisic<br/>SSD o disc dur"]

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

Els punts que importen d'aquest recorregut:

  • La frontera cara és la crida al sistema (requadre taronja). Cada read obliga a passar del codi de la teva aplicació al nucli del sistema operatiu i tornar. Aquest canvi de context costa ordres de magnitud més que executar unes quantes instruccions en memòria. Si fas una crida al sistema per cada caràcter, el cost és demolidor: és exactament el que passa amb FileReader sense memòria intermèdia, i ho mesuraràs a l'apartat 7.
  • La descodificació és un pas real (requadre blau clar del mig). Els bytes que arriben del disc no són caràcters. Algú ha de decidir quin byte o grup de bytes forma quin caràcter, i aquest algú és el charset. Si ningú no l'especifica, se'n fa servir un per defecte, i aquí comencen els problemes de l'apartat 10.
  • El sistema operatiu ja té la seva pròpia memòria cau (la page cache). Per això llegir dues vegades el mateix fitxer petit és molt més ràpid la segona vegada: la segona no arriba al disc. Això també explica per què mesurar el rendiment d'E/S és traïdor.
  • Escriure no significa "és al disc". Quan escrius, la dada passa a memòries intermèdies. Fins que no es buiden, un tall de corrent la perd. És el tema central de 07-02.

Queda't amb la regla que governa tot el mòdul: com menys crides al sistema, millor. Tot el disseny de l'E/S de Java —les memòries intermèdies, els blocs, transferTo— existeix per reduir aquest nombre.

  1. Camins absoluts i relatius, i el directori de treball

Per llegir un fitxer cal localitzar-lo, i aquí apareix el primer entrebanc clàssic: "a mi em funcionava i al servidor diu que no troba el fitxer".

Un camí absolut parteix de l'arrel del sistema de fitxers i és autosuficient:

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

Un camí relatiu no parteix de l'arrel, sinó del directori de treball actual del procés:

dades/cataleg.txt
./dades/cataleg.txt
../config/bibliotech.properties

La pregunta clau és: relatiu a què? I la resposta és la que sorprèn tothom: relatiu al directori des del qual es va llançar la JVM, no al directori on hi ha el .class ni al del projecte. Aquest directori és a la propietat de sistema user.dir:

public class OnSoc {
    public static void main(String[] args) {
        System.out.println("Directori de treball: " + System.getProperty("user.dir"));
        System.out.println("Directori de l'usuari: " + System.getProperty("user.home"));
        System.out.println("Directori temporal:   " + System.getProperty("java.io.tmpdir"));

        java.io.File f = new java.io.File("dades/cataleg.txt");
        System.out.println("Cami relatiu donat:    " + f.getPath());
        System.out.println("Es resol com:          " + f.getAbsolutePath());
        System.out.println("Existeix?              " + f.exists());
    }
}

Executa aquest programa des de dos llocs diferents i veuràs el problema en directe:

# Des de l'arrel del projecte
$ cd /home/marta/bibliotech
$ java -cp target/classes OnSoc
Directori de treball: /home/marta/bibliotech
Es resol com:          /home/marta/bibliotech/dades/cataleg.txt
Existeix?              true

# Des del directori de classes
$ cd /home/marta/bibliotech/target/classes
$ java OnSoc
Directori de treball: /home/marta/bibliotech/target/classes
Es resol com:          /home/marta/bibliotech/target/classes/dades/cataleg.txt
Existeix?              false

El mateix programa, el mateix fitxer al disc, dos resultats diferents. Això és el que passa quan funciona a l'IDE (que llança des de l'arrel del projecte) i falla en executar-lo amb un script (que llança des d'un altre lloc).

Com es tracta això professionalment:

Estratègia Quan fer-la servir
Camí per paràmetre (args[0] o propietat -Dcami=...) Gairebé sempre. Qui executa decideix on són les dades
Camí relatiu a user.home Configuració d'usuari: System.getProperty("user.home") + "/.bibliotech/"
Camí absolut en fitxer de configuració Desplegaments en servidor. Ho veuràs a 07-07
Recurs del classpath (getResourceAsStream) Dades que viatgen dins del .jar i no canvien
Camí relatiu a seques Només en proves i eines de línia d'ordres, i documentant-ho

I un consell que estalvia molt de temps: quan un fitxer "no existeix", imprimeix sempre el camí absolut, mai el que t'han passat. El missatge No es troba 'dades/cataleg.txt' no et diu res; No es troba '/home/marta/bibliotech/target/classes/dades/cataleg.txt' et resol el problema en tres segons. Aquesta és una aplicació directa de la lliçó 06-04: una excepció ha de transportar les dades que qui la llegeix necessita per decidir.

  1. Separadors de camí i portabilitat

Windows fa servir \ com a separador de directoris; Linux, macOS i pràcticament tota la resta fan servir /. Java ofereix dues constants per no fixar-ho al codi:

import java.io.File;

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

        // Construccio portable d'un cami
        String cami = "dades" + File.separator + "cataleg.txt";
        System.out.println("Cami portable: " + cami);

        // Alternativa molt millor: el constructor de File amb pare i fill
        File f = new File("dades", "cataleg.txt");
        System.out.println("Amb File(pare, fill): " + f.getPath());
    }
}

Distingeix les dues constants, que es confonen sovint:

Constant Linux/macOS Windows Per a què serveix
File.separator / \ Separar directoris dins d'un camí
File.pathSeparator : ; Separar camins dins d'una llista, com el CLASSPATH

Ara bé, la bona notícia pràctica: Windows accepta / a gairebé totes les API de Java. new File("dades/cataleg.txt") funciona a Windows. Per això, a la pràctica:

  • Per a literals curts al codi, escriu / i no t'ho compliquis: és llegible i funciona a tot arreu.
  • Per compondre camins a partir de trossos, no concatenis cadenes: fes servir new File(pare, fill) o, encara millor, Path.resolve() de NIO.2, que veuràs a 07-06 i és la forma moderna i correcta.
  • No escriguis mai "C:\\dades\\cataleg.txt" al codi. A més de no ser portable, aquesta doble barra invertida és una font inesgotable d'errades.

  1. La classe File: l'API heretada que encara et trobaràs

java.io.File és de Java 1.0. Representa un camí, no un fitxer obert: pots crear un File que apunti a una cosa inexistent sense que passi res.

import java.io.File;

public class InspeccionarFitxer {

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

        System.out.println("=== " + cami + " ===");
        System.out.println("  Cami absolut  : " + f.getAbsolutePath());
        System.out.println("  Nom           : " + f.getName());
        System.out.println("  Directori     : " + f.getParent());
        System.out.println("  Existeix?     : " + f.exists());

        if (!f.exists()) {
            System.out.println("  (no hi ha res mes per inspeccionar)");
            return;
        }

        System.out.println("  Es fitxer?    : " + f.isFile());
        System.out.println("  Es carpeta?   : " + f.isDirectory());
        System.out.println("  Llegible?     : " + f.canRead());
        System.out.println("  Escrivible?   : " + f.canWrite());
        System.out.println("  Ocult?        : " + f.isHidden());
        System.out.println("  Mida          : " + f.length() + " bytes");
        System.out.println("  Modificat     : " + f.lastModified() + " (millisegons des de 1970)");
    }

    public static void main(String[] args) {
        inspeccionar("dades/cataleg.txt");
        inspeccionar("dades");
        inspeccionar("no-existeix-aixo.txt");
    }
}

Els mètodes que més es fan servir:

Mètode Retorna Nota important
exists() boolean false també si no tens permís per saber-ho
isFile() / isDirectory() boolean Tots dos false si no existeix
canRead() / canWrite() boolean Depèn de l'usuari que executa la JVM
length() long Bytes. 0 si no existeix, indistingible d'un fitxer buit
getName() String Només el nom, sense directoris
getPath() String El camí tal com s'ha donat
getAbsolutePath() String Resolt contra user.dir, sense normalitzar els ..
getCanonicalPath() String Absolut i normalitzat. Llança IOException
lastModified() long Millisegons des de 1970. 0 si no existeix
listFiles() File[] null si no és un directori o falla. Parany clàssic
delete() boolean false sense dir per què
mkdirs() boolean Crea també els directoris intermedis

I aquí hi ha el gran defecte d'aquesta API, el que en va motivar la substitució:

File f = new File("/dades/important.txt");
boolean esborrat = f.delete();

if (!esborrat) {
    // Per que? No existia? No tinc permis? Estava obert?
    // Es un directori no buit? L'API no ho diu. Un false mut.
    System.out.println("No s'ha pogut esborrar");
}

Aquest false mut és exactament l'antipatró que el mòdul 6 va dedicar set lliçons a eradicar: comunicar una fallada sense dir quina. L'API moderna, NIO.2 (java.nio.file.Path i Files, lliçó 07-06), llança excepcions específiques —NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException— en lloc de retornar boolean.

Aleshores, per què aprenem File? Per tres motius molt pràctics. Primer, perquè hi ha milions de línies de codi escrites amb ella i les llegiràs. Segon, perquè moltes API encara demanen un File com a paràmetre. I tercer, perquè la conversió entre tots dos mons és trivial: file.toPath() i path.toFile(). Fes servir NIO.2 al codi nou; entén File per llegir l'antic.

  1. Lectura amb FileReader: caràcter a caràcter

FileReader és la classe més elemental per llegir text d'un fitxer. El seu mètode fonamental és read(), i el seu contracte té un detall que cal entendre bé:

public int read() throws IOException

Retorna un int, no un char. Per què? Perquè necessita un valor extra que no sigui un caràcter vàlid per assenyalar el final de fitxer, i aquest valor és -1. Els caràcters vàlids van de 0 a 65535; -1 no col·lisiona amb cap. Si el mètode retornés char, no hi hauria manera de distingir "he llegit el caràcter 0" de "s'ha acabat".

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

public class LecturaCaracterACaracter {

    public static void mostrar(String cami) throws IOException {
        try (FileReader lector = new FileReader(cami)) {
            int codi;                         // int, NO char
            while ((codi = lector.read()) != -1) {
                char c = (char) codi;         // conversio explicita
                System.out.print(c);
            }
        }
        // El try-with-resources ja l'ha tancat (06-06)
    }

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

Desglossem el bucle, perquè la seva forma és idiomàtica i la veuràs pertot arreu:

while ((codi = lector.read()) != -1) { ... }
  1. lector.read() llegeix el caràcter següent i avança la posició.
  2. codi = ... desa el resultat a la variable. En Java, una assignació és una expressió el valor de la qual és l'assignat; per això pot fer-se servir dins de la condició.
  3. Els parèntesis externs són obligatoris: sense ells, codi = lector.read() != -1 intentaria assignar un boolean a un int i no compilaria.
  4. La comparació decideix si continuar.

I ara, el problema. Aquest codi és correcte però lent, i convé entendre exactament quant:

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

public class MesurarLecturaLenta {

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

    public static void main(String[] args) throws IOException {
        long inici = System.nanoTime();
        long n = comptarCaracters("dades/cataleg-gran.txt");
        long ms = (System.nanoTime() - inici) / 1_000_000;

        System.out.printf("%d caracters llegits en %d ms%n", n, ms);
    }
}

Amb un fitxer de 5 MB, en una màquina d'escriptori corrent, els ordres de magnitud són aquests (xifres il·lustratives, no un mesurament formal):

Tècnica Crides al sistema aproximades Temps orientatiu
FileReader.read() caràcter a caràcter ~5 000 000 ~4 500 ms
FileReader.read(char[8192]) per blocs ~640 ~90 ms
BufferedReader.readLine() (lliçó 07-04) ~640 ~60 ms

Dos ordres de magnitud de diferència, i la causa és exactament la de l'apartat 3: cada read() sense memòria intermèdia es tradueix en una petició al sistema operatiu. Cinc milions de creuaments de frontera per llegir cinc megabytes.

La solució intermèdia, sense sortir encara de FileReader, és llegir per blocs amb la sobrecàrrega que accepta un array:

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

public class LecturaPerBlocs {

    public static String llegirTot(String cami) throws IOException {
        StringBuilder contingut = new StringBuilder();

        try (FileReader lector = new FileReader(cami)) {
            char[] bloc = new char[8192];           // 8 KB, mida habitual
            int llegits;

            // read(char[]) retorna QUANTS caracters ha posat a l'array,
            // o -1 si no hi havia res per llegir. Pot retornar MENYS de 8192
            // encara que quedin dades: no suposis mai que omple l'array.
            while ((llegits = lector.read(bloc)) != -1) {
                contingut.append(bloc, 0, llegits);   // nomes la part util
            }
        }
        return contingut.toString();
    }
}

Dos detalls crítics d'aquest codi, i tots dos són font d'errors reals:

  • read(char[]) retorna el nombre d'elements llegits, que pot ser menor que la mida de l'array encara que el fitxer no s'hagi acabat. No suposis mai que omple la memòria intermèdia.
  • Cal fer servir append(bloc, 0, llegits), no append(bloc). Si fas servir la versió sense límits, a l'última volta afegiràs la brossa que va quedar a l'array de la volta anterior. És una fallada que només es manifesta al final del fitxer i amb contingut aparentment aleatori.

La forma definitiva de resoldre això —embolcallar el FileReader en un BufferedReader que gestiona el bloc per tu— és el tema de 07-04. Aquí queda clar el perquè; allà en veuràs el com amb tot el detall.

  1. Lectura amb Scanner sobre un fitxer

Ja coneixes Scanner del mòdul 1, llegint de System.in. La mateixa classe llegeix d'un fitxer: només canvia el que li passes al constructor.

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

public class LecturaAmbScanner {

    public static void mostrarLinies(String cami) throws FileNotFoundException {
        // Scanner es Closeable: try-with-resources obligatori (06-06).
        // Tancar-lo tanca tambe el fitxer subjacent.
        try (Scanner sc = new Scanner(new File(cami))) {
            int numero = 1;
            while (sc.hasNextLine()) {
                String linia = sc.nextLine();
                System.out.printf("%3d | %s%n", numero++, linia);
            }
        }
    }
}

La parella hasNextLine() / nextLine() és el patró canònic i mereix un avís:

Comprova sempre amb hasNextLine() abans de cridar nextLine(). Si crides nextLine() sense que quedin línies, llança NoSuchElementException, que és no comprovada: el compilador no t'avisa i peta en execució. És el mateix contracte d'Iterator que vas veure a 05-02.

Scanner no es queda en línies: sap analitzar tipus, i aquí hi ha la seva comoditat. Suposa aquest fitxer de préstecs 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

Es llegeix així, camp a camp, sense partir cadenes a mà:

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

public class LlegirPrestecs {

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

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

Compte amb dos comportaments de Scanner que causen sorpreses:

  • nextDouble() depèn de la configuració regional. Amb l'idioma català, el separador decimal és la coma, així que 0.25 pot fallar amb InputMismatchException mentre que 0,25 funciona. És una fallada que apareix només en algunes màquines i desconcerta. La solució és fixar la configuració explícitament:
import java.util.Locale;

Scanner sc = new Scanner(new File(cami));
sc.useLocale(Locale.ROOT);      // punt decimal, sempre, a qualsevol maquina

Aquest mateix problema, aplicat als fitxers CSV que s'obren en un full de càlcul català, reapareix a 07-07.

  • nextInt() no consumeix el salt de línia. Si barreges nextInt() amb nextLine(), el nextLine() retornarà la cadena buida que queda fins al final de la línia actual. És el parany que ja vas veure amb la consola al mòdul 1, i aquí és idèntic.

Comparació honesta de les dues API vistes fins ara:

FileReader a pèl Scanner sobre fitxer
Unitat de lectura Caràcter o bloc de caràcters Línia, paraula, int, double, expressió regular
Anàlisi A mà Inclosa
Velocitat Lenta sense memòria intermèdia Lenta: fa servir expressions regulars per dins
Errors de format No els detecta InputMismatchException
Excepció del constructor FileNotFoundException FileNotFoundException
Codificació Segon paràmetre Charset useLocale per a nombres, Charset al constructor
Bo per a Copiar, processar sense estructura Fitxers petits amb format tabulat
Dolent per a Qualsevol cosa amb estructura Fitxers grans: és el més lent dels tres

Per a un fitxer de configuració de vint línies, Scanner és còmode i la seva lentitud és irrellevant. Per al fitxer de catàleg de deu mil materials, la resposta correcta és BufferedReader, i la veuràs a 07-04.

  1. useDelimiter i l'anàlisi còmoda

Per defecte, Scanner separa per espais en blanc. useDelimiter canvia aquest criteri a qualsevol expressió regular, cosa que permet llegir fitxers amb separadors propis sense partir cadenes a mà.

Amb aquest fitxer de catàleg:

978-0000000001;Java Eficac;Bloch;2018
978-0000000002;Patrons de Disseny;Gamma;1994
978-0000000003;Refactoritzacio;Fowler;1999

Es llegeix així:

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

public class LlegirAmbDelimitador {

    public static void llegir(String cami) throws FileNotFoundException {
        try (Scanner sc = new Scanner(new File(cami))) {

            // Delimitador: un punt i coma O un salt de linia (en qualsevol
            // de les seves tres formes). \\R representa qualsevol terminador de linia.
            sc.useDelimiter(";|\\R");

            while (sc.hasNext()) {
                String isbn   = sc.next();
                String titol  = sc.next();
                String autor  = sc.next();
                int    any    = sc.nextInt();

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

Delimitadors útils:

Patró Significat
";" Només punt i coma
";|\\R" Punt i coma o qualsevol salt de línia
"\\s*,\\s*" Coma amb espais opcionals al voltant
"\\R" Només salts de línia: equival a llegir línia a línia
"\\Z" Final d'entrada: llegeix el fitxer sencer com un sol testimoni

Aquest últim truc té la seva gràcia per a fitxers molt petits:

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

Funciona, però és un pedaç. Des de Java 11 existeix Files.readString(Path), que fa el mateix de forma explícita i correcta, i el veuràs a 07-06. Reconeix el truc quan el llegeixis en codi aliè; no l'escriguis tu.

I un advertiment que cal donar ja, tot i que es desenvolupi a 07-07: partir un CSV per ; o per , és correcte només mentre cap camp no contingui el separador. Tan bon punt aparegui un títol com "Java: el llenguatge, la màquina i l'ecosistema" dins de cometes, aquest codi produeix camps mal alineats en silenci. La lliçó 07-07 implementa un lector CSV que sí que ho fa bé.

  1. Codificació de caràcters: UTF-8 i el desastre dels accents

Aquest apartat és el més important de la lliçó. La majoria de les fallades d'E/S que veuràs a la teva carrera professional no són fallades de lògica: són fallades de codificació.

El problema de fons

Un fitxer conté bytes. Un String conté caràcters. Convertir d'un a l'altre requereix una taula de correspondències, i aquesta taula és el charset o codificació.

  • ASCII (1963): 128 caràcters, un byte cadascun. Sense ç, sense à, sense .
  • ISO-8859-1 (Latin-1): 256 caràcters, un byte cadascun. Té ç i vocals accentuades. No té ni res que no sigui europeu occidental.
  • Windows-1252: variant de l'anterior, la de Windows a Europa occidental. Gairebé igual però no exactament, cosa que genera fallades subtils.
  • UTF-8 (1993): cobreix tot Unicode amb longitud variable d'1 a 4 bytes. ASCII n'és un subconjunt exacte. És l'estàndard de facto del web i de l'intercanvi de dades.

Quant 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 0xA7) 1 (0xE7)
3 (0xE2 0x82 0xAC) No representable
Un emoji 4 No representable

Conseqüència directa que sorprèn molta gent: en UTF-8, el nombre de caràcters d'un text no és el nombre de bytes del fitxer. "Eficaç" són 6 caràcters i 7 bytes. Per això File.length() (bytes) no et diu quants caràcters hi ha.

Què passa exactament si t'equivoques

Escrius "Préstec vençut" en UTF-8 i ho llegeixes com a ISO-8859-1. Els dos bytes de la ç (0xC3 0xA7) s'interpreten com dos caràcters independents:

Escrit en UTF-8:         Préstec vençut
Llegit com a ISO-8859-1: Préstec vençut

A l'inrevés, escrius en ISO-8859-1 i llegeixes en UTF-8. El byte 0xE7 de la ç no forma una seqüència UTF-8 vàlida, i el descodificador el substitueix pel caràcter de reemplaçament:

Escrit en ISO-8859-1:    Préstec vençut
Llegit com a UTF-8:      Pr�stec ven�ut

Aquest ç i aquest són els dos símptomes que cal aprendre a reconèixer a l'instant. Quan els vegis, no busquis la fallada a la teva lògica: és codificació.

I hi ha una cosa pitjor que la lletjor. Fixa't en aquest cas:

// Fitxer escrit en UTF-8, llegit com a ISO-8859-1
String estat = "Préstec vençut";        // el que s'ha carregat a memoria

// La cerca de l'usuari, escrita correctament, no troba res:
if (estat.equals("Préstec vençut")) {     // false
    // no hi entra mai
}
System.out.println(estat.length());        // 16, no 14

Les dades continuen malament després de llegir-les. Es desaran malament, es compararan malament i es mostraran malament. La corrupció es propaga.

La codificació per defecte i per què no hi has de confiar

Fins a Java 17 inclòs, quan no especifiques charset, FileReader i FileWriter fan servir la codificació per defecte de la plataforma, que depèn del sistema operatiu i de la configuració regional:

Entorn Codificació per defecte històrica
Linux modern UTF-8
macOS UTF-8
Windows a Catalunya Windows-1252
Windows al Japó Shift_JIS
Contenidor Docker minimalista Sovint US-ASCII

Això significa que el mateix programa llegint el mateix fitxer produeix resultats diferents en màquines diferents. És la causa del clàssic "al meu portàtil es veu bé i al servidor surt amb interrogants".

Java 18 (JEP 400) va canviar el valor per defecte de file.encoding a UTF-8 a totes les plataformes, cosa que és una gran millora. Però:

  • Si treballes amb Java 17 —la versió de referència d'aquest curs i la més usada en producció— continues tenint el comportament antic.
  • Encara que el teu projecte sigui Java 21, el codi que especifica el charset és autoexplicatiu i no depèn d'una propietat que algú pugui canviar amb -Dfile.encoding.

Regla professional, sense excepcions: especifica sempre el charset explícitament en tota operació de text. No costa res i elimina una categoria sencera de fallades.

Com fer-ho bé

Des de Java 11, FileReader accepta un Charset al constructor:

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

public class LecturaAmbCharset {

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

En versions anteriors —i continua sent la forma més general, la que veuràs a 07-03— es fa servir el pont 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(cami), StandardCharsets.UTF_8)) {
    // ...
}

I Scanner també l'accepta:

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

Les constants disponibles a java.nio.charset.StandardCharsets, garantides a tota JVM:

Constant Nom
StandardCharsets.UTF_8 UTF-8. La teva opció per defecte sempre
StandardCharsets.ISO_8859_1 Latin-1. Només per llegir fitxers antics
StandardCharsets.US_ASCII ASCII de 7 bits
StandardCharsets.UTF_16 UTF-16 amb marca d'ordre de bytes

Fes servir les constants, no les cadenes. new FileReader(cami, StandardCharsets.UTF_8) no pot fallar; la versió que accepta el nom com a cadena llança UnsupportedEncodingException si escrius "UTF8", "utf-8 " amb un espai o qualsevol altra errada. És un error de compilació convertit en error d'execució sense cap avantatge.

  1. FileNotFoundException davant d'IOException

L'E/S és el territori natural de les excepcions comprovades del mòdul 6, i per un motiu que ara resulta evident: el fitxer és del món exterior. Pot no existir, pot haver canviat de permisos, el disc es pot omplir, algú pot desconnectar la unitat de xarxa. Res d'això no és una fallada del teu programa, i tot és previsible.

La jerarquia rellevant:

flowchart TD
    T["Throwable"] --> E["Exception"]
    E --> IOE["IOException<br/>(comprovada)"]
    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 invalid)"]

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

Què significa cadascuna a la pràctica:

Excepció Quan es llança Què se sol fer
FileNotFoundException El fitxer no existeix, és un directori, o no hi ha permís de lectura Degradar: fer servir un valor per defecte, o demanar el camí correcte
IOException Fallada genèrica en llegir: disc, xarxa, dispositiu Registrar i propagar. Poques vegades recuperable
EOFException Final inesperat llegint dades binàries estructurades (07-03) Fitxer truncat o corrupte: avortar la càrrega
MalformedInputException Bytes que no formen caràcters vàlids en el charset donat Charset equivocat. Revisar l'apartat 10

Nota enganyosa sobre el nom: FileNotFoundException no significa només "no existeix". També es llança si el camí apunta a un directori o si el fitxer existeix però no tens permís de lectura. Per això el missatge del sistema operatiu és tan valuós i no l'has de descartar mai.

L'ordre dels catch segueix la regla de 06-02, del que és específic al que és general:

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

public void carregar(String cami) {
    try (Scanner sc = new Scanner(new File(cami), StandardCharsets.UTF_8)) {
        // ... llegir ...

    } catch (FileNotFoundException e) {
        // L'especific primer. Aquest cas es pot tractar de forma diferent.
        LOG.log(Level.WARNING, "No s'ha trobat el fitxer: " + cami, e);

    } catch (IOException e) {
        // El general despres. A l'inreves NO COMPILA: codi inabastable.
        LOG.log(Level.SEVERE, "Fallada d'E/S llegint " + cami, e);
    }
}

  1. Degradació elegant: BiblioTech arrenca sense catàleg

Ho ajuntarem tot amb la primera peça real del mòdul. CarregadorCataleg llegeix un fitxer de text i construeix materials, i —això és l'important— si el fitxer no existeix, l'aplicació arrenca igualment amb el catàleg buit.

Aquest és exactament el criteri de 06-07 per distingir el que és recuperable del que és irrecuperable: degrada quan el servei reduït continuï sent correcte. Una biblioteca sense materials és una biblioteca buida, que és un estat perfectament vàlid i en el qual es poden donar d'alta materials. No hi ha res d'incorrecte. Molt diferent seria arrencar sense conèixer la tarifa de les multes: allà no es pot degradar, perquè cobraríem imports falsos.

El format del fitxer:

# Cataleg de BiblioTech - Nexus Software
# tipus;referencia;titol;autor;any
LLIBRE;978-0000000001;Java Eficac;Joshua Bloch;2018
LLIBRE;978-0000000002;Patrons de Disseny;Erich Gamma;1994
LLIBRE;978-0000000003;Refactoritzacio;Martin Fowler;1999

I el carregador:

package com.nexussoftware.bibliotech.servei;

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.domini.Llibre;
import com.nexussoftware.bibliotech.domini.ReferenciaDuplicadaException;

/**
 * Carrega el cataleg de BiblioTech des d'un fitxer de text.
 *
 * Politica d'errors (06-07):
 *   - Fitxer absent          -> DEGRADAR: cataleg buit i avis al log.
 *   - Linia amb format dolent-> DESCARTAR aquesta linia i seguir amb les altres.
 *   - Fallada d'E/S greu     -> propagar com a CatalegNoAccessibleException.
 *
 * Aquesta versio fa servir Scanner per claredat. A 07-04 se substitueix per
 * BufferedReader, que es el correcte per a fitxers de milers de linies.
 */
public class CarregadorCataleg {

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

    private static final String COMENTARI = "#";
    private static final String SEPARADOR = ";";
    private static final int    CAMPS_LLIBRE = 5;

    private final String cami;

    private int liniesLlegides    = 0;
    private int materialsAlta     = 0;
    private int liniesDescartades = 0;

    public CarregadorCataleg(String cami) {
        this.cami = java.util.Objects.requireNonNull(cami, "El cami no pot ser nul");
    }

    /**
     * Carrega el cataleg. NO llanca MAI per fitxer absent: degrada.
     *
     * @return un Cataleg, possiblement buit, mai null
     */
    public Cataleg carregar() {
        Cataleg cataleg = new Cataleg();
        File fitxer = new File(cami);

        // Avis util: registrar SEMPRE el cami absolut, no el relatiu (apartat 4)
        LOG.config(() -> "Carregant cataleg des de " + fitxer.getAbsolutePath());

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

            while (sc.hasNextLine()) {
                String linia = sc.nextLine();
                liniesLlegides++;
                processarLinia(cataleg, linia, liniesLlegides);
            }

            LOG.info(String.format(
                    "Cataleg carregat: %d materials de %d linies (%d descartades)",
                    materialsAlta, liniesLlegides, liniesDescartades));

        } catch (FileNotFoundException e) {
            // DEGRADACIO ELEGANT. No es una fallada de l'aplicacio: la primera
            // vegada que s'executa BiblioTech, aquest fitxer encara no existeix.
            // Nivell WARNING, no SEVERE: quan tot es SEVERE ningu no mira els
            // errors de veritat (06-07).
            LOG.log(Level.WARNING,
                    "No hi ha fitxer de cataleg a " + fitxer.getAbsolutePath()
                            + "; s'arrenca amb el cataleg buit", e);
        }

        return cataleg;
    }

    /** Processa una linia. Les fallades de format descarten la linia, no avorten la carrega. */
    private void processarLinia(Cataleg cataleg, String linia, int numero) {
        String neta = linia.trim();

        // Linies buides i comentaris: s'ignoren en silenci, son normals
        if (neta.isEmpty() || neta.startsWith(COMENTARI)) {
            return;
        }

        String[] camps = neta.split(SEPARADOR);

        if (camps.length != CAMPS_LLIBRE) {
            descartar(numero, "s'esperaven " + CAMPS_LLIBRE
                    + " camps i n'hi ha " + camps.length);
            return;
        }
        if (!"LLIBRE".equalsIgnoreCase(camps[0].trim())) {
            descartar(numero, "tipus desconegut '" + camps[0].trim() + "'");
            return;
        }

        try {
            String isbn  = camps[1].trim();
            String titol = camps[2].trim();
            String autor = camps[3].trim();
            int    any   = Integer.parseInt(camps[4].trim());

            cataleg.registrar(new Llibre(titol, autor, isbn, any));
            materialsAlta++;

        } catch (NumberFormatException e) {
            // L'any no era un numero. Es tradueix a un descart amb context (06-03).
            descartar(numero, "l'any '" + camps[4].trim() + "' no es un numero");

        } catch (ReferenciaDuplicadaException e) {
            // Excepcio propia del modul 6: l'ISBN ja hi era. Es descarta la linia.
            descartar(numero, e.getMessage());
        }
    }

    private void descartar(int numero, String motiu) {
        liniesDescartades++;
        LOG.warning(() -> "Linia " + numero + " descartada: " + motiu);
    }

    public int getLiniesLlegides()    { return liniesLlegides; }
    public int getMaterialsAlta()     { return materialsAlta; }
    public int getLiniesDescartades() { return liniesDescartades; }
}

I l'arrencada de l'aplicació queda així:

package com.nexussoftware.bibliotech.presentacio;

import com.nexussoftware.bibliotech.servei.CarregadorCataleg;
import com.nexussoftware.bibliotech.servei.Cataleg;
import com.nexussoftware.bibliotech.infraestructura.ConfiguracioLog;

public class BiblioTechApp {

    /** Cami per defecte; es pot canviar per argument de linia d'ordres. */
    private static final String CAMI_CATALEG_DEFECTE = "dades/cataleg.txt";

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

        String cami = (args.length > 0) ? args[0] : CAMI_CATALEG_DEFECTE;

        Cataleg cataleg = new CarregadorCataleg(cami).carregar();

        System.out.println("BiblioTech - Nexus Software");
        System.out.println("Materials al cataleg: " + cataleg.mida());

        new MenuBiblioTech(cataleg).executar();
    }
}

Les tres decisions de disseny que convé assenyalar:

  1. El camí arriba per argument, amb un valor per defecte. És l'estratègia recomanada de l'apartat 4: qui executa decideix on són les dades.
  2. carregar() no retorna mai null. Retorna un Cataleg buit si no hi ha fitxer. És la regla que 06-07 va establir: el null no és una forma de comunicar res.
  3. Una línia dolenta no avorta el fitxer. Es descarta, es compta i es registra. Que un catàleg de deu mil línies no es carregui perquè la línia 4 200 té un any mal escrit seria una política pèssima. Això és l'"objecte resultat" de 06-07 aplicat a la importació, i a 07-04 es convertirà en un informe complet.

  1. Fitxers grans i per què no llegir-ho tot en memòria

Tanquem amb un advertiment de dimensionament. És temptador escriure això:

// PERILLOS amb fitxers grans
String tot = llegirFitxerSencerComACadena("dades/historic-prestecs.txt");

I per a un fitxer de configuració de 2 KB és perfectament raonable. Però per a l'històric de préstecs de tres anys, no:

Mida del fitxer Memòria aproximada de l'String Viable amb heap de 512 MB
10 KB ~20 KB Sí, sense pensar-hi
5 MB ~10 MB
200 MB ~400 MB Al límit, amb risc
2 GB ~4 GB OutOfMemoryError

Per què el factor de dos: internament un String de Java 9 endavant desa un byte per caràcter quan tot el text és Latin-1 i dos bytes per caràcter tan bon punt apareix un caràcter fora d'aquest rang. Un sol emoji o un caràcter ciríl·lic en un fitxer de 2 GB duplica la memòria de l'String complet. I a més necessites el StringBuilder intermedi mentre el construeixes, així que al pic arribes a tenir el contingut dues vegades.

Un OutOfMemoryError és un Error, no una Exception: no es captura, no es recupera i tomba el procés, amb la salvetat de la frontera de main que vas veure a 06-07.

L'alternativa correcta és processar en flux (streaming): llegir una línia, processar-la, oblidar-la, llegir la següent. La memòria usada és la d'una línia, no la del fitxer, i el consum és constant encara que el fitxer tingui 50 GB:

// Esquema de processament en flux. La versio completa, a 07-04.
try (BufferedReader lector = new BufferedReader(
        new FileReader(cami, StandardCharsets.UTF_8))) {

    String linia;
    while ((linia = lector.readLine()) != null) {
        processar(linia);         // es fa servir i es descarta
    }
}

La regla pràctica:

Carrega el fitxer sencer en memòria només si en coneixes la mida màxima i és petita. Configuració, plantilles, fitxers de pocs KB: endavant. Dades, registres, exportacions, qualsevol cosa que creixi amb l'ús: processa en flux.

Aquesta és exactament la raó de ser de BufferedReader, que és la lliçó 07-04.

Errors Comuns i Consells

  • Fer servir camins relatius i no saber respecte a què. El clàssic "funciona a l'IDE i falla en producció". Imprimeix System.getProperty("user.dir") tan bon punt tinguis dubtes i passa els camins per paràmetre.
  • Informar de l'error amb el camí relatiu. No es troba 'dades/cataleg.txt' no serveix de res. Registra sempre getAbsolutePath().
  • No especificar el charset. L'error més car del mòdul a llarg termini, perquè no falla: corromp en silenci. StandardCharsets.UTF_8, sempre, en tota operació de text.
  • Confondre ç amb ?. ç significa que vas escriure UTF-8 i vas llegir Latin-1. ? o significa el contrari. Reconèixer el símptoma t'estalvia mitja hora cada vegada.
  • Fer servir cadenes en lloc de constants de charset. "UTF8" compila i peta en execució. StandardCharsets.UTF_8 no es pot escriure malament.
  • Declarar char c = lector.read(). No compila, i per un bon motiu: cal el -1 per al final de fitxer, que no cap en un char.
  • Oblidar els parèntesis a while ((c = lector.read()) != -1). Sense ells no compila. Amb ells, és el bucle idiomàtic que veuràs sempre.
  • Fer servir append(bloc) en lloc d'append(bloc, 0, llegits). Afegeix brossa de la volta anterior a l'última lectura. Falla només al final del fitxer, que és el que ho fa difícil de diagnosticar.
  • Cridar nextLine() sense comprovar hasNextLine(). NoSuchElementException, no comprovada, en execució.
  • Barrejar nextInt() i nextLine() sense consumir el salt de línia. El mateix parany del mòdul 1, idèntic sobre fitxers.
  • Confiar en nextDouble() sense fixar el Locale. En una màquina amb configuració catalana, 0.25 pot fallar. sc.useLocale(Locale.ROOT).
  • Llegir caràcter a caràcter un fitxer gran. Correcte i cent vegades més lent. Blocs o BufferedReader (07-04).
  • Carregar en un String un fitxer de mida desconeguda. OutOfMemoryError, que no es captura. Processa en flux.
  • Interpretar length() == 0 com a "no existeix". Un fitxer buit dona el mateix. Fes servir exists().
  • Ignorar que listFiles() pot retornar null. NullPointerException al for. NIO.2 ho resol (07-06).
  • Consell: escriu una única classe que sàpiga llegir cada tipus de fitxer. Si tens new FileReader(...) repartit per vint classes, canviar el charset o el format serà un rastreig. Concentra l'E/S a la capa que li correspon, com fa CarregadorCataleg.
  • Consell: prova sempre amb un fitxer que contingui ç, à i . Un fitxer de prova només amb ASCII t'ocultarà totes les fallades de codificació fins que arribi a producció.
  • Consell: prova també amb el fitxer absent i amb el fitxer buit. Són els dos casos que més s'obliden i els dos que més passen, sobretot el primer dia de vida del sistema.

Exercicis

Exercici 1: diagnòstic de camins

Escriu una classe DiagnosticCami amb un mètode public static void diagnosticar(String cami) que imprimeixi un informe complet i útil sobre un camí, pensat perquè algú hi pugui resoldre un problema:

  1. El camí tal com s'ha donat i la seva forma absoluta.
  2. El directori de treball actual.
  3. Si existeix; i si no existeix, si existeix el seu directori pare (per distingir "falta el fitxer" de "el directori no és aquest").
  4. Si existeix: si és fitxer o directori, si és llegible, la seva mida i el nombre de línies.
  5. Un diagnòstic final en una frase amb la causa més probable.

Prova'l amb un camí correcte, un d'inexistent i un directori.

Exercici 2: detector de codificació

Escriu DetectorCodificacio amb un mètode public static void comparar(String cami) que llegeixi el mateix fitxer tres vegades —com a UTF-8, com a ISO-8859-1 i com a US-ASCII— i mostri les tres primeres línies de cada lectura, per veure l'efecte sobre els accents i les ce trencades.

Després, afegeix public static boolean semblaUtf8(String cami), que retorni true si el fitxer es descodifica sense cap caràcter de reemplaçament (\uFFFD) en llegir-lo com a UTF-8. Explica en un comentari per què aquesta comprovació detecta "això no és UTF-8" però no pot detectar "això és Latin-1 llegit com a UTF-8 a l'inrevés".

Prepara un fitxer de prova amb Préstec vençut, Refactorització i Reunió de març.

Exercici 3: carregador d'empleats amb informe

Amplia la idea de CarregadorCataleg als empleats. Donat un fitxer com aquest:

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

Escriu CarregadorEmpleats amb:

  1. public ResultatCarrega carregar(), que retorni un objecte resultat (idea de 06-07) amb la llista d'empleats carregats i la llista d'errors, en lloc de llançar a la primera fallada.
  2. Validació de cada línia: exactament 3 camps, identificador amb format E-NNN, nom no buit i nombre de préstecs entre 0 i Empleat.MAX_PRESTECS_SIMULTANIS.
  3. Degradació si el fitxer no existeix: resultat buit amb un avís, sense excepció.
  4. Charset explícit i try-with-resources.
  5. Un main de demostració que imprimeixi l'informe amb printf.

Afegeix al fitxer de prova una línia amb dos camps, una amb un identificador X-999 i una amb 9 préstecs, per comprovar que les tres es descarten amb el seu motiu i les bones es carreguen.

Solucions

Solució 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 diagnostic d'un cami.
 *
 * L'objectiu NO es saber si el fitxer existeix, sino poder explicar PER QUE
 * no existeix, que es el que de veritat cal per arreglar-ho (06-04).
 */
public class DiagnosticCami {

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

        System.out.println("========================================");
        System.out.println("DIAGNOSTIC DE: " + cami);
        System.out.println("========================================");

        // 1. Cami donat i cami resolt
        System.out.println("  Cami donat       : " + f.getPath());
        System.out.println("  Cami absolut     : " + f.getAbsolutePath());
        System.out.println("  Es absolut?      : " + f.isAbsolute());

        // 2. Context d'execucio: la peca que gairebe sempre falta
        System.out.println("  Directori actual : " + System.getProperty("user.dir"));

        // 3. Existencia, distingint fitxer de directori pare
        boolean existeix = f.exists();
        System.out.println("  Existeix?        : " + existeix);

        File pare = f.getAbsoluteFile().getParentFile();
        boolean existeixPare = (pare != null && pare.exists());
        System.out.println("  Directori pare   : "
                + (pare == null ? "(cap)" : pare.getAbsolutePath())
                + (existeixPare ? "  [existeix]" : "  [NO existeix]"));

        // 4. Detalls nomes si existeix
        if (existeix) {
            System.out.println("  Es fitxer?       : " + f.isFile());
            System.out.println("  Es directori?    : " + f.isDirectory());
            System.out.println("  Llegible?        : " + f.canRead());
            System.out.println("  Escrivible?      : " + f.canWrite());
            System.out.println("  Mida             : " + f.length() + " bytes");

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

        // 5. Conclusio en una frase
        System.out.println("  ----");
        System.out.println("  CONCLUSIO: " + concloure(f, existeix, existeixPare));
        System.out.println();
    }

    /** Compta linies de forma segura: si falla, ho diu en lloc de propagar-ho. */
    private static String comptarLinies(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) {
            // Pot passar entre l'exists() i l'open(): es una condicio de
            // cursa TOCTOU, exactament l'advertida a 06-07.
            return "(no s'ha pogut llegir: " + e.getMessage() + ")";
        }
    }

    private static String concloure(File f, boolean existeix, boolean existeixPare) {
        if (existeix && f.isFile() && f.canRead()) {
            return "El fitxer existeix i es pot llegir. Tot correcte.";
        }
        if (existeix && f.isDirectory()) {
            return "El cami apunta a un DIRECTORI, no a un fitxer. "
                    + "En obrir-lo es llancaria FileNotFoundException, "
                    + "malgrat el que suggereix el seu nom.";
        }
        if (existeix && !f.canRead()) {
            return "El fitxer existeix pero l'usuari que executa la JVM "
                    + "no te permis de lectura. Revisa els permisos.";
        }
        if (!existeix && !existeixPare) {
            return "No existeix ni el fitxer ni el seu directori. El mes probable "
                    + "es que el cami relatiu s'estigui resolent des d'un altre "
                    + "directori de treball del que s'esperava.";
        }
        return "El directori existeix pero el fitxer no. Comprova el nom "
                + "exacte, incloses les majuscules: a Linux distingeixen.";
    }

    public static void main(String[] args) {
        diagnosticar("dades/cataleg.txt");      // correcte
        diagnosticar("dades");                  // un directori
        diagnosticar("dades/no-existeix.txt");  // falta el fitxer
        diagnosticar("altre/cami/dolent.txt");  // falta el directori
    }
}

El que ensenya aquest exercici: la diferència entre detectar una fallada i explicar-la. Els cinc casos de concloure corresponen a cinc causes diferents que l'API resumeix totes en un exists() a false. Distingir-les és el que converteix un missatge d'error en una solució. Fixa't també en el comentari sobre TOCTOU: entre comprovar exists() i obrir el fitxer pot passar qualsevol cosa, així que la comprovació prèvia no substitueix el catch.

Solució 2

package com.nexussoftware.bibliotech.util;

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

/**
 * Mostra l'efecte de llegir el mateix fitxer amb codificacions diferents.
 */
public class DetectorCodificacio {

    /** Caràcter de reemplaçament que insereix el descodificador davant de bytes invàlids. */
    private static final char REEMPLACAMENT = '\uFFFD';

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

        for (Charset cs : candidats) {
            System.out.println("--- Llegit com a " + cs.name() + " ---");
            try {
                String[] linies = primeresLinies(cami, cs, 3);
                for (String l : linies) {
                    System.out.println("    " + l);
                }
            } catch (IOException e) {
                System.out.println("    ERROR: " + e.getMessage());
            }
            System.out.println();
        }
    }

    /** Llegeix les n primeres linies amb el charset donat. */
    private static String[] primeresLinies(String cami, Charset cs, int n) throws IOException {
        String[] resultat = new String[n];
        int index = 0;
        StringBuilder actual = new StringBuilder();

        try (FileReader lector = new FileReader(cami, cs)) {
            int c;
            while (index < n && (c = lector.read()) != -1) {
                if (c == '\n') {
                    resultat[index++] = actual.toString();
                    actual.setLength(0);
                } else if (c != '\r') {
                    actual.append((char) c);
                }
            }
        }
        // Ultima linia sense salt final
        if (index < n && actual.length() > 0) {
            resultat[index++] = actual.toString();
        }
        for (int i = index; i < n; i++) {
            resultat[i] = "(no hi ha mes linies)";
        }
        return resultat;
    }

    /**
     * Es descodifica el fitxer com a UTF-8 sense caracters de reemplacament?
     *
     * PER QUE FUNCIONA EN UN SENTIT: UTF-8 te una gramatica estricta.
     * El byte 0xE7 (la 'ç' de Latin-1) anuncia una sequencia de 3 bytes que
     * en un fitxer Latin-1 no vindra ben formada, aixi que el descodificador
     * insereix \uFFFD. Detectem "aixo NO es UTF-8".
     *
     * PER QUE NO FUNCIONA EN L'ALTRE: un fitxer UTF-8 llegit com a Latin-1
     * NO produeix MAI reemplacaments, perque en Latin-1 els 256 bytes possibles son
     * caracters valids. Simplement surten els caracters equivocats
     * ("vençut"). Un descodificador no pot saber que allo esta malament: per a ell
     * es text perfectament legal. Per aixo NO EXISTEIX deteccio fiable de
     * codificacio, i per aixo cal ESPECIFICAR-LA sempre.
     */
    public static boolean semblaUtf8(String cami) throws IOException {
        try (FileReader lector = new FileReader(cami, StandardCharsets.UTF_8)) {
            int c;
            while ((c = lector.read()) != -1) {
                if (c == REEMPLACAMENT) {
                    return false;
                }
            }
        }
        return true;
    }

    public static void main(String[] args) throws IOException {
        String cami = "dades/prova-accents.txt";
        comparar(cami);
        System.out.println("Sembla UTF-8? " + semblaUtf8(cami));
    }
}

Amb un fitxer de prova desat en UTF-8 que contingui Préstec vençut, Refactorització i Reunió de març, la sortida és:

--- Llegit com a UTF-8 ---
    Préstec vençut
    Refactorització
    Reunió de març

--- Llegit com a ISO-8859-1 ---
    Préstec vençut
    Refactorització
    Reunió de març

--- Llegit com a US-ASCII ---
    Pr??stec ven??ut
    Refactoritzaci??
    Reuni?? de mar??

Sembla UTF-8? true

La conclusió pràctica és la del comentari: la detecció de codificació és fonamentalment impossible en el cas general. Els editors de text l'endevinen amb heurístiques i de vegades s'equivoquen. Per això cap quantitat de perspicàcia al codi no substitueix la regla simple: especifica sempre el charset, en llegir i en escriure.

Solució 3

package com.nexussoftware.bibliotech.servei;

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.domini.Empleat;

/**
 * Carrega empleats des d'un fitxer de text acumulant errors en lloc
 * d'avortar al primer (objecte resultat, 06-07).
 */
public class CarregadorEmpleats {

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

    private static final String SEPARADOR  = ";";
    private static final String COMENTARI  = "#";
    private static final int    NUM_CAMPS  = 3;
    private static final String PATRO_ID   = "E-\\d{3}";

    private final String cami;

    public CarregadorEmpleats(String cami) {
        this.cami = Objects.requireNonNull(cami, "El cami no pot ser nul");
    }

    /** Resultat de la carrega: el que ha anat be i el que ha anat malament, junt. */
    public static class ResultatCarrega {
        private final List<Empleat> empleats = new ArrayList<>();
        private final List<String>  errors   = new ArrayList<>();
        private boolean fitxerTrobat = true;

        void afegir(Empleat e)           { empleats.add(e); }
        void error(int linia, String m)  { errors.add("Linia " + linia + ": " + m); }
        void marcarFitxerAbsent()        { fitxerTrobat = false; }

        public List<Empleat> getEmpleats()   { return List.copyOf(empleats); }
        public List<String>  getErrors()     { return List.copyOf(errors); }
        public boolean hiHaErrors()          { return !errors.isEmpty(); }
        public boolean fitxerTrobat()        { return fitxerTrobat; }
        public int total()                   { return empleats.size() + errors.size(); }
    }

    public ResultatCarrega carregar() {
        ResultatCarrega resultat = new ResultatCarrega();
        File fitxer = new File(cami);

        try (Scanner sc = new Scanner(fitxer, StandardCharsets.UTF_8)) {
            int numero = 0;
            while (sc.hasNextLine()) {
                numero++;
                processar(sc.nextLine(), numero, resultat);
            }

        } catch (FileNotFoundException e) {
            // DEGRADACIO: resultat buit, avis, i sense excepcio cap amunt
            resultat.marcarFitxerAbsent();
            LOG.log(Level.WARNING, "No hi ha fitxer d'empleats a "
                    + fitxer.getAbsolutePath() + "; s'arrenca sense empleats", e);
        }
        return resultat;
    }

    private void processar(String linia, int numero, ResultatCarrega r) {
        String neta = linia.trim();
        if (neta.isEmpty() || neta.startsWith(COMENTARI)) {
            return;                                   // normal, no es un error
        }

        // -1 a split conserva els camps buits del final: "E-001;;" dona 3 camps.
        // Sense el -1 en donaria 1, i el missatge d'error seria enganyos.
        String[] camps = neta.split(SEPARADOR, -1);

        if (camps.length != NUM_CAMPS) {
            r.error(numero, "s'esperaven " + NUM_CAMPS + " camps i n'hi ha " + camps.length);
            return;
        }

        String id     = camps[0].trim();
        String nom    = camps[1].trim();
        String actius = camps[2].trim();

        if (!id.matches(PATRO_ID)) {
            r.error(numero, "l'identificador '" + id + "' no te el format E-NNN");
            return;
        }
        if (nom.isEmpty()) {
            r.error(numero, "el nom es buit");
            return;
        }

        int prestecs;
        try {
            prestecs = Integer.parseInt(actius);
        } catch (NumberFormatException e) {
            r.error(numero, "'" + actius + "' no es un numero de prestecs valid");
            return;
        }
        if (prestecs < 0 || prestecs > Empleat.MAX_PRESTECS_SIMULTANIS) {
            r.error(numero, "prestecs actius fora de rang (0.."
                    + Empleat.MAX_PRESTECS_SIMULTANIS + "): " + prestecs);
            return;
        }

        Empleat empleat = new Empleat(nom, id);
        for (int i = 0; i < prestecs; i++) {
            empleat.registrarPrestec();               // restaura l'estat desat
        }
        r.afegir(empleat);
    }

    public static void main(String[] args) {
        ResultatCarrega r = new CarregadorEmpleats("dades/empleats.txt").carregar();

        System.out.println("=== CARREGA D'EMPLEATS ===");
        if (!r.fitxerTrobat()) {
            System.out.println("  (no hi havia fitxer: s'arrenca sense empleats)");
            return;
        }

        System.out.printf("  Linies processades: %d%n", r.total());
        System.out.printf("  Empleats carregats: %d%n", r.getEmpleats().size());
        System.out.printf("  Linies amb error  : %d%n", r.getErrors().size());

        if (!r.getEmpleats().isEmpty()) {
            System.out.println("  --- Carregats ---");
            for (Empleat e : r.getEmpleats()) {
                System.out.printf("    %-8s %s%n", e.getIdentificador(), e.getNom());
            }
        }
        if (r.hiHaErrors()) {
            System.out.println("  --- Descartats ---");
            for (String error : r.getErrors()) {
                System.out.println("    " + error);
            }
        }
    }
}

Amb aquest fitxer de prova:

# identificador;nom;prestecs_actius
E-001;Marta Ruiz;2
E-002;Diego Alonso;0
E-003;Nuria Vidal;1
E-004;Nomes dos camps
X-999;Format dolent;1
E-005;Massa;9

La sortida és:

=== CARREGA D'EMPLEATS ===
  Linies processades: 6
  Empleats carregats: 3
  Linies amb error  : 3
  --- Carregats ---
    E-001    Marta Ruiz
    E-002    Diego Alonso
    E-003    Nuria Vidal
  --- Descartats ---
    Linia 5: s'esperaven 3 camps i n'hi ha 2
    Linia 6: l'identificador 'X-999' no te el format E-NNN
    Linia 7: prestecs actius fora de rang (0..3): 9

Les tres decisions que val la pena assenyalar:

  1. L'objecte resultat en lloc d'una excepció per línia. Qui importa un fitxer vol veure tots els problemes de cop per corregir-los i tornar-ho a intentar, no descobrir-los d'un en un. És exactament el cas d'ús que 06-07 va plantejar per a l'objecte resultat.
  2. split(SEPARADOR, -1). Sense aquest -1, split descarta els camps buits finals, així que E-001;; donaria 1 camp en lloc de 3 i el missatge d'error seria incorrecte. És un parany clàssic que reapareixerà a 07-07.
  3. El fitxer absent no és un error de línia. Es marca a part, perquè són dues situacions diferents: "no hi ha dades" i "hi ha dades mal escrites". Confondre-les produeix informes inútils.

Conclusió

Has posat els fonaments de la persistència de BiblioTech.

Entens què és un fitxer de veritat: una seqüència de bytes amb un nom, sense tipus ni estructura, davant d'un objecte en memòria amb camps i referències. Saps que passar d'un a l'altre exigeix una decisió de format explícita —un material per línia, camps separats per ;— i que aquesta decisió és el germen del CSV de 07-07. I coneixes el recorregut complet de la dada fins al disc, amb la conclusió que governa tot el mòdul: la crida al sistema és l'operació cara, i tot el disseny de l'E/S existeix per reduir-ne el nombre.

Saps localitzar un fitxer: camins absoluts i relatius, el paper de user.dir com a origen dels relatius, i per què el mateix programa "no troba" el mateix fitxer segons des d'on es llanci. Tens les cinc estratègies professionals per no dependre d'això, amb la recomanació de passar el camí per paràmetre, i la regla de registrar sempre el camí absolut als missatges d'error. Coneixes File.separator davant de File.pathSeparator i per què és millor compondre camins que concatenar cadenes.

Coneixes java.io.Fileexists, isFile, canRead, length, getAbsolutePath— i, sobretot, el defecte que la va condemnar: els boolean muts que diuen que alguna cosa ha fallat sense dir per què, exactament l'antipatró que el mòdul 6 va eradicar. Saps que 07-06 la substitueix per Path i Files, i que la conversió entre totes dues és un toPath().

Saps llegir amb FileReader caràcter a caràcter, amb l'int de retorn i el -1 de final de fitxer, i per què aquest bucle és dos ordres de magnitud més lent del que cal. Coneixes la lectura per blocs amb read(char[]) i els seus dos paranys: el valor de retorn que pot ser menor que l'array, i l'append(bloc, 0, llegits) que evita arrossegar brossa. I saps llegir amb Scanner sobre fitxer, amb hasNextLine/nextLine, l'anàlisi de tipus, useDelimiter amb expressions regulars, i les seves dues sorpreses: el Locale que canvia el separador decimal i el nextInt() que no consumeix el salt de línia.

I tens l'apartat que més incidències reals evita: la codificació. Saps què és UTF-8, per què ç ocupa dos bytes, què significa exactament veure ç i què significa veure , per què la codificació per defecte de la plataforma és una bomba de rellotgeria entre màquines, què va canviar Java 18 i per què això no t'eximeix d'especificar-la. I saps que detectar la codificació és impossible en el cas general, cosa que converteix la regla en absoluta: StandardCharsets.UTF_8, explícit, sempre.

BiblioTech ja llegeix. CarregadorCataleg construeix el catàleg des d'un fitxer de text, descarta les línies mal formades comptant-les i registrant-les en lloc d'avortar la càrrega sencera, tradueix NumberFormatException i ReferenciaDuplicadaException a descarts amb context, i —el més important— degrada elegantment: si no hi ha fitxer de catàleg, arrenca amb el catàleg buit i un WARNING, perquè una biblioteca buida és un estat correcte. És la política d'errors de 06-07 aplicada a l'E/S des de la primera línia de codi.

Queda la meitat que falta, i és la que fa por. Llegir és segur; escriure destrueix. A la lliçó 07-02, Escriptura de fitxers, veuràs FileWriter i el paràmetre append que decideix entre afegir al final i esborrar el fitxer sencer, que és l'error més car de tot el mòdul; PrintWriter amb el printf que ja fas servir des del mòdul 1; la memòria intermèdia i el buidatge, i per què el que has escrit pot no ser al disc fins que tanquis; el patró d'escriptura atòmica —escriure a un temporal i reanomenar— que impedeix deixar un fitxer a mitges quan alguna cosa falla pel mig, germà directe de la compensació al finally que ja saps escriure; i el separador de línia del sistema, amb el motiu pel qual \n no sempre n'hi ha prou. En acabar-la, ExportadorCataleg deixarà de ser una classe que sap tancar-se però no escriure, i BiblioTech desarà per primera vegada el que ha fet.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats