Lettura dei File in Java

Articolo che illustra le differenti API Java per la lettura di un file.

il
Software Developer, Collaboratore di IProgrammatori

Le API Java per leggere i file.

L'acquisizione di dati da un file o la semplice lettura di un file per poi spostarlo su altra destinazione o mostrarlo ad un utente è una delle operazioni più comuni in programmazione. In base al proprio intento e alle operazioni da effettuare sul file sarà necessario adottare un determinato approccio in favore di altri. Nel corso degli anni Java si è evoluto, ha introdotto nuovi approcci, nuove strutture dati, sono state sviluppate librerie esterne ed interne molto utili ed ecco perchè il numero di possibili soluzioni al nostro problema è davvero aumentato.

Lettura file in Java

Questo articolo mostra come leggere e processare un file in Java in diversi scenari, consigliando gli approcci più performanti.

Tipologie di File e Strutture dati associate


Come primo obiettivo suddividiamo i possibili file da leggere in 2 tipologie:

  1. File di testo: file il cui contenuto è la semplice concatenazione di caratteri di testo e che, se aperti in un qualunque editor, sono interpretabili
  2. File binari: file il cui contenuto è la concatenazione di byte interpretabili correttamente solo in determinati ambiti o con determinati software

Attenzione però, un file in formato base64 rientra nella prima categoria, in quanto il suo contenuto è sì codificato, ma sempre di tipo testuale. Sono invece validi esempi di file binari le immagini, gli audio, i pdf...
Vediamo adesso le possibili strutture dati offerte dal Java per l'elaborazione dei file sempre restando in ambito di lettura:

  • java.io.Reader: Classe astratta per la lettura di caratteri a pacchetti di byte. Le sottoclassi possono offrire funzionalità aggiuntive, performances ottimizzate o entrambe insieme.
  • java.io.InputStream: Classe astratta per la lettura di file che contengono gruppi di byte. Le sottoclassi devono implementare un metodo che consenta la lettura dei byte in sequenza. NOTA BENE: seppur il nome possa dare a pensare si tratti di uno Stream di Java 8 questa classe è precedente e non correlata al concetto di java.util.stream.Stream (che tra l'altro è un'interfaccia)
  • java.io.File: Implementazione(sotto forma di classe) generica ed indipendente dalla piattaforma del concetto di file o path ad una cartella. Per alcuni aspetti questa classe è stata sostituita dalla java.nio.file.Files ma risulta sempre utile e pertanto è tuttora ampiamente utilizzata
  • java.nio.file.Files: Classe non estendibile che fornisce metodi statici di utilità generica per operazioni sui file. Col tempo questa classe è stata arricchita e pertanto acquisisce sempre maggior potere e popolarità.
  • java.util.Scanner: come anche il package suggerisce questa classe non serve semplicemente per l'acquisizione di file ma in realtà è riconducibile all'elaborazione tramite regex di dati primitivi e stringhe; la fonte di questi dati puo essere un input utente, un file, una variabile o altro. Essendo implementata tramite regex lo Scanner non è la soluzione più performante per la lettura di un file soprattutto su file di grandi dimensioni.
  • java.io.RandomAccessFile: implementazione del concetto di file ad accesso randomico come array di byte immagazzinati sul file system. Supporta sia operazioni di lettura che di scrittura basandosi su un indice posizionale che analizza i byte in maniera posizionale. Esso consente la modifica di interi pezzi del file, cosa che altre API non consentono ma è indubbiamente e di gran lunga  la peggiore di tutte le possibilità elencate in quanto a performances.

Abbiamo una panoramica delle possibilità offerte dalle API di Java per la lettura dei file (senza tener conto di implementazioni in librerie esterne per il momento) per la lettura di un file. Quello che adesso dobbiamo delineare prima di poter scegliere un approccio è la nostra finalità:

Si tratta di un file di testo o di un file binario?
Per i file binari avremo 2 possibilità:

  1. Caricare semplicemente il file all'interno di un oggetto Java così da poterlo, ad esempio, mostrare all'utente o spostare su altra fonte, quindi senza elaborarne il contenuto.
  2. Elaborare il contenuto

