Metodologie di Programmazione: Lezione 8

Riccardo Silvestri

Files

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.

Localizzare files

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)
Ritorna un oggetto di tipo 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()
Ritorna un 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()
Ritorna un 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()
Ritorna un 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()
Ritorna true se questo percorso è assoluto.

Un metodo particolarmente importante è

Path toAbsolutePath()
Ritorna un 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.

Ottenere informazioni su file e directory

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)
Ritorna true se l'oggetto Path è il percorso a un file o directory effettivamente esistente.
static boolean isDirectory(Path path, LinkOption...options)
Ritorna true se l'oggetto Path è il percorso a una directory esistente.
static boolean isRegularFile(Path path, LinkOption...options)
Ritorna true se l'oggetto Path è il percorso a un file regolare esistente.
static boolean isSymbolicLink(Path path)
Ritorna true se l'oggetto Path è un percorso a un link simbolico esistente.
Gli argomenti opzionali 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.

Errori ed eccezioni (terza parte)

Eccezioni controllate

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.

Gerarchia delle eccezioni

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.

Lettura di file di testo

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
Legge tutte le linee dal file di percorso 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
Esattamente come readAllLines(path, StandardCharsets.UTF_8).
Scriviamo un semplice metodo nella classe 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
Crea un nuovo Scanner che legge dal file dato e usa il charset con il nome specificato per decodificare i caratteri.
Scanner(Path source) throws IOException
Come Scanner(source, Charset.defaultCharset().name())
Come esempio scriviamo un metodo che legge tramite 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
Apre il file in lettura ritornando un BufferedReader. I caratteri sono decodificati usando il charset specificato.
Un 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.

Scrittura di testo in un file

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
Scrive le linee nel file codificando i caratteri secondo lo specificato charset. Gli ultimi argomenti 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
Apre un file in scrittura ritornando un oggetto BufferedWriter. I caratteri saranno codificati tramite lo specificato charset. Gli argomenti options hanno lo stesso significato del metodo write visto sopra.
Dopo aver ottenuto un 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.

Lettura e scrittura di bytes in file

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
Ritorna un array di bytes con tutti i bytes del file.
Path write(Path path, byte[] bytes, OpenOption... options) throws IOException
Scrive i bytes dell'array nel file. La modalità d'apertura del file è data da options come per i metodi visti precedentemente.
InputStream newInputStream(Path path, OpenOption... options) throws IOException
Apre il file e ritorna un InputStream per leggere dal file. La modalità d'apertura di default è READ.
OutputStream newOutputStream(Path path, OpenOption... options) throws IOException
Apre un file e ritorna un OutputStream per scrivere nel file. La modalità d'apertura può essere specificata tramite options.
Oggetti di tipo 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.

Esercizi

[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


  1. Infatti la “vecchia” classe File permette di localizzare file solamente rispetto al file system di default.

  2. Il file system di default è quello direttamente accessibile alla JVM.