Metodologie di Programmazione: Lezione 8
La piattaforma Java offre più di 60 classi che hanno a che fare con i flussi (streams) e che direttamente o indirettamente sono quindi anche connesse all'uso dei file. In questa introduzione alla manipolazione dei file in Java vedremo solamente una piccola parte di questa vasta libreria sufficiente però a coprire tutti gli usi più comuni.
Tratteremo anche alcuni aspetti relativi alle eccezioni che sono connessi all'accesso a risorse come file system e file.
Prima di poter creare/rimuovere, leggere/scrivere file o creare/rimuovere directory bisogna sapere come localizzare file e directory. Non c'è un modo standard di specificare l'"indirizzo" (o percorso, path) di un file, il file system di ogni piattaforma (Windows, Linux, ecc.) usa convenzioni proprie. La libreria di Java cerca di essere onnicomprensiva uniformando le procedura per l'accesso a un qualsiasi file system, non solo quello di default ma anche quelli periferici, remoti e virtuali. Per queste ragioni già a partire da Java 7 è stato introdotto un nuovo package java.nio.file
con nuove interfacce e classi. Queste hanno soppiantato totalmente o parzialmente alcune classi nel package java.io
, in particolare la classe File
.
Il percorso di un file (o directory) è rappresentato tramite un oggetto di tipo Path
. Proprio per garantire la massima generalità e flessibilità, Path
è un'interfaccia e non c'è alcuna classe (pubblica) nella piattaforma Java che implementa tale interfaccia. Questo perché essendo la forma dei percorsi dei file dipendente dal sottostante file system sarebbe difficoltoso avere una classe che concretizza Path
e che sia usabile per un qualsiasi file system1.
Quindi per creare un Path
ci sono solamente factory methods, i quali fabbricano Path
relativamente a un certo file system. In questo modo è come se il file system si occupasse di creare e gestire i Path
. Alcuni di questi factory methods sono forniti dalla classe Paths
. In questa introduzione ci limiteremo a quello che crea Path
rispetto al file system di default2:
static Path get(String first, String... more)
Path
che rappresenta un percorso che si ottiene congiungendo first
con le eventuali altre parti in more
secondo le regole del file system di default.
Permette di specificare sia percorsi assoluti che relativi:
Path absU = Paths.get("/", "user", "rik"); // Percorso assoluto Linux/Mac
Path absW = Paths.get("C:", "doc"); // Percorso assoluto Windows
Path rel = Paths.get("dati", "files"); // Percorso relativo
Affinché il percorso sia interpretato come assoluto la prima componente deve essere una componente radice, ad esempio "/"
per i file system tipo Unix o qualcosa che identifica un volume come "C:"
per Windows. In tutti gli altri casi il percorso è interpretato come relativo. Bisogna rendere subito chiaro che un oggetto di tipo Path
non rappresenta necessariamente un percorso a un file o directory effettivamente esistente ma solamente un percorso possibile per il sottostante file system. Quando è creato viene controllato solamente che sia un percorso formalmente valido, in caso negativo viene lanciata InvalidPathException
, ma non viene controllata l'esistenza di nessuna delle componenti del percorso.
I vari metodi di Path
sono quasi tutti devoluti alla manipolazione di percorsi (possibili). Eccone alcuni tra i più comuni:
Path getFileName()
Path
relativo all'ultimo nome di questo percorso o null
se ha zero nomi. Ad esempio,
Path p = Paths.get("docs", "a.txt");
p.getFileName(); // Un oggetto equivalente a Paths.get("a.txt")
Path getParent()
Path
che è il percorso genitore di questo percorso, cioè questo percorso escluso l'ultimo nome, o null
se non ha un percorso genitore. Ad esempio,
Path p = Paths.get("docs", "a.txt");
p = p.getParent(); // Un oggetto equivalente a Paths.get("docs")
p = p.getParent(); // null
Path getRoot()
Path
che rappresenta la radice di questo percorso o null
se non ha radice. Ad esempio,
Path p = Paths.get("/", "user");
p.getRoot(); // Un oggetto equivalente a Paths.get("/")
p = Paths.get("docs", "a.txt");
p.getRoot(); // null
boolean isAbsolute()
true
se questo percorso è assoluto.Un metodo particolarmente importante è
Path toAbsolutePath()
Path
che rappresenta il percorso assoluto di questo percorso. Se questo percorso è assoluto, ritorna questo percorso. Altrimenti viene risolto rispetto a una directory determinata dal file system sottostante.
Se il file system è quello di default, la directory usata per risolvere il percorso relativo è la working directory. Tipicamente è la directory da cui è stata lanciata la JVM che esegue il programma. Può essere ottenuta da
System.getProperty("user.dir")
che ritorna una stringa con il percorso della working directory. Può anche essere ottenuta da un Path
relativo alla stringa vuota:
Path wd = Paths.get("").toAbsolutePath();
out.println(wd);
out.println(System.getProperty("user.dir"));
I due println
stampano lo stesso percorso assoluto, cioè quello della working directory. Si noti che un Path
ridefinisce toString
ritornando una stringa che rappresenta il percorso.
Quasi tutte le classi e metodi che manipolano file e directory, eccetto alcune di quelle più vecchie, usano oggetti Path
.
La classe Files
sempre in java.nio.file
, offre molti metodi statici che operano su file e directory. Ecco alcuni esempi dei metodi di Files
.
static boolean exists(Path path, LinkOption...options)
true
se l'oggetto Path
è il percorso a un file o directory effettivamente esistente.static boolean isDirectory(Path path, LinkOption...options)
true
se l'oggetto Path
è il percorso a una directory esistente.static boolean isRegularFile(Path path, LinkOption...options)
true
se l'oggetto Path
è il percorso a un file regolare esistente.static boolean isSymbolicLink(Path path)
true
se l'oggetto Path
è un percorso a un link simbolico esistente.options
servono solamente ad imporre che non siano seguiti link simbolici, per default se il percorso porta a un link simbolico questo è seguito.
Possiamo scrivere un semplice metodo di prova per questi metodi in una classe TestFile
:
package mp;
import java.nio.file.*;
import java.util.*;
import static java.lang.System.out;
/** Una classe per fare test sui file */
public class TestFile {
public static void main(String[] args) {
info();
}
/** Prova alcuni metodi di {@link java.nio.file.Files} chiedendo un percorso
* da tastiera e controllando se esiste, se è una directory, ecc. */
private static void info() {
Scanner input = new Scanner(System.in);
out.print("Digita un percorso: ");
String pathname = input.nextLine();
Path path = Paths.get(pathname);
out.println("Percorso assoluto: "+path.toAbsolutePath());
boolean exist = Files.exists(path);
out.println("Esiste? "+exist);
if (exist) {
out.println("Directory? "+Files.isDirectory(path));
out.println("File regolare? "+Files.isRegularFile(path));
out.println("Link simbolico? "+Files.isSymbolicLink(path));
}
}
}
Ci sono molte altre operazioni che si possono fare tramite i metodi della classe Files
tra cui leggere e scrivere il contenuto di file. Però tutti questi metodi, a differenza di quelli visti finora, devono accedere al file o alla directory e l'accesso potrebbe fallire. In tal caso i metodi lanciano eccezioni di un tipo particolare che il programma è obbligato a gestire o comunque a considerare. Prima di continuare coi file trattiamo questo argomento.
Alcune eccezioni sono "controllate" dal compilatore nel senso che il compilatore controlla che queste siano state prese in considerazione nel programma. Tutte le eccezioni che abbiamo visto finora e tantissime altre non sono di questo tipo e sono chiamate unchecked exceptions (eccezioni non controllate). Le checked exceptions (eccezioni controllate) riguardano situazioni che possono normalmente verificarsi durante operazioni di Input/Output. Non si tratta di errori o condizioni che il programmatore può avere sotto il suo controllo (come una divisione per zero o l'accesso ad un array tramite un indice fuori range). Ad esempio, operazioni come la lettura da file o l'apertura di una connessione di rete possono fallire e il programma non può fare nulla per prevenire tali fallimenti perché i file system e le reti non possono essere sotto il diretto controllo del programma. Quindi per le eccezioni di questo tipo (cioè le eccezioni controllate) il compilatore richiede che il programma le gestisca esplicitamente. Questo significa che se si invoca un metodo che può lanciare un'eccezione controllata il metodo invocante deve o catturare l'eccezione (tramite una opportuna clausola catch) o dichiarare nell'intestazione che il metodo può lanciare l'eccezione controllata. Ad esempio il metodo size
di Files
che ritorna il numero di bytes del file a cui porta il percorso di input ha la seguente intestazione:
public static long size(Path path) throws IOException
Con la parola chiave throws
dichiara che il metodo size
può lanciare un'eccezione (controllata) di tipo IOException
. Siccome il metodo size
non cattura l'eccezione controllata ma semplicemente la lancia o la rilancia questo significa che il metodo che invoca size
sarà obbligato o a catturare l'eccezione o a dichiarare di lanciarla. È bene sottolineare che il compilatore, non potendo verificare che le eccezioni controllate siano effettivamente gestite, si accontenta di verificare che esse siano state prese in considerazione o tramite cattura (anche se il blocco della clausola catch rimane vuoto) o tramite esplicito rilancio con la dichiarazione throws
.
Il seguente diagramma schematizza la gerarchia dei tipi che possono essere lanciati.
La classe più generale è Throwable
con le due sotto-classi dirette Error
e Exception
. Le sotto-classi di Error
sono intese segnalare condizioni di errore a cui tipicamente non si può dare alcuna risposta (ad es. OutOfMemoryError
) e quindi sono unchecked e il programma non dovrebbe neanche tentare di gestirle. Le sotto-classi di Exception
possono essere di due tipi o sono anche sotto-classi di RuntimeException
e in tal caso sono unchecked o non lo sono e allora sono checked. Ad esempio, IllegalArgumentException
è una sotto-classe di RuntimeException
e quindi è unchecked mentre IOException
non lo è e quindi è checked.
Adesso che abbiamo un'idea delle linee guida di Java per la gestione dei possibili errori che possono accadere durante le operazioni su risorse come i file, possiamo ritornare ai file.
La piattaforma Java offre molti modi diversi di leggere file e in particolare file di testo. Ci limiteremo a discutere solamente gli strumenti di uso più frequente che sono comunque piuttosto potenti. Se vogliamo semplicemente leggere le linee di un file di testo possiamo usare i seguenti metodi statici di Files
.
List<String> readAllLines(Path path, Charset cs) throws IOException
path
e le ritorna in una lista. I bytes letti sono decodificati in caratteri tramite il Charset
specificato. Il riconoscimento dei fine linea è universale, cioè sono riconosciuti sia quelli tipo Linux che quelli tipo Windows. Se si verifica un errore durante le operazioni di lettura, lancia IOException
, come è dichiarato nell'intestazione.List<String> readAllLines(Path path) throws IOException
readAllLines(path, StandardCharsets.UTF_8)
.TestFile
che legge un file usando readAllLines
:
/** Se il percorso dato porta a un file regolare con estensione ".txt", stampa
* il numero di linee del file e anche al più 5 linee random.
* @param p il percorso del file
* @throws IOException se la lettura del file va in errore */
private static void infoContent(Path p) throws IOException {
if (!Files.isRegularFile(p) || !p.toString().endsWith(".txt"))
return;
List<String> lines = Files.readAllLines(p);
int n = lines.size();
out.println("Numero linee: "+n);
for (int i = 0 ; i < Math.min(n, 5) ; i++) {
int r = (int)Math.floor(Math.random()*n); // Sceglie una linea random
out.println(Utils.q(lines.get(r)));
}
}
Si noti che il metodo non gestisce direttamente l'eccezione controllata IOException
e quindi deve dichiarare nell'intestazione che può lanciarla. Per provarlo lo possiamo invocare dal metodo info
:
private static void info() {
. . .
if (exist) {
. . .
try {
infoContent(path);
} catch (IOException e) { out.println(e); }
}
}
Qui abbiamo deciso di catturare l'eccezione controllata IOException
.
Se si vuole leggere un file di testo in modo più raffinato, cioè, non solo linee ma magari numeri o parole, si può usare la classe Scanner
. Tra i vari costruttori ci sono anche i seguenti:
Scanner(Path source, String charsetName) throws IOException
Scanner(Path source) throws IOException
Scanner(source, Charset.defaultCharset().name())
Scanner
le parole di un file di testo e ritorna una mappa che conta le occorrenze delle parole.
try-with-resources Tuttavia, l'oggetto Scanner
potrebbe andare in errore lanciando un'eccezione durante il suo utilizzo e allora lo Scanner
e il file sottostante potrebbero rimanere aperti. Potremmo trattare tale situazione usando un try
con una clausola finally
in cui viene invocato il metodo close
di Scanner
. Queste situazioni sono molto frequenti e così il linguaggio Java ha introdotto un try
specializzato che si chiama try-with-resources che nella forma più semplice ha la seguente sintassi:
try (Resource res = ...) {
operazioni con res
}
Questo garantisce che anche se si verificano delle eccezioni nel blocco del try
la risorsa res
sarà comunque chiusa all'uscita dal blocco, sia che si esca normalmente che tramite eccezione. È solamente richiesto che il tipo della risorsa implementi l'interfaccia AutoCloseable
che ha un unico metodo close
. Ad esempio Scanner
implementa AutoCloseable
. Nella sua forma più generale il try-with-resources può trattare più risorse simultaneamente e avere clausole catch
e finally
, quest'ultime sono eseguite dopo che le risorse sono state chiuse.
Torniamo al nostro metodo che definiamo nella classe TestFiles
:
/** Ritorna una mappa che ad ogni parola del file specificato associa il
* numero di occorrenze. Per parola si intende una sequenza di lettere
* (riconosciute dal metodo {@link java.lang.Character#isLetter(char)}) di
* lunghezza massimale. Le parole sono sensibili alle maiuscole/minuscole.
* @param filepath il percorso del file
* @param charset il charset per decodificare i caratteri
* @return una mappa che conta le occorenze delle parole */
public static Map<String, Integer> wordMap(Path filepath, String charset)
throws IOException {
try (Scanner scan = new Scanner(filepath, charset)) {
scan.useDelimiter("[^\\p{IsLetter}]+"); // Caratteri non lettere
Map<String, Integer> map = new HashMap<>();
while (scan.hasNext()) {
String w = scan.next();
Integer n = map.get(w);
map.put(w, (n != null ? n + 1 : 1));
}
return map;
}
}
Per far sì che i token dello Scanner
siano le parole abbiamo reimpostato il delimitatore con scan.useDelimiter("[^\\p{IsLetter}]+")
. Questo usa l'espressione regolare "[^\\p{IsLetter}]+"
per definire un delimitatore che cattura una qualsiasi sequenza di caratteri diversi dalle lettere. Per il momento non approfondiremo la sintassi delle espressioni regolari accettate da Java.
Per mettere alla prova il metodo wordMap
vorremmo poter stampare la mappa prodotta. Ma se il file non è molto piccolo la mappa conterrà migliaia di parole. Allora per evitare di stampare mappe così grandi, introduciamo un metodo che estrae un piccolo campione random da una mappa generica. Siccome potrebbe essere utile in altre occasioni, lo definiamo in mp.util.Utils
:
/** Ritorna una mappa che contiene un campione random della mappa data.
* @param map la mappa da campionare
* @param expectedSize numero atteso di chiavi nella mappa campione
* @return la mappa ottenuta campionando la mappa data */
private static <K,V> Map<K,V> randSample(Map<K,V> map, int expectedSize) {
Map<K,V> sample = new HashMap<>();
if (map.size() == 0) return sample;
double p = ((double)expectedSize)/map.size(); // Probabilità di selezionare
// una chiave
for (K k : map.keySet())
if (Math.random() <= p)
sample.put(k, map.get(k));
return sample;
}
Adesso possiamo scrivere un metodo per provare wordMap
(in TestFiles
):
/** Chiede all'utente di digitare il percorso di un file di testo e il nome
* di un charset per decodificarne i caratteri e stampa il numero di parole
* distinte nel file e un campione di al più 100 parole con i relativi
* conteggi. Continua a chiedere in input un file finché non viene immessa
* una linea vuota. */
private static void test_wordMap() {
Scanner input = new Scanner(System.in);
while (true) {
out.println("Digita il percorso di un file di testo"+
" (linea vuota per terminare): ");
String filepath = input.nextLine();
if (filepath.isEmpty()) break;
out.println("Digita il nome del charset: ");
String cs = input.nextLine();
try {
Map<String, Integer> map = wordMap(Paths.get(filepath), cs);
out.println("Numero parole: "+map.size());
out.println("Sample: " + Utils.randSample(map, 100));
} catch (IOException e) {
out.println(e);
}
}
}
Se si vuole avere il massimo controllo su ciò che si legge da un file di testo, si può usare il seguente metodo statico, sempre della classe Files
:
BufferedReader newBufferedReader(Path path, Charset cs) throws IOException
BufferedReader
. I caratteri sono decodificati usando il charset specificato.BufferedReader
permette di leggere singoli caratteri, linee e blocchi di caratteri. Il metodo newBufferedReader
si può usare in un try-with-resources dato che BufferedReader
implementa AutoCloseable
.
Se si vogliono scrivere delle linee di testo in un file si può usare il seguente metodo statico di Files
:
Path write(Path path, Iterable<? extends CharSequence> lines, Charset cs, OpenOption...options) throws IOException
options
determinano la modalità d'apertura del file. Se non sono specificati, di default sono CREATE
(crealo se non esiste), TRUNCATE_EXISTING
(se già esiste troncalo a zero), e WRITE
(aprilo in scrittura). Le possibili opzioni sono le costanti dell'enumerazione StandardOpenOption
.
Se invece si vuole scrivere in un file in modo simile a come si stampa sullo standard output tramite System.out
, si può aprire prima di tutto il file in scrittura tramite il seguente metodo statico di Files
:
BufferedWriter newBufferedWriter(Path path, Charset cs, OpenOption... options) throws IOException
BufferedWriter
. I caratteri saranno codificati tramite lo specificato charset. Gli argomenti options
hanno lo stesso significato del metodo write
visto sopra.BufferedWriter
che permette di scrivere singoli caratteri, blocchi di caratteri e stringhe si può creare un PrintWriter
su di esso, ad esempio
PrintWriter fout = new PrintWriter(Files.newBufferedWriter(path, cs));
Così sull'oggetto fout
si possono usare tutti i metodi che si possono usare su System.out
come print
, println
, printf
, ecc. Sia BufferedWriter
che PrintWriter
implementano AutoCloseable
e quindi possono essere usati con un try-with-resources.
Ciò che abbiamo visto finora riguarda la lettura e scrittura di caratteri in file che contengono testo. Se invece vogliamo leggere o scrivere file che contengono sequenze di bytes che possono significare qualsiasi cosa (immagini, video, dati, ecc.), oppure vogliamo leggere o scrivere file di testo ma al livello dei bytes e non dei caratteri, possiamo usare altri metodi statici messi a disposizione sempre dalla classe Files
.
byte[] readAllBytes(Path path) throws IOException
Path write(Path path, byte[] bytes, OpenOption... options) throws IOException
options
come per i metodi visti precedentemente.InputStream newInputStream(Path path, OpenOption... options) throws IOException
InputStream
per leggere dal file. La modalità d'apertura di default è READ
.OutputStream newOutputStream(Path path, OpenOption... options) throws IOException
OutputStream
per scrivere nel file. La modalità d'apertura può essere specificata tramite options
.InputStream
e OutputStream
permettono di leggere e scrivere singoli byte o blocchi di byte. Entrambi implementano l'interfaccia AutoCloseable
e quindi possono essere usati con un try-with-resources.
Come menzionato all'inizio la parte della piattaforma Java che riguarda i file è molto vasta. Le classi e metodi introdotti in questa lezione ne rappresentano solamente una piccola parte, anche se comprendono tutti quelli di uso più frequente. Nelle lezioni successive avremo modo di vedere esempi significativi di utilizzo degli strumenti qui descritti e di altri.
[WordMapIgnoreCase] Modificare wordMap
o definire un nuovo metodo che è come wordMap
ma le parole sono messe tutte in minuscolo.
[ListaParole] Definire un metodo che prende in input il percorso di un file, che si assume contenga testo, e un charset e ritorna la lista di tutte le occorrenze di parole del file nell'ordine in cui compaiono.
[ListaParoleDistinte] Lo stesso dell'esercizio precedente ma nella lista non ci devono essere ripetizioni, l'ordine delle prime occorrenze delle parole deve essere rispettato.
[Indice] Definire un metodo che prende in input il percorso di un file, che si assume contenga testo, e un charset e ritorna una mappa che ad ogni parola del file associa la lista degli indici di linea in cui compare (senza ripetizioni).
[CharMap] Definire un metodo che prende in input il percorso di un file, che si assume contenga testo, e un charset e ritorna una mappa che ad ogni carattere del file associa il numero di occorrenze.
[DigitLines] Definire un metodo che prende in input il percorso di un file, che si assume contenga testo, e un charset e ritorna una lista di stringhe con tutte le linee del file che contengono almeno un carattere che è una cifra secondo il metodo isDigit
.
[ParoleInComune] Definire un metodo che prende in input due percorsi di file e un charset, che si assume contengano testo decodificabile con il charset, e ritorna un insieme con le parole che sono presenti in entrambi i file.
[FileStat] Scrivere un programma che prende dalla linea di comando il percorso di un file. Assumendo che il file contenga testo (con caratteri codificati tramite il charset di default), il programma stampa a video varie informazioni circa il file: numero di bytes, numero di caratteri, numero di linee, numero di parole distinte, ecc.
[FileIndex] Scrivere un programma che prende dalla linea di comando due percorsi di file. Assumendo che il primo file contenga testo (con caratteri codificati tramite il charset di default) e controllando che il secondo file non esista già, il programma scrive nel secondo file la lista di tutte le parole distinte nel file, una per linea, e accanto ad ognuna l'elenco degli indici di linea in cui appare.
[ByteMap] Definire un metodo che prende in input il percorso di un file qualsiasi e ritorna una mappa che ad ogni valore di byte presente nel file associa il numero di byte con quel valore nel file.
15 Mar 2016