Trattandosi di file la cui estensione spazia dalle immagini ai video agli audio ai pdf e così via dovremo per forza di cose scegliere un API Java specifica per quel tipo. Per la stragrande maggioranza delle estensioni parliamo di implementare un'API esterna per avere maggior controllo e soprattutto ottimizzare le performances e pertanto l'argomento non è oggetto di questo articolo.

Qualora volessimo invece semplicemente caricare il file nel programma dovremmo, sempre tenendo conto dell'estensione, scegliere tra un'API base del java quella più idonea. Nel caso ad esempio di immagini ci viene in aiuto la classe di utility javax.imageio.ImageIO

Dobbiamo ora vedere quali delle precedenti scartare a priori e quali invece approfondire; ma la cosa più importante di tutte è la possibilità di combinare le precedenti per estrapolarne solo i pregi. Non verrà mostrata alcuna implementazione dello scanner in quanto, come già anticipato, non è tra le migliori per performances. Non verrà mostrata neppure la casistica del RandomAccessFile, indubbiamente la peggiore di tutte le soluzioni finora proposte.

Implementare lettura file in Java

Qualunque implementazione della lettura di file si sceglie è da notare che per gestire un file riga per riga è più performante utilizzare una LinkedList invece di un ArrayList. Altra raccomandazione da fare è che la lettura del file di per sè è un procedimento molto veloce, quello che rallenta è poi l'assegnazione di ogni riga o carattere ad una stringa. Ecco perchè bisogna ottimizzare questo processo quanto più possibile.

Per poter accedere al contenuto di un File in Java avremo la necessità di virtualizzare un path che punta al file stesso, da tale path avremo il riferimento ad un java.io.File o un java.io.InputStream. Nel caso del File deve essere passato ad un InputStream. Ottenuto il nostro InputStream dovremo fare in modo tale da renderlo interpretabile, ecco perchè lo diamo in pasto ad un oggetto di tipo Reader come ad esempio un InputStreamReader o ad una sua implementazione come la java.io.FileReader. Ciò che otteniamo è un Reader, il quale puo essere opzionalemente immesso in un buffer, un tipo di memoria che migliora le performances del nostro applicativo. Dopo aver dato tutti questi nomi mostriamo di seguito alcuni esempi pratici dei suddetti oggetti:

new BufferedReader(new InputStreamReader(new FileInputStream(new File(path)), StandardCharsets.UTF_8));
new BufferedReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8));
new BufferedReader(new FileReader(new File(path), StandardCharsets.UTF_8));

Qual'è la differenza tra le precedenti? Perfettamente nessuna. Si tratta dello stesso codice che viene scritto in forme diverse, difatti internamente il FileReader (che ricordiamo essere a tutti gli effetti un InputStreamReader) costruisce un FileInputStream. Sempre internamente alle suddette API passare una stringa o passare il File è identico: anche passando una stringa verrà sempre creato un file col costruttore new File(path).

Trattandosi dello stesso codice possiamo solo consigliare di utilizzare la prima forma per minuscole differenze di gestione delle variabili (ottimizzazione delle allocazioni) e per evitare fraintendimenti: utilizzando sempre gli oggetti che sono alla base delle implementazioni e non dei wrapper è più semplice ricordarne i nomi ed il funzionamento logico, inoltre un wrapper sarà sempre più verboso della propria implementazione interna(seppur internamente e non esternamente). L'unica ragione che porta a preferire una rispetto alle altre versioni è la leggibilità.

Come si è visto la semplice classe java.io.File non consente di manipolare il contenuto di un file. Essa serve a creare, eliminare ed effettuare altre operazioni sul file in quanto oggetto di un file system. Semplicemente tramite un path virtualizza tale percorso al concetto di file, che esso esista o no (in questo caso, volendo, consente di crearlo). Nel caso di lettura del contenuto di un file questo oggetto serve solo come appoggio e necessita di interagire con altri oggetti. Fermo restando che la precedente è di per sé la migliore implementazione possibile, con l'evolversi Java ha pensato a sintassi più compatte, più semplici e più performanti (solo perchè il margine d'errore è minore). Queste alternative le vediamo nella classe java.nio.Files.

List<String> list = Files.readAllLines(Paths.get(path));
final Stream<String> stream = Files.lines(Paths.get(path));
final BufferedReader br = Files.newBufferedReader(Paths.get(path));

