|
Java Tutorial - Parte 1 0.1
Un tutorial per esempi
|
Fino a questo momento non ci siamo mai preoccupati del nome del file sorgente nel quale scriviamo la nostra classe. Non solo, nel nostro piccolo programma di gestione delle figure geometriche abbiamo definito due classi all'interno di un unico file sorgente (vedi I files del capitolo).
Adesso però vogliamo organizzare meglio il nostro progetto; i sorgenti dovranno essere suddivisi in cartelle, ognuna delle quali tratterà un argomento specifico. Inoltre, vogliamo evitare che i nomi delle classi del nostro progetto possano andare in conflitto con i nomi di classi di altri progetti.
Per esempio, la classe Rectangle che rappresenta la figura piana di un rettangolo potrebbe probabilmente avere lo stesso nome in altri progetti e questo potrebbe causare un conflitto tra i nomi delle classi.
In questo capitolo e nei successivi scriveremo una piccola applicazione che estrae e conteggia i numeri primi minori di / uguale ad un dato numero n che sarà di tipo long. Useremo un algoritmo leggermente migliore della "divisione per tentativi", noto come Crivello di Eratostene ed organizzeremo i sorgenti in cartelle separate che rappresentano i packages del nostro progetto.
Cercheremo di ottimizzare il codice per ottenere migliori performances ed imparerete ad interfacciare i programmi Java con il codice nativo scritto in linguaggio "C" che consente di superare alcuni limiti di Java.
Prendiamo come esempio il progetto di gestione delle figure geometriche nel quale abbiamo definito la classe Rectangle (vedi Un primo approccio) e poniamoci una domanda: quante altre classi Rectangle potrebbero esserci nei programmi Java? Sicuramente molte; lo stesso Java ne definisce una nella sua libreria standard: java.awt.Rectangle.
Java, come tutti i linguaggi di programmazione, impedisce che due o più identificatori possano avere lo stesso nome poichè ci sarebbe un conflitto: il compilatore non saprebbe quale essere usato.
La soluzione al problema del conflitto nei nomi delle classi sono i packages. Come abbiamo avuto già modo di osservare in Il disegno dei poligoni prima di usare un qualsiasi tipo di dato non-primitivo è necessario definirlo oppure importare il package in cui esso è definito.
Per esempio, per creare un oggetto di classe BufferedImage abbiamo dovuto importare il tipo di dato dal suo package:
Una alternativa alla importazione del package è quella di specificare il fully qualified name del tipo di dato cioè il nome qualificato completo, composto dal nome della classe preceduto dal nome del package nel quale è stata definita.
Ci sarebbe ancora una questione da risolvere: anche se completamente qualificato, un nome di classe potrebbe andare ancora in conflitto con un altro se anche i nomi dei packages coincidono. La strategia introdotta in Java per evitare il conflitto è quello di usare i nomi di dominio Internet rovesciati per qualificare i packages. Per esempio, se la società Acme Spa che possiede il dominio acme.com, sviluppa un package per la gestione delle figure geometriche, potrebbe dare al package il nome:
com.acme.geometry
Ben difficilmente potrà esistere un package con lo stesso nome, a meno che non sia sviluppato dalla stessa Acme SpA. Quella suesposta è una regola non scritta: il programmatore è comunque libero di scegliere il nome di package che preferisce.
Il nome che io ho scelto per questo package è javatutor.primes dal momento che il sottoscritto, al momento, non possiede alcun dominio Internet.
Il package ha una struttura che ricalca la organizzazione a cartelle e sottocartelle del filesystem del nostro sistema. Per creare il package javatutor.primes creiamo una cartella chiamata primes della cartella javatutor/projects; questa sarà la cartella radice del progetto..
|-> javatutor1
|
-> projects
|
-> hellow
|
-> mcmd
|
-> geometry
|
-> primes = cartella radice del progetto
Dalla cartella radice creiamo la sottocartella javatutor/primes e, quando il progetto diventerà mano a mano più complesso, avremo una gerarchia simile a questa:
|-> projects
|
-> primes = cartella radice del progetto
|
-> javatutor
|
-> primes
|
-> crivello
|
-> jni
|
-> ffmLa cartella del package javatutor/primes contiene i seguenti files:
| Nome del file | package | Bote |
|---|---|---|
| OverflowException.java | javatutor.primes | La classe eccezione |
| ListPrimes.java | javatutor.primes | La interfaccia per la lista dei primi |
| DivisionLoop.java | javatutor.primes | Algortimo per tentativi di divisione |
| package-info.java | javatutor.primes | documentazione per il package |
| overview.html | il file HTML per javadoc | |
| docoptions.txt | il file opzioni per javadoc |
Analizziamo alcuni files:
overview.html è il file della pagina principale della documentazione ottenuta con l'utility javadoc che il lettore ha già incontrato in Il file overview.txt docoptions.txt è il file delle opzioni per l'utility javadoc il lettore ha già incontrato in Il file docoptions.txt package-info.java è il file della pagina principale della documentazione del singolo package ottenuta con la utility javadoc Aprite il file sorgente ListPrimes.java che definisce i metodi che restituiscono il conteggio e/o la lista dei numeri primi minori/uguali ad un dato numero n. Analizziamo il sorgente in questione. Innanzitutto, perchè una interfaccia e non una classe? Avevo già accennato al concetto di interfaccia nel capitolo relativo al disegno dei poligoni, ricordate? (vedi Le interfacce).
Una interfaccia è simile ad una classe base astratta ma con alcuni vantaggi:
Inoltre, una interfaccia può essere implementata da classi che non hanno nulla in comune e che non potrebbero mai derivare da un capostipite comune. questo si può applicare anche alle classi che restituiscono una lista di numeri primi: in futuro potrebbero essere sviluppati algoritmi che nulla hanno in comune con quelli che scriviamo oggi.
La prima istruzione di ogni file sorgente che fà parte di un package deve essere la parola chiave package seguita dal nome del package del quale fa parte la classe che stiamo scrivendo. Solitamente, subito dopo la dichiarazione del package, si trovano le direttive di importazione. Esempio:
Successivamente, viene definita la interfaccia:
Abbiamo imparato che gli attributi di accesso servono ad impedire l'acceso ai membri dati ed ai metodi da parte di metodi esterni alla classe: questo consente di mantenere coerenti i dati interni di una classe oltre a poter usare un dato interno in un formato più semplice da gestire (vedi Astrazione dei dati). Gli attributi visti finora sono:
public: l'accesso è garantito a tutti i metodi sia interni che esterni alla classe protected: l'accesso è garantito a tutti i metodi interni della classe e di tutte le sue derivate private: l'accesso è garantito ai soli metodi interni alla classeMa se non viene specificato alcun attributo di accesso, quale è quello di default? Per una qualsiasi classe, membro dati o metodo, l'attributo di default è una modalità che ancora non abbiamo affrontato: package private. Questo tipo di attributo indica che il membro dati o il metodo è accessibile da parte dei metodi di tutte le classi che sono definite nello stesso package.
Fà eccezione a questa regola la interfaccia: l'attributo di default dei suoi metodi è public.
Anche la classe in quanto tipo di dato può avere un attributo di accesso: può essere public oppure private e, se non viene specificato alcun attributo, anche la classe può essere package private. Se vogliamo dare accesso pubblico ad una classe, dobbiamo specificarlo esplicitamente:
Finora non abbiamo mai definito una classe pubblica: non ce ne era bisogno poichè tutti i sorgenti delle nostre piccole aplicazioni erano ubicati nella stessa cartella e non facevano parte di alcun package o, meglio, facevano parte di un unnamed package (=package anonimo).
Nella realtà, invece, la maggior parte delle classi sono sempre definite pubbliche; questo però comporta una limitazione: il compilatore Java pretende che ogni classe pubblica sia definita in un file sorgente a se stante e che il nome del file sorgente sia uguale a quello della classe definita in esso. Per questo motivo, se vogliamo rendere pubblica la interfaccia ListPrimes dobbiamo definirla nel file sorgente ListPrimes.java e nessuna altra classe pubblica può essere definita nello stesso file sorgente.
La interfaccia espone due versioni del metodo getPrimesCount:
La prima versione è un metodo astratto che deve essere implementato nelle classi che implementano la interfaccia. Il metodo accetta due parametri:
start: il numero di partenza del conteggio end: il numero finale del conteggiochiameremo questi due parametri i limiti entro i quali devono essere conteggiati i numeri primi; essi vengono valutati come valore assoluto e start è sempre il minore dei due argomenti mentre end è il maggiore.
La seconda versione del metodo non è astratta: nella interfaccia vi è la definizione del metodo:
Poichè questa versione sovrascritta del metodo getPrimesCount è una particolare forma del metodo astratto in cui start è uguale a 2, la implementazione di default nella interfaccia è quella di richiamare il metodo astratto con argomento start uguale a 2, il più piccolo dei numeri primi.
Pertanto è possibile implementare un metodo in una interfaccia usando l'attributo default. Vi è sempre la possibilità, per le classi che implementano la interfaccia, di sovrascrivere questo metodo di default.
Anche questo metodo possiede due versioni sovrascritte le quali hanno lo stesso significato del metodo getPrimesCount ma, invece di restituire solo il conteggio dei numeri primi ne restuiscono la lista:
Analogamente a quanto visto per i metodi precedenti, la prima versione è un metodo astratto che deve essere implementato nelle classi che implementano la interfaccia. Gli argomenti start ed end sono i due limiti entro i quali devono essere estratti i numeri primi; questi argomenti vengono valutati in valore assoluto e start è sempre il minore dei due argomenti mentre end è il maggiore.
Anche in questo caso, la implementazione di default del secondo metodo è quella di richiamare il metodo astratto fornendo come argomento start il valore 2.
Come abbiamo visto, il nostro piccolo programmino non restituisce solo il conteggio dei numeri primi esistenti tra due estremi ma anche la lista di essi in una collection di tipo List.
Quanti elementi, al massimo, può contenere una List? E quanti potrebbero essere, potenzialmente, i numeri primi esistenti tra il numero 2 ed il massimo valore memorizzabile in un long?
Come accennato in La interfaccia ListPrimes non esiste una formula analitica che fornisce il numero esatto di:
P(n) = numeri_primi <= n
ma esiste una sua approssimazione che è data dalla formula:
P(n) = n / log(n)
dove log(n) è il logaritmo naturale di n. Applicando la formula approssimativa otteniamo che:
P(n) = Long.MAX_VALUE / log( Long.MAX_VALUE ) = 211214493616576547
che è pari a circa 2,1x1017, un numero decisamente più alto del numero massimo di elementi che si possono aggiungere ad una lista o ad una array che è pari a Integer.MAX_VALUE.
Pertanto, se n è abbastanza grande possiamo avere una condizione di overflow (=superamento della capacità) della lista che memorizza i numeri primi. Questa condizione deve essere testata ed è necessario interrompere la elaborazione se dovesse verificarsi.
Inoltre, questa condizione deve essere segnalata all'utente. Come? Ma sollevando una eccezione, ovviamente!
Ecco perchè i metodi getPrimesList definiti nella interfaccia possono sollevare eccezioni di tipo OverflowException implementata nel file sorgente con lo stesso nome della classe, vedi I metodi getPrimesList.
Avremmo potuto restituire la lista di numeri primi in una array, questo è certo:
ma quanti elementi avremmo dovuto allocare? Non lo sappiamo con precisione. Potremmo usare il metodo getPrimesCount per conoscerlo con esattezza ma avremmo perso tempo inutilmente dal momento che le operazioni di estrazione e conteggio dei numeri primi sono le stesse.
La soluzione a questo problema è quella di restituire un oggetto che implementi la interfaccia List e, nello specifico, un oggetto di classe ArrayList: vi sono molte classi che implementano la interfaccia List; esse prendono il nome di collections.
Una collection incapsula il concetto di array e ne condivide la organizzazione in quanto è un contenitore di oggetti ma possiede molti vantaggi:
Quindi la soluzione migliore è quella di istanziare una ArrayList specificando nel costruttore il numero previsto di elementi calcolato con la formula approssimativa e lasciare che sia la classe ArrayList a gestirne il contenuto.
Dopo aver calcolato la dimensione approssimativa della lista, istanziamo l'oggetto primes specificando nel costruttore il numero previsto di elementi. Questo consente di avere un certo vantaggio perchè è pur vero che una ArrayList può crescere dinamicamente ma l'operazione di ridimensionamento ha un costo in termini di tempo.
In seguito, richiamiamo il metodo ensureCapacity in modo da costringere la ArrayList ad allocare il numero di elementi minimo che è sempre pari ad almeno il numero previsto: se l'operazione dovesse fallire verrà sollevata una eccezione.
Per approfondire il concetto delle collezioni vedi The Java Tutorial - Generics
Siete curiosi di sapere di quanto è errato il risultato approssimato rispetto al conteggio reale dei numeri primi? Nella tabella sottostante potete vedere, per N fino a quarante-miliardi, il conteggio di P(n) ricavato dalla formula e quello reale, la differenza in valore assoluto ed in percentuale tra le due modalità di calcolo:
| N | n / log(n) | count | difference | % error |
|---|---|---|---|---|
| 100.000 | 8.686 | 9.592 | 906 | 9,45% |
| 1.000.000 | 72.382 | 78.498 | 6.116 | 7,79% |
| 5.000.000 | 324.150 | 348.513 | 24.363 | 6,99% |
| 10.000.000 | 620.421 | 664.579 | 44.158 | 6,64% |
| 40.000.000 | 2.285.141 | 2.433.654 | 148.513 | 6,10% |
| 100.000.000 | 5.428.681 | 5.761.455 | 332.774 | 5,78% |
| 200.000.000 | 10.463.629 | 11.078.937 | 615.308 | 5,55% |
| 400.000.000 | 20.194.906 | 21.336.326 | 1.141.420 | 5,35% |
| 1.000.000.000 | 48.254.942 | 50.847.534 | 2.592.592 | 5,10% |
| 5.000.000.000 | 223.886.908 | 234.954.233 | 11.067.325 | 4,71% |
| 10.000.000.000 | 434.294.482 | 455.052.511 | 20.758.029 | 4,56% |
| 20.000.000.000 | 843.205.936 | 882.206.716 | 39.000.780 | 4,42% |
| 40.000.000.000 | 1.638.528.672 | 1.711.955.433 | 73.426.761 | 4,29% |
Come il lettore potrà osservare la differenza tra il conteggio di P(n) stimato e quello reale è piuttosto importante.
La interfaccia definisce un metodo di default, quindi implementato, per stabilire se il numero fornito come argomento, valutato come valore assoluto, è primo oppure no. L'algortimo usato è quello "a divisione per tentativi", quindi poco efficiente, ma è anche l'unico di cui disponiamo al momento che non sia probabilistico:
Il metodo getKnownPrimes della inetrfaccia ListPrimes è un metodo statico e ritorna la lista dei numeri primi compresi tra due estremi start ed end con end minore o uguale a 10.000. La lista ritornata non viene calcolata con un algoritmo ma è hardcoded nella interfaccia stessa. Questo metodo viene usato per verificare che gli algoritmi da noi scritti ritornino i risultati corretti confrontando la lista dei numeri primi ritornata dal algoritmo con la lista dei numeri primi conosciuti:
Il file sorgente DivisionLoop.java contiene la omonima classe che è la implementazione della interfaccia ListPrimes: essa usa l'inefficente algoritmo di iterare su tutti i numeri compresi nei due estremi e di verificare se essi sono primi o meno usando il metodo di default isPrime. L'algoritmo usato è praticamente uguale a quello già visto in La interfaccia ListPrimes.
Questo metodo è davvero identico a quello visto nel codice a riferimento con solo due piccoli accorgimenti:
start è sempre il minore dei due estremi mentre l'argomento end è il maggiore dei dueLa implementazione di questo metodo ricalca quello del conteggio ma con gli accorgimenti che abbiamo visto nelle sezioni precedenti:
ArrayList con una capacità iniziale pari alla dimensione stimata del numero di elementi da inserirvi ArrayList viene sollevata una eccezione di tipo OverflowException L'algoritmo vero e proprio è davvero molto semplice (ma anche molto inefficente):
Non c'è alcun bisogno di implementare questo metodo della interfaccia dal momento che l'algoritmo DivisionLoop usa quello di default della interfaccia.
Conosciamo già, almeno in parte, le performances di Java nel conteggio dei numeri primi presenti da 2 ad un certo numero limite e sappiamo che non è affatto un linguaggio lento, anzi, i suoi tempi si avvicinano a quelli di un linguaggio compilato come il "C"; vedi I risultati del test.
Quello che però vogliamo sapere è se questi eccellenti risultati si ottengono anche con algoritmi diversi e non solo: possiamo anche capire che Java è veloce nel contare i numeri primi ma quando si tratta di memorizzarli in una lista come se la cava?
Benchè il nostro algoritmo prevede una capacità iniziale per la lista che si avvicina alla dimensione necessaria, l'inserimento di in elemento in una ArrayList ha un overhead (=penalizzazione, sovraccarico) apprezzabile?
In secondo luogo, la lista di numeri primi a cui siamo interessati potrebbe essere molto più corta di quelle totale; in altre parole, anche se la estrazione dei numeri primi deve avvenire tra 2 e end potremmo essere interessati solo agli ultimi 10 numeri primi della lista. Peranto prevederemo una specifica opzione per questo.
La applicazione che andremo a scrivere dovrebbe avere una sintassi come questa:
java -ea Performance [OPTIONS] ALGO [START] END
in cui OPTIONS può essere:
-h: visualizza l'help del comando, come di consueto -t: estrae e visualizza la lista dei numeri primi -k: visualizza la lista dei numeri primi conosciuti e minori di 10000 -e: estrae la lista ma non la visualizza -aNUM: estrae la lista ma visualizza solo gli ultimi NUM numeri primiSe il comando viene eseguito senza opzioni, la app esegue solo il conteggio dei numeri primi compresi tra START, il cui default è 2, ed END.
L'argomento ALGO è il codice del algoritmo da utilizzare; al momento il solo algoritmo disponibile è il DivisionLoop che abbreviamo in DL ma nei prossimi capitoli ne scriveremo altri.
A volte è possibile ottenere migliori performances semplicemente scrivendo algoritmi migliori o usando alcuni trucchetti di programmazione. E' quello che cercherò di insegnare al lettore nei prossimi capitoli; non è necessario scrivere tutte le features (=funzionalità) oppure cercare la migliore ottimizzazione fin da subito.
Java non è affatto un linguaggio lento, anzi, ma ha dei limiti soprattutto con la gestione della memoria. Però lo stesso Java mette a disposizione alcuni strumenti che possono aggirare questi limiti e, nello specifico, Java permette di richiamare dai suoi metodi, codice nativo, qundi compilato con un linguaggio come il "C".
Questi strumenti sono due; la Java Native Interface (JNI) e la Foreign Function and Memory (FFM) che utilizzeremo nei prossimi capitoli.
Tornando agli algoritmi di estrazione e conteggio dei numeri primi, la seguente tabella elenca quelli che andremo ad implementare in questo tutorial:
| Codice | Classe | Descrizione algoritmo |
|---|---|---|
| DL | DivisionLoop | per tentativi di divisione |
| C1 | Crivello | Crivello di Eratostene |
| C2 | Crivello2 | Crivello di Eratostene migliorato |
| C3 | Crivello3 | Crivello ulteriormente migliorato |
| JNI | CrivelloJNI | Crivello in JNI, estrazione a singolo bit |
| JNIby | CrivelloJNIby | Crivello in JNI, estrazione a byte |
| JNIar | CrivelloJNIar | Crivello in JNI, estrazione in una array |
| JNIcb | CrivelloJNIcb | Crivello in JNI, estrazione con callback |
| FFM | CrivelloFFM | Crivello in FFM, estrazione a singolo bit |
| FFMcb | CrivelloFFMcb | Crivello in FFM, estrazione con callback |
| FFMar | CrivelloFFMar | Crivello in FFM, estrazione con segmento nativo |
Arrivato a questo punto, il lettore non dovrebbe avere particolari problemi a leggere il sorgente della applicazione Performance.java: a parte i due metodi main e parseCmdLine che oramai già conosciamo a memoria, vi è praticamente un solo altro metodo: il metodo run:
Dopo aver ottenuto il tempo di sistema iniziale, il metodo istanzia la classe specifica per l'algoritmo desiderato con una espressione switch; in seguito, richiama il metodo che estrae la lista o il solo conteggio dei primi e, alla fine delle operazioni, ottiene il tempo di sistema finale.
La differenza tra i due tempi rappresenta il tempo impiegato nella operazione: da notare che il tempo finale viene preso prima di visualizzare eventuali risultati: anche questa operazione richiede tempo ma noi siamo interessait solo ai tempi di elaborazione, non di visualizzazione.
Compiliamo i sorgenti e procediamo a verificare i tempi sul conteggio. Fino a questo momento abbiamo sempre compilato i sorgenti spostandoci nella cartella che li conteneva ma ora che abbiamo organizzato i nostri sorgenti all'interno di un package, questo non è possibile.
Per compilare ed eseguire una applicazione Java che fà parte di un package dobbiamo tenere come directory corrente la "cartella radice" del package:
>cd javatutor1\projects\primes >javac javatutor\primes\*.java
Per cominciare eseguiremo il test con il solo conteggio dei primi, fino a 1.000.000 come già fatto in I risultati del test.
>java -ea javatutor.primes.Performance DL 1000000 Test performances of ListPrimes implementations Starting number: 2 Ending number: 1000000 Using implementation: DivisionLoop count of primes: 78498 Time elapsed: 0,5
Il tempo impiegato è lo stesso di quanto già ottenuto. Proviamo se la estrazione dei numeri primi è penalizzante:
>java -ea javatutor.primes.Performance -e DL 1000000 Test performances of ListPrimes implementations Starting number: 2 Ending number: 1000000 Using implementation: DivisionLoop count of primes: 78498 Time elapsed: 0,5
Ora vogliamo sapere quali sono i dieci numeri primi più grandi minori di un-milione:
>java -ea javatutor.primes.Performance -s10 DL 1000000 Test performances of ListPrimes implementations Starting number: 2 Ending number: 1000000 Using implementation: DivisionLoop List of the last 10 prime numbers: 999863 999883 999907 999917 999931 999953 999959 999961 999979 999983 count of primes: 78498 Time elapsed: 0,6
Abbiamo appurato che il tempo impiegato per contare i numeri primi o per estrarli è sostanzialmete lo stesso; questo indica che la implementazione della classe ArrayList in Java è davvero efficiente.
Argomento precedente - Argomento successivo - Indice Generale