Metodologie di Programmazione: Lezione 9
A volte si devono creare oggetti la cui classe attuale non è importante mentre ha importanza l'interfaccia implementata o la classe estesa. Oppure l'oggetto creato è usato solamente all'interno di un metodo e non è esportato all'esterno. In questi casi definire una classe normale (top level) o una classe annidata per creare tali oggetti sembra essere quasi uno spreco e comunque ciò rende troppo ampia la visibilità della classe. Proprio per offrire una soluzione soddisfacente a queste situazioni Java permette di definire classi in un qualsiasi blocco di codice, come il corpo di un metodo o di un costruttore. Come vedremo questo porta anche a una sintassi più snella e se ben usata più leggibile.
Le classi definite in un blocco di codice sono dette classi interne locali (inner local classes) o semplicemente classi locali. Come si evince dal loro nome, sono classi accessibili solamente all'interno del blocco in cui sono definite. Però le istanze di tali classi sono oggetti normali che possono essere passati come argomenti o ritornati da metodi. Le principali caratteristiche di una classe locale sono le seguenti.
final
o sono effettivamente final
1.Come primi esempi vedremo situazioni in cui una classe locale serve a poter creare un oggetto che è usato esclusivamente all'interno del metodo.
Vogliamo definire un metodo per calcolare i coefficienti binomiali2. Un modo molto semplice di calcolare il coefficiente binomiale C(n, k) è tramite la formula ricorsiva:
La spiegazione è altrettanto semplice: il numero di k-sottoinsiemi di un insieme di n elementi si può ottenere sommando il numero di k-sottoinsiemi dell'insieme meno un elemento x (questo numero è uguale a C(n − 1, k)) e il numero di k-sottoinsiemi che contengono l'elemento x (che è uguale a C(n − 1, k − 1)). I casi base sono C(n, 0) = 1 e C(n, n) = 1. Definiamo quindi un metodo per effettuare tale calcolo in un classe mp.LocalClasses
che useremo per esempi sulle classi locali:
package mp;
import java.math.BigInteger;
/** Una classe per fare esempi sulla classi locali */
public class LocalClasses {
/** Ritorna il coefficiente binomiale n su k. Ovvero, il numero di
* k-sottoinsiemi di un insieme di n elementi. Calcolo basato sulla formula
* ricorsiva: C(n, k) = C(n-1, k) + C(n-1, k-1).
* @param n,k parametri del coefficiente binomiale
* @return il coefficiente binomiale n su k */
public static BigInteger binom(int n, int k) {
if (k == 0 || k == n) return BigInteger.valueOf(1);
else return binom(n - 1, k).add(binom(n - 1, k - 1));
}
}
Abbiamo usato i BigInteger
che rappresentano interi di dimensioni arbitrariamente grandi perché i coefficienti binomiali anche con parametri n e k relativamente piccoli hanno valori molto grandi. Il problema con questa implementazione è che è piuttosto inefficiente. Lo possiamo vedere se la proviamo con un metodo di test:
private static void test_binom() {
Scanner input = new Scanner(System.in);
while (true) {
out.print("Test binom: digita n e k: ");
int n = input.nextInt(), k = input.nextInt();
if (k > n || k < 0) break;
out.println(binom(n, k));
}
}
Già per n = 30, k = 15 il nostro metodo arranca. La ragione è che il numero di chiamate ricorsive cresce esponenzialmente al crescere dei parametri n e k. Per n = 30, k = 15 è precisamente 310235039, cioè più di trecento milioni di chiamate ricorsive. Come in molti altri casi di calcoli ricorsivi, la maggior parte delle chiamate ricorsive sono ripetute tantissime volte. Ad esempio, nel calcolo di C(30, 15) il calcolo di C(28, 14) è ripetuto due volte e quelli di C(27, 14) e C(27, 13) sono ripetuti tre volte, e così via a crescere. Per evitare di ripetere inutilmente calcoli già fatti, si può usare la tecnica della memoizzazione3 che consiste nel salvare i calcoli parziali per poterli usare in seguito senza doverli ricalcolare. Si tratta di usare una tabella c
in cui salvare i valori di C(., .) che sono calcolati durante il calcolo di C(n, k). Potremmo usare un campo statico per mantenere tale tabella ma non sarebbe una scelta soddisfacente perché il campo che è un dettaglio implementativo del metodo binom
sarebbe visibile in tutta la classe. Allora possiamo usare una classe locale definita nel metodo binom
:
/** Ritorna il coefficiente binomiale n su k. Ovvero, il numero di
* k-sottoinsiemi di un insieme di n elementi. Calcolo basato sulla formula
* ricorsiva: (n, k) = (n-1, k) + (n-1, k-1). La ricorsione è resa efficiente
* dalla memoizzazione.
* @param n,k parametri del coefficiente binomiale
* @return il coefficiente binomiale n su k */
public static BigInteger binomM(int n, int k) {
class Binom { // Classe locale per mantenere la tabella di memoizzazione
Binom(int n, int k) { // Crea una tabella di memoizzazione
c = new BigInteger[n+1][k+1]; // sufficientemente grande
}
BigInteger compute(int n, int k) {
if (c[n][k] == null) { // Se non è già stato calcolato...
if (k == 0 || k == n) c[n][k] = BigInteger.valueOf(1);
else c[n][k] = compute(n - 1, k).add(compute(n - 1, k - 1));
}
return c[n][k];
}
BigInteger[][] c; // Tabella di memoizzazione
}
Binom b = new Binom(n, k); // Prepara per la ricorsione memoizzata
return b.compute(n, k); // Esegue la ricorsione memoizzata
}
Ora il calcolo di C(30, 15) è notevolmente più veloce, il numero di chiamate ricorsive da più di trecento milioni scende ad appena 451. Con la versione memoizzata possiamo calcolare ad esempio C(1000, 500) che con il metodo binom
sarebbe praticamente impossibile (millenni di tempo di calcolo non basterebbero).
Grazie alle classi locali possiamo in questo caso nascondere tutti i dettagli implementativi del nostro metodo all'interno del metodo stesso. Possiamo anche dare una definizione un po' più semplice, che evita di definire anche il costruttore della classe locale:
/** Implementazione alternativa di {@link mp.LocalClasses#binomM(int,int)} */
public static BigInteger binomM2(int n, int k) {
class Binom {
BigInteger compute(int n, int k) {
if (c[n][k] == null) {
if (k == 0 || k == n) c[n][k] = BigInteger.valueOf(1);
else c[n][k] = compute(n - 1, k).add(compute(n - 1, k - 1));
}
return c[n][k];
}
BigInteger[][] c = new BigInteger[n+1][k+1];
}
return new Binom().compute(n, k);
}
In quest'ultima implementazione nell'inizializzazione della tabella c
si sono usati i valori dei parametri del metodo n
e k
. Ciò è ammissibile perché i parametri sono effettivamente final
, cioè il loro valore non viene mai modificato. Vedremo fra poco che possiamo dare un'implementazione ancora più compatta di questo metodo.
Vogliamo scrivere un metodo che prende in input un percorso di una directory e ritorna in una stringa la rappresentazione dell'albero delle directory e file contenuti a qualsiasi livello nella directory di input. Per fare ciò possiamo usare il seguente metodo della classe Files
:
Path walkFileTree(Path start, FileVisitor<? super Path> visitor)
start
. La visita è depth-first (cioè in profondità, visita prima una directory e poi tutto il suo contenuto) e su ogni file e directory incontrati invoca l'oggetto visitor
.visitor
passato al metodo walkFileTree
permette di fare qualcosa su ogni file e directory incontrati durante la visita. Nel nostro caso vogliamo usarlo per riportare nella stringa la loro rappresentazione. Tale oggetto deve implementare la seguente interfaccia:
FileVisitor<T>
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
dir
prima che il suo contenuto sia visitato.FileVisitResult postVisitDirectory(T dir, IOException exc)
dir
, dopo che il suo contenuto è stato visitato.FileVisitResult visitFile(T file, BasicFileAttributes attrs)
file
.FileVisitResult visitFileFailed(T file, IOException exc)
file
fallisce.attrs
di tipo BasicFileAttributes
. Ad esempio con attrs.creationTime()
si ottiene la data di creazione e con attrs.size()
la lunghezza in bytes del file. Il valore ritornato da tutti i metodi dell'interfaccia FileVisitor
è di tipo FileVisitResult
che è una enumerazione e che serve a comunicare al metodo che effettua la visita se si vuole continuare la visita, FileVisitResult.CONTINUE
, se si vuole saltare la visita di una directory e del suo contenuto, FileVisitResult.SKIP_SUBTREE
, o se si vuole terminare, FileVisitResult.TERMINATE
. Il package java.nio.file
offre anche una classe SimpleFileVisitor<T>
con una implementazione di default dei metodi di FileVisitor<T>
per facilitare l'implementazione dell'interfaccia nel caso in cui non si voglia implementare tutti e quattro i metodi. Noi la useremo perché, per semplicità, non vogliamo implementare il metodo visitFileFailed
. Adesso possiamo definire il nostro metodo che produce una stringa che rappresenta l'albero delle directory e file (lo definiamo nella classe mp.util.Utils
perché potrebbe essere utile in futuro):
/** Ritorna una stringa che rappresenta l'albero di directory e file a partire
* dal percorso specificato.
* @param rootpath percorso della directory radice
* @return una stringa che rappresenta l'albero di directory e file
* @throws IOException se si verifi qualche errore nell'accesso ai file/dir */
public static String fileTreeToString(Path rootpath) throws IOException {
// Classe locale che realizza un visitatore di un albero di dir e file
class Visitor extends SimpleFileVisitor<Path> {
@Override
public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs)
throws IOException {
if (Files.isHidden(dir)) return FileVisitResult.SKIP_SUBTREE;
s += pre + (pre.isEmpty() ? "" : "---")+dir.getFileName()+"\n";
pre += " |";
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
s += pre + "---"+file.getFileName()+" "+attrs.size()+"\n";
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
pre = pre.substring(0, pre.length() - 4);
s += pre + "\n";
return FileVisitResult.CONTINUE;
}
String s = "", pre = "";
}
Visitor vis = new Visitor(); // Crea il visitatore e lo passa
Files.walkFileTree(rootpath, vis); // al metodo che effettua la visita
return vis.s; // Al termine della visita, in s c'è
} // la rappresentazione dell'albero
Quindi la classe locale Visitor
estende la classe SimpleFileVisitor<Path>
e ridefinisce tre dei quattro metodi. La stringa che alla fine conterrà la rappresentazione dell'albero è mantenuta nel campo s
dell'oggetto. Inoltre dobbiamo anche mantenere e continuamente aggiornare una stringa pre
per l'indentazione dei nomi dei file/dir. Si noti che pre
è aumentato di " |"
quando si inizia la visita di una directory in preVisitDirectory
ed è riportato indietro quando finisce la vista della directory in postVisitDirectory
. In preVisitDirectory
saltiamo la visita di una directory e del suo contenuto se è nascosta, cioè il metodo Files.isHidden
ritorna true
. Adesso scriviamo un semplice metodo di prova:
private static void test_fileTreeToString() {
Scanner input = new Scanner(System.in);
out.println("Test fileTreeToString: Digita un pathname: ");
String pathname = input.nextLine();
Path root = Paths.get(pathname).toAbsolutePath();
try {
out.println(fileTreeToString(root));
} catch(IOException e) { out.println(e); }
}
Se usato rispetto al project MetProg
con il percorso relativo alla working directory otteniamo:
MetProg
|---files
| |---alice_it_latin1.txt 173401
| |---alice_it_latin1_sample.txt 1367
| |---alice_it_utf8.txt 175249
| |---alice_it_utf8_sample.txt 1386
|
|---MetProg.iml 423
|---out
| |---production
| | |---MetProg
| | | |---mp
| | | | |---app
| | | | | |---Checker.class 409
| | | | |
| | | | |---AziendaApp$1.class 184
| | | | |---AziendaApp$CheckIndirizzo.class 784
| | | | |---AziendaApp$CheckNomeCognome.class 931
| | | | |---AziendaApp$CheckTelefono.class 778
| | | | |---AziendaApp.class 4144
| | | | |---Dipendente$1.class 184
| | | | |---Dipendente$Contatti.class 1019
| | | | |---Dipendente.class 3274
| | | | |---Dirigente.class 972
| | | | |---DPair.class 1146
| | | | |---Generic.class 3337
| | | | |---LPoint.class 1188
| | | | |---MathApp.class 1569
| | | | |---Pair.class 498
| | | | |---tapp
| | | | | |---MenuApp.class 1454
| | | | |
| | | | |---TestFile.class 4387
| | | | |---Tests.class 3737
| | | | |---util
| | | | | |---HTree.class 3085
| | | | | |---Utils$1.class 811
| | | | | |---Utils$1Visitor.class 2761
| | | | | |---Utils$Align.class 1074
| | | | | |---Utils.class 10981
| | | | |
| | | |
| | |
| |
|
|---src
| |---mp
| | |---app
| | | |---Checker.java 920
| | |
| | |---AziendaApp.java 4679
| | |---Dipendente.java 4376
| | |---Dirigente.java 1305
| | |---Generic.java 4216
| | |---MathApp.java 1072
| | |---tapp
| | | |---MenuApp.java 1585
| | |
| | |---TestFile.java 3715
| | |---Tests.java 4590
| | |---util
| | | |---HTree.java 2709
| | | |---Utils.java 14299
| | |
| |
|
Finora abbiamo visto esempi di classi locali per creare oggetti il cui scopo è di mantenere dei dati durante un calcolo o implementare un'interfaccia che serve per poter operare con un altro metodo. Ma gli oggetti creati possono avere anche una vita più lunga e anzi possono essere ritornati dal metodo affinché possano essere usati all'esterno di esso.
Negli esempi precedenti il nome della classe locale non è importante perché è usato solamente per creare un unico oggetto (e d'altronde il nome non è visibile al di fuori del metodo in cui la classe locale è definita). Questa situazione accade piuttosto spesso e così Java dà la possibilità di usare una classe interna anonima (anonymous inner class) o, più brevemente, classe anonima. Una classe anonima è definita e istanziata simultaneamente in un'unica espressione. La sintassi dell'espressione definitoria-creazionale (che definisce la classe e la istanzia) è leggermente differente a seconda che la classe anonima estenda una classe (astratta o concreta) o implementi un'interfaccia:
new NomeC(eventuali parametri) { // NomeC, nome della classe che viene estesa
corpo della classe anonima // I parametri sono quelli di un costruttore
} // della classe NomeC
new NomeI() { // NomeI, nome dell'interfaccia implementata
corpo della classe anonima
}
Da questa sintassi è chiaro perché sono chiamate classi anonime: non c'è il nome della nuova classe. Inoltre, le classi anonime non possono avere costruttori. Nel caso la classe anonima estenda una classe il costruttore usato è uno di quelli della classe che viene estesa. Nel caso dell'interfaccia il costruttore è quello di default senza parametri. Per il resto sono uguali a una classe locale. L'espressione definitoria-creazionale può essere usata come una qualsiasi altra espressione creazionale. Per chiarire meglio il caso di una classe anonima che estende una classe che ha almeno un costruttore esplicito. Se volessimo definire una classe anonima che estende la classe mp.LPoint
potremmo scrivere in un qualche metodo:
LPoint p = new LPoint(0, 0, "A") {
. . .
};
Questo è essenzialmente equivalente (eccetto che per il nome MyLP
della classe locale) a:
class MyLP extends LPoint {
public MyLP() { super(0, 0, "A"); }
. . .
}
LPoint pp = new MyLP();
Vediamo subito un esempio. Il metodo binomM2
siccome usa una sola istanza della classe locale definita può essere riscritto con una classe anonima:
/** Implementazione alternativa di {@link mp.LocalClasses#binomM2(int,int)} */
public static BigInteger binomM3(int n, int k) {
return new Object() {
BigInteger compute(int n, int k) {
if (c[n][k] == null) {
if (k == 0 || k == n) c[n][k] = BigInteger.valueOf(1);
else c[n][k] = compute(n - 1, k).add(compute(n - 1, k - 1));
}
return c[n][k];
}
BigInteger[][] c = new BigInteger[n+1][k+1];
}.compute(n, k);
}
In questo caso la classe anonima semplicemente estende Object
. Anche il metodo fileTreeToString
potrebbe essere riscritto tramite classe anonima dato che usa una sola istanza della classe locale definita.
Le classi anonime sono spesso usate per implementare un'interfaccia. E questo è ciò che spesso accade quando si vuole definire un cosiddetto factory method, cioè un metodo che fabbrica oggetti, tipicamente oggetti che implementano una certa interfaccia. Ad esempio, la nostra interfaccia mp.app.Checker
può essere migliorata aggiungendo metodi statici che fabbricano Checker
di uso comune. Aggiungiamo allora un factory method che ritorna un Checker
per controllare che i caratteri della stringa appartengano a uno specificato insieme.
public interface Checker {
. . .
/** Ritorna un Checker che controlla che la stringa non sia {@code null}
* e che i suoi caratteri siano in {@code chars} o, se {@code letters} è
* {@code true}, che siano lettere.
* @param chars caratteri permessi
* @param letters se {@code true}, sono permesse anche le lettere
* @return un Checker che controlla che i caratteri siano quelli dati */
static Checker getCheckChars(String chars, boolean letters) {
return new Checker() {
@Override
public String valid(String s) {
if (s == null) return "Non può essere null";
for (char c : s.toCharArray())
if (chars.indexOf(c) < 0 &&
(!letters || !Character.isLetter(c)))
return "Il carattere '"+c+"' non è valido";
return null;
}
};
}
}
Si osservi che l'oggetto Checker
ritornato usa i valori dei parametri chars
e letters
. Tali valori sono costanti, cioè non possono cambiare, altrimenti la classe anonima non avrebbe potuto usarli. Quando una classe locale (anonima o non) usa i valori di variabili locali o parametri del metodo in cui è definita si dice che cattura i loro valori. Per essere più precisi è l'oggetto istanza delle classe locale che cattura tali valori.
Definiamo anche un altro factory method in Checker
per controllare la lunghezza:
/** Ritorna un Checker che controlla che la lunghezza della stringa sia
* compresa tra i limiti specificati.
* @param min,max minima e massima lunghezza
* @return un Checker che controlla che la lunghezza sia nei limiti dati */
static Checker getCheckLen(int min, int max) {
return new Checker() {
@Override
public String valid(String s) {
if (s == null) return "Non può essere null";
int len = s.length();
if (len < min || len > max)
return "La lunghezza deve essere tra "+min+" e "+max;
return null;
}
};
}
Ora vogliamo definire un metodo che ci permette di combinare più Checker
, cioè che ritorna un Checker
che esegue i controlli relativi a due o più Checker
specificati.
/** Ritorna un Checker che controlla che la stringa passi tutti i controlli
* dei Checker specificati. Il metodo valid del Checker ritornato, se la
* stringa non passa tutti i controlli, ritorna l'errore del primo Checker
* insoddisfatto.
* @param cc i Checker che eseguono i controlli
* @return un Checker che controlla che tutti i Checker siano soddisfatti */
static Checker getCheckAll(Checker...cc) {
return new Checker() {
@Override
public String valid(String s) {
for (Checker c : cc) {
String r = c.valid(s);
if (r != null) return r;
}
return null;
}
};
}
Ad esempio, se vogliamo un Checker
che controlli sia che i caratteri siano lettere sia che la lunghezza non superi 8, possiamo crearlo molto semplicemente così
import mp.app.Checker;
import static mp.app.Checker.*;
. . .
Checker checkCL = getCheckAll(getCheckChars("", true), getCheckLen(0, 8));
Nella libreria di Java ci sono relativamente poche interfacce che hanno factory methods perché solamente a partire dalla recente versione 8 di Java un'interfaccia può avere metodi statici. Questa è, ad esempio, la ragione per cui è stata introdotta la classe Collections
invece di aggiungere i metodi di Collections
(che sono tutti statici) all'interfaccia Collection
.
In una prossima lezione vedremo che quando si deve implementare un'interfaccia che ha un solo metodo (astratto), come Checker
, Java mette a disposizione una sintassi più snella e conveniente di quella delle classi anonime.
[NumeriDiStirling] I numeri di Stirling del secondo tipo sono denominati S(n, k) e sono il numero di modi di partizionare un insieme di n elementi in k sottoinsiemi non vuoti. Tali numeri soddisfano la seguente formula ricorsiva:
con i casi base S(0, 0) = 1 e S(n, 0) = 0 per n > 0. Definire un metodo BigInteger stirling(int n, int k)
che calcola S(n, k). Usare la formula ricorsiva e la memoizzaione per rendere efficiente il calcolo. Per maggiori informazioni sui numeri di Stirling si veda Stirling Numbers
.
[FileTreeToString1] Definire una versione più versatile del metodo fileTreeToString
che permette di personalizzare le stringhe "---"
e " |"
.
[FileTreeToString2] Definire una versione del metodo fileTreeToString
che prende in input anche un oggetto di tipo BiPredicate<Path,BasicFileAttributes>
il quale deve essere usato per decidere se la directory o il file incontrato durante la visita deve essere rappresentato o meno nella stringa di output. Siccome il nuovo metodo è così versatile, deve di default anche includere le directory nascoste. Mettere alla prova il metodo, ad esempio, producendo l'albero che comprende solamente le directory e file con una data di creazione posteriore a una certa data.
[CopyFileTree] Definire un metodo void copyFileTree(Path src, Path dst) throws IOException
che copia tutti i file e directory contenuti a qualsiasi livello in src
nella nuova destinazione dst
. Il metodo non deve seguire i link simbolici, se presenti li deve solamente copiare.
Suggerimento: usare il metodo Files.walkFileTree
implementando opportunamente l'interfaccia FileVisitor<Path>
usando il metodo Path copy(Path source, Path target, CopyOption... options)
per copiare file e dir.
[CopyFileTree+] Migliorare il metodo dell'esercito precedente in modo che prenda in input anche un oggetto di tipo BiPredicate<Path,BasicFileAttributes>
che deve essere usato per determinare quali directory e file devono essere copiati.
[FileTreeToHTree] Definire un metodo HTree<Path> fileTreeToHTree(Path root)
che ritorna un HTree<Path>
che rappresenta l'albero di file e directory contenuto in root
.
Suggerimento: usare il metodo Files.walkFileTree
implementando opportunamente l'interfaccia FileVisitor<Path>
. Potrebbe essere necessario usare uno stack per mantenere il cammino di directory dalla radice fino al file/dir corrente.
[CheckNum] Aggiungere all'interfaccia Checker
un metodo statico Checker getCheckNum(int...radices)
che ritorna un Checker
che controlla che la stringa sia interpretabile come un intero scritto in una delle basi specificate secondo il metodo statico parseUnsignedLong
di Long
.
[CheckAny] Aggiungere all'interfaccia Checker
un metodo statico Checker getCheckAny(Checker...cc)
che ritorna un Checker
che controlla che almeno uno dei Checker
in cc
è soddisfatto. Se nessuno è soddisfatto il metodo valid
ritorna la concatenazione delle stringhe di errore di tutti i Checker
in cc
.
[HTree] Implementare l'interfaccia Iterable<T>
nella classe HTree<T>
in modo tale che iteri i nodi dell'albero in depth-first, cioè in profondità, per ogni nodo prima il nodo e poi i suoi discendenti.
23 Mar 2015
Una variabile locale o parametro è effettivamente final
se è stata inizializzata (un parametro è sempre inizializzato dal valore che gli è stato assegnato al momento dell'invocazione del metodo) e il suo valore non viene più modificato.↩
Il coefficiente binomiale dipende da due interi n ≥ k ≥ 0, ed è anche detto n su k, ha valore uguale al numero di sottoinsiemi di cardinalità k di un insieme di cardinalità n. Per maggiori informazioni si veda Binomial Coefficient.↩
Il termine memoizzazione deriva dall'inglese memoization che a sua volta è stato coniato dal latino memorandum. Per maggiori informazioni si veda Memoization.↩