La lliçó anterior va acabar amb una frase que convé prendre's seriosament: llegir és segur, escriure destrueix. Si t'equivoques llegint, obtens dades incorrectes i ho notes. Si t'equivoques escrivint, el fitxer anterior ja no existeix, i pot ser que no ho notis fins que algú el necessiti.

En aquesta lliçó BiblioTech desa per primera vegada. I perquè desi bé, hi ha tres coses que cal entendre abans d'escriure una línia: el paràmetre append, que decideix entre afegir al final del fitxer i buidar-lo del tot; la memòria intermèdia, que explica per què el que has escrit pot no ser encara al disc; i l'escriptura atòmica, el patró que impedeix deixar un fitxer a mitges quan el procés falla a mig camí.

Aquest tercer punt és el mateix problema que vas resoldre a 06-05 amb la compensació al finally: mantenir el sistema en un estat coherent passi el que passi. Allà l'estat era a la memòria; aquí és al disc, i les conseqüències duren més.

Com a tota la lliçó anterior, tot recurs s'obre amb try-with-resources (06-06). Es dona per sabut i no es torna a justificar. En escriptura importa encara més que en lectura, perquè el close() d'un escriptor buida la memòria intermèdia al disc: si no tanques, no has escrit.

Contingut

  1. FileWriter: crear, sobreescriure i afegir
  2. El paràmetre append, o com perdre un fitxer sencer
  3. Escriure text amb write
  4. PrintWriter: l'embolcall còmode
  5. La memòria intermèdia i el buidatge: flush davant de close
  6. Què passa si el programa acaba sense tancar
  7. Codificació explícita en escriure
  8. El separador de línia del sistema
  9. Permisos, fitxers de només lectura i IOException
  10. Escriptura atòmica: temporal i reanomenat
  11. Fitxers de bloqueig i sobreescriptura accidental
  12. BiblioTech: ExportadorCataleg escriu de veritat
  13. Errors Comuns i Consells
  14. Exercicis

  1. FileWriter: crear, sobreescriure i afegir

FileWriter és la contrapartida exacta de FileReader: escriu text en un fitxer. El seu comportament en l'obertura és el primer que cal fixar:

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

public class PrimeraEscriptura {

    public static void main(String[] args) throws IOException {
        try (FileWriter escriptor = new FileWriter("sortida.txt", StandardCharsets.UTF_8)) {
            escriptor.write("Cataleg de BiblioTech\n");
            escriptor.write("Nexus Software\n");
        }
        System.out.println("Escrit.");
    }
}

Què passa exactament en obrir:

Situació prèvia Comportament per defecte
El fitxer no existeix Es crea, buit, i s'hi escriu
El fitxer existeix Es trunca a zero bytes i s'escriu des del principi
El directori pare no existeix IOException: FileWriter no crea directoris
No hi ha permís d'escriptura IOException (FileNotFoundException, que l'estén)

Fixa't en la segona fila, perquè és la que t'arruïna el dia: obrir un FileWriter sobre un fitxer existent el buida immediatament, en el moment de la construcció, abans que escriguis res. Si obres el fitxer i després llances una excepció abans d'escriure, et quedes amb un fitxer buit i sense les dades anteriors.

I fixa't també en la tercera: FileWriter no crea el directori. Si escrius a dades/cataleg.txt i no existeix dades/, obtens una IOException el missatge típic de la qual és "No such file or directory", que molts interpreten com a "no troba el fitxer" quan en realitat falta la carpeta. Crear directoris és tasca de mkdirs() a l'API antiga o de Files.createDirectories() a NIO.2 (07-06).

Els constructors disponibles:

// Sobreescriu. Charset de la plataforma: NO FER SERVIR (07-01, apartat 10)
new FileWriter("sortida.txt");

// Sobreescriu, charset explicit (Java 11+). AQUESTA es la forma correcta
new FileWriter("sortida.txt", StandardCharsets.UTF_8);

// Afegeix al final, charset de la plataforma
new FileWriter("sortida.txt", true);

// Afegeix al final, charset explicit (Java 11+). L'altra forma correcta
new FileWriter("sortida.txt", StandardCharsets.UTF_8, true);

Compte amb l'ambigüitat del segon paràmetre. new FileWriter(cami, true) significa "afegir". new FileWriter(cami, StandardCharsets.UTF_8) significa "sobreescriure amb UTF-8". S'assemblen molt i signifiquen coses diferents. Quan vulguis les dues coses, fes servir la versió de tres paràmetres, i fixa't que el boolean va l'últim.

  1. El paràmetre append, o com perdre un fitxer sencer

Aquest és l'apartat que cal llegir dues vegades. La taula és curta i les conseqüències són llargues:

Constructor Fitxer nou Fitxer existent amb dades
new FileWriter(cami, UTF_8) Es crea Es buida. Les dades anteriors es perden
new FileWriter(cami, UTF_8, false) Es crea Es buida. Idèntic a l'anterior
new FileWriter(cami, UTF_8, true) Es crea Es conserva i s'escriu al final

El cas real, i passa constantment:

// BiblioTech registra cada prestec en un fitxer d'auditoria.
// El fitxer porta tres anys acumulant operacions.

public void registrarOperacio(String linia) throws IOException {
    // BUG: falta el 'true'. Cada crida ESBORRA tot l'historic
    // i deixa nomes l'ultima linia.
    try (FileWriter escriptor = new FileWriter("dades/auditoria.txt", StandardCharsets.UTF_8)) {
        escriptor.write(linia + System.lineSeparator());
    }
}

Aquest codi funciona: no llança excepcions, no dona avisos, el fitxer existeix i té contingut. Només que té una línia en lloc de tres-centes mil. I com que cada execució el torna a deixar amb una línia, la fallada és perfectament estable i silenciosa. Es descobreix el dia que algú demana l'històric.

La versió correcta:

public void registrarOperacio(String linia) throws IOException {
    // El 'true' final: AFEGIR, no sobreescriure
    try (FileWriter escriptor =
                 new FileWriter("dades/auditoria.txt", StandardCharsets.UTF_8, true)) {
        escriptor.write(linia + System.lineSeparator());
    }
}

Tres defenses professionals contra aquest error:

  1. Anomena la intenció. No deixis el true solt a la crida:
private static final boolean AFEGIR       = true;
private static final boolean SOBREESCRIURE = false;

new FileWriter(cami, StandardCharsets.UTF_8, AFEGIR);   // es llegeix sol
  1. Fes servir les opcions explícites de NIO.2 quan arribis a 07-06. StandardOpenOption.APPEND i StandardOpenOption.TRUNCATE_EXISTING diuen literalment el que fan, i no hi ha manera de confondre-les amb un charset.

  2. Escriu sempre a un fitxer temporal i reanomena quan estiguis regenerant un fitxer complet. És l'apartat 10, i elimina la categoria sencera de problemes.

I un advertiment que va més enllà d'aquest paràmetre:

Obrir un FileWriter per "comprovar alguna cosa" ja destrueix el fitxer. No existeix un mode "obrir només si puc". Si necessites saber si el fitxer existeix abans de decidir, comprova-ho abans d'obrir, i tot i així recorda l'avís de TOCTOU de 06-07: entre la comprovació i l'obertura, el món pot canviar.

  1. Escriure text amb write

Writer —la superclasse de FileWriter— ofereix diverses sobrecàrregues de write:

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

public class FormesDeEscriure {

    public static void main(String[] args) throws IOException {
        try (FileWriter w = new FileWriter("demo.txt", StandardCharsets.UTF_8)) {

            w.write("Text complet");                 // String sencer
            w.write('\n');                          // un sol caracter (int)
            w.write("Nomes una part", 6, 3);        // subcadena: des de 6, 3 caracters -> "una"
            w.write('\n');

            char[] buffer = { 'B', 'i', 'b', 'l', 'i', 'o' };
            w.write(buffer);                        // array complet
            w.write(buffer, 0, 3);                  // part de l'array -> "Bib"

            w.append("Encadenable")                 // append retorna el Writer
             .append(' ')
             .append("i comode");
        }
    }
}
Mètode Escriu
write(String) La cadena completa
write(String, int inici, int longitud) Una subcadena, sense crear objectes intermedis
write(int) Un sol caràcter, donat pel seu codi
write(char[]) L'array complet
write(char[], int inici, int longitud) Part de l'array
append(CharSequence) Igual que write(String), però retorna el Writer, encadenable

Un detall que descol·loca: write(int) escriu un caràcter, no el nombre. w.write(65) escriu la lletra A, no el text 65. Si vols escriure el nombre, converteix-lo: w.write(String.valueOf(65)). És la mateixa asimetria que ja vas veure a read() de 07-01, i pel mateix motiu: la unitat de l'API és el caràcter, i l'int hi és perquè hi càpiga el -1.

I la mancança pràctica més evident: FileWriter no té println. No hi ha cap mètode que afegeixi el salt de línia per tu, ni cap que formati. Has d'escriure el separador a mà cada vegada. Això ho resol PrintWriter.

  1. PrintWriter: l'embolcall còmode

