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 <u>mai</u> 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 { /* ... */ }
// LISTATO 6
package myprogram.nodebug;
public class MyDebug extends debugger.nodebug.Debug { /* ... */ }

Il secondo listato applica la stessa tecnica alla classe Debug del package nodebug.

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!