Implementazione della Java Virtual Mini-Machine
Descrizione del problema
In questo caso di studio realizziamo un esecutore per la Java Virtual Mini-Machine (JVMM), una versione minimale ma comunque espressiva della Java Virtual Machine. In particolare, consideriamo il seguente insieme di istruzioni
Istruzione | Prima | $\to$ | Dopo | Descrizione |
---|---|---|---|---|
$\PUSH{v}$ | $\to$ | $v$ | Inserisce il valore $v$ sullo stack | |
$\LOAD{x}$ | $\to$ | $v$ | Legge il valore $v$ dallo slot di $x$ e lo inserisce sullo stack | |
$\STORE{x}$ | $v$ | $\to$ | Rimuove $v$ dallo stack e lo scrive nello slot di $x$ | |
$\OP{f}$ | $w,v$ | $\to$ | $f(v,w)$ | Rimuove $w$ e poi $v$ dallo stack e inserisce $f(v,w)$ sullo stack |
$\IF{\mathcal{R}}\ell$ | $w,v$ | $\to$ | Rimuove $w$ e poi $v$ dallo stack e salta a $\ell$ se $(v, w) \in \mathcal{R}$ | |
$\RETURN$ | $v$ | $\to$ | Rimuove $v$ dallo stack e termina l’esecuzione con risultato $v$ |
in cui $v$ e $w$ rappresentano valori della JVMM che limitiamo ai numeri interi, $x$ rappresenta il nome di una variabile locale, $f$ rappresenta una funzione binaria su valori (somma, sottrazione, moltiplicazione, ecc.) ed $\mathcal{R}$ rappresenta una relazione binaria tra valori (uguale a, minore di, ecc.). Per ogni istruzione, la tabella mostra gli eventuali argomenti, i valori che l’istruzione si aspetta di trovare in cima allo stack prima di essere eseguita (a sinistra del simbolo $\to$) ed i valori che l’istruzione inserisce sullo stack durante la sua esecuzione (a destra del simbolo $\to$). Quando più valori sono indicati, si intende che quello più a sinistra è quello in cima allo stack.
Ad esempio, il programma
\[\begin{array}{rl} & \LOAD{x} \\ & \LOAD{y} \\ & \IF{<}{\ell} \\ & \LOAD{x} \\ & \RETURN \\ \ell: & \LOAD{y} \\ & \RETURN \end{array}\]calcola il massimo tra i valori memorizzati nelle variabili $x$ ed $y$.
Definizione delle strutture fondamentali
Nella JVMM vi sono tre strutture fondamentali:
- lo stack, usato come contenitore di dimensione variabile per valori temporanei, dal quale le istruzioni rimuovono valori e sul quale le istruzioni inseriscono risultati;
- il frame, definito come una collezione finita di slot, uno per ogni variabile usata dal programma. Uno slot è una cella di memoria contenente il valore della variabile corrispondente;
- il codice, ovvero la sequenza di istruzioni che compongono il programma da eseguire.
Implementare la JVMM significa stabilire e definire opportune rappresentazioni per queste strutture. Iniziamo definendo alcuni alias di tipo per valori, variabili, stack e frame:
type Value = Int
type Var = Int
type Stack = [Value]
type Frame = [Value]
In questa versione della JVMM gli unici valori sono numeri interi,
dunque il tipo Value
non è altro che un nome alternativo (e più
significativo) del tipo Int
. Come nella JVM, si accede alle
variabili memorizzate in un frame attraverso il loro indice, per cui
definiamo Var
come alias per Int
. Infine, possiamo usare le
liste come comoda rappresentazine per stack e frame della JVMM, che
non sono altro che collezioni di valori.
Per quanto riguarda lo stack, usiamo del costruttore :
per
modellare l’inserimento di un valore in cima allo stack ed il
pattern matching per rimuovere uno o più valori dallo stack. Ad
esempio, la lista
[2, 3]
rappresenta lo stack con il valore 2 in cima ed il valore 3 subito sotto.
Per quanto riguarda il frame, stabiliamo che lo slot per la
variabile con indice $x$ è l’$x$-esimo elemento della lista. Dunque,
la lista di cui sopra rappresenta un frame con due variabili, quella
con indice 0 ha valore 2 e quella con indice 1 ha valore 3. Per
leggere e scrivere il valore di una variabile definiamo due funzione
ausiliarie load
e store
come segue.
load :: Var -> Frame -> Value
load _ [] = 0
load 0 (v : _) = v
load n (_ : vs) = load (n - 1) vs
store :: Var -> Value -> Frame -> Frame
store 0 v [] = [v]
store 0 v (_ : vs) = v : vs
store n v [] = 0 : store (n - 1) v []
store n v (w : vs) = w : store (n - 1) v vs
La funzione load
, applicata a una variabile $x$ e a un frame $f$,
scorre $f$ fino a individuare lo slot in posizione $x$. Se $x$
eccede la lunghezza del frame, viene restituito il valore di default
0 (modelliamo così il fatto che ogni variabile “nuova” è
implicitamente inizializzata con 0). La funzione store
, applicata
a una variabile $x$, a un valore $v$ e a un frame $f$, produce un
nuovo frame che ha la stessa struttura di $f$ ma in cui la variabile
con indice $x$ ha valore $v$. Come per load
, realizziamo store
in modo da estendere opportunamente il frame qualora si scriva per
la prima volta una nuova variabile.
La rappresentazione più naturale del bytecode è come lista di istruzioni:
type Code = [Instruction]
Per quanto riguarda le istruzioni, ci troviamo di fronte a entità
che possono essere di tipologia differente (PUSH
, LOAD
,
STORE
, ecc.) e tali per cui ogni tipologia può avere zero o
più argomenti. Ad esempio, l’istruzione PUSH
ha come argomento
un valore, sia LOAD
che STORE
hanno come argomento il nome
di una variabile, e RETURN
non ha argomenti. Queste osservazioni
suggeriscono che per rappresentare una singola istruzione della JVMM
è comodo definire un nuovo tipo di dato algebrico con tanti
costruttori quante sono le istruzioni da rappresentare. Ogni
costruttore sarà dotato di tanti argomenti quanti sono gli argomenti
dell’istruzione corrispondente. In codice Haskell abbiamo:
data Instruction
= PUSH Value
| LOAD Var
| STORE Var
| OP (Value -> Value -> Value)
| IF (Value -> Value -> Bool) Code
| RETURN
Da notare che i costruttori OP
e IF
hanno un argomento di tipo
funzione che rappresenta l’operazione binaria (nel caso di OP
) o
la relazione (nel caso di IF
). In particolare, modelliamo una
relazione binaria come una funzione a due argomenti e codominio
Bool
.
Realizzazione dell’esecutore
Modelliamo l’esecutore della JVMM come una funzione run
che,
applicata a un frammento di codice e a un frame, restituisce il
risultato dell’esecuzione, ovvero il valore in cima alla pila al
momento dell’esecuzione dell’istruzione RETURN
. Definiamo run
come specializzazione di una funzione ausiliaria aux
che ha come
argomenti, oltre al codice da eseguire e al frame, anche lo stato
corrente dello stack.
run :: Code -> Frame -> Value
run = aux []
where
aux :: Stack -> Code -> Frame -> Value
aux (v : []) (RETURN : _) _ = v
aux vs (PUSH v : is) fr = aux (v : vs) is fr
aux vs (LOAD x : is) fr = aux (load x fr : vs) is fr
aux (v : vs) (STORE x : is) fr = aux vs is (store x v fr)
aux (w : v : vs) (OP f : is) fr = aux (f v w : vs) is fr
aux (w : v : vs) (IF p is : _) fr | p v w = aux vs is fr
aux (_ : _ : vs) (IF _ _ : is) fr = aux vs is fr
La funzione ausiliaria aux
è definita per casi sulla prima
istruzione da eseguire. Da notare l’uso simultaneo del pattern
matching sui vari argomenti di aux
in modo da mettere in evidenza
gli eventuali operandi (sullo stack) usati dall’istruzione e la
continuazione del codice dopo l’istruzione stessa.
- Nel caso di
RETURN
l’esecuzione termina restituendo il valorev
trovato in cima allo stack. Per come sono specificati i pattern, è necessario chev
sia l’unico valore sullo stack e cheRETURN
sia l’ultima istruzione del codice. - L’istruzione
PUSH
aggiorna lo stato dello stack inserendo in cima l’argomento dell’istruzione stessa. Notare l’applicazione ricorsiva diaux
in cui l’argomento che rappresenta lo stack viene opportunamente modificato. - Le istruzioni
LOAD
eSTORE
leggono e scrivono il frame, rispettivamente. - L’istruzione
OP
applica la funzionef
specificata come argomento ai primi due operandi in cima allo stack, inserendo poi il risultato sullo stack. Occorre prestare attenzione al fatto che l’operando in cima allo stack (w
) è di fatto il secondo che è stato inserito, mentre l’operando immediatamente sottostante (v
) è il primo. Dunque,f
viene applicata prima av
e poi aw
. Questo dettaglio è fondamentale per quelle operazioni non commutative come la sottrazione o la divisione. - Ci sono due casi possibili per l’istruzione
IF
, a seconda che la condizione che determina il salto sia verificata oppure no Anche qui occorre mettere in evidenza i due operandi in cima allo stack e applicare il predicatop
nell’ordine giusto.
A titolo di esempio riportiamo il codice di un metodo che calcola il
fattoriale del valore memorizzato nella variabile n
:
fattoriale :: Code
fattoriale = init
where
init = PUSH 1 :
STORE res :
loop
loop = LOAD n :
PUSH 0 :
IF (==) fine :
LOAD n :
LOAD res :
OP (*) :
STORE res :
LOAD n :
PUSH 1 :
OP (-) :
STORE n :
loop
fine = LOAD res :
RETURN : []
n = 0
res = 1
Il codice segue la struttura classica dell’algoritmo iterativo per
il calcolo del fattoriale. Viene usata una variabile locale res
inizializzata a 1 per memorizzare il valore parziale del prodotto $n
\times (n - 1) \times \cdots \times 2 \times 1$. Il ciclo
dell’algoritmo inizia controllando se n
è diventata 0. In tal
caso, res
contiene il risultato. Altrimenti, res
viene
aggiornato moltiplicandolo per il valore corrente di n
, n
viene
decrementato di 1 e si ripete il ciclo. Una caratteristica
interessante di questa definizione è che fa uso di liste
infinite. In particolare, si associa il nome loop
al codice
corrispondente al ciclo dell’algoritmo e, laddove occorre effettuare
un “salto” a quel codice, si scrive semplicemente tale nome.
run fattoriale [10]
Come esempio ulteriore riportiamo anche il codice di un metodo che
calcola il k
-esimo numero nella sequenza di Fibonacci:
fibonacci :: Code
fibonacci = init
where
init = PUSH 0 :
STORE m :
PUSH 1 :
STORE n :
loop
loop = LOAD k :
PUSH 0 :
IF (==) fine :
LOAD n :
LOAD n :
LOAD m :
OP (+) :
STORE n :
STORE m :
LOAD k :
PUSH 1 :
OP (-) :
STORE k :
loop
fine = LOAD m :
RETURN : []
k = 0
m = 1
n = 2
run fibonacci [10]
Esercizi
- Scrivere un frammento di bytecode per la JVMM che calcola il massimo comun divisore di due numeri $m$ ed $n$ usando l’algoritmo di Euclide.
- Estendere la JVMM con le istruzioni
DUP
,SWAP
,POP
eNOP
la cui semantica è descritta in questa pagina. Scrivere alcuni semplici frammenti di bytecode per verificare la correttezza delle estensioni realizzate. - Estendere la JVMM con una istruzione
UOP
per rappresentare operatori unari. Ad esempio, tale istruzione deve rendere possibile modellare l’istruzioneineg
della JVM. Scrivere un semplice frammento di bytecode per verificare la correttezza dell’estensione.
Le soluzioni degli esercizi sono incluse nel file JVMM.hs che contiene il codice discusso in questo caso di studio.