PrintWriter embolcalla un altre Writer i li afegeix tota la comoditat de System.out: els mateixos print, println i printf que fas servir des del mòdul 1.

import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

public class InformeAmbPrintWriter {

    public static void main(String[] args) throws IOException {
        try (PrintWriter sortida = new PrintWriter(
                new FileWriter("informe.txt", StandardCharsets.UTF_8))) {

            sortida.println("INFORME DE MULTES - BiblioTech");
            sortida.println("==============================");
            sortida.println();

            // printf amb la mateixa sintaxi del modul 1
            sortida.printf("%-25s %-15s %8s%n", "MATERIAL", "EMPLEAT", "MULTA");
            sortida.printf("%-25s %-15s %8.2f%n", "Java Eficac",  "Marta Ruiz",   3.75);
            sortida.printf("%-25s %-15s %8.2f%n", "Patrons de Disseny", "Diego Alonso", 0.00);
            sortida.printf("%-25s %-15s %8.2f%n", "Refactoritzacio", "Nuria Vidal",  20.00);

            // Locale explicit: a Catalunya el separador decimal es la coma.
            // Per a un fitxer que un altre programa hagi de llegir, fixa Locale.ROOT.
            sortida.printf(Locale.ROOT, "%nTOTAL: %.2f EUR%n", 23.75);
        }
    }
}

Resultat:

INFORME DE MULTES - BiblioTech
==============================

MATERIAL                  EMPLEAT            MULTA
Java Eficac               Marta Ruiz          3,75
Patrons de Disseny        Diego Alonso        0,00
Refactoritzacio           Nuria Vidal        20,00

TOTAL: 23.75 EUR

Fixa't en la diferència entre les línies del cos (amb coma decimal, perquè fan servir el Locale de la màquina) i la del total (amb punt, perquè fixa Locale.ROOT). En un fitxer destinat que el llegeixi un altre programa, fixa sempre el Locale; en un destinat que el llegeixi una persona a Catalunya, la coma és el correcte. Aquest mateix conflicte reapareixerà amb els CSV a 07-07 i és més important del que sembla.

El que aporta PrintWriter sobre FileWriter:

Mètode Què fa
println(x) Escriu i afegeix el separador de línia del sistema
print(x) Escriu sense salt. Accepta qualsevol tipus, inclosos objectes (fa servir toString())
printf(fmt, args...) Formata com System.out.printf
format(fmt, args...) Idèntic a printf. Existeix per simetria amb String.format
write(String) Heretat de Writer

I ara el parany gran de PrintWriter, que cal conèixer sí o sí:

PrintWriter s'empassa les excepcions. Els seus mètodes print, println i printf no declaren IOException. Si el disc s'omple o el dispositiu falla, no ho sabràs: l'error es desa en un marcador intern en lloc de llançar-se.

L'única manera d'assabentar-te'n és preguntar:

try (PrintWriter sortida = new PrintWriter(
        new FileWriter("informe.txt", StandardCharsets.UTF_8))) {

    sortida.println("linia 1");
    sortida.println("linia 2");

    // COMPROVACIO OBLIGATORIA en codi serios.
    // checkError() buida la memoria intermedia i retorna true si HI HA HAGUT alguna
    // fallada en qualsevol moment des que es va obrir.
    if (sortida.checkError()) {
        throw new IOException("Fallada en escriure l'informe (detectada per checkError)");
    }
}

Aquest disseny ve del fet que PrintWriter va néixer per a System.out, on una fallada d'escriptura en consola no havia d'obligar a posar try/catch a cada println. Per a un fitxer, aquest compromís és perillós.

Regla pràctica:

  • Per a sortida per consola i bolcats de depuració: PrintWriter sense més, amb la seva comoditat.
  • Per a fitxers el contingut dels quals importa: PrintWriter amb checkError() abans de tancar, o directament BufferedWriter (07-04), els mètodes del qual sí que declaren IOException.

Un avís més sobre els constructors de PrintWriter:

// Aquests DOS obren el fitxer pel seu compte, amb el charset de la plataforma
// a les versions antigues. Son comodes i traidors.
new PrintWriter("sortida.txt");
new PrintWriter(new File("sortida.txt"));

// Amb charset explicit (Java 10+): correcte
new PrintWriter("sortida.txt", StandardCharsets.UTF_8);

// Embolcallant un Writer que tu controles: la forma mes clara i flexible
new PrintWriter(new FileWriter("sortida.txt", StandardCharsets.UTF_8, true));

L'última és la recomanada: tu decideixes el charset i el mode append, i PrintWriter només aporta el formatatge. És composició, i a 07-03 veuràs que aquest és el principi que organitza tota l'API de fluxos.

  1. La memòria intermèdia i el buidatge: flush davant de close

Aquí hi ha el concepte que separa qui escriu fitxers de qui escriu fitxers .

Quan crides write, les dades no van al disc. S'acumulen en una memòria intermèdia a la RAM, i només s'envien al sistema operatiu quan aquesta s'omple o quan algú ho demana explícitament. I hi ha més d'un nivell d'acumulació:

flowchart TD
    A["sortida.println(...)"] --> B["Memoria intermedia de PrintWriter<br/>o BufferedWriter"]
    B -->|"flush o memoria plena"| C["Memoria intermedia interna del FileWriter"]
    C -->|"crida al sistema write"| D["Cache de pagina del<br/>sistema operatiu"]
    D -->|"fsync o el mateix SO"| E["Disc fisic"]

    style B fill:#e3f2fd
    style C fill:#e3f2fd
    style D fill:#fff3e0
    style E fill:#f3e5f5

I aquí hi ha la clau que gairebé ningú no té clara:

Operació Garanteix
write(...) Que les dades són a la memòria intermèdia de l'aplicació. Res més
flush() Que les dades han arribat al sistema operatiu. Ja no depenen del teu procés
close() Un flush() més l'alliberament del descriptor de fitxer
FileDescriptor.sync() Que les dades són físicament al disc. L'única cosa que sobreviu a un tall de corrent

És a dir: close() protegeix davant que el teu programa acabi; només sync() protegeix davant que se'n vagi la llum. Per a la immensa majoria de les aplicacions, close() és suficient i sync() és un cost innecessari. Per a un sistema on perdre l'última operació sigui inacceptable —un cobrament, un assentament comptable—, cal arribar fins al disc.

Demostració de la diferència entre flush i no fer res:

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

public class DemoBuffer {

    public static void main(String[] args) throws Exception {
        FileWriter w = new FileWriter("demo-buffer.txt", StandardCharsets.UTF_8);

        w.write("Primera linia\n");
        System.out.println("Despres de write, mida al disc: " + mida());   // 0

        w.flush();
        System.out.println("Despres de flush, mida al disc: " + mida());   // 14

        w.write("Segona linia\n");
        System.out.println("Despres del 2n write, mida:     " + mida());   // 14

        w.close();
        System.out.println("Despres de close, mida al disc: " + mida());   // 27
    }

    private static long mida() {
        return new java.io.File("demo-buffer.txt").length();
    }
}

Sortida:

Despres de write, mida al disc: 0
Despres de flush, mida al disc: 14
Despres del 2n write, mida:     14
Despres de close, mida al disc: 27

El fitxer té 0 bytes després del primer write. Les dades existeixen, però són a la memòria del procés. Si en aquell instant algú mira el fitxer des de fora, el veu buit.

Conseqüència pràctica molt comuna: si estàs escrivint un fitxer de registre i el consultes amb tail -f mentre el programa corre, no veuràs res durant molta estona, perquè la memòria intermèdia no s'ha omplert. Això no és una fallada del teu codi; és la memòria intermèdia fent la seva feina. Per a registres que cal poder seguir en viu, es fa flush() després de cada línia, a costa del rendiment —i això és exactament el que fa el FileHandler de java.util.logging que vas configurar a 06-07, i el motiu que el registre tingui un cost apreciable—.

Quan cridar flush() explícitament:

  • Quan un altre procés o persona necessita veure les dades ja, sense esperar el tancament.
  • Quan mantindràs el fitxer obert molt de temps i vols acotar quant es perdria en una caiguda.
  • Abans d'una operació llarga o arriscada, per deixar al disc el que s'ha escrit fins aquell punt.
  • Mai just abans de close(): és redundant, close() ja ho fa.

  1. Què passa si el programa acaba sense tancar

Aquesta demostració convé executar-la, perquè el resultat sorprèn:

import java.io.FileWriter;
import java.nio.charset.StandardCharsets;

public class SenseTancar {

    public static void main(String[] args) throws Exception {
        // MALAMENT A PROPOSIT: sense try-with-resources i sense close
        FileWriter w = new FileWriter("sense-tancar.txt", StandardCharsets.UTF_8);
        w.write("Aquesta linia pot no arribar mai al disc.\n");

        System.out.println("Acabant sense tancar...");
        // main acaba aqui. No hi ha close(). No hi ha flush().
    }
}

Resultat habitual: el fitxer existeix i és buit. Les dades es van quedar a la memòria intermèdia del procés, i en acabar el procés aquesta memòria desapareix amb ell.

