Premessa
Venendo da un’esperienza
di C/C++ una delle prime cose di cui abbiamo sentito la mancanza in Java
sono state le asserzioni: comode, pratiche e… quando non ti servono te
ne puoi sempre sbarazzare inserendo negli appositi header delle direttive
del
precompilatore. Il nostro scopo era quello di riuscire a creare una situazione
analoga anche in Java rispettando però i seguenti vincoli:
-
Il codice doveva
rimanere 100% PureJava.
-
La possibilità
di gestire più livelli di debug in modo semplice e diretto, in modo
da poterli utilizzare in
maniera autonoma
gli uni dagli altri.
-
Poter disabilitare/abilitare
il debugger senza effettuare onerose ricerche all’interno del nostro codice
per commentare
tutte le chiamate di debug.
Il problema
A Java non manca
niente per poter creare (come del resto avevamo fatto nel C++) delle classi
Assert che possano ricoprire in modo abbastanza completo tale ruolo, fino
a che però non si presenta il problema di eliminarle dal codice
perché magari è stato ritenuto maturo e pronto per essere
rilasciato come release finale (su questo argomento ci sarebbe da aprire
un acceso dibattito, ma lasciamo stare). In questi casi o si abbandona
lo standard e ci si appoggia a qualche precompilatore che svolga lo stesso
ruolo come nel C (metodo da noi subito scartato, in quanto non rispettava
le linee di progetto che ci eravamo imposti), oppure ci si arma di sana
pazienza e si esegue una ricerca di tutte le chiamate alla classe Assert
rimovendole o meglio commentandole.Se poi mettiamo il caso che sia necessario
rimettere mano al nostro codice per effettuare delle modifiche (cosa del
tutto normale e frequente), ecco che dobbiamo nuovamente andare a inserire
le asserzioni eliminate precedentemente (se ci va bene dobbiamo solo de-commentarle).
Tutto questo costa fatica, ma soprattutto tempo, e si sa che nello sviluppo
di software il tempo è molto prezioso (del resto dove non lo è
?).
E’ da questa
premessa che abbiamo cominciato a cercare una soluzione che permetta di
gestire tale
situazione nella
maniera più pulita possibile.l’idea si è basata su una tecnica
accennata da Bruce Eckel nel suo libro Thinking in Java ( disponibile gratuitamente
al sito http://www.BruceEckel.com),
precisamente nel capitolo 5.
Un primo approccio
In pratica il
gioco ruota attorno a due package simmetrici (nella definizione ma non
nell’implementazione) che all’interno definiscono le stesse classi con
gli stessi metodi. Per motivi prestazionali tutti i metodi sono definiti
static in maniera tale che le chiamate siano eseguite tramite la classe
e non un’istanza di questa.Vediamo un esempio per chiarire meglio il concetto.
Definiamo due package, uno lo chiamiamo debugger e l’altro nodebugger.
Già dai due nomi si dovrebbe capire quali siano le loro intenzioni:
LISTATO
1:
package
debugger;
public
class Debug {
[...]
public
final static void print(String msg) {
System.out.println(msg);
}
[...]
}
LISTATO
2:
package
nodebugger;
public
class Debug {
[...]
public
final static void print(String msg) {
/*do nothing*/
}
[...]
}
Come si può
vedere dal listato 1 nel package debugger è definita
una classe Debug analoga a quella del package nodebugger
ma l’implementazione è diversa, anzi nel package nodebugger
il membro print(String msg) è addirittura vuoto. Questo ci
permette di utilizzare la classe debugger.Debug nel nostro codice
sorgente richiamandone il metodo print qualora ci serva per stampare
messaggi di debug, successivamente per disabilitare tale opzione basterà
modificare l’import della classe con quella nodebugger.Debug
e tutte le chiamate alla nostra print non verranno più risolte
(o meglio un certo overhead probabilmente continuerà ad esserci
in quanto la chiamata verrà comunque eseguita, ma sarà sicuramente
molto inferiore rispetto al tempo di esecuzione della stampa sullo standard
output che faceva la precedente funzione print. In pratica rimane
solo la chiamata nello stack, che forse un compilatore di bytecode intelligente
potrebbe eliminare. In ogni caso questo overhead è nella maggior
parte dei casi sicuramente trascurabile).
Logico che poi
le varie funzioni di debug non si fermano solo alla semplice stampa di
un messaggio sullo standard output/error ma ve ne saranno altre per molteplici
scopi, dalle semplici funzioni di output di valori fino ad arrivare a funzioni
più complesse per la gestione di timer, asserzioni, stack-trace,
ecc.
La regola e’
che per una funzione implementata nella classe debugger.Debug ci
sia una definizione (ma non l’implementazione) anche nella classe nodebugger.Debug
in modo da poter interscambiare tali classi. Ci sono però alcune
eccezioni, infatti davanti a un membro statico del tipo:
LISTATO
3:
package
debugger;
public
class Debug {
[…]
public
static long getFreeMemory() {
Runtime
rt = Runtime.getRuntime();
return
rt.freeMemory();
}
[…]
}
che viene
utilizzato nel nostro codice sorgente in questo modo:
LISTATO
4:
package
myprogram;
import
debugger.debug.Debug;
public
class MyClass {
[…]
public
void myMember() {
[…]
if
( allocSize > Debug.getFreeMemory() ) […]
[…]
}
[…]
}
viene spontaneo
chiedersi cosa succederebbe se si scambiasse la classe del package debugger
con quella del nodebugger che magari non implementa tale funzione,
ma restituisce un valore di default pari a zero o ancora peggio restituisce
un puntatore null nel caso il valore di ritorno sia un oggetto.Sicuramente
la soluzione presentata in questo caso non è ottimale, per cui è
necessario imporsi delle limitazioni progettuali:
-
Non produrre mai
delle situazioni di side-effect all’interno delle funzioni membro della
classe Debug, in quanto queste potrebbero creare brutte sorprese
quando viene a mancare l’implementazione che le produce durante lo scambio
dei due package.
-
Cercare il più
possibile di creare funzioni membro statiche di Debug che restituiscano
void (cioè niente), per ovviare al caso dell’esempio nel
listato 4.
-
Quando non si può
rispettare la seconda raccomandazione riportare, oltre che alla definizione,
anche la stessa implementazione del metodo della classe del package debugger
nel package nodebugger (anche se purtroppo in questo caso non si
ha alcun beneficio dal lato delle prestazioni): in questo modo si è
sicuri che anche scambiando i package la nostra funzione getFreeMemory
del precedente esempio si comporti sempre nello stesso modo.
Seguendo queste
raccomandazioni si dovrebbe riuscire a ovviare ai problemi derivanti dal
side-effect (che comunque rimane sempre una tecnica per la quale è
sempre meglio valutare soluzioni alternative).
Affiniamo
la tecnica
Per perfezionare
ancora meglio quanto detto sopra, ma soprattutto per capitalizzare meglio
il codice che andremo a scrivere si potrebbero definire altre due raccomandazioni:
Prima di utilizzare
le classi di debug, definire un’ulteriore classe di supporto che semplicemente
eredita dalla classe Debug originale:
LISTATO 5:
package myprogram.debug;
public class MyDebug extends
debugger.debug.Debug {/**/}
Analogamente fare la stessa cosa
per la classe Debug del package nodebug:
LISTATO 6:
package myprogram.nodebug;
public class MyDebug extends
debugger.nodebug.Debug { /**/}
Questo ci
permetterà di disabilitare tutti i debug della nostra applicazione
semplicemente cambiando la classe genitore della myprogram.debug.MyDebug
con la classe debugger.nodebug.Debug.Stessa cosa vale per la classe
myprogram.nodebug.MyDebug che potrà invece attivare tutti
i debug attualmente disattivati.
Utilizzare, poi,
all’interno delle proprie classi sempre una estensione delle MyDebug in
modo tale da avere
più livelli
di debug indipendenti (o quasi) l’uno dall’altro. In questo modo si potranno
attivare dei livelli piuttosto che altri ed avere un output di debug più
chiaro da capire. Per esempio:
LISTATO
7:
package
myprogram.main;
//Definisco
dei livelli di debug
class
DebugLevel1 extends myprogram.debug.MyDebug { /**/ }
//attualmente
attivo
class
DebugLevel2 extends myprogram.nodebug.MyDebug { **/}
//attualmente
disattivo
public
class MyClass {
MyClass()
{}
public
void myMember1() {
DebugLevel1.prt(“Precondizione
della funzione
membro
myMember1():”+data);
[…]
}
public
void myMember2() {
DebugLevel2.prt(“Precondizione
della
funzione
membro myMember2(): ”+data);
[…]
}
[…]
}
Finalmente
il package debugger
Mantenendo le
linee di progetto fin qui definite si può passare all’implementazione
dei vari package che formeranno il Debugger. Teniamo a precisare che quella
qui riportata è una soluzione di base nata sul campo ed è
attualmente utilizzata in diversi programmi Java da noi sviluppati, ma
nonostante tutto può comunque essere ampliata e personalizzata secondo
le varie esigenze.
Il package principale
debugger è suddiviso in ulteriori quattro package: assertion,
debug, noassertion, nodebug. Il motivo della scelta
di separare le asserzioni dal normale debug è dovuto soprattutto
al peso che noi diamo all’interno del nostro codice a queste. Infatti molto
spesso ci capita di voler disabilitare ogni stampa/informazione di debug
ma lasciare comunque attive le chiamate alla classe Assert, magari
anche nel codice finale che viene rilasciato. Questa scelta ci facilita
le cose in quanto la gestione dei quattro package (simmetrici due a due)
è completamente indipendente l’uno dall’altro.
Passiamo ad
analizzare le singole classi, premettendo però che per approfondire
ulteriormente si può sempre far riferimento alla relativa documentazione
html dell’intero package (nonché ai sorgenti java).
Le asserzioni
All’interno
del percorso debugger.assertion e debugger.noassertion troviamo
le due classi ‘gemelle’ Assert, uguali nella loro definizione ma
non nella loro implementazione. Infatti la classe appartenente al package
debugger.noassertion non implementa nessuna delle cinque funzione
membro. (Queste classi, proprio per il loro ruolo all’interno del package,
vengono spesso definite da noi come classi ‘fake’).I metodi principali
di Assert sono isTrue(boolean) / isTrue(boolean,
String) che lanciano un’assert-fail (in pratica eseguono il metodo
prtErr) nel qual caso l’espressione booleana non sia vera. Il parametro
stringa della seconda funzione serve solo come label per riuscire ad identificarla
meglio in caso questa fallisca. I corrispettivi metodi isFalse(boolean)
/ isFalse(boolean, String) funzionano analogamente,
ma al contrario dei precedenti, lanciano l’assert-fail se l’espressione
booleana è vera.La funzione prtErr(String), che tra
l’altro è definita private, si occupa di inviare sullo standard
error i dati dell’asserzione fallita e una stampa dello stak-trace per
riuscire ad identificare meglio dove questa e’ stata lanciata. Inoltre
per avvisare ulteriormente del fallimento viene inviato un segnale acustico
allo speaker del PC.Si e’ discusso molto sul fatto che i metodi isTrue
e isFalse dovessero o meno lanciare un’eccezione in modo da obbligare
il relativo catch ed avere una gestione al livello superiore dell’asserzione
fallita. Alla fine si è arrivati alla conclusione che la cosa avrebbe
complicato maggiormente la gestione (soprattutto quella della così
detta classe ‘fake’) ed i benefici, almeno nel nostro caso, non erano così
meritevoli. Comunque se si ritiene indispensabile questa caratteristica
si può sempre aggiungere ulteriori metodi che la implementino.
Il Debug
I restanti due
package debugger.debug e debugger.nodebug contengono rispettivamente
la classe Debug. I metodi implementati sono di vario tipo a seconda
della gestione che si vuole fare: si va dalla classica formattazione dei
dati da inviare nello standard output (si vedano i vari metodi prt
e prtValue) alla gestione più complessa dei timer per cronometrare
alcune parti del codice. Inoltre è stato inserito un metodo isEnable()
che ritorna true nel caso della classe debugger.debug.Debug e false
nel caso della corrispettiva classe ‘fake’ debugger.nodebug.Debug,
in modo da poter conoscere se l’attuale classe statica a cui si fa riferimento
ha il debugger attivo oppure no.
E’ stata aggiunta
anche la possibilità di avere un salvataggio su file di tutti i
dati inviati allo standard output/error tramite una specie di log (attenzione
questo non è da confondere con la gestione dei log, che è
tutta un’altra cosa). La peculiarità di quest’ultima caratteristica
è che il log è attivo/disattivo allo stesso momento per tutte
le classi che ereditano da debugger.debug.Debug. Questo vuol dire
che se io ho due classi DebugLevel1 e DebugLevel2 che entrambi derivano
da tale classe (debug attivo per tutti e due i livelli) e attivo il log
sul DebugLevel1 lo attiverò indirettamente anche su il DebugLevel2.
Per finire ci
sono anche alcuni metodi che ritornano un valore (per esempio getFreeMemory()
e getTotalMemory(), che servono rispettivamente per avere la quantità
di memoria libera e totale disponibile per il processo corrente). Questi,
per il motivo citato nella raccomandazione n. 3, sono stati mantenuti uguali
anche nella relativa classe ‘fake’.
L'utilizzo
Vediamo un esempio
di utilizzo del package debugger:
LISTATO 8:
package
myprogram.nodebug;
public
class MyDebug extends debugger.nodebug.Debug {
/*Inserire
qui eventuali estensioni */
}
LISTATO
9:
package
myprogram.debug;
public
class MyDebug extends debugger.debug.Debug {
/*Inserire
qui eventuali estensioni */
}
LISTATO
10:
package
myprogram.noassertion;
public
class MyAssert extends debugger.noassertion.Assert {
/*Inserire
qui eventuali estensioni */
}
LISTATO
11:
package
myprogram.assertion;
public
class MyAssert extends debugger.assertion.Assert {
/*Inserire
qui eventuali estensioni */
}
LISTATO
12:
package
myprogram.main;
//Definisco
dei livelli di debug
class
DebugLevel1 extends myprogram.debug.MyDebug { /**/}
//attualmente
attivo
class
DebugLevel2 extends myprogram.nodebug.MyDebug { /**/}
//attualmente
disattivo
public
class MyClass {
//Definisco
dei livelli di asserzioni
class
AssertLevel1 extends myprogram.assertion.MyAssert { /**/ }
//attualmente
attivo
class
AssertLevel2 extends myprogram.noassertion.MyAssert { /**/ }
private
int data = 0;
MyClass()
{}
public
void myMember1() {
AssertLevel1.isTrue(data>0,
“Precondizione
della funzione membro myMember1()”);
int
val = 0;
[…]
DebugLevel1.prtValue(“val”,val);
[…]
DebugLevel1.startTimer(“MyTimer1”);
[…]
DebugLevel1.prtTimer(“MyTimer1”);
[…]
DebugLevel1.stopTimer(“MyTimer1”);
[…]
}
public
void myMember2() {
AssertLevel2.isTrue(data>0,
“Precondizione
della funzione membro myMember2()”);
[…]
DebugLevel2.prt(“Apertura
del database…”);
[…]
}
[…]
}
Conclusione
Come si può
notare con questo sistema siamo riusciti ad ottenere un debugger che risponde
alle caratteristiche che ci eravamo prefissati ed ha un utilizzo abbastanza
semplice ma soprattutto immediato. Sicuramente le migliorie che si possono
fare sono moltissime, soprattutto la personalizzazione e adattamento alle
proprie abitudini durante la stesura del codice. Per fare questo basta
ampliare le classi che ereditano da quelle base del package debugger mantenendo
sempre come riferimento le linee guida elencate poc’anzi.Per eventuali
chiarimenti, critiche e consigli contattateci direttamente via e-mail.
BUON DEBUG!
|