Anche qui le differenze sono poche. Il primo metodo, come è facile vedere è indubbiamente il più compatto, ma internamente utilizza un'ArrayList, ecco perchè offre un magine di personalizzazione minore. Il secondo approccio si serve degli Stream di Java 8, vedremo dopo come processarli per ottenere una lista di stringhe. Il terzo approccio ci restituisce un BufferedReader. Internamente anche le prime 2 utilizzano lo stesso metodo newBufferedReader, ecco perchè è piuttosto indifferente l'approccio utilizzato per la semplice lettura. Tutto dipende dall'utilizzo che dobbiamo fare successivamente del nostro file. Se ad esempio avessimo bisogno di porre il tutto in una lista di stringhe il primo esempio è indubbiamente compatto, ma non ottimizzato (internamente utilizza un ArrayList e non consente pertanto di passare per una LinkedList se non con un cast successivo). Anche qui, utilizzando sempre un BufferedReader, le performances sono identiche o in certi casi migliori degli esempi precedenti.

Vediamo ora come processare il risultato ottenuto dopo la lettura del file:

Nel caso dei BufferedReader dovremo semplicemente ciclare le righe o i caratteri (a seconda del proprio intento); anche qui a seconda della versione di Java potremo utilizzare uno stream o applicare un semplice ciclo (for o while a scelta propria):

BufferedReader bR = // ottenere un BufferedReader come mostrato precedentemente
List<String> result = new ArrayList<>();
String line = null;
for (;;) { //esempio con un ciclo for
line = bR.readLine();
if (line == null)
break;
result.add(line);
} while ((line = bR.readLine()) != null) { //esempio con un ciclo while
result.add(line);
}

Con Java 8 e gli stream esiste un altro modo molto compatto per ciclare le righe di un BufferedReader:

List<String> result = bR.lines().collect(Collectors.toCollection(LinkedList::new)); // esempio con uno stream

Quest'ultima sintassi ci viene in aiuto anche per processare gli esempi precedente basati sulla classe java.nio.Files.

Volendo processare un file carattere per carattere basterà semplicemente utilizzare il metodo read() invece del metodo readLine() della classe BufferedReader oppure.

Un altra casistica è quando vogliamo immagazzinare l'intero contenuto di un file in un'unica stringa, in tal caso ci basterà utilizzare il seguente codice, introdotto a partire da java 7:

String s = new String(Files.readAllBytes(Paths.get(path)));

Conclusioni sui metodi di lettura file con Java

Quale approccio risulta più performante e quale usare quindi? Poniamo la principale domanda: sapendo che le performances sono mediamente simili quale sarà la nostra priorità: la leggibilità, l'utilizzo di un API quanto più aggiornata possibile oppure ottenere sempre il massimo possibile delle performances anche se di pochissimo superiori? L'utilizzo più performante di tutti è dato dal semplice BufferReader.readLine(), tanto semplice quanto efficace. In virtù del fatto che qualunque rallentamento sarà dovuto all'elaborazione da noi effettuata a seguito della lettura la nostra soluzione prevede l'utilizzo di una sintassi quanto più semplice e standard possibile.

Proponiamo di seguito una soluzione per la lettura di ogni riga in un file di testo al puro e semplice scopo di immagazzinare le righe in una lista e poi una soluzione nel caso in cui si decida di effettuare altre operazioni sulle singole righe:

Soluzione 1

List<String> list = null;
try (final Stream<String> stream = Files.lines(Paths.get(path))) {
	list = stream.collect(Collectors.toCollection(LinkedList::new));
} catch (IOException e) {
    e.printStackTrace();
}

Soluzione 2

try (final BufferedReader bR = new BufferedReader(
    new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8));) {
        while ((line = bR.readLine()) != null) {
            //eseguire della logica su ogni singola riga
        }
} catch (final IOException e) {
    e.printStackTrace();
}

La soluzione 1 è compatta, semplice e aggiornata. La seconda soluzione è più verbosa ma anche quanto di più vicino allo standard possibile, ecco perchè consigliamo entrambe come vincitori, così da portare il lettore ad una padronanza degli strumenti sia precedenti che successivi a java 8.