Preguntes que sorgeixen sempre:

  • No el tanca el recol·lector de brossa? El finalize() d'algunes classes d'E/S feia una cosa semblant, però està obsolet des de Java 9 i eliminat en versions recents. Mai no va ser una garantia: el recol·lector no promet executar-se abans que el procés acabi. No hi comptis mai.
  • I si el procés el mata el sistema? Amb kill -9 o un tall de corrent, es perd tot el que no hagi passat al sistema operatiu. Ni tan sols els shutdown hooks de 06-05 s'executen amb kill -9.
  • I si acaba amb excepció? Sense try-with-resources, es perd igual. Amb try-with-resources, el tancament està garantit i les dades arriben.

La versió correcta, i l'única que s'escriu en codi real:

try (FileWriter w = new FileWriter("ben-tancat.txt", StandardCharsets.UTF_8)) {
    w.write("Aquesta linia SI que arriba al disc.\n");
}   // close() garantit: amb exit, amb excepcio i amb return (06-06)

Això dona un pes extra al try-with-resources en escriptura. En lectura, no tancar és una fuita de recursos: molest però no destructiu. En escriptura, no tancar és perdre dades. El fitxer queda buit o truncat, i sovint ningú no se n'assabenta fins molt després.

  1. Codificació explícita en escriure

Tot el de 07-01 sobre codificació s'aplica igual, amb un matís que empitjora les coses:

Una fallada de codificació en llegir es veu i s'arregla. En escriure, es grava. Si escrius un catàleg amb el charset equivocat, el fitxer queda malament al disc. Pots continuar llegint-lo bé mentre facis servir el mateix charset equivocat, i el problema només apareix quan una altra eina —un editor, un full de càlcul, un altre sistema— intenta llegir-lo amb UTF-8. Per llavors el fitxer porta mesos acumulant dades corruptes.

I hi ha un cas pitjor encara: escriure un caràcter que el charset de destinació no pot representar:

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

public class CaracterNoRepresentable {

    public static void main(String[] args) throws IOException {
        // US-ASCII no te 'ç' ni '€'
        try (FileWriter w = new FileWriter("ascii.txt", StandardCharsets.US_ASCII)) {
            w.write("Els Patrons de Disseny costen 45 €");
        }
        // NO llanca excepcio. Substitueix el que no es representable per '?'.
        // Al fitxer queda: "Els Patrons de Disseny costen 45 ?"
    }
}

Silenci total i dades destruïdes. El comportament per defecte del codificador és CodingErrorAction.REPLACE. Si vols que falli en lloc de destruir, cal baixar al nivell de CharsetEncoder, que és API de java.nio.charset i queda fora de l'abast d'aquesta lliçó; l'important és que sàpigues que el silenci és el comportament per defecte.

La regla és la de 07-01, repetida perquè mereix repetir-se:

// SEMPRE, a cada obertura d'escriptura de text
new FileWriter(cami, StandardCharsets.UTF_8);
new FileWriter(cami, StandardCharsets.UTF_8, AFEGIR);
new PrintWriter(new FileWriter(cami, StandardCharsets.UTF_8));

I una simetria que cal respectar sempre: qui escriu i qui llegeix el mateix fitxer han de fer servir el mateix charset. Si ExportadorCataleg escriu en UTF-8, CarregadorCataleg ha de llegir en UTF-8. El millor és que aquest charset estigui declarat una sola vegada en una constant compartida:

package com.nexussoftware.bibliotech.infraestructura;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/** Convencions de format de tots els fitxers de BiblioTech. */
public final class FormatFitxers {

    /** Charset unic de tots els fitxers de text del sistema. */
    public static final Charset CHARSET = StandardCharsets.UTF_8;

    /** Separador de camps dels fitxers tabulats. */
    public static final String SEPARADOR_CAMPS = ";";

    /** Prefix de les linies de comentari. */
    public static final String COMENTARI = "#";

    private FormatFitxers() { }
}

Un sol lloc per canviar, i cap possibilitat que el lector i l'escriptor discrepin.

  1. El separador de línia del sistema

Els sistemes operatius no es van posar d'acord en com acabar una línia:

Sistema Bytes Escapada en Java Nom
Linux, macOS modern 0x0A \n LF
Windows 0x0D 0x0A \r\n CRLF
macOS clàssic (fins al 2001) 0x0D \r CR

Java exposa el del sistema actual:

String salt = System.lineSeparator();      // "\n" o "\r\n" segons el sistema

Quan importa? Depèn de qui hagi de llegir el fitxer:

Consumidor del fitxer Li molesta \n a Windows?
El teu propi programa amb BufferedReader.readLine() No: accepta les tres formes
Un editor modern (VS Code, Notepad++, IntelliJ) No
El Bloc de notes de Windows antic : ho mostrava tot en una línia
Eines de línia d'ordres de Windows De vegades
Un altre sistema que esperi el format natiu

A la pràctica:

  • PrintWriter.println() ja fa servir el separador del sistema. No has de fer res.
  • BufferedWriter.newLine() també. És el mètode correcte (07-04).
  • Escriure "\n" a mà en un write produeix sempre LF, sigui quin sigui el sistema.

Ara bé, hi ha un argument a favor d'escriure \n sempre, i és seriós: la reproductibilitat. Si un fitxer generat a Windows porta CRLF i el mateix fitxer generat a Linux porta LF, un control de versions els veurà com a completament diferents encara que el contingut sigui idèntic. Per a fitxers de dades que es versionen o es comparen, molts equips fixen LF a propòsit.

La decisió, resumida:

Tipus de fitxer Separador recomanat
Informe perquè el llegeixi una persona al seu sistema System.lineSeparator() (o println)
Fitxer de dades que es compara o es versiona "\n" fix, i documentar-ho
Fitxer que un altre sistema defineix El que aquell sistema exigeixi
Protocol de xarxa El que digui el protocol. HTTP exigeix CRLF (mòdul 9)

A BiblioTech farem servir System.lineSeparator() per als informes i "\n" fix per als fitxers de dades. La constant va, com el charset, a FormatFitxers.

  1. Permisos, fitxers de només lectura i IOException

Escriure pot fallar per causes que no depenen del teu codi:

Causa Excepció Missatge típic
No existeix el directori pare FileNotFoundException No such file or directory
Sense permís d'escriptura FileNotFoundException Permission denied
Fitxer marcat de només lectura FileNotFoundException Access is denied (Windows)
El camí és un directori FileNotFoundException Is a directory
Disc ple IOException No space left on device
Unitat de xarxa desconnectada IOException Varia
Quota d'usuari superada IOException Disk quota exceeded

Observa un detall important: les fallades d'obertura arriben com a FileNotFoundException —igual que en lectura, amb el nom igual d'enganyós— i les fallades durant l'escriptura arriben com a IOException genèrica. I el més traïdor de tots és el disc ple, perquè no passa en obrir sinó a mig fitxer: quan salta, ja tens mig fitxer escrit.

Aquest és precisament l'argument definitiu a favor de l'apartat següent.

Gestió per capes, amb el criteri de 06-07:

import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;

public class EscripturaAmbGestio {

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

    public void exportar(String cami, String contingut) throws CatalegNoAccessibleException {
        try (FileWriter w = new FileWriter(cami, StandardCharsets.UTF_8)) {
            w.write(contingut);

        } catch (FileNotFoundException e) {
            // Problema d'UBICACIO o PERMISOS: l'usuari pot corregir-ho
            LOG.log(Level.WARNING, "No es pot escriure a "
                    + new java.io.File(cami).getAbsolutePath(), e);
            throw new CatalegNoAccessibleException(cami, e);   // 06-04, amb la causa

        } catch (IOException e) {
            // Problema DURANT l'escriptura: disc ple, xarxa caiguda...
            LOG.log(Level.SEVERE, "Fallada d'E/S escrivint " + cami, e);
            throw new CatalegNoAccessibleException(cami, e);
        }
    }
}

Fixa't que es reutilitza CatalegNoAccessibleException, l'excepció comprovada que vas declarar a 06-04 precisament per a això: una fallada d'infraestructura de la qual l'aplicació té una alternativa raonable, i que per tant mereix que el compilador obligui a decidir què fer. La causa original viatja a dins, segons la regla d'encadenament de 06-03.

  1. Escriptura atòmica: temporal i reanomenat

Aquest és el patró professional de la lliçó. El problema és aquest:

// PERILLOS: regenera el cataleg complet sobreescrivint el fitxer bo
try (PrintWriter w = new PrintWriter(
        new FileWriter("dades/cataleg.txt", StandardCharsets.UTF_8))) {

    for (Material m : cataleg.llistar()) {       // 10 000 materials
        w.println(serialitzar(m));
    }
    // Si falla al material 6 000 (disc ple, excepcio a serialitzar,
    // el proces mor), el fitxer queda amb 6 000 linies i el cataleg
    // ORIGINAL JA NO EXISTEIX: es va truncar en obrir.
}

El fitxer bo es va destruir en l'instant d'obrir el FileWriter, i el nou no va arribar a completar-se. Has perdut el catàleg. I el pitjor: el fitxer resultant sembla vàlid —té format correcte, es llegeix sense errors— només que li falten 4 000 materials. Una fallada silenciosa, que és la pitjor classe.

