Introduzione
In
due precedenti articoli ([1],[2]) dedicati al rapporto tra frameworks e
design patterns abbiamo trattato il tema dei patterns come mezzo per documentare
architetture software. Partendo dall'idea che ogni framework può
essere descritto attraverso un'interazione di patterns, abbiamo analizzato
il JDK per mostrare come diventerebbe più semplice la comprensione
di alcune sue singole parti se queste venissero sistematicamente descritte
in termini di patterns.
In
questo articolo riprendiamo invece il discorso nella sua sequenza originale,
cioè non più i patterns per descrivere un'architettura esistente,
bensì i patterns come punto di partenza per decisioni riguardanti
design e implementazione.
Esempio: Automi
a stati finiti
L'esempio
che consideriamo per trattare il design nasce dalla necessità di
implementare un framework per la realizzazione di macchine a stati finiti
(finite state machines, FSM). Punto essenziale dell'architettura a framework
rispetto ad altre realizzazioni esistenti deve essere l'offerta di una
struttura di partenza con parti flessibili che ne permettano un facile
adattamento, sfruttando la tecnologia ad oggetti. Chi utilizza il framework
non deve solo poter modificare le relazioni esistenti tra i vari stati
della macchina (cosa facilmente realizzabile cambiando i dati di partenza,
cioè prevedendo un documento in input che descriva la struttura
particolare della macchina desiderata), ma deve poter adattare il meccanismo
stesso che sta alla base della realizzazione.
In
termini di framework object-oriented questo significa che l'architettura
deve mettere a disposizione parti flessibili e adattabili (i cosiddetti
hot spots) che attraverso polimorfismo ed ereditarietà permettano
la generazione di applicazioni diverse, basate sulla stessa architettura
di base, cioè sullo stesso framework.
Prima
di iniziare con la spiegazione dell'architettura vediamo alcune nozioni
di base su FSM e frameworks. Per una descrizione più dettagliata
e formale dei primi, trattati in tutti i corsi di informatica, rimando
a [3], mentre per i secondi rimando a [4] e [5].
Automi a stati
finiti
Un
automa a stati finiti è il modello matematico di una macchina in
grado di accettare un insieme di sequenze, tratte da un alfabeto predefinito.
Le implementazioni di automi a stati finiti considerano spesso unicamente
le lettere come possibile alfabeto. Il nostro framework prevede invece
la possibilità di definire come alfabeto qualsiasi insieme di oggetti.
La
rappresentazione grafica più comune degli automi è quella
del transition diagram. Questo contiene la rappresentazione degli stati
(cerchi) e le possibili transizioni (frecce) per passare da uno stato all'altro.
Il cerchio con bordo doppio rappresenta uno stato finale.
L'automa
rappresentato nella figura seguente accetta (yes/no) qualsiasi sequenza
di "0" e "1", in cui il simbolo "1" compare un numero dispari di volte.
Quando
la macchina a stati finiti non serve unicamente ad accettare una sequenza
in input, ma contemporaneamente genera anche un output, direttamente dipendente
dalla sequenza in entrata, si parla di transducer.
In
un transducer ogni transizione presenta un simbolo di input e un simbolo
di output. Un transducer può essere ad esempio utilizzato per gestire
informazioni di tipo morfolinguistico, come mostrato dalla figura che segue.
Ogni transizione presenta un valore in input e, separato da "/", un valore
in output.
Da
notare che un transducer del genere permette la lettura sia in input che
in output.
Framework
Come
già anticipato in [1], parliamo di framework quando abbiamo una
serie di classi che collaborano tra di loro formando un design riutilizzabile
per una specifica classe di software. Lo scopo di un framework è
quello di definire la struttura di base di un'applicazione, classi e oggetti,
e, soprattutto, la sequenza principale di un programma. Questi parametri
iniziali permettono al programmatore di concentrarsi su parti specifiche
della sua applicazione. Costruirà infatti l'applicazione utilizzando
classi del framework o definendo sottoclassi di classi preesistenti, adattando,
in altre parole, il framework nei suoi punti "flessibili", anche chiamati
"hot spots".
Un
framework può essere visto come un programma astratto. Solo la definizione
di alcune classi concrete permette di generare un'applicazione. Il vantaggio
consiste nel fatto che le decisioni più importanti riguardanti il
design sono già state prese, permettendo uno sviluppo più
veloce e una migliore manutenzione.
Introduciamo
qui il ruolo di sviluppatore di applicazioni inteso come programmatore
che "adatta" il framework per realizzare applicazioni specifiche.
Prima astrazione:
transizione
La
prima generalizzazione che vogliamo realizzare è quella riguardante
i possibili alfabeti, in altre parole il tipo di contenuto delle transizioni
(transitions, arcs).
Ogni
elemento di tipo Arc contiene la coppia input/output (o solo input, nel
caso di una macchina semplice, considerata quindi un tipo particolare di
transducer). Si tratta di un'astrazione "chiave", perché ogni applicazione
che utilizzi il framework come struttura portante, deve avere la possibilità
di definire suoi tipi specifici di input e output (adattamento del framework,
customization).
Per
questo tipo di astrazione utilizziamo il pattern interface/implementation,
già trovato in Swing e analizzato e spiegato in [2]. Si tratta di
un pattern molto utile nelle architetture a framework: lo useremo per un
paio di altre astrazioni "chiave". Questo pattern realizza i tre livelli
di astrazione: interfaccia, classe astratta, classi concrete.
L'interfaccia
Arc rappresenta la parte stabile del framework. Dev'essere fissa perché
una sua modifica rappresenterebbe una catena di cambiamenti e adattamenti
all'interno del framework e delle applicazioni derivate. È l'uso
di "interface" che permette il riutilizzo del design.
La
classe astratta AbstractArc rappresenta invece un livello intermedio, una
sorta di implementazione minima in comune tra tutte le classi concrete.
Mette inoltre a disposizione un'implementazione di default, permettendo
quindi allo sviluppatore che volesse realizzare la classe concreta di concentrarsi
sui metodi veramente necessari per differenziare la sua implementazione
da altre. C'è a questo livello un riutilizzo parziale sia di design
che di implementazione.
L'ultimo
livello è quello dell'implementazione vera e propria, che in un
framework è costituita da un certo numero di classi cosiddette "out
of the box", nel nostro esempio riportiamo TransArc, l'elemento arc di
un transducer, e DfaArc, l'elemento arc di una macchina a stati finiti
semplice (dfa: deterministic finite-state automaton).
Interfaccia
In
interfaccia si tratta di definire il minimo comune denominatore di operazioni
su arc utilizzate in tutto il framework.
public
interface Arc {
public Object getInput();
public Object getOutput();
public boolean equal(Arc other);
public int compareInputTo(Object otherInput);
public int compareOutputTo(Object otherOutput);
public void write(OutputFileWrapper outStream);
public Arc read(InputFileWrapper inputStream);
}
Le
operazioni di Arc servono a gestire in modo generico le relazioni tra input
e output e ad interfacciare questi elementi con il resto del framework.
La generalizzazione dei singoli elementi in Java è molto semplice,
vista la presenza della classe Object.
Livello astratto
Il
ruolo della classe astratta nel pattern interface/implementation è
quello garantire una base minima di funzionalità per lo sviluppatore
di applicazioni.
public
abstract class AbstractArc implements Arc {
protected Object input = null, output = null;
public Object getInput(){
return input;
}
public Object getOutput(){
return output;
}
public boolean equal(Arc other){
return (compareInputTo(other.getInput()) == 0) &&
(compareInputTo(other.getOutput()) == 0);
}
private int getInputHash(){
if (input != null)
return input.hashCode();
return 0;
}
private int getOutputHash(){
if (output != null)
return output.hashCode();
return 0;
}
public boolean equals(Object obj){
return (obj instanceof Arc) && equal((Arc)obj);
}
public int hashCode(){
return getInputHash() ^ getOutputHash();
}
}
Ecco
alcuni commenti su questa classe.
-
Le variabili
input e output vengono già definite a questo livello per facilitare
il compito dello sviluppatore di applicazioni.
-
Solo tre
metodi implementano i metodi definiti in interfaccia: equal(), getInput()
e getOutput().
-
Due metodi
privati sono stati aggiunti unicamente per aggiungere un controllo interno
alla classe sui valori nulli di input e output.
-
I due
ultimi metodi pubblici (equals() e hashCode()) riscrivono la funzionalità
ereditata da Object nel caso in cui si dovesse utilizzare elementi di tipo
Arc in combinazione con tabelle hash.
Anche
questi due metodi possono essere riscritti dallo sviluppatore di applicazioni.
La
reimplementazione di equals() è essenziale per garantire il controllo
di uguaglianza all'interno dell'infrastruttura di liste del JDK. Queste
utilizzano infatti equals() come operatore discriminante.
Ogni
volta che si cambia equals() bisognerebbe anche adattare hashCode(), in
modo da mantenere vera la relazione:
if
(a.equals(b))
then
(a.hashCode() == b.hashCode())
La
negazione non è vera, cioè se due oggetti non sono uguali,
questo non significa che i loro valori hashCode() siano necessariamente
differenti.
È
vera però la relazione:
if
(a.hashCode() != b.hashCode())
then
(!a.equals(b))
Il
valore hashCode() serve quindi a migliorare l'efficienza, perché
è il primo elemento analizzato in una tabella hash per verificare
l'uguaglianza, o, in altre parole, per determinare la posizione. Se due
elementi hanno codice hash diverso, sono considerati diversi.
L'implementazione
di hashCode() nella classe astratta serve a garantire una buona approssimazione,
ma può essere riscritta dallo sviluppatore di applicazioni, nel
caso trovasse una formula più adatta al suo caso concreto.
Livello concreto
Il
pattern in questione richiede che ci sia un livello concreto che rappresenti
il cosiddetto "out of the box", cioè la classe concreta pronta per
l'utilizzo.
Supponiamo
di voler realizzare come applicazione del framework un transducer che abbia
come alfabeto dei caratteri.
Un
buon utilizzo del pattern deve servire a implementare nella classe concreta
solo i metodi direttamente legati alla scelta del tipo di dati per l'alfabeto,
nel nostro caso il tipo Character. Tralasciamo le implementazioni di read()
e write(), perché non necessarie per la comprensione del design.
public
class TransArc extends AbstractArc
{
public int compareInputTo(Object otherInput){
return ((Character)input).compareTo(otherInput);
}
public int compareOutputTo(Object otherOutput){
return ((Character)output).compareTo(otherOutput);
}
public void write(OutputFileWrapper outputStream){
...
}
public Arc read(InputFileWrapper inputStream){
...
}
}
Conclusione
In
questo articolo abbiamo introdotto l'idea di framework e l'utilizzo di
patterns come punto di partenza per la definizione del design. Dopo aver
specificato il tipo di problema che vogliamo affrontare, abbiamo realizzato
una prima semplice astrazione attraverso il pattern interface/implementation.
Con questa astrazione abbiamo potuto creare una generalizzazione sul tipo
di dati utilizzato dal framework.
Nel
prossimo articolo ci concentreremo su altri due elementi adattabili: l'attraversamento
della macchina a stati finiti e l'estrazione di informazioni dalla stessa.
Bibliografia
[1]
Pedrazzini Sandro: Frameworks e Patterns: Documentare con Patterns, Moka
Byte, http://www.mokabyte.com, Febbraio 2001.
[2]
Pedrazzini Sandro: Frameworks e Patterns: A Caccia di Patterns, Moka Byte,
http://www.mokabyte.com, Marzo 2001.
[3]
Carrol John, Long Darrel: Theory of Finite Automata, Prentice-Hall, 1989.
[4]
Gamma E., Helm R.Johnson R., Vlissides J.: Design Patterns, Elements of
Reusable Object-Oriented Software, Addison Wesley, 1995.
[5]
Pedrazzini Sandro: The Jacaranda Framework, http://a.die.supsi.ch/~pedrazz/jacaranda
Sandro
Pedrazzini ha studiato ingegneria informatica al politecnico di Zurigo
e ha ottenuto il Ph.D. all'università di Basilea. Da anni si occupa
di progettazione e sviluppo object-oriented. Attualmente collabora con
l'università di Basilea in progetti di trasferimento tecnologico
e tiene corsi di design e sviluppo software presso la SUPSI, Scuola Universitaria
Professionale della Svizzera Italiana, a Lugano. |