Un archivio che sopravvive all'esecuzione
Finora molti esempi hanno creato i dati in memoria e li hanno perduti alla fine di main. Una biblioteca, invece, deve poter leggere domani i titoli registrati oggi. Questo cambiamento introduce un confine: il programma controlla i suoi oggetti, ma non controlla completamente il file system. Il file può mancare, essere illeggibile o cambiare mentre lo consultiamo; una scrittura può finire lo spazio disponibile. La prima domanda non è «quale metodo di Files devo ricordare?», ma «che cosa sto trasferendo, fra quali luoghi, e che cosa deve restare vero se il trasferimento fallisce?».
Chiamiamo I/O l'insieme delle operazioni di ingresso e uscita. Una sorgente fornisce dati, una destinazione li riceve. Un file è una possibile sorgente o destinazione; anche una connessione di rete lo è. Una risorsa è un oggetto che mantiene aperto qualcosa al di fuori della normale memoria degli oggetti Java, per esempio un descrittore di file. Chiudere la risorsa non è un dettaglio cosmetico: libera quella capacità e conclude l'operazione secondo il contratto dell'API.
La figura 25.1 segue un testo dall'archivio fino al programma. I byte sul disco vengono interpretati con una codifica; il risultato sono caratteri e stringhe. Nel percorso inverso, i caratteri devono essere codificati in byte. Se l'archivio contiene un'immagine, quella conversione testuale sarebbe un errore: l'immagine va trattata come byte. Questa distinzione collega il capitolo 11 su Unicode al capitolo 12 sulle eccezioni.
Byte, caratteri e codifica
InputStream legge byte, OutputStream li scrive. Il valore restituito da read() è un intero da 0 a 255, oppure -1 quando il flusso è terminato: -1 non è un byte del file. Una chiamata che legge in un array può ottenere meno byte di quelli richiesti, anche quando altri dati arriveranno. Il codice che ha bisogno di tutto il contenuto deve ripetere la lettura fino a fine flusso; transferTo esprime una copia semplice, ma non rende la destinazione automaticamente atomica o recuperabile dopo un guasto.
Reader e Writer lavorano invece con caratteri Java, rappresentati nei loro metodi elementari da unità UTF-16. Un Reader non equivale a un lettore di code point Unicode: un simbolo fuori dal piano multilingue di base usa due unità. Per passare fra byte e caratteri servono un decoder e un encoder. InputStreamReader e OutputStreamWriter fanno da ponte; Files.newBufferedReader e Files.newBufferedWriter sono scelte comode quando l'origine è un Path. Nel programma completo indichiamo StandardCharsets.UTF_8 in entrambe le direzioni, così il formato dell'archivio è dichiarato dal codice.
Concetto chiave – Il charset è parte del formato. UTF-8 stabilisce come rappresentare i caratteri in byte. La parola Java
Stringnon è già «un file UTF-8»: è testo in memoria. Se produttore e lettore scelgono codifiche diverse, il testo può corrompersi o la decodifica può fallire. Quando il formato è noto, non affidiamoci al charset predefinito della macchina; un eventuale byte order mark e il trattamento degli input malformati vanno decisi dal contratto del file.
Un buffer trattiene temporaneamente dati per ridurre il numero di accessi all'origine. Avvolgere un flusso in BufferedInputStream o un lettore in BufferedReader può aiutare quando leggiamo molti frammenti piccoli. Non risolve però il problema di un algoritmo che carica un archivio enorme tutto in memoria. Files.readString è adatto a un file piccolo di cui possiamo accettare il costo in memoria; per un archivio lungo usiamo un lettore e processiamo una riga alla volta. L'analoga scelta in uscita è fra Files.writeString e una scrittura progressiva con BufferedWriter.
Un caso concreto aiuta a scegliere. Se dobbiamo copiare un'immagine senza modificarla, Files.copy esprime già l'operazione fra due percorsi. Se la sorgente è una connessione e la destinazione è un file, InputStream.transferTo(OutputStream) trasferisce byte senza introdurre una codifica testuale. Se invece dobbiamo contare titoli che iniziano con una lettera, serve interpretare righe e caratteri, quindi il lettore testuale è parte della soluzione. Il fatto che tutte e tre le operazioni «leggano un file» non le rende intercambiabili: cambia il significato dei dati.
Con una lettura manuale a blocchi, il valore restituito da read(byte[]) indica quanti elementi dell'array sono stati riempiti; soltanto quelli vanno scritti. Un programma che scrive sempre l'intero array può aggiungere al file di uscita byte vecchi nell'ultimo giro. Anche 0 e -1 hanno significati diversi quando una particolare API può restituire zero: la fine del flusso è segnalata da -1. Per imparare questa disciplina, si può copiare un file di lunghezza non multipla della dimensione del buffer e confrontare byte per byte il risultato.
Decorare un flusso: ogni strato aggiunge un compito
Supponiamo di leggere da una sorgente che fornisce pochi byte alla volta. Non cambia il formato, ma cambia il costo di ogni accesso. Possiamo avvolgere la sorgente con un buffer e poi con un lettore testuale: sorgente di byte → buffer → decoder → lettore di righe. La decorazione mantiene il contratto di base e aggiunge un comportamento. Un BufferedReader non sa da solo quale fosse la codifica dei byte prima del decoder; un BufferedInputStream non sa dove finisca una riga. Ogni strato risponde a una domanda diversa.
Quando siamo noi a creare tutta la catena, chiudiamo il suo oggetto esterno con try-with-resources: i wrapper ordinari propagano la chiusura alla risorsa sottostante. Se un metodo riceve uno stream dal chiamante, invece, il contratto deve dire chi lo possiede. Un metodo di copia non dovrebbe chiudere senza avviso System.out o uno stream che il chiamante deve riutilizzare. transferTo trasferisce il contenuto, ma non chiude i due estremi. La proprietà della risorsa è una responsabilità progettuale, non qualcosa che il compilatore deduce dal tipo.
Nota bene – Fine del flusso e disponibilità.
available()non misura la lunghezza complessiva dell'ingresso e non è una prova di fine flusso: stima byte leggibili senza bloccare.readAllBytes()legge tutto e consuma memoria proporzionale ai dati;readNBytes(n)può restituire meno dinbyte se l'ingresso termina. Se il formato richiede esattamente una certa quantità, controlliamo la lunghezza ottenuta.markeresetconsentono di tornare a un punto soltanto negli stream che li supportano e nei limiti dichiarati; non trasformano qualunque sorgente in un file ad accesso casuale.
Nel programma sui contratti I/O usiamo un ingresso in memoria che limita deliberatamente ogni lettura a tre byte. Il ciclo scrive soltanto il numero restituito e il confronto finale controlla l'intero risultato. È un esperimento piccolo, ma toglie l'illusione che «ho chiesto otto byte» significhi «ne ho ricevuti otto». La variante sostituisce l'array in memoria con una sorgente di rete: il principio resta valido anche se ora una lettura può attendere nuovi dati.
Per un formato binario con numeri primitivi, DataInputStream e DataOutputStream aggiungono operazioni come readInt e writeInt. Il contratto deve fissare ordine dei campi, dimensioni, versione e risposta al troncamento; readInt segnala una fine anticipata con EOFException. writeUTF usa una forma UTF-8 modificata e una lunghezza limitata: non è una scorciatoia per un file di testo UTF-8 generale. Per dati compressi possiamo aggiungere gli stream di java.util.zip, ma la dimensione decompressa richiede limiti propri: un ingresso piccolo può produrre molto più contenuto. Il criterio rimane scegliere lo strato che rappresenta il formato, non aggiungerne finché l'esempio funziona.
Una decodifica che rifiuta dati ambigui
Il catalogo arriva da un sistema esterno che dichiara UTF-8. Vogliamo rifiutare una sequenza di byte malformata anziché sostituirla con un carattere e pubblicare un titolo diverso. Un CharsetDecoder consente di scegliere CodingErrorAction.REPORT per l'ingresso malformato e per i caratteri non rappresentabili. Possiamo passarlo a InputStreamReader, poi avvolgere questo in BufferedReader. Non affidiamoci a una presunta uniformità dei costruttori: un ponte costruito soltanto con un charset può avere una politica di sostituzione, mentre il contratto di Files.newBufferedReader prevede errori per sequenze malformate o non mappabili.
Per costruire questo lettore, il seguente frammento assume un InputStream ingresso già disponibile. Il metodo che esegue la lettura propaga o gestisce IOException; chiudendo lettore chiudiamo anche ingresso, secondo il contratto di proprietà appena stabilito.
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try (var lettore = new BufferedReader(new InputStreamReader(ingresso, decoder))) {
String riga;
while ((riga = lettore.readLine()) != null) {
System.out.println(riga);
}
}
Importiamo CodingErrorAction da java.nio.charset e i due lettori da java.io. Il decoder viene creato per questo flusso: non lo condividiamo fra letture concorrenti. Stampare è soltanto l'azione dimostrativa; nell'importatore la riga va validata prima di produrre il risultato.
La verifica usa i byte C3 28: il primo inizia una sequenza UTF-8 che il secondo non completa. Il lettore configurato per il rifiuto deve lanciare MalformedInputException, non produrre un titolo. Poi proviamo un titolo valido con un carattere accentato e uno fuori dal piano multilingue di base. Il successo sui soli caratteri ASCII non avrebbe verificato la parte interessante della codifica.
Approfondimento – Una sequenza può attraversare due blocchi. Un carattere UTF-8 può occupare più byte. Se decodifichiamo ogni blocco isolatamente con
new String(blocco, UTF_8), il confine del buffer può tagliare la sequenza e corromperla. Un lettore o un decoder mantenuto lungo il flusso conserva lo stato necessario. L'I/O a blocchi suddivide il trasporto, non definisce i confini dei caratteri o dei record del formato.
Anche leggere «una riga alla volta» ha un limite: readLine() costruisce l'intera riga in memoria e rimuove il terminatore. Un file senza a capo può quindi essere una singola riga enorme. Se l'ingresso non è controllato, definiamo un massimo per byte, caratteri e lunghezza delle righe, imponendolo durante la lettura, non dopo aver allocato tutto. newLine() usa il separatore della piattaforma; se il protocollo richiede LF, scriviamo esplicitamente \n. La trasformazione del nostro catalogo sceglie un archivio locale: per un formato di scambio rendiamo esplicita anche questa scelta.
Il nome di un file non è il suo contenuto
Path rappresenta un percorso nel file system; Files esegue le operazioni su quel percorso. Con Path.of("dati", "libri.txt") il programma costruisce un percorso relativo: il suo significato dipende dalla directory di lavoro del processo. Un percorso assoluto, come quello restituito da toAbsolutePath(), indica una posizione rispetto alla radice del file system. Nessuno dei due garantisce che il file esista. resolve unisce parti di percorso; relativize descrive come raggiungere un percorso da un altro, quando il provider lo consente e le radici sono compatibili.
normalize() elimina elementi lessicali come . e coppie nome/... Non legge il file system e non è una verifica di sicurezza. In presenza di collegamenti simbolici, il percorso normalizzato e quello realmente visitato possono differire. toRealPath() interroga il file system e risolve i collegamenti secondo le opzioni indicate, ma richiede che il percorso sia accessibile. Se un'applicazione accetta nomi di file da un utente, un semplice controllo testuale sul prefisso non basta a garantire che tutte le aperture future rimangano entro una directory: altri processi possono cambiare il file system fra controllo e uso.
Prendiamo Path.of("archivi").resolve("2026").resolve("libri.txt"). L'API conserva i separatori appropriati al provider: non serve inserire manualmente / o \\ nella stringa. Su un percorso relativo, toAbsolutePath usa la directory di lavoro; non trasforma il percorso in una risorsa fissa dell'applicazione. Se l'utente avvia il programma da un'altra cartella, quel medesimo nome relativo può designare un altro file. Il programma deve quindi ricevere la directory di lavoro come parametro o ricavarla da una configurazione esplicita. Un percorso usato nei test può essere creato con Files.createTempDirectory, come fa il nostro esempio, per evitare dipendenze dal computer dell'autore.
Una chiamata a Files.exists può restituire false sia quando il file manca sia quando non è possibile determinarne l'esistenza. In ogni caso if (Files.exists(p)) Files.readString(p) non elimina l'eccezione: fra le due chiamate il file può sparire. È meglio tentare l'operazione, poi trattare NoSuchFileException, AccessDeniedException o una più generale IOException secondo la decisione che l'applicazione deve prendere. Il capitolo sulle eccezioni ci ha preparato proprio a distinguere il fallimento previsto dall'invariante violato.
Aprire in scrittura significa scegliere che cosa può andare perduto
Prima di modificare un archivio dobbiamo rispondere a una domanda: il file deve essere nuovo, sostituito oppure prolungato? Le opzioni di apertura rendono la risposta concreta. Con le impostazioni ordinarie di Files.newBufferedWriter il file viene creato se manca e troncato se esiste. È comodo per un risultato rigenerabile; è pericoloso se credevamo di aggiungere un titolo alla fine. APPEND chiede di scrivere in coda; CREATE_NEW richiede che il nome non esista e fa fallire l'apertura se è già occupato. CREATE consente invece anche un file esistente.
Immaginiamo di creare una ricevuta numerata. Il controllo «se non esiste, creala» lascia un intervallo in cui un altro processo può usare lo stesso nome. Un'apertura con CREATE_NEW esprime direttamente il vincolo. Il programma di prova crea la ricevuta e tenta di crearla una seconda volta: attendiamo FileAlreadyExistsException e verifichiamo che il contenuto originale sia rimasto intatto. Se il requisito cambia in «aggiungi righe al registro», la scelta diventa APPEND, ma dobbiamo ancora definire coordinamento e recupero per scrittori concorrenti. Non assumiamo che una sequenza di più scritture costituisca un record indivisibile su ogni provider.
flush() spinge dati dal buffer verso lo strato successivo. Non certifica che siano già su un supporto persistente; close() può fallire mentre completa la scrittura. Per questo il nostro importatore pubblica dopo essere uscito dal blocco delle risorse. FileChannel.force può richiedere la persistenza di aggiornamenti del file nelle condizioni documentate, ma la durabilità di una sostituzione coinvolge anche il file system e i metadati della directory. Non ricaviamo una promessa universale di recupero da un singolo metodo.
Un'importazione che non pubblica risultati a metà
Supponiamo di trasformare un archivio di titoli. Vogliamo eliminare righe vuote e spazi ai bordi, poi scrivere i titoli in maiuscolo. Se scriviamo direttamente sul file finale e la riga cento contiene un dato non valido, i lettori vedranno un archivio troncato. La soluzione svolta usa un file provvisorio nella stessa directory della destinazione. Solo dopo la lettura, la validazione e la chiusura del writer chiede uno spostamento atomico. La figura 25.2 rende visibile la differenza fra «sto costruendo» e «è pronto per i lettori».
Il codice è completo e può essere compilato con javac --release 25 ImportaArchivio.java ed eseguito con java ImportaArchivio. I percorsi del programma di prova vengono creati in una directory temporanea: l'esempio non dipende dal nome di una cartella sul computer del lettore.
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
public class ImportaArchivio {
static int importa(Path sorgente, Path destinazione) throws IOException {
Path cartella = destinazione.toAbsolutePath().getParent();
Path provvisorio = Files.createTempFile(cartella, "archivio-", ".tmp");
boolean pubblicato = false;
Throwable fallimento = null;
try {
int righe = 0;
int numeroRiga = 0;
try (BufferedReader lettore = Files.newBufferedReader(
sorgente, StandardCharsets.UTF_8);
BufferedWriter scrittore = Files.newBufferedWriter(
provvisorio, StandardCharsets.UTF_8)) {
String riga;
while ((riga = lettore.readLine()) != null) {
numeroRiga++;
String valore = riga.strip();
if (valore.isEmpty()) {
continue;
}
if (valore.contains(";")) {
throw new IOException("Separatore inatteso alla riga " + numeroRiga);
}
scrittore.write(valore.toUpperCase(java.util.Locale.ROOT));
scrittore.newLine();
righe++;
}
}
try {
Files.move(provvisorio, destinazione,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException ex) {
throw new IOException("Pubblicazione atomica non disponibile", ex);
}
pubblicato = true;
return righe;
} catch (IOException | RuntimeException | Error ex) {
fallimento = ex;
throw ex;
} finally {
if (!pubblicato) {
try {
Files.deleteIfExists(provvisorio);
} catch (IOException | RuntimeException pulizia) {
if (fallimento != null) {
fallimento.addSuppressed(pulizia);
} else {
throw pulizia;
}
}
}
}
}
public static void main(String[] args) throws IOException {
Path cartella = Files.createTempDirectory("esempio-c25-");
Path sorgente = cartella.resolve("ingresso.txt");
Path destinazione = cartella.resolve("uscita.txt");
try {
Files.writeString(sorgente, " libro \n\njava\n", StandardCharsets.UTF_8);
System.out.println("Righe importate: " + importa(sorgente, destinazione));
System.out.print(Files.readString(destinazione, StandardCharsets.UTF_8));
} finally {
Files.deleteIfExists(destinazione);
Files.deleteIfExists(sorgente);
Files.deleteIfExists(cartella);
}
}
}
createTempFile crea davvero il file, invece di inventare un nome sperando che sia libero. Il file viene creato accanto alla destinazione perché uno spostamento atomico fra file system diversi in genere non è disponibile. La variabile pubblicato distingue il successo dall'uscita per eccezione; nel secondo caso finally tenta di eliminare il provvisorio. Se la pulizia fallisce durante la gestione di un errore, addSuppressed conserva quel secondo problema senza nascondere il primo. Un provvisorio non eliminato richiederà una pulizia successiva: non possiamo promettere che ogni guasto lasci la directory vuota. Le due risorse del try vengono chiuse in ordine inverso. Se la lettura o la scrittura fallisce e anche la chiusura fallisce, l'errore di chiusura viene conservato come eccezione soppressa: non cancella la causa che ha interrotto il lavoro.
Nel ciclo, readLine() restituisce null alla fine. Ogni riga non vuota viene validata prima di essere scritta. La condizione sul punto e virgola è un vincolo inventato per l'esempio, non una regola dei file di testo. Il contatore numeroRiga avanza anche sulle righe vuote, mentre righe conta soltanto i titoli importati: il messaggio di errore indica così la posizione fisica nell’ingresso. Un vero archivio avrebbe inoltre una grammatica dichiarata e una politica sui duplicati. Eseguendo il programma, osserviamo Righe importate: 2, poi LIBRO e JAVA su righe distinte. L'esito dimostra il percorso positivo; per provare quello negativo inseriamo titolo;errato nel sorgente e verifichiamo che il file finale precedente non sia stato sostituito.
La prova del fallimento esegue proprio quest'ultimo esperimento su file temporanei: crea una destinazione con VERSIONE PRECEDENTE, incontra una riga malformata, poi controlla che il contenuto precedente sia intatto e che nessun file provvisorio sia rimasto nella cartella. Stampa Destinazione precedente intatta; provvisorio rimosso. Il test rende osservabile la proprietà per cui abbiamo introdotto il file provvisorio; una semplice esecuzione positiva non l'avrebbe dimostrata. Non prova ancora la resistenza a un crash improvviso o il comportamento di un provider diverso.
Attenzione – Atomico non significa durabile.
ATOMIC_MOVErichiede al provider uno spostamento indivisibile per gli osservatori; se non è supportato il programma fallisce esplicitamente. La combinazione delle opzioni e la sostituzione di una destinazione esistente hanno dettagli dipendenti dal provider: un sistema reale deve provarli sul proprio file system. Lo spostamento atomico non promette, da solo, che i byte sopravvivano a uno spegnimento improvviso: quella è una diversa garanzia di persistenza. Se l'applicazione accetta una pubblicazione non atomica, deve definire e documentare un piano di recupero, invece di nascondere il ripiego dentro uncatch.
Il termine «atomico» era comparso nel capitolo 20A per una decisione su stato condiviso nella JVM. Qui il confine è diverso: gli osservatori del file system devono vedere il vecchio file oppure il nuovo, secondo le garanzie del provider. Un contatore Java atomico non rende indivisibile la pubblicazione del file; ATOMIC_MOVE non rende atomici i campi Java aggiornati prima o dopo lo spostamento. Quando un'operazione richiede entrambe le cose, occorre progettare esplicitamente il coordinamento e la gestione del fallimento fra i due mondi.
Esplorare directory senza trattenere risorse
Files.createDirectories crea le directory mancanti di un percorso, mentre createDirectory richiede che il padre esista già. Files.list restituisce uno Stream<Path> che mantiene aperta la directory: va chiuso con try-with-resources, anche se il tipo somiglia agli stream del capitolo 18. Files.walk visita più livelli e richiede la stessa disciplina. DirectoryStream è un'altra scelta per iterare una directory senza costruire subito una lista. Un FileVisitor rende espliciti i momenti in cui entriamo in una directory, vediamo un file, incontriamo un errore e usciamo: serve soprattutto quando copia, pulizia o ricerca devono reagire in modo diverso a ciascun evento.
Per eliminare un albero non basta chiamare Files.delete sulla sua radice non vuota. Occorre una visita in cui i figli vengano trattati prima della directory. Una cancellazione può fallire dopo avere già rimosso alcuni figli: il programma deve decidere se continuare, fermarsi o registrare i risultati parziali. Lo stesso vale per una copia o uno spostamento che attraversa più file. Files.copy e Files.move operano su percorsi indicati; non sono una transazione generale sull'intero albero. I collegamenti simbolici richiedono una scelta esplicita: copiarli come collegamenti o seguirli cambia sia il contenuto sia i confini attraversati.
Per una semplice ricerca locale possiamo scrivere un frammento che chiude lo stream anche se il filtro lancia un'eccezione:
try (var percorsi = Files.list(cartella)) {
long testi = percorsi.filter(p -> p.getFileName().toString()
.endsWith(".txt")).count();
System.out.println("File di testo: " + testi);
}
Questo frammento assume che cartella sia un Path già definito. Files.list osserva soltanto i figli diretti, non l'intero albero; Files.walk cambierebbe il problema e il costo della visita. Il filtro sui nomi non dimostra che un file sia davvero testo: un'estensione è una convenzione, non una proprietà verificata del contenuto. Se un altro processo crea o rimuove file mentre iteriamo, non dobbiamo trattare il risultato come una fotografia atomica della directory.
I file temporanei, gli attributi e i permessi sono altre domande sul confine. Files.createTempFile lascia scegliere una directory e crea un nome non già occupato; i permessi iniziali e gli attributi disponibili dipendono dal provider. Una vista POSIX dei permessi non è garantita su ogni sistema. Files.readAttributes e metodi come size forniscono una fotografia: il file può cambiare dopo la lettura. Non confondiamo una misura osservata con una prenotazione della risorsa.
Percorsi, collegamenti e visite: scegliere che cosa stiamo attraversando
La biblioteca riceve il nome relativo ingressi/../catalogo.txt. resolve e normalize aiutano a costruire e semplificare il nome. Se la parte da risolvere è assoluta, però, resolve restituisce quella parte: non la costringe dentro la directory di base. Se il nome proviene dall'esterno, il requisito «restare nella cartella autorizzata» richiede controlli ulteriori. Il confronto lessicale dopo la normalizzazione è un primo controllo, ma non gestisce da solo i collegamenti simbolici o una sostituzione concorrente del percorso.
Un collegamento simbolico contiene un riferimento a un altro percorso. Il nome che osserviamo e il file raggiunto possono quindi appartenere a luoghi diversi. NOFOLLOW_LINKS modifica il comportamento delle operazioni che lo accettano; non è una modalità globale attivata una volta per tutto il programma. Per una copia decidiamo se interessa il collegamento oppure il suo contenuto. Per una visita ricorsiva Files.walk non segue di default i collegamenti simbolici; abilitare FOLLOW_LINKS introduce anche il problema dei cicli e dei confini attraversati.
Concetto chiave – Sicurezza e portabilità sono contratti. Un
Pathappartiene a un provider di file system, che traduce le operazioni Java sul sistema concreto. Radici, sensibilità alle maiuscole, separatori e supporto degli attributi possono differire. Un percorso valido non è un'autorizzazione; un percorso normalizzato non è una prenotazione del file. Per operazioni sensibili su directory modificabili da altri attori servono una progettazione dei permessi e, quando disponibile, strumenti comeSecureDirectoryStream, che operano rispetto a una directory aperta.
Per la scansione del catalogo, walkFileTree con un SimpleFileVisitor permette di dichiarare che cosa fare in visitFile, visitFileFailed e postVisitDirectory. Quest'ultimo riceve anche l'eventuale errore di visita: cancellare senza controllarlo può nascondere il motivo per cui non abbiamo completato l'albero. I risultati CONTINUE, SKIP_SUBTREE e TERMINATE esprimono una politica, non la riparazione di un errore. Una visita può interrompersi dopo effetti già prodotti; conserviamo un resoconto quando quei risultati devono essere recuperabili.
Files.lines restituisce uno stream testuale lazy che va chiuso. Un errore può emergere durante il consumo come UncheckedIOException, non soltanto nell'apertura come IOException. Una pipeline del capitolo 18 non rende automaticamente pura o priva di risorse la sorgente. La variante utile è un file che scompare o diventa illeggibile durante la scansione: prima scegliamo se il rapporto deve essere completo oppure parziale e dichiarato, poi scriviamo la gestione dell'errore.
Leggere una risorsa impacchettata
Un'immagine distribuita dentro un JAR non è automaticamente un Path del file system ordinario. Class.getResource risolve un nome assoluto con / dalla radice del class path o del modulo, oppure un nome relativo al package della classe; ClassLoader.getResource usa nomi senza / iniziale e ha regole di ricerca diverse. Se serve leggere una risorsa impacchettata, getResourceAsStream evita di trasformare arbitrariamente il suo URL in un percorso. Il capitolo 21 sui classloader spiega perché la posizione fisica di una classe e delle sue risorse non va presunta.
Immaginiamo un file config/esempio.txt distribuito accanto alle classi. ImportaArchivio.class.getResourceAsStream("/config/esempio.txt") chiede il nome dalla radice e restituisce uno stream o null se la risorsa non è trovata. Prima di passarla a un InputStreamReader, controlliamo il caso assente e dichiariamo UTF-8 se il contenuto è testo. Lo stream va chiuso. Se il file deve essere modificato dall'utente, non deve invece essere trattato come risorsa interna immutabile: scegliamo un Path esterno configurabile. La prova di trasferimento è impacchettare davvero la risorsa in un JAR e ripetere la lettura senza assumere che il JAR sia stato estratto su disco.
Canali e stato di un buffer
Un Channel offre un'altra interfaccia per dati e dispositivi. ByteBuffer tiene distinti capacità, posizione e limite: la capacità è lo spazio del buffer, la posizione indica dove avverrà la prossima lettura o scrittura e il limite delimita la parte attiva. flip() prepara alla lettura i byte appena scritti nel buffer; clear() prepara a riempirlo di nuovo senza cancellare fisicamente i byte. È un modello utile per I/O avanzato, ma un archivio testuale lineare non richiede di sostituire automaticamente un BufferedReader con canali e buffer.
Immaginiamo un buffer di capacità otto. Dopo aver scritto tre byte, la posizione vale tre e il limite resta otto. flip() porta il limite a tre e la posizione a zero: una lettura successiva vede soltanto i tre byte validi. Se saltassimo flip(), tenteremmo di leggere dalla posizione tre fino al limite otto, cioè dalla parte non preparata per la lettura. Se chiamassimo clear() pensando che azzeri i dati, potremmo invece osservare ancora i vecchi byte attraverso una vista inappropriata. Questi numeri sono un modello di stato, non una raccomandazione di usare un buffer di otto byte in produzione.
Dai byte al canale: una copia con stato esplicito
Se il requisito è raggiungere una posizione precisa del file o integrare un'elaborazione già basata su buffer, FileChannel può essere adatto. Manteniamo separati lo stato del canale, che comprende la posizione nel file, e quello del ByteBuffer, che comprende la regione attiva in memoria. Il canale riempie il buffer; flip() espone i dati appena ottenuti; la scrittura consuma quella regione; clear() prepara il giro seguente.
Nel programma ContrattiIO copiamo undici byte con un buffer di capacità quattro. Avremo due blocchi pieni e uno parziale. La scrittura viene ripetuta finché hasRemaining() diventa falso: una singola write non promette di consumare tutto il buffer. Per questo esempio usiamo canali di file ordinari; con un canale non bloccante il ritorno di zero richiederebbe una strategia di attesa, altrimenti un ciclo potrebbe occupare la CPU senza avanzare.
Il nucleo della copia rende visibili i quattro passaggi. Qui origine e destinazione sono FileChannel aperti nel try-with-resources del programma completo; ByteBuffer appartiene a java.nio.
ByteBuffer buffer = ByteBuffer.allocate(4);
while (origine.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) destinazione.write(buffer);
buffer.clear();
}
Nell'ultimo giro vengono letti tre byte: flip() limita a tre la regione da scrivere e impedisce di ricopiare il quarto byte rimasto dal giro precedente. Il confronto di tutti i byte fra ingresso e uscita verifica questo dettaglio, oltre alla lunghezza finale.
Il seguente schema mostra il primo giro, con quattro byte validi. Non cancelliamo i dati quando cambiamo fase: cambiamo gli indici che autorizzano a leggerli o scriverli.
compact() serve a un problema diverso: abbiamo consumato soltanto parte dei dati e dobbiamo conservare quelli residui per completare, per esempio, un messaggio. Li sposta all'inizio e prepara spazio per altro ingresso. Usare clear() in quel punto perderebbe la regione residua dal nostro protocollo di lettura. rewind() permette invece di rileggere la regione attiva conservandone il limite. La verifica consiste nel seguire posizione e limite dopo ogni passaggio, non nell'imparare a memoria tre nomi simili.
Approfondimento – NIO non significa sempre non bloccante. Un
FileChannelnon diventa selezionabile solo perché appartiene a NIO.Selectorcoordina canali selezionabili, come alcuni canali di rete, registrati in modalità non bloccante.AsynchronousFileChannelpropone un altro contratto, basato sul completamento di operazioni: il buffer non va riutilizzato mentre l'operazione che lo usa è ancora in corso. Un buffer diretto o un file mappato in memoria può essere utile in specifici carichi, ma comporta costi e un ciclo di vita diversi dal semplice array. Scegliamoli dopo una misura e una necessità concreta.
Se leggiamo numeri con ByteBuffer.getInt, il formato deve stabilire l'ordine dei byte e il buffer deve avere abbastanza byte residui. Il fatto che un intero Java sia lo stesso valore su due macchine non significa che ogni protocollo lo codifichi nello stesso ordine. La copia del nostro esempio evita questo problema perché conserva byte senza interpretarli. La variante è un formato binario con intestazione: prima definiamo lunghezza e ordine, poi aggiungiamo la lettura dei campi.
Osservare una directory e rileggere lo stato
WatchService può segnalare cambiamenti a una directory. È utile per aggiornare una vista o avviare una nuova scansione, non per trattare ogni notifica come una registrazione affidabile e completa: eventi possono essere accorpati, persi o consegnati con semantiche dipendenti dal sistema. Quando il servizio segnala overflow, una strategia sensata è riconciliare lo stato effettivo della directory. Il medesimo principio visto in C20 torna qui: la notifica invita a verificare una condizione, non sostituisce la condizione.
Supponiamo che la biblioteca aggiorni l'archivio quando un altro programma crea un file in una cartella. Una scansione periodica potrebbe essere sufficiente; WatchService diventa interessante quando vogliamo reagire presto. Registriamo la directory per gli eventi pertinenti, poi un ciclo legge le chiavi e i relativi eventi. Il nome riportato in un evento è relativo alla directory registrata: si risolve su quella directory prima di leggere il file. Dopo aver consumato gli eventi si reimposta la chiave, controllando se è ancora valida. Se gli eventi vengono persi, la soluzione corretta è una nuova scansione dello stato, non assumere che il flusso di notifiche ricostruisca da solo tutto ciò che è accaduto. Il polling può essere più semplice quando la tempestività non è critica; questa è una scelta guidata dal problema.
Notifiche, completamento e verifica dello stato
Un evento di creazione non garantisce che chi scrive abbia finito il file. Se importiamo immediatamente, possiamo leggere un archivio ancora incompleto. Un protocollo utile fa scrivere al produttore un provvisorio e pubblicare il nome definitivo soltanto al completamento; il consumatore rilegge lo stato e applica comunque la validazione. Il WatchService segnala che vale la pena controllare, mentre il formato e la pubblicazione stabiliscono che cosa accettare.
Nel ciclo, una WatchKey raccoglie eventi; dopo pollEvents() chiamiamo reset() e, se restituisce false, la registrazione non è più valida. OVERFLOW richiede una scansione di riconciliazione. Registrare una directory non registra automaticamente tutti i suoi discendenti. La chiusura del servizio e l'interruzione del thread che attende una chiave fanno parte dello spegnimento; non confondiamo un arresto richiesto con un guasto da riprovare all'infinito.
La prova di trasferimento crea rapidamente più file, li rinomina e poi confronta il catalogo con una scansione completa. Non imponiamo come risultato un certo numero di notifiche: verifichiamo che la riconciliazione arrivi allo stato corretto. Se il sistema richiede una storia completa e ordinata di tutte le modifiche, questo servizio non è il registro degli eventi necessario. Occorre un protocollo applicativo che conservi quella storia.
Serializzazione storica e formati esterni
La serializzazione nativa di oggetti Java appartiene al patrimonio di sistemi esistenti, ma lega il formato alle classi e apre problemi di compatibilità e sicurezza. Per un nuovo archivio esterno scegliamo un formato con contratto esplicito e un parser adatto; Java SE non fornisce un unico parser generale per JSON, CSV e XML. Il file dell'esempio è volutamente più semplice e dichiara da sé la sua piccola grammatica.
Immaginiamo che un collega proponga di sostituire il file di titoli con un ObjectOutputStream: salverebbe direttamente gli oggetti del catalogo e sembrerebbe evitare il lavoro di definire le righe. Il vantaggio iniziale ha un costo. Quando cambiano le classi, i dati salvati in precedenza possono non essere più leggibili secondo il contratto atteso; quando il file proviene dall'esterno, ObjectInputStream ricostruisce grafi di oggetti e non va trattato come un parser innocuo per input non fidati. Il filtro della deserializzazione documentato per Java 25 può restringere classi e dimensioni ammesse in un sistema legacy, ma non crea da solo un formato pubblico semplice e stabile.
Se il catalogo deve essere scambiato con un altro programma, definiamo prima i dati necessari, la codifica, i limiti di dimensione, la versione del formato e la risposta ai campi non validi. Scegliamo poi un parser che rispetti quel contratto e proviamo sia un file valido sia uno troncato. Se invece dobbiamo mantenere un archivio Java già distribuito, partiamo dall'inventario delle classi realmente presenti e da una migrazione controllata; non lo convertiamo alla cieca. Il mattone riusabile è riconoscere chi leggerà il file e per quanto tempo: questa domanda guida il formato più del numero di righe necessario a scrivere il primo esempio.
Verificare il mattone e trasferirlo
Cambiamo il problema: i titoli devono essere unici, l'input può contenere milioni di righe e il file finale non deve cambiare se una riga è malformata. La lettura progressiva resta adatta alla memoria disponibile; occorre però decidere come riconoscere i duplicati, con una Set in memoria se la dimensione è gestibile oppure con una strategia esterna se non lo è. La pubblicazione del provvisorio rimane l'ultimo passo. Se il consumatore deve vedere tutti i titoli o nessuno, una scrittura diretta perde la proprietà richiesta anche se ogni riga è corretta.
Una seconda variante sostituisce l'archivio locale con una risorsa nel JAR. Ora non possiamo pubblicarla con Files.move: dobbiamo distinguere la risorsa di sola lettura impacchettata dal file di lavoro esterno. Saper formulare questa differenza è la prova del capitolo: associare il problema alla corretta astrazione, riconoscere il limite e cambiare soluzione quando cambia il confine.
Riferimenti essenziali. La specifica
Filesdi Java 25 descrive operazioni, opzioni e possibili errori del file system; non garantisce che ogni provider offra le stesse operazioni atomiche. La documentazione dei charset definisce la conversione fra byte e caratteri. Queste fonti sostengono i contratti delle API; la politica dell'archivio, inclusa la scelta di fallire senza ripiego, appartiene al nostro esempio.Riferimenti per gli approfondimenti. InputStream definisce letture parziali e trasferimenti; CharsetDecoder rende esplicita la politica sugli errori. ByteBuffer descrive gli indici del buffer e FileChannel il contratto dei canali di file. Questi riferimenti permettono di controllare i confini dei piccoli esperimenti, senza trasformare un risultato locale in una garanzia su ogni dispositivo.