La solució és el patró escriure-i-reanomenar:

flowchart LR
    A["cataleg.txt<br/>versio bona"] --> B{"Escriure-ho tot a<br/>cataleg.txt.tmp"}
    B -->|"exit"| C["Reanomenar tmp<br/>sobre cataleg.txt"]
    B -->|"fallada"| D["Esborrar el tmp"]
    C --> E["cataleg.txt<br/>versio nova completa"]
    D --> F["cataleg.txt<br/>versio bona INTACTA"]

    style A fill:#e8f5e9
    style E fill:#e8f5e9
    style F fill:#e8f5e9
    style D fill:#ffebee

Per què funciona: el reanomenat dins del mateix sistema de fitxers és una operació atòmica als ulls de qui llegeix. No existeix un instant en què el fitxer estigui a mitges. Un lector concurrent veu la versió antiga completa o la nova completa, mai una barreja. I si alguna cosa falla abans del reanomenat, el fitxer bo ni s'ha tocat.

La implementació:

package com.nexussoftware.bibliotech.infraestructura;

import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Escriptura atomica de fitxers de text.
 *
 * Escriu en un fitxer temporal i, NOMES si tot ha anat be, el reanomena
 * sobre el definitiu. Garanteix que el fitxer desti no queda mai a mitges.
 *
 * Es l'equivalent al disc de la compensacio al finally de 06-05: si
 * l'operacio no es completa, l'estat anterior es conserva intacte.
 */
public final class EscripturaAtomica {

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

    private static final String SUFIX_TEMPORAL = ".tmp";

    private EscripturaAtomica() { }

    /**
     * Contracte del que s'escriura. Interficie funcional (04-06):
     * permet passar la logica d'escriptura com a lambda.
     *
     * No es java.util.function.Consumer perque necessitem que pugui
     * declarar IOException, i Consumer.accept() no la declara.
     */
    @FunctionalInterface
    public interface Contingut {
        void escriureA(PrintWriter sortida) throws IOException;
    }

    /**
     * Escriu de forma atomica.
     *
     * @param desti     fitxer final
     * @param contingut que cal escriure
     * @throws IOException si falla l'escriptura o el reanomenat
     */
    public static void escriure(File desti, Contingut contingut) throws IOException {
        File temporal = new File(desti.getAbsolutePath() + SUFIX_TEMPORAL);

        // 1. Assegurar que existeix el directori pare (FileWriter no el crea)
        File pare = desti.getAbsoluteFile().getParentFile();
        if (pare != null && !pare.exists() && !pare.mkdirs()) {
            throw new IOException("No s'ha pogut crear el directori " + pare.getAbsolutePath());
        }

        boolean completat = false;
        try {
            // 2. Escriure-ho TOT al temporal
            try (PrintWriter sortida = new PrintWriter(
                    new FileWriter(temporal, StandardCharsets.UTF_8))) {

                contingut.escriureA(sortida);

                // PrintWriter s'empassa les IOException: cal preguntar-li
                if (sortida.checkError()) {
                    throw new IOException("Fallada en escriure a "
                            + temporal.getAbsolutePath());
                }
            }   // close() garantit: aqui el temporal esta tancat i complet

            // 3. Reanomenar NOMES si arribem fins aqui
            if (!reanomenar(temporal, desti)) {
                throw new IOException("No s'ha pogut reanomenar "
                        + temporal.getName() + " sobre " + desti.getName());
            }
            completat = true;
            LOG.fine(() -> "Escriptura atomica completada: " + desti.getAbsolutePath());

        } finally {
            // 4. COMPENSACIO (06-05): si no s'ha completat, no deixar brossa.
            //    El desti original continua intacte perque no es va obrir mai.
            if (!completat && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Ha quedat un fitxer temporal sense esborrar: "
                        + temporal.getAbsolutePath());
            }
        }
    }

    /**
     * Reanomena el temporal sobre el desti.
     *
     * File.renameTo retorna un boolean mut (07-01) i a Windows falla si
     * el desti existeix. D'aqui l'esborrat previ. A 07-06 aixo es resol
     * amb Files.move i ATOMIC_MOVE, que es la forma correcta.
     */
    private static boolean reanomenar(File temporal, File desti) {
        if (desti.exists() && !desti.delete()) {
            return false;
        }
        return temporal.renameTo(desti);
    }
}

Es fa servir així, amb una lambda:

EscripturaAtomica.escriure(new File("dades/cataleg.txt"), sortida -> {
    sortida.println("# Cataleg de BiblioTech");
    for (Material m : cataleg.llistar()) {
        sortida.printf("%s;%s;%s;%b%n",
                m.getTipus(), m.getReferencia(), m.getTitol(), m.estaDisponible());
    }
});

Tres observacions sobre aquesta implementació:

  1. El finally compensa igual que a 06-05. Si no s'ha completat, s'esborra el temporal. I el destí no necessita compensació perquè no es va obrir mai: aquesta és tota la gràcia del patró.
  2. checkError() és obligatori amb PrintWriter. Sense ell, un disc ple passaria desapercebut i reanomenaries un fitxer truncat sobre el bo, que és just el que volíem evitar.
  3. renameTo és defectuósboolean mut, comportament diferent a Windows, finestra entre el delete i el renameTo—. A 07-06 se substitueix per Files.move(origen, desti, StandardCopyOption.ATOMIC_MOVE), que sí que és atòmica de veritat i llança excepcions informatives. La versió d'aquí és la que calia escriure abans de Java 7, i serveix per entendre exactament què garanteix NIO.2.

Quan fer servir escriptura atòmica i quan no:

Situació Atòmica?
Regenerar un fitxer complet (catàleg, exportació, configuració) Sí, sempre
Afegir una línia a un registre d'auditoria No: s'obre en mode append i es tanca
Fitxer temporal de treball que s'esborra després No cal
Qualsevol fitxer que un altre procés pugui estar llegint Sí, imprescindible

  1. Fitxers de bloqueig i sobreescriptura accidental

Dos perills més, breus però reals.

Dos processos escrivint el mateix fitxer. Si dues instàncies de BiblioTech exporten el catàleg alhora, el resultat és impredictible: línies entremesclades, fitxer truncat, o una de les dues escriptures perduda. Ni el sistema operatiu ni Java ho impedeixen per defecte.

La solució tradicional és un fitxer de bloqueig: un fitxer la sola existència del qual significa "ocupat".

package com.nexussoftware.bibliotech.infraestructura;

import java.io.File;
import java.io.IOException;

/**
 * Bloqueig entre processos basat en l'existencia d'un fitxer.
 *
 * AutoCloseable (06-06): el bloqueig s'allibera en sortir del try, se surti
 * com se surti. Sense aquesta garantia, un bloqueig orfe deixa el sistema
 * inutilitzable fins que algu esborri el fitxer a ma.
 */
public class BloqueigFitxer implements AutoCloseable {

    private final File bloqueig;
    private boolean adquirit = false;

    public BloqueigFitxer(String camiProtegit) throws IOException {
        this.bloqueig = new File(camiProtegit + ".lock");

        // createNewFile() es ATOMIC: comprova i crea en una sola operacio.
        // Per aixo serveix com a bloqueig i un exists() + create() NO serviria:
        // entre les dues crides hi cap l'altre proces (TOCTOU, 06-07).
        if (!bloqueig.createNewFile()) {
            throw new IOException("El fitxer '" + camiProtegit
                    + "' l'esta fent servir un altre proces"
                    + " (existeix " + bloqueig.getName() + ")");
        }
        adquirit = true;
        bloqueig.deleteOnExit();     // xarxa de seguretat davant d'una sortida brusca
    }

    @Override
    public void close() {
        if (!adquirit) {
            return;                  // idempotent (06-06)
        }
        adquirit = false;
        if (!bloqueig.delete()) {
            System.err.println("Avis: no s'ha pogut alliberar " + bloqueig.getAbsolutePath());
        }
    }
}

Ús:

try (BloqueigFitxer lock = new BloqueigFitxer("dades/cataleg.txt")) {
    EscripturaAtomica.escriure(new File("dades/cataleg.txt"), sortida -> { /* ... */ });
}   // el bloqueig s'allibera aqui, passi el que passi

Limitacions honestes d'aquest mecanisme, que cal conèixer: si el procés mor de forma brusca, el .lock queda orfe i bloqueja tots els altres. Els sistemes seriosos hi desen a dins l'identificador del procés i la seva hora d'inici per poder detectar bloqueigs caducats. Java ofereix a més FileLock a través de FileChannel, que sí que és un bloqueig real del sistema operatiu. I tan bon punt entrin fils en joc, el problema canvia de naturalesa: això és el mòdul 8.

Sobreescriptura accidental. Abans de regenerar un fitxer important, desa'n una còpia:

/** Conserva la versio anterior abans de sobreescriure. */
private static void ferCopiaSeguretat(File fitxer) {
    if (!fitxer.exists()) {
        return;
    }
    File copia = new File(fitxer.getAbsolutePath() + ".bak");
    if (copia.exists()) {
        copia.delete();
    }
    if (!fitxer.renameTo(copia)) {
        LOG.warning("No s'ha pogut fer copia de seguretat de " + fitxer.getName());
    }
}

A 07-06 això es converteix en una còpia de seguretat rotativa amb NIO.2: .bak.1, .bak.2, .bak.3, conservant les tres últimes versions.

  1. BiblioTech: ExportadorCataleg escriu de veritat

Hora de saldar el deute. A 06-06 vas declarar ExportadorCataleg com a Closeable i vas explicar per què el seu close() sí que ha de propagar IOException —perquè en tancar es buida la memòria intermèdia, i si això falla les dades no estan escrites—. Però la seva escriptura era un esbós. Ara s'escriu sencera, amb tot el d'aquesta lliçó.

package com.nexussoftware.bibliotech.servei;

import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domini.Llibre;
import com.nexussoftware.bibliotech.domini.Material;
import com.nexussoftware.bibliotech.domini.Prestec;
import com.nexussoftware.bibliotech.infraestructura.EscripturaAtomica;
import com.nexussoftware.bibliotech.infraestructura.FormatFitxers;

/**
 * Exporta el cataleg i el registre de prestecs de BiblioTech a fitxers
 * de text, de forma ATOMICA: el fitxer desti no queda mai a mitges.
 *
 * Canvi de disseny respecte a 06-06: aquesta classe ja NO es Closeable.
 * No mante cap fitxer obert entre crides; cada exportacio obre, escriu i
 * tanca dins d'EscripturaAtomica. Un objecte que no posseeix recursos
 * vius no ha de ser tancable: seria una promesa buida.
 *
 * El format es el que llegeix CarregadorCataleg (07-01). Tots dos comparteixen
 * les constants de FormatFitxers perque no puguin discrepar.
 */
public class ExportadorCataleg {

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

    private static final String CAPCALERA_CATALEG =
            FormatFitxers.COMENTARI + " Cataleg de BiblioTech - Nexus Software";
    private static final String CAMPS_CATALEG =
            FormatFitxers.COMENTARI + " tipus;referencia;titol;autor;any";

    private final File directori;

    public ExportadorCataleg(String directoriDades) {
        this.directori = new File(
                Objects.requireNonNull(directoriDades, "El directori no pot ser nul"));
    }

    /**
     * Exporta el cataleg complet.
     *
     * @return nombre de materials escrits
     * @throws IOException si no s'ha pogut completar l'escriptura
     */
    public int exportarCataleg(Cataleg cataleg) throws IOException {
        Objects.requireNonNull(cataleg, "El cataleg no pot ser nul");

        List<Material> materials = cataleg.llistar();
        File desti = new File(directori, "cataleg.txt");

        long inici = System.nanoTime();

        EscripturaAtomica.escriure(desti, sortida -> {
            sortida.println(CAPCALERA_CATALEG);
            sortida.println(CAMPS_CATALEG);
            for (Material m : materials) {
                sortida.println(serialitzar(m));
            }
        });

        double ms = (System.nanoTime() - inici) / 1_000_000.0;

        // Logger, mai System.out per a diagnostic (06-07). Forma mandrosa.
        LOG.info(() -> String.format("Cataleg exportat: %d materials a %s (%.1f ms)",
                materials.size(), desti.getAbsolutePath(), ms));

        return materials.size();
    }

    /**
     * Serialitza un material a una linia.
     *
     * Els camps es sanegen: un ';' dins d'un titol trencaria el fitxer
     * en rellegir-lo. Aqui se substitueix; a 07-07 es fara BE, entrecometant
     * i escapant segons la convencio CSV.
     */
    private String serialitzar(Material m) {
        String sep = FormatFitxers.SEPARADOR_CAMPS;

        if (m instanceof Llibre llibre) {                // patro de 03-06
            return String.join(sep,
                    "LLIBRE",
                    sanejar(llibre.getIsbn()),
                    sanejar(llibre.getTitol()),
                    sanejar(llibre.getAutor()),
                    String.valueOf(llibre.getAnyPublicacio()));
        }
        return String.join(sep,
                m.getTipus().toUpperCase(),
                sanejar(m.getReferencia()),
                sanejar(m.getTitol()),
                "-",
                "0");
    }

    /** Elimina separadors i salts de linia que trencarien el format. */
    private String sanejar(String valor) {
        if (valor == null) {
            return "";
        }
        return valor.replace(FormatFitxers.SEPARADOR_CAMPS, ",")
                    .replace("\n", " ")
                    .replace("\r", " ")
                    .trim();
    }

    /**
     * Afegeix una linia al registre d'auditoria de prestecs.
     *
     * AQUEST fitxer SI que s'obre en mode AFEGIR: es un historic acumulatiu,
     * no un fitxer que es regenera. No porta escriptura atomica perque no
     * hi ha res per reemplacar; nomes s'agrega al final.
     */
    public void registrarPrestec(Prestec prestec, String operacio) {
        Objects.requireNonNull(prestec, "El prestec no pot ser nul");

        File auditoria = new File(directori, "auditoria.txt");

        // El 'true' final es AFEGIR. Sense ell, cada crida esborraria l'historic.
        try (PrintWriter sortida = new PrintWriter(
                new java.io.FileWriter(auditoria, FormatFitxers.CHARSET, true))) {

            sortida.printf("%s;%s;%s;%s%n",
                    operacio,
                    prestec.getReferencia(),
                    prestec.getMaterial().getReferencia(),
                    prestec.getTitular().getIdentificador());

            if (sortida.checkError()) {
                throw new IOException("Fallada escrivint a " + auditoria.getAbsolutePath());
            }

        } catch (IOException e) {
            // POLITICA: l'auditoria NO ha de tombar l'operacio de negoci.
            // El prestec ja s'ha registrat en memoria i es valid.
            // Es degrada: s'avisa al log i es continua (06-07).
            LOG.log(Level.WARNING, "No s'ha pogut auditar l'operacio "
                    + operacio + " de " + prestec.getReferencia(), e);
        }
    }
}

Les cinc decisions de disseny que cal entendre d'aquesta classe:

  1. Ha deixat de ser Closeable. A 06-06 mantenia un BufferedWriter obert durant tota la seva vida. Ara cada exportació obre i tanca dins d'EscripturaAtomica, així que no posseeix cap recurs viu. Un objecte que no té res per tancar no ha d'implementar Closeable: seria una promesa buida que fa que qui el fa servir escrigui un try-with-resources inútil.
  2. El catàleg s'escriu de forma atòmica; l'auditoria, en mode append. Són dues naturaleses diferents: un es regenera sencer, l'altre creix. L'escriptura atòmica és per regenerar; el mode append és per créixer. Confondre'ls produeix, en un sentit, un fitxer destruït, i en l'altre, un fitxer que ho duplica tot cada vegada.
  3. La fallada d'auditoria no avorta l'operació. El préstec és vàlid encara que no s'hagi pogut auditar. És la distinció recuperable/irrecuperable de 06-07 aplicada aquí: no auditar és una pèrdua d'informació, no una dada incorrecta. Si la política de l'empresa exigís el contrari —i en un sistema financer ho exigiria—, aquesta decisió s'invertiria i es documentaria.
  4. El checkError() és als dos llocs. Sense ell, PrintWriter no diu res quan el disc s'omple.
  5. sanejar() és un pedaç declarat com a tal. Substituir el ; per una coma perd informació: el títol torna malament. És un compromís conscient fins a 07-07, on l'escapada CSV ho resoldrà de veritat. Marcar els pedaços en un comentari és part de l'ofici.

I el desat en sortir, enganxat al shutdown hook de 06-05:

package com.nexussoftware.bibliotech.presentacio;

import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;

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

public class BiblioTechApp {

    private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());
    private static final String DIRECTORI_DADES = "dades";

    public static void main(String[] args) {
        ConfiguracioLog.inicialitzar();

        Cataleg cataleg = new CarregadorCataleg(DIRECTORI_DADES + "/cataleg.txt").carregar();
        ExportadorCataleg exportador = new ExportadorCataleg(DIRECTORI_DADES);

        // Desat en sortir. Cobreix la sortida normal i Ctrl+C; NO cobreix kill -9.
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                int n = exportador.exportarCataleg(cataleg);
                LOG.info("Cataleg desat en sortir: " + n + " materials");
            } catch (IOException e) {
                LOG.log(Level.SEVERE, "NO S'HA POGUT DESAR EL CATALEG EN SORTIR", e);
            }
        }, "desat-final"));

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

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

Avís important sobre el hook. Desar només en sortir és fràgil: kill -9, un tall de corrent o un OutOfMemoryError se'l salten. I el hook té un temps limitat abans que el sistema mati el procés. En un sistema real es desa també després de cada operació rellevant, o de forma periòdica. El hook és la xarxa de seguretat, no l'estratègia.

Estat de BiblioTech en tancar aquesta lliçó: el catàleg es carrega en arrencar (07-01) i es desa en sortir (07-02). Per primera vegada en set mòduls, l'aplicació recorda.

Errors Comuns i Consells

  • Oblidar el true del mode append. L'error més car del mòdul. Cada execució esborra l'històric i deixa una línia. No falla, no avisa: destrueix en silenci. Fes servir una constant AFEGIR amb nom.
  • Confondre new FileWriter(cami, true) amb new FileWriter(cami, UTF_8). S'assemblen i signifiquen coses oposades. Quan vulguis les dues, fes servir la versió de tres paràmetres amb el boolean al final.
  • Creure que obrir el fitxer és inofensiu. Construir un FileWriter sense append trunca el fitxer immediatament, abans d'escriure res. Si després falla, et quedes sense dades i sense fitxer nou.
  • No tancar l'escriptor. El fitxer queda buit. En lectura no tancar és una fuita; en escriptura és pèrdua de dades. try-with-resources sempre.
  • Confiar en el recol·lector de brossa per tancar. finalize() està obsolet i eliminat, i mai no va ser una garantia.
  • Confondre flush() amb "és al disc". flush() arriba al sistema operatiu; només sync() arriba al plat. Per a gairebé tot, close() en té prou.
  • Ignorar que PrintWriter s'empassa les IOException. Un disc ple passa desapercebut i reanomenes un fitxer truncat sobre el bo. checkError() abans de tancar, o fes servir BufferedWriter.
  • Escriure sobre el fitxer definitiu. Si falla a mitges, perds l'original i obtens un d'incomplet que sembla vàlid. Temporal i reanomenat.
  • No crear el directori pare. FileWriter no el crea; llança IOException amb un missatge que sembla dir una altra cosa.
  • No especificar el charset en escriure. Pitjor que en lectura: les dades queden mal gravades, i el problema apareix mesos després quan una altra eina les llegeix.
  • Escriure caràcters no representables al charset destí. Se substitueixen per ? sense avisar. i ç en US-ASCII desapareixen en silenci.
  • Que el lector i l'escriptor facin servir charsets diferents. Declara el charset una sola vegada en una constant compartida.
  • Fer servir \n quan el consumidor espera CRLF, o a l'inrevés. println i newLine() fan servir el del sistema; per a fitxers que es versionen, fixa \n i documenta-ho.
  • Creure que write(65) escriu "65". Escriu la lletra A. És un caràcter, no un nombre.
  • Dos processos escrivint el mateix fitxer. Resultat impredictible. Fitxer de bloqueig, i tot i així amb els seus límits.
  • Consell: pregunta't sempre "aquest fitxer es regenera o creix?". Regenerar exigeix escriptura atòmica; créixer exigeix mode append. Tota la lliçó cap en aquesta pregunta.
  • Consell: prova d'omplir el disc. Escriu en un dispositiu petit —una partició temporal d'uns quants MB— i comprova què fa el teu codi quan s'acaba. La majoria del codi no ho suporta, i la fallada a mig fitxer és l'escenari que l'escriptura atòmica existeix per cobrir.
  • Consell: comprova-ho amb un editor extern. Obre el fitxer generat amb un altre programa i amb una altra codificació. Si només el llegeixes amb el teu propi codi, els errors de format i de charset són invisibles.
  • Consell: no escriguis la contrasenya ni el DNI al fitxer. Tot el de 06-07 sobre què no registrar s'aplica igual al que es persisteix, i amb més motiu: el fitxer es queda.

Exercicis

Exercici 1: demostrador d'append

Escriu DemoAppend que demostri empíricament la diferència entre els dos modes:

  1. Un mètode escriureLinia(String cami, String text, boolean afegir) que escrigui una línia amb el mode indicat i charset UTF-8.
  2. Un mètode mostrar(String cami) que imprimeixi el contingut i el nombre de línies.
  3. Un main que: esborri el fitxer si existeix; escrigui tres línies sense append mostrant l'estat després de cadascuna; i repeteixi l'experiment amb append.
  4. Un comentari final que expliqui el resultat en una frase.

Exercici 2: escriptura atòmica amb verificació

Amplia EscripturaAtomica amb un mètode escriureVerificat(File desti, Contingut contingut, int liniesEsperades) que, a més del que ja fa:

  1. Compti les línies realment escrites al temporal.
  2. Abans de reanomenar, verifiqui que el nombre coincideix amb liniesEsperades; si no, llanci IOException i no reanomeni.
  3. Comprovi també que el temporal té mida més gran que zero.
  4. Registri al logger el resultat de la verificació.

Escriu un main que provoqui la fallada a propòsit —una lambda que llanci una excepció a mitges— i comprovi que el fitxer destí conserva el seu contingut anterior.

Exercici 3: registre d'auditoria rotatiu

Escriu AuditoriaBiblioTech que afegeixi línies a un fitxer d'auditoria amb rotació per mida:

  1. registrar(String operacio, String referencia, String idEmpleat) que afegeixi una línia amb format operacio;referencia;idEmpleat en mode append.
  2. Abans d'escriure, si el fitxer supera MIDA_MAXIMA (fes servir 1 KB per poder provar-ho), reanomena'l a auditoria-1.txt, desplaçant els anteriors fins a un màxim de 3, i comença'n un de nou.
  3. Charset explícit i separador de línia fix \n (és un fitxer de dades, no un informe).
  4. Una fallada d'escriptura no s'ha de propagar: es registra al logger i es continua, seguint la política d'ExportadorCataleg.
  5. Un main que generi 200 operacions i mostri els fitxers resultants amb les seves mides.

Solucions

Solució 1

package com.nexussoftware.bibliotech.demo;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

/**
 * Demostracio empirica del parametre append.
 */
public class DemoAppend {

    /** Noms per al boolean: evita el 'true' solt i illegible. */
    private static final boolean AFEGIR        = true;
    private static final boolean SOBREESCRIURE = false;

    private static final String CAMI = "demo-append.txt";

    public static void escriureLinia(String cami, String text, boolean afegir)
            throws IOException {
        try (FileWriter w = new FileWriter(cami, StandardCharsets.UTF_8, afegir)) {
            w.write(text);
            w.write('\n');
        }   // close() buida la memoria intermedia: sense ell, el fitxer quedaria buit
    }

    public static void mostrar(String cami) {
        File f = new File(cami);
        if (!f.exists()) {
            System.out.println("    (el fitxer no existeix)");
            return;
        }

        System.out.println("    mida: " + f.length() + " bytes");
        try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
            int n = 0;
            while (sc.hasNextLine()) {
                System.out.println("      | " + sc.nextLine());
                n++;
            }
            System.out.println("    linies: " + n);
        } catch (IOException e) {
            System.out.println("    error en llegir: " + e.getMessage());
        }
    }

    public static void main(String[] args) throws IOException {
        File f = new File(CAMI);
        if (f.exists() && !f.delete()) {
            System.err.println("No s'ha pogut esborrar el fitxer previ");
            return;
        }

        System.out.println("=== EXPERIMENT 1: SENSE append (sobreescriure) ===");
        for (int i = 1; i <= 3; i++) {
            escriureLinia(CAMI, "Operacio numero " + i, SOBREESCRIURE);
            System.out.println("  Despres d'escriure la linia " + i + ":");
            mostrar(CAMI);
        }

        f.delete();

        System.out.println();
        System.out.println("=== EXPERIMENT 2: AMB append (afegir) ===");
        for (int i = 1; i <= 3; i++) {
            escriureLinia(CAMI, "Operacio numero " + i, AFEGIR);
            System.out.println("  Despres d'escriure la linia " + i + ":");
            mostrar(CAMI);
        }

        // CONCLUSIO: sense append, cada escriptura TRUNCA el fitxer a zero
        // en el moment d'obrir-lo, aixi que nomes sobreviu l'ultima linia.
        // Amb append, el punter se situa al final i el contingut creix.
    }
}

Sortida:

=== EXPERIMENT 1: SENSE append (sobreescriure) ===
  Despres d'escriure la linia 1:
    mida: 18 bytes
      | Operacio numero 1
    linies: 1
  Despres d'escriure la linia 2:
    mida: 18 bytes
      | Operacio numero 2
    linies: 1
  Despres d'escriure la linia 3:
    mida: 18 bytes
      | Operacio numero 3
    linies: 1

=== EXPERIMENT 2: AMB append (afegir) ===
  Despres d'escriure la linia 1:
    mida: 18 bytes
      | Operacio numero 1
    linies: 1
  Despres d'escriure la linia 2:
    mida: 36 bytes
      | Operacio numero 1
      | Operacio numero 2
    linies: 2
  Despres d'escriure la linia 3:
    mida: 54 bytes
      | Operacio numero 1
      | Operacio numero 2
      | Operacio numero 3
    linies: 3

L'experiment 1 és el bug de l'apartat 2 en directe. Fixa't que la mida es manté constant en 18 bytes: el fitxer no creix perquè cada obertura el buida. Res no falla, res no avisa, i l'històric no existeix.

Solució 2

package com.nexussoftware.bibliotech.infraestructura;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Escriptura atomica amb verificacio previa al reanomenat.
 *
 * Afegeix una comprovacio d'integritat ABANS de substituir el fitxer bo:
 * si el resultat no es l'esperat, l'original es conserva intacte.
 */
public final class EscripturaAtomicaVerificada {

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

    private static final String SUFIX_TEMPORAL = ".tmp";

    private EscripturaAtomicaVerificada() { }

    @FunctionalInterface
    public interface Contingut {
        void escriureA(PrintWriter sortida) throws IOException;
    }

    /**
     * Escriu de forma atomica verificant el resultat.
     *
     * @param liniesEsperades nombre de linies que HA de tenir el resultat
     * @throws IOException si falla l'escriptura o la verificacio
     */
    public static void escriureVerificat(File desti, Contingut contingut,
                                         int liniesEsperades) throws IOException {

        File temporal = new File(desti.getAbsolutePath() + SUFIX_TEMPORAL);

        File pare = desti.getAbsoluteFile().getParentFile();
        if (pare != null && !pare.exists() && !pare.mkdirs()) {
            throw new IOException("No s'ha pogut crear el directori " + pare.getAbsolutePath());
        }

        boolean completat = false;
        try {
            // ---- FASE 1: escriure al temporal ----
            try (PrintWriter sortida = new PrintWriter(
                    new FileWriter(temporal, StandardCharsets.UTF_8))) {

                contingut.escriureA(sortida);

                if (sortida.checkError()) {
                    throw new IOException("Fallada d'escriptura a " + temporal.getName());
                }
            }

            // ---- FASE 2: verificar ABANS de tocar el desti ----
            long mida = temporal.length();
            if (mida == 0) {
                throw new IOException("El fitxer temporal ha quedat buit; no se substitueix "
                        + desti.getName());
            }

            int liniesReals = comptarLinies(temporal);
            if (liniesReals != liniesEsperades) {
                throw new IOException(String.format(
                        "Verificacio fallida a %s: s'esperaven %d linies i n'hi ha %d. "
                                + "El fitxer original NO s'ha modificat.",
                        desti.getName(), liniesEsperades, liniesReals));
            }

            LOG.fine(() -> String.format("Verificacio OK: %d linies, %d bytes",
                    liniesReals, mida));

            // ---- FASE 3: substituir. Nomes si tot l'anterior ha anat be ----
            if (desti.exists() && !desti.delete()) {
                throw new IOException("No s'ha pogut eliminar el desti " + desti.getName());
            }
            if (!temporal.renameTo(desti)) {
                throw new IOException("No s'ha pogut reanomenar " + temporal.getName());
            }

            completat = true;
            LOG.info(() -> String.format("Escriptura verificada de %s: %d linies, %d bytes",
                    desti.getAbsolutePath(), liniesEsperades, mida));

        } finally {
            // COMPENSACIO (06-05): sense brossa, i amb l'original intacte
            if (!completat && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Temporal sense esborrar: " + temporal.getAbsolutePath());
            }
        }
    }

    private static int comptarLinies(File f) throws IOException {
        int n = 0;
        try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
            while (sc.hasNextLine()) {
                sc.nextLine();
                n++;
            }
        }
        return n;
    }

    // ------------------------- DEMOSTRACIO -------------------------

    public static void main(String[] args) throws IOException {
        File desti = new File("dades/cataleg-verificat.txt");

        // 1. Escriptura correcta de 3 linies
        escriureVerificat(desti, sortida -> {
            sortida.println("LLIBRE;978-0000000001;Java Eficac;Bloch;2018");
            sortida.println("LLIBRE;978-0000000002;Patrons de Disseny;Gamma;1994");
            sortida.println("LLIBRE;978-0000000003;Refactoritzacio;Fowler;1999");
        }, 3);

        System.out.println("Despres de l'escriptura correcta: "
                + desti.length() + " bytes, "
                + comptarLinies(desti) + " linies");

        // 2. Escriptura que FALLA a mitges
        try {
            escriureVerificat(desti, sortida -> {
                sortida.println("LLIBRE;978-0000000004;Llibre nou;Autor;2024");
                // Fallada simulada: disc ple, error de serialitzacio, el que sigui
                throw new IOException("Fallada simulada a mitja exportacio");
            }, 4);

        } catch (IOException e) {
            System.out.println("Fallada capturada: " + e.getMessage());
        }

        // 3. COMPROVACIO CLAU: el fitxer bo continua alla, sencer
        System.out.println("Despres de la fallada:            "
                + desti.length() + " bytes, "
                + comptarLinies(desti) + " linies");

        System.out.println("Ha quedat brossa .tmp? "
                + new File(desti.getAbsolutePath() + ".tmp").exists());

        // 4. Escriptura amb nombre de linies incorrecte: es rebutja
        try {
            escriureVerificat(desti, sortida -> sortida.println("Nomes una linia"), 5);
        } catch (IOException e) {
            System.out.println("Verificacio: " + e.getMessage());
        }

        System.out.println("Despres de la verificacio fallida: "
                + comptarLinies(desti) + " linies (intacte)");
    }
}

Sortida:

Despres de l'escriptura correcta: 147 bytes, 3 linies
Fallada capturada: Fallada simulada a mitja exportacio
Despres de la fallada:            147 bytes, 3 linies
Ha quedat brossa .tmp? false
Verificacio: Verificacio fallida a cataleg-verificat.txt: s'esperaven 5 linies i n'hi ha 1. El fitxer original NO s'ha modificat.
Despres de la verificacio fallida: 3 linies (intacte)

Les tres línies que importen d'aquesta sortida són la tercera, la quarta i l'última: després d'una fallada a mitja escriptura i després d'una verificació fallida, el fitxer bo continua amb les seves 3 línies i no ha quedat brossa temporal. Això és exactament el que aporta el patró, i és impossible d'aconseguir escrivint directament sobre el destí.

Fixa't també que la verificació va entre l'escriptura i el reanomenat. Aquest buit és el que fa que el patró sigui tan valuós: pots comprovar el que vulguis —nombre de línies, capçalera, format, fins i tot rellegir-lo i analitzar-lo sencer— abans de comprometre't a substituir l'original.

Solució 3

package com.nexussoftware.bibliotech.infraestructura;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Registre d'auditoria de BiblioTech amb rotacio per mida.
 *
 * Mode AFEGIR: el fitxer creix, no es regenera. Per aixo NO fa servir
 * escriptura atomica: no hi ha res per reemplacar.
 *
 * Separador de linia fix '\n': es un fitxer de DADES que es pot comparar
 * entre maquines, no un informe per llegir a la pantalla (apartat 8).
 */
public class AuditoriaBiblioTech {

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

    private static final boolean AFEGIR = true;
    private static final String  SALT   = "\n";
    private static final String  SEP    = ";";

    /** 1 KB, petit a proposit per poder provar la rotacio. */
    private static final long MIDA_MAXIMA = 1024L;

    /** Fitxers historics que es conserven: auditoria-1 .. auditoria-3. */
    private static final int MAX_HISTORICS = 3;

    private final File directori;
    private final String nomBase;

    private int operacionsRegistrades = 0;
    private int rotacions = 0;
    private int fallades = 0;

    public AuditoriaBiblioTech(String directori) {
        this.directori = new File(
                Objects.requireNonNull(directori, "El directori no pot ser nul"));
        this.nomBase = "auditoria";

        if (!this.directori.exists() && !this.directori.mkdirs()) {
            LOG.warning("No s'ha pogut crear " + this.directori.getAbsolutePath());
        }
    }

    /**
     * Registra una operacio.
     *
     * POLITICA: una fallada d'auditoria NO es propaga MAI. L'operacio de
     * negoci ja es valida; perdre'n el rastre es una degradacio acceptable
     * i s'avisa al log (06-07).
     */
    public void registrar(String operacio, String referencia, String idEmpleat) {
        Objects.requireNonNull(operacio, "L'operacio no pot ser nulla");

        File actual = new File(directori, nomBase + ".txt");

        try {
            // 1. Rotar ABANS d'escriure, si toca
            if (actual.exists() && actual.length() >= MIDA_MAXIMA) {
                rotar(actual);
            }

            // 2. Afegir la linia. El 'true' es el que fa que aixo sigui
            //    un historic i no un fitxer d'una sola linia.
            try (PrintWriter sortida = new PrintWriter(
                    new FileWriter(actual, StandardCharsets.UTF_8, AFEGIR))) {

                sortida.write(String.join(SEP, operacio, referencia, idEmpleat));
                sortida.write(SALT);

                if (sortida.checkError()) {
                    throw new IOException("Fallada escrivint a " + actual.getAbsolutePath());
                }
            }
            operacionsRegistrades++;

        } catch (IOException e) {
            fallades++;
            LOG.log(Level.WARNING, "No s'ha pogut auditar " + operacio
                    + " de " + referencia, e);
            // Sense rellancar: la degradacio es deliberada
        }
    }

    /**
     * Desplaca els historics: -2 passa a -3, -1 passa a -2, l'actual a -1.
     *
     * Es recorre de MAJOR a MENOR. A l'inreves, el primer reanomenat
     * matxucaria el seguent abans d'haver-lo desplacat.
     */
    private void rotar(File actual) throws IOException {
        // El mes antic es perd
        File mesAntic = historic(MAX_HISTORICS);
        if (mesAntic.exists() && !mesAntic.delete()) {
            throw new IOException("No s'ha pogut esborrar " + mesAntic.getName());
        }

        for (int i = MAX_HISTORICS - 1; i >= 1; i--) {
            File origen = historic(i);
            File desti  = historic(i + 1);
            if (origen.exists() && !origen.renameTo(desti)) {
                throw new IOException("No s'ha pogut rotar " + origen.getName());
            }
        }

        if (!actual.renameTo(historic(1))) {
            throw new IOException("No s'ha pogut rotar " + actual.getName());
        }

        rotacions++;
        LOG.fine(() -> "Auditoria rotada (rotacio numero " + rotacions + ")");
    }

    private File historic(int n) {
        return new File(directori, nomBase + "-" + n + ".txt");
    }

    public int getOperacionsRegistrades() { return operacionsRegistrades; }
    public int getRotacions()             { return rotacions; }
    public int getFallades()              { return fallades; }

    // ------------------------- DEMOSTRACIO -------------------------

    public static void main(String[] args) {
        AuditoriaBiblioTech auditoria = new AuditoriaBiblioTech("dades/auditoria");

        String[] operacions = { "PRESTEC", "DEVOLUCIO", "ALTA", "BAIXA" };
        String[] empleats   = { "E-001", "E-002", "E-003" };

        for (int i = 1; i <= 200; i++) {
            auditoria.registrar(
                    operacions[i % operacions.length],
                    String.format("PR-%04d", i),
                    empleats[i % empleats.length]);
        }

        System.out.println("=== AUDITORIA ===");
        System.out.printf("  Operacions registrades: %d%n",
                auditoria.getOperacionsRegistrades());
        System.out.printf("  Rotacions realitzades : %d%n", auditoria.getRotacions());
        System.out.printf("  Fallades              : %d%n", auditoria.getFallades());

        System.out.println("  --- Fitxers ---");
        File dir = new File("dades/auditoria");
        File[] fitxers = dir.listFiles();       // pot ser null: es comprova
        if (fitxers != null) {
            java.util.Arrays.sort(fitxers);     // 05-09
            for (File f : fitxers) {
                System.out.printf("    %-20s %6d bytes%n", f.getName(), f.length());
            }
        }
    }
}

Sortida:

=== AUDITORIA ===
  Operacions registrades: 200
  Rotacions realitzades : 4
  Fallades              : 0
  --- Fitxers ---
    auditoria-1.txt        1042 bytes
    auditoria-2.txt        1040 bytes
    auditoria-3.txt        1039 bytes
    auditoria.txt            85 bytes

Els quatre punts didàctics:

  1. El bucle de rotació va de major a menor. És el detall que més es falla. Si rotessis d'1 a 3, el primer renameTo matxucaria auditoria-2.txt abans d'haver-lo desplaçat a -3, i perdries tot l'històric menys l'últim. Fes-te el dibuix mental de les tres fletxes abans d'escriure-ho.
  2. Es rota abans d'escriure, no després. Així el fitxer no supera mai el límit de forma apreciable, i no cal preocupar-se de la mida de la línia que hi entrarà.
  3. La fallada no es propaga i es compta. El comptador fallades permet detectar a l'informe que l'auditoria està degradada, encara que l'aplicació continuï funcionant. Degradar sense deixar rastre seria pitjor que fallar.
  4. listFiles() pot retornar null. La comprovació no és paranoia: retorna null si el directori no existeix o si falla l'accés. És el boolean mut de l'apartat 6 de 07-01 en una altra forma, i a 07-06 ho resol Files.list().

I observa que aquest mecanisme és exactament el que fa per dins el FileHandler de java.util.logging que vas configurar a 06-07, amb el seu bibliotech-%g.log, els seus 5 fitxers i el seu límit d'1 MB. Ara ja saps com està implementat.

Conclusió

BiblioTech ja desa.

Coneixes FileWriter i el seu comportament en l'obertura: crea el fitxer si no existeix i —això és el que cal recordar per damunt de tot— el trunca a zero bytes si existeix, en l'instant de construir-lo, abans d'escriure res. Domines el paràmetre append i saps que oblidar-lo converteix un històric de tres-centes mil línies en un fitxer d'una, sense excepció, sense avís i de forma perfectament estable. Tens les tres defenses: anomenar el boolean amb una constant, fer servir les opcions explícites de NIO.2 que veuràs a 07-06, i escriure sempre a un temporal quan regeneris un fitxer sencer.

Saps escriure amb write en les seves cinc sobrecàrregues, amb l'asimetria de write(int) que escriu un caràcter i no un nombre, i coneixes PrintWriter com a embolcall que aporta println, printf i format reutilitzant el formatatge del mòdul 1 —amb Locale.ROOT quan el fitxer l'ha de llegir un programa i el Locale del sistema quan l'ha de llegir una persona—. I coneixes el seu parany major: PrintWriter no llança IOException, se la guarda en un marcador, així que un disc ple passa desapercebut si no crides checkError().

Entens la memòria intermèdia i la cadena completa de buidatges: write arriba a la memòria intermèdia de l'aplicació, flush arriba al sistema operatiu, close fa flush i allibera, i només sync arriba al plat del disc. Saps per què un fitxer acabat d'escriure té 0 bytes, per què tail -f no mostra res durant una estona, i què passa exactament si el programa acaba sense tancar: el fitxer queda buit, perquè el recol·lector de brossa no tanca res i finalize() està eliminat. Per això el try-with-resources importa més en escriure que en llegir: allà no tancar és una fuita; aquí és pèrdua de dades.

Saps que el charset explícit és encara més crític en escriure, perquè l'error queda gravat i només apareix quan una altra eina llegeix el fitxer; i que escriure un caràcter no representable —una ç en US-ASCII— el substitueix per ? sense dir res. Coneixes System.lineSeparator(), les tres convencions de final de línia, i el criteri per triar: el del sistema per als informes, \n fix per als fitxers de dades que es comparen o es versionen. I saps que el charset i el separador s'han de declarar una sola vegada en una constant compartida pel lector i l'escriptor.

I tens el patró que separa el codi aficionat del professional: l'escriptura atòmica. Escriure en un temporal, verificar, i reanomenar només al final. Saps per què funciona —el reanomenat és atòmic per a qui llegeix, així que mai no es veu un fitxer a mitges—, saps que el finally compensa esborrant el temporal exactament com vas aprendre a 06-05, i saps que el destí no necessita compensació perquè no es va obrir mai. I saps quan aplicar-la: quan el fitxer es regenera; mai quan el fitxer creix, que és el cas del mode append. Tota la lliçó cap en aquesta pregunta: aquest fitxer es regenera o creix?

Coneixes també els fitxers de bloqueig amb createNewFile() com a operació atòmica de comprovar-i-crear, les seves limitacions honestes —bloqueigs orfes, l'alternativa de FileLock—, i les còpies de seguretat rotatives que 07-06 farà bé amb NIO.2.

BiblioTech, en tancar aquesta lliçó, carrega el seu catàleg en arrencar i el desa en sortir. ExportadorCataleg escriu de veritat, de forma atòmica, comparteix amb CarregadorCataleg les constants de FormatFitxers perquè no puguin discrepar, i ha deixat de ser Closeable perquè ja no posseeix cap recurs viu —un objecte que no té res per tancar no ha de prometre que es tanca—. El seu registre d'auditoria s'obre en mode append perquè creix, i les seves fallades no tomben l'operació de negoci, sinó que degraden amb un avís al log. I un shutdown hook garanteix el desat en la sortida normal, amb l'advertència explícita que un kill -9 se'l salta i que l'estratègia seriosa és desar també durant l'execució.

Queda una pregunta de fons que les dues lliçons han esquivat. Has fet servir FileReader, FileWriter, Scanner i PrintWriter com si fossin peces soltes, i no ho són: formen part d'un disseny amb una estructura molt deliberada. Per què PrintWriter embolcalla FileWriter en lloc de substituir-lo? Per què hi ha dues famílies diferents de classes, unes acabades en Reader/Writer i altres en InputStream/OutputStream? I com es llegeix una imatge, que no és text?

A la lliçó 07-03, Fluxos de fitxers, es respon. Veuràs el model conceptual del flux amb el seu vocabulari de font, destinació i direcció; les dues jerarquies —bytes i caràcters— i per què havien de ser dues; la distinció entre classes de node i classes de filtre, que és el patró Decorador en estat pur i explica d'una vegada aquests new BufferedInputStream(new FileInputStream(...)) que apareixen pertot arreu; els ponts InputStreamReader i OutputStreamWriter, que són exactament el punt on es decideix la codificació que portes dues lliçons especificant; la lectura i l'escriptura de dades binàries per bytes i per blocs, amb la còpia de la imatge de portada d'un llibre; i transferTo, la forma moderna de copiar un flux sencer en una línia. En acabar-la, deixaràs de fer servir aquestes classes de memòria i començaràs a compondre-les sabent exactament què fa cada capa.

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