Assembly Base con MASM
Capitolo 26: La ricorsione
Un determinato procedimento si definisce ricorsivo quando contiene un richiamo a se
stesso; la ricorsione può essere diretta o indiretta. Si parla di ricorsione diretta
o auto ricorsione, quando un procedimento richiama direttamente se stesso; si parla,
invece, di ricorsione indiretta o mutua ricorsione, quando un procedimento
A richiama un procedimento B che, a sua volta, richiama di nuovo il
procedimento A.
In generale, la ricorsione indiretta può coinvolgere n procedimenti A1, A2, A3,
..., An; in tal caso, il procedimento A1 richiama il procedimento A2
che richiama il procedimento A3 e così via, sino al procedimento An che
richiama di nuovo il procedimento A1.
In matematica troviamo numerosi esempi di procedimenti ricorsivi; in questo caso, il termine
procedimento è riferito ad un algoritmo identificato da una funzione matematica. Come già
sappiamo, una funzione matematica riceve una lista di argomenti (variabili indipendenti)
che vengono elaborati da un apposito algoritmo in modo da ottenere un risultato finale
(variabile dipendente); possiamo dire quindi che un algoritmo è ricorsivo quando la sua
definizione contiene un richiamo (diretto o indiretto) all'algoritmo stesso.
Consideriamo, ad esempio, il caso della funzione fattoriale; dato un numero intero
positivo n, si definisce fattoriale di n il prodotto dei primi n
numeri interi positivi consecutivi decrescenti, a partire da n. Simbolicamente, il
fattoriale di n si scrive:
n!
Inoltre, per convenzione si pone:
0! = 1
(per la dimostrazione si può consultare un testo di matematica).
Se, ad esempio, n=5, si ottiene:
5! = 5 * 4 * 3 * 2 * 1 = 120
Si osserva subito che:
5! = 5 * 4 * 3 * 2 * 1 = 5 * (4 * 3 * 2 * 1) = 5 * 4! = 5 * (5 - 1)!
In sostanza, si può scrivere la relazione generale:
n! = n * (n - 1)!
Questa relazione dice che il fattoriale di n si ottiene moltiplicando n per
il fattoriale di n-1; l'algoritmo appena enunciato è chiaramente ricorsivo in quanto
contiene un riferimento a se stesso.
Un algoritmo ricorsivo può essere paragonato ad una sorta di loop dal quale si esce non
appena viene raggiunta una determinata condizione (condizione di uscita); nel caso del
fattoriale di n si nota subito che la condizione di uscita è rappresentata da:
(n - 1) = 0 e cioè: n = 1
In questo caso, infatti, si ha:
(n - 1)! = (1 - 1)! = 0! = 1
Vediamo allora come si applicano questi concetti per calcolare ricorsivamente il fattoriale
del numero 5; possiamo scrivere:
A questo punto abbiamo ottenuto tutti i fattori necessari, per cui, procedendo a ritroso
si ottiene:
Un altro caso di funzione matematica definibile ricorsivamente è rappresentato
dall'elevamento a potenza intera positiva, di un numero intero positivo diverso
da 1; dati due numeri interi positivi m e n, con n diverso
da 1, si definisce potenza di ordine m del numero n, il prodotto di
m fattori tutti uguali a n.
Simbolicamente, la potenza di ordine m del numero n viene rappresentata come:
nm
Se, ad esempio, n=2 e m=4 possiamo scrivere quindi:
24 = 2 * 2 * 2 * 2 = 16
Anche in questo caso si osserva subito che:
24 = 2 * 2 * 2 * 2 = 2 * (2 * 2 * 2) = 2 * 23 = 2 * 24 - 1
Possiamo scrivere quindi la relazione generale:
nm = n * nm - 1
Questa relazione dice che la potenza di ordine m del numero n si ottiene
moltiplicando n per la potenza di ordine m-1 del numero n; anche
questo algoritmo è chiaramente ricorsivo in quanto contiene un riferimento a se stesso.
Per calcolare questa funzione in modo ricorsivo, dobbiamo determinare la condizione di
uscita dalla ricorsione; tale condizione può essere facilmente individuata osservando
che, ad esempio, si ha:
Dati allora tre numeri interi positivi n, a e b, con n diverso
da 1, possiamo scrivere quindi in generale:
Da questa formula ricaviamo subito:
In definitiva, data la formula generale:
nm = n * nm - 1
possiamo dire che la condizione di uscita dalla ricorsione è:
(m - 1) = 0 e cioè: m = 1
In questo caso, infatti, si ha:
nm - 1 = n1 - 1 = n0 = 1
Vediamo, ad esempio, come si calcola ricorsivamente la potenza con esponente 4 del
numero 2:
A questo punto abbiamo ottenuto tutti i fattori necessari, per cui, procedendo a ritroso
si ottiene:
26.1 La ricorsione nei linguaggi di programmazione
I linguaggi di programmazione che supportano la ricorsione, permettono la definizione
di sottoprogrammi ricorsivi; un sottoprogramma si definisce ricorsivo quando contiene
una chiamata, diretta o indiretta, a se stesso.
Si ha la ricorsione diretta quando un sottoprogramma chiama direttamente se stesso; si
ha, invece, la ricorsione indiretta quando un sottoprogramma A chiama un
sottoprogramma B che, a sua volta, chiama di nuovo il sottoprogramma A.
In generale, dati n sottoprogrammi indicati con A1, A2, A3,
..., An, si ha la ricorsione indiretta quando A1 chiama A2 che
chiama A3 e così via, sino ad An che chiama di nuovo A1.
Si può osservare che nel caso di ricorsione diretta, un sottoprogramma ricorsivo svolge
allo stesso tempo il ruolo di caller e di callee!
Tra i casi più importanti di linguaggi di programmazione che supportano la ricorsione
troviamo, ad esempio, il C e il Pascal; la ricorsione, invece, non viene
supportata dalle vecchie versioni del BASIC e del FORTRAN77.
In Figura 26.1 vediamo l'implementazione non ricorsiva di un sottoprogramma che calcola il
fattoriale di un numero n.
Figura 26.1 - Calcolo non ricorsivo di n!
|
|
In Figura 26.2 vediamo, invece, l'implementazione ricorsiva di un sottoprogramma che
calcola il fattoriale di un numero n.
Figura 26.2 - Calcolo ricorsivo di n!
|
|
Nelle architetture a 16 bit, il tipo unsigned long del C
rappresenta un intero senza segno a 32 bit compreso quindi tra 0 e
4294967295; il Pascal non dispone di un tale tipo di dato, per cui
al suo posto si può utilizzare il Longint (intero con segno a 32
bit) che ci permette, però, di gestire interi positivi compresi tra 0 e
+2147483647.
Nel calcolo del fattoriale di un numero n bisogna tenere presente che anche
per valori di n relativamente piccoli, si possono ottenere come risultato numeri
piuttosto grandi. Ad esempio, già 9! supera abbondantemente il valore massimo
(65535) rappresentabile con 16 bit; analogamente, 13! supera il
valore massimo (4294967295) rappresentabile con 32 bit!
In Figura 26.3 vediamo l'implementazione non ricorsiva di un sottoprogramma che calcola
la potenza con esponente m di un numero n.
Figura 26.3 - Calcolo non ricorsivo di nm
|
|
In Figura 26.4 vediamo, invece, l'implementazione ricorsiva di un sottoprogramma che calcola
la potenza con esponente m di un numero n.
Figura 26.4 - Calcolo ricorsivo di nm
|
|
In generale, si nota subito che la versione ricorsiva di un generico algoritmo è più
compatta e più comprensibile della versione non ricorsiva; si nota, inoltre, che
l'implementazione pratica di un algoritmo ricorsivo rispecchia fedelmente la
formulazione matematica dell'algoritmo stesso.
26.2 La ricorsione in Assembly
Analizzando la Figura 26.2 e la Figura 26.4 si può avere l'impressione che la
ricorsione sia una sorta di fenomeno "paranormale"; questa impressione è legata
al fatto che un sottoprogramma ricorsivo sembra in grado di fornirci "magicamente"
il risultato esatto dopo aver chiamato ripetutamente se stesso e senza aver
effettuato, apparentemente, alcun calcolo!
In realtà, i fenomeni paranormali e le magie, ovviamente, non centrano niente; vediamo
allora con l'ausilio dell'Assembly, come può essere svelato il "mistero" della
ricorsione.
Supponiamo di voler implementare una procedura ricorsiva dotata di argomenti, variabili
locali e valore di ritorno; il problema fondamentale che dobbiamo affrontare è legato
al metodo da seguire per garantire che, ogni volta che la procedura chiama se stessa,
vengano create nuove copie degli argomenti e delle variabili locali che non vadano a
sovrascrivere le copie precedentemente inserite nello stack.
Per chiarire questo aspetto, vediamo un esempio pratico in Assembly, relativo
al calcolo ricorsivo del fattoriale di un numero n; scriviamo una procedura
fatt che riceve in BX un numero intero senza segno a 16 bit e
restituisce in EAX il risultato a 32 bit.
In base a quanto detto in precedenza, per ottenere un risultato valido in EAX,
dobbiamo inserire in BX valori compresi tra 0 e 12; affinché i
calcoli si svolgano in modo corretto, prima di chiamare la procedura fatt è
necessario azzerare i 16 bit più significativi di EBX. La prima versione
di questa procedura, ispirata all'esempio di Figura 26.2, è la seguente:
Sulla sinistra notiamo che per ogni istruzione è presente il relativo offset e il
codice macchina prodotto dall'assembler; da queste informazioni si ricava che la
procedura fatt viene definita all'offset 0064h di un segmento di codice.
Analizziamo in dettaglio quello che succede nel caso in cui fatt venga chiamata
con (E)BX=3; in questo caso, fatt dovrebbe restituirci EAX=6. Per
rendere più chiara la spiegazione viene mostrato, passo dopo passo, lo stato dello
stack del programma; le condizioni iniziali sono rappresentate da SP=0200h e
si assume, inoltre, che l'indirizzo di ritorno sia 0031h.
La CPU incontra nel programma principale una istruzione di chiamata NEAR
a fatt ed esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=019Eh
- salva l'indirizzo di ritorno 0031h in SS:SP=SS:019Eh
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati in fatt abbiamo quindi: SP=019Eh e (E)BX=3, mentre
in EAX viene caricato il valore 1; siccome BX è diverso da zero,
la CPU salta all'istruzione DEC ed esegue i seguenti passi:
- sottrae uno a BX e ottiene BX=2
- sottrae due byte a SP e ottiene SP=019Ch
- salva l'indirizzo di ritorno 0072h in SS:SP=SS:019Ch
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati ricorsivamente in fatt abbiamo: SP=019Ch e (E)BX=2,
mentre in EAX viene caricato il valore 1; siccome BX è diverso da
zero, la CPU salta all'istruzione DEC ed esegue i seguenti passi:
- sottrae uno a BX e ottiene BX=1
- sottrae due byte a SP e ottiene SP=019Ah
- salva l'indirizzo di ritorno 0072h in SS:SP=SS:019Ah
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati ricorsivamente in fatt abbiamo: SP=019Ah e (E)BX=1,
mentre in EAX viene caricato il valore 1; siccome BX è diverso da
zero, la CPU salta all'istruzione DEC ed esegue i seguenti passi:
- sottrae uno a BX e ottiene BX=0
- sottrae due byte a SP e ottiene SP=0198h
- salva l'indirizzo di ritorno 0072h in SS:SP=SS:0198h
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati ricorsivamente in fatt abbiamo: SP=0198h e (E)BX=0,
mentre in EAX viene caricato il valore 1; siccome BX vale zero, la
CPU salta all'etichetta exit_fatt e, incontrando l'istruzione RET, di
tipo NEAR, esegue i seguenti passi:
- estrae da SP=0198h l'indirizzo di ritorno 0072h e lo carica in IP
- somma due byte a SP e ottiene SP=019Ah
- salta a CS:IP=CS:0072h con valore di ritorno EAX=1
All'indirizzo CS:0072h troviamo l'istruzione MUL e in quel momento abbiamo:
SP=019Ah, EAX=1 e (E)BX=0; l'istruzione MUL moltiplica
EAX=1 per EBX=0 e ottiene EDX:EAX=0. Subito dopo la CPU
incontra l'istruzione RET di tipo NEAR ed esegue i seguenti passi:
- estrae da SP=019Ah l'indirizzo di ritorno 0072h e lo carica in IP
- somma due byte a SP e ottiene SP=019Ch
- salta a CS:IP=CS:0072h con valore di ritorno EAX=0
All'indirizzo CS:0072h troviamo l'istruzione MUL e in quel momento abbiamo:
SP=019Ch, EAX=0 e (E)BX=0; l'istruzione MUL moltiplica
EAX=0 per EBX=0 e ottiene EDX:EAX=0. Subito dopo la CPU
incontra l'istruzione RET di tipo NEAR ed esegue i seguenti passi:
- estrae da SP=019Ch l'indirizzo di ritorno 0072h e lo carica in IP
- somma due byte a SP e ottiene SP=019Eh
- salta a CS:IP=CS:0072h con valore di ritorno EAX=0
All'indirizzo CS:0072h troviamo l'istruzione MUL e in quel momento abbiamo:
SP=019Eh, EAX=0 e (E)BX=0; l'istruzione MUL moltiplica
EAX=0 per EBX=0 e ottiene EDX:EAX=0. Subito dopo la CPU
incontra l'istruzione RET di tipo NEAR ed esegue i seguenti passi:
- estrae da SP=019Eh l'indirizzo di ritorno 0031h e lo carica in IP
- somma due byte a SP e ottiene SP=0200h
- salta a CS:IP=CS:0031h con valore di ritorno EAX=0
Il programma principale riottiene il controllo e trova in EAX il valore zero che,
chiaramente, non rappresenta il risultato corretto; ripetendo l'esperimento con altri
valori passati a fatt nel registro (E)BX (escluso 0), si ottiene
sempre EAX=0.
Come si spiega questa situazione?
Analizzando il meccanismo ricorsivo appena descritto, si intuisce subito che il problema
risiede nel fatto che ad ogni chiamata ricorsiva di fatt, il registro BX
viene decrementato di uno senza che sia preservato, però, il suo contenuto originario;
dopo l'ultima chiamata ricorsiva, BX ormai vale zero, per cui la moltiplicazione
produce un risultato nullo!
Tutte le successive moltiplicazioni tra EAX e EBX producono di conseguenza
un risultato nullo in quanto il fattore EBX vale zero; dopo l'ultima moltiplicazione,
il controllo torna al programma principale che trova EAX=0.
La soluzione a questo problema ci viene suggerita dal meccanismo appena descritto, che
permette alla CPU di gestire correttamente nello stack gli indirizzi di ritorno
relativi alle varie chiamate ricorsive di fatt; grazie a questo meccanismo, i
vari indirizzi di ritorno vanno ad occupare sempre posizioni differenti nello stack,
evitando così di sovrapporsi l'uno all'altro.
Possiamo utilizzare allora questa stessa tecnica per gestire nello stack la sequenza di
valori che assume BX nel corso della ricorsione; in questo modo, possiamo evitare
che le modifiche apportate a BX ci facciano perdere il contenuto originale.
In base alle considerazioni appena esposte, possiamo dire che la procedura fatt,
subito dopo l'istruzione JZ, deve svolgere i seguenti passi:
- salvare il contenuto corrente di BX
- decrementare il contenuto corrente di BX
- chiamare ricorsivamente fatt
- ripristinare il vecchio contenuto di BX
In seguito a queste modifiche, la procedura fatt assume il seguente aspetto:
Proviamo a ripetere l'esperimento e vediamo cosa succede chiamando fatt con
(E)BX=3; come al solito, supponiamo che la situazione iniziale dello stack sia
SP=0200h e che l'indirizzo di ritorno al caller sia 0031h.
La CPU incontra nel programma principale una istruzione di chiamata NEAR
a fatt ed esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=019Eh
- salva l'indirizzo di ritorno 0031h in SS:SP=SS:019Eh
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati in fatt abbiamo quindi: SP=019Eh e (E)BX=3, mentre
in EAX viene caricato il valore 1; siccome BX è diverso da zero,
la CPU salta all'istruzione PUSH ed esegue i seguenti passi:
- sottrae due byte a SP e ottiene SP=019Ch
- salva il valore 3 di BX in SS:SP=SS:019Ch
- sottrae uno a BX e ottiene BX=2
- sottrae due byte a SP e ottiene SP=019Ah
- salva l'indirizzo di ritorno 0073h in SS:SP=SS:019Ah
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati ricorsivamente in fatt abbiamo: SP=019Ah e (E)BX=2,
mentre in EAX viene caricato il valore 1; siccome BX è diverso da
zero, la CPU salta all'istruzione PUSH ed esegue i seguenti passi:
- sottrae due byte a SP e ottiene SP=0198h
- salva il valore 2 di BX in SS:SP=SS:0198h
- sottrae uno a BX e ottiene BX=1
- sottrae due byte a SP e ottiene SP=0196h
- salva l'indirizzo di ritorno 0073h in SS:SP=SS:0196h
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati ricorsivamente in fatt abbiamo: SP=0196h e (E)BX=1,
mentre in EAX viene caricato il valore 1; siccome BX è diverso da
zero, la CPU salta all'istruzione PUSH ed esegue i seguenti passi:
- sottrae due byte a SP e ottiene SP=0194h
- salva il valore 1 di BX in SS:SP=SS:0194h
- sottrae uno a BX e ottiene BX=0
- sottrae due byte a SP e ottiene SP=0192h
- salva l'indirizzo di ritorno 0073h in SS:SP=SS:0192h
- carica in IP l'offset 0064h di fatt e salta a CS:IP=CS:0064h
Appena entrati ricorsivamente in fatt abbiamo: SP=0192h e (E)BX=0,
mentre in EAX viene caricato il valore 1; siccome BX vale zero, la
CPU salta all'etichetta exit_fatt e incontrando l'istruzione RET
di tipo NEAR, esegue i seguenti passi:
- estrae da SP=0192h l'indirizzo di ritorno 0073h e lo carica in IP
- somma due byte a SP e ottiene SP=0194h
- salta a CS:IP=CS:0073h con valore di ritorno EAX=1
All'indirizzo CS:0073h troviamo l'istruzione POP e in quel momento abbiamo:
SP=0194h, EAX=1 e (E)BX=0; la CPU esegue quindi i seguenti
passi:
- estrae da SP=0194h il valore 0001h e lo carica in BX
- somma due byte a SP e ottiene SP=0196h
- moltiplica EAX=1 per EBX=1 e ottiene EDX:EAX=1
Subito dopo la CPU incontra un'istruzione RET di tipo NEAR ed esegue
i seguenti passi:
- estrae da SP=0196h l'indirizzo di ritorno 0073h e lo carica in IP
- somma due byte a SP e ottiene SP=0198h
- salta a CS:IP=CS:0073h con valore di ritorno EAX=1
All'indirizzo CS:0073h troviamo l'istruzione POP e in quel momento abbiamo:
SP=0198h, EAX=1 e (E)BX=1; la CPU esegue quindi i seguenti
passi:
- estrae da SP=0198h il valore 0002h e lo carica in BX
- somma due byte a SP e ottiene SP=019Ah
- moltiplica EAX=1 per EBX=2 e ottiene EDX:EAX=2
Subito dopo la CPU incontra un'istruzione RET di tipo NEAR ed esegue
i seguenti passi:
- estrae da SP=019Ah l'indirizzo di ritorno 0073h e lo carica in IP
- somma due byte a SP e ottiene SP=019Ch
- salta a CS:IP=CS:0073h con valore di ritorno EAX=2
All'indirizzo CS:0073h troviamo l'istruzione POP e in quel momento abbiamo:
SP=019Ch, EAX=2 e (E)BX=2; la CPU esegue quindi i seguenti
passi:
- estrae da SP=019Ch il valore 0003h e lo carica in BX
- somma due byte a SP e ottiene SP=019Eh
- moltiplica EAX=2 per EBX=3 e ottiene EDX:EAX=6
Subito dopo la CPU incontra un'istruzione RET di tipo NEAR ed esegue
i seguenti passi:
- estrae da SP=019Eh l'indirizzo di ritorno 0031h e lo carica in IP
- somma due byte a SP e ottiene SP=0200h
- salta a CS:IP=CS:0031h con valore di ritorno EAX=6
Questa volta il programma principale riottiene il controllo e trova in EAX il
valore 6 che è, ovviamente, il risultato corretto; si può constatare che fatt
calcola correttamente il fattoriale di tutti i numeri compresi tra 0 e 12.
Grazie all'uso dello stack, la procedura fatt può preservare il contenuto di
BX prima di decrementarlo e passarlo ricorsivamente a se stessa; ad ogni chiamata
ricorsiva, la procedura fatt gestisce un valore di BX indipendente dal
valore relativo alle altre chiamate.
Il metodo appena illustrato, utilizza i registri per il passaggio degli argomenti alla
procedura fatt; come già sappiamo, si tratta di un metodo che permette di ottenere
una notevole efficienza. L'aspetto negativo è rappresentato dal fatto che in questo modo
stiamo impegnando diversi registri che potrebbero servirci per altri scopi; la situazione
poi tende a complicarsi quando utilizziamo ulteriori registri per definire anche variabili
locali all'interno di una procedura.
Per ovviare a tutti questi inconvenienti, possiamo sfruttare i concetti esposti nel
precedente capitolo in relazione alle convenzioni adottate dai linguaggi di alto livello
per il passaggio degli argomenti alle procedure, per la creazione delle variabili locali
e per i valori di ritorno; vediamo allora come possiamo implementare la procedura
fatt seguendo le convenzioni dei compilatori C e Pascal.
La procedura fatt, in versione C, assume l'aspetto seguente:
Il programma principale prima di chiamare fatt deve inserire nello stack un
valore compreso tra 0 e 12, del quale vogliamo calcolare il fattoriale;
subito dopo avviene la chiamata di fatt. All'interno di fatt, il
parametro n viene letto dallo stack all'indirizzo SS:BP+4; questo perché
fatt è una procedura di tipo NEAR.
Prima di chiamare ricorsivamente fatt dobbiamo inserire nello stack una copia
dell'argomento n decrementato di 1; a tale proposito viene utilizzato
il registro AX per evitare di modificare l'argomento n originale presente
nello stack.
Subito dopo la chiamata ricorsiva di fatt dobbiamo ripulire lo stack sommando
2 byte a SP; ricordiamo, infatti, che secondo la convenzione C
il caller è tenuto a ripulire lo stack dagli argomenti passati ad una procedura. Questo
stesso lavoro deve essere effettuato dal programma principale una volta che ha riottenuto
il controllo.
La procedura fatt in versione Pascal assume l'aspetto seguente:
L'unica differenza rispetto alla versione C è rappresentata dal fatto che secondo la
convenzione Pascal è compito del callee ripulire lo stack dagli argomenti passati ad
una procedura; a tale proposito, la procedura fatt utilizza l'istruzione RET
con l'operando immediato 2 che somma 2 byte a SP prima di restituire il
controllo al caller.
Se vogliamo interfacciare queste procedure con i compilatori C e Pascal a
16 bit, dobbiamo ricordarci che i valori di ritorno a 32 bit devono essere
restituiti in DX:AX e non in EAX; a tale proposito possiamo utilizzare
l'istruzione:
shld edx, eax, 16
Come è stato spiegato in un precedente capitolo, questa istruzione fa scorrere di 16
posti verso sinistra i bit di EDX; i 16 posti rimasti liberi a destra vengono
riempiti con i 16 bit più significativi di EAX, mentre il contenuto di
EAX rimane inalterato. Attraverso questa istruzione, il contenuto di EAX
viene copiato in DX:AX; nel rispetto della convenzione little endian, il
registro DX contiene la WORD più significativa di EAX. Ulteriori
dettagli su questi argomenti vengono illustrati nei capitoli dedicati all'interfacciamento
tra l'Assembly e i linguaggi di alto livello.
Analizziamo in dettaglio un ulteriore esempio rappresentato dalla versione Pascal
di una procedura chiamata Potenza, che calcola ricorsivamente la potenza di ordine
m di un numero n; questa procedura assume il seguente aspetto:
Vediamo ora quello che succede quando il programma principale chiama Potenza con
argomenti base=2 e esponente=3; se la procedura non contiene errori,
dovrebbe restituirci in EAX il valore 23=8.
Supponiamo che le condizioni iniziali dello stack siano rappresentate da SP=0200h
e che l'indirizzo di ritorno al caller sia 0033h.
La CPU incontra nel programma principale la seguente sequenza di istruzioni:
ed esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=019Eh
- salva il valore 2 (copia di base) in SS:SP=SS:019Eh
- sottrae due byte a SP e ottiene SP=019Ch
- salva il valore 3 (copia di esponente) in SS:SP=SS:019Ch
- sottrae due byte a SP e ottiene SP=019Ah
- salva l'indirizzo di ritorno 0033h in SS:SP=SS:019Ah
- carica in IP l'offset 007Ch di Potenza e salta a
CS:IP=CS:007Ch
Appena entrati in Potenza, incontriamo l'istruzione PUSH bp che preserva
nello stack il contenuto originario di BP (che indichiamo con oldBP1); la
CPU esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=0198h
- salva il valore oldBP1 in SS:SP=SS:0198h
Subito dopo, il contenuto (0198h) di SP viene copiato in BP, per
cui possiamo dire che il parametro n=2 si trova in SS:BP+6, il parametro
m=3 si trova in SS:BP+4, l'indirizzo di ritorno 0033h si trova in
SS:BP+2, mentre oldBP1 si trova in SS:BP.
A questo punto abbiamo quindi: SP=0198h, n=2 e m=3, mentre in
EAX viene caricato il valore 1; siccome m è diverso da zero, la
CPU salta all'istruzione PUSH n ed esegue i seguenti passi:
- sottrae due byte a SP e ottiene SP=0196h
- salva il valore 2 (copia di n) in SS:SP=SS:0196h
- decrementa la copia AX di m e ottiene AX=2
- sottrae due byte a SP e ottiene SP=0194h
- salva il valore AX=2 in SS:SP=SS:0194h
- sottrae due byte a SP e ottiene SP=0192h
- salva l'indirizzo di ritorno 0096h in SS:SP=SS:0192h
- carica in IP l'offset 007Ch di Potenza e salta a
CS:IP=CS:007Ch
Appena entrati ricorsivamente in Potenza, incontriamo l'istruzione PUSH bp
che preserva nello stack il contenuto originario di BP (che indichiamo con
oldBP2); la CPU esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=0190h
- salva il valore oldBP2 in SS:SP=SS:0190h
Subito dopo, il contenuto (0190h) di SP viene copiato in BP, per
cui possiamo dire che il parametro n=2 si trova in SS:BP+6, il parametro
m=2 si trova in SS:BP+4, l'indirizzo di ritorno 0096h si trova in
SS:BP+2, mentre oldBP2 si trova in SS:BP.
A questo punto abbiamo quindi: SP=0190h, n=2 e m=2, mentre in
EAX viene caricato il valore 1; siccome m è diverso da zero, la
CPU salta all'istruzione PUSH n ed esegue i seguenti passi:
- sottrae due byte a SP e ottiene SP=018Eh
- salva il valore 2 (copia di n) in SS:SP=SS:018Eh
- decrementa la copia AX di m e ottiene AX=1
- sottrae due byte a SP e ottiene SP=018Ch
- salva il valore AX=1 in SS:SP=SS:018Ch
- sottrae due byte a SP e ottiene SP=018Ah
- salva l'indirizzo di ritorno 0096h in SS:SP=SS:018Ah
- carica in IP l'offset 007Ch di Potenza e salta a
CS:IP=CS:007Ch
Appena entrati ricorsivamente in Potenza, incontriamo l'istruzione PUSH bp
che preserva nello stack il contenuto originario di BP (che indichiamo con
oldBP3); la CPU esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=0188h
- salva il valore oldBP3 in SS:SP=SS:0188h
Subito dopo, il contenuto (0188h) di SP viene copiato in BP, per
cui possiamo dire che il parametro n=2 si trova in SS:BP+6, il parametro
m=1 si trova in SS:BP+4, l'indirizzo di ritorno 0096h si trova in
SS:BP+2, mentre oldBP3 si trova in SS:BP.
A questo punto abbiamo quindi: SP=0188h, n=2 e m=1, mentre in
EAX viene caricato il valore 1; siccome m è diverso da zero, la
CPU salta all'istruzione PUSH n ed esegue i seguenti passi:
- sottrae due byte a SP e ottiene SP=0186h
- salva il valore 2 (copia di n) in SS:SP=SS:0186h
- decrementa la copia AX di m e ottiene AX=0
- sottrae due byte a SP e ottiene SP=0184h
- salva il valore AX=0 in SS:SP=SS:0184h
- sottrae due byte a SP e ottiene SP=0182h
- salva l'indirizzo di ritorno 0096h in SS:SP=SS:0182h
- carica in IP l'offset 007Ch di Potenza e salta a
CS:IP=CS:007Ch
Appena entrati ricorsivamente in Potenza, incontriamo l'istruzione PUSH bp
che preserva nello stack il contenuto originario di BP (che indichiamo con
oldBP4); la CPU esegue quindi i seguenti passi:
- sottrae due byte a SP e ottiene SP=0180h
- salva il valore oldBP4 in SS:SP=SS:0180h
Subito dopo, il contenuto (0180h) di SP viene copiato in BP, per
cui possiamo dire che il parametro n=2 si trova in SS:BP+6, il parametro
m=0 si trova in SS:BP+4, l'indirizzo di ritorno 0096h si trova in
SS:BP+2, mentre oldBP4 si trova in SS:BP.
A questo punto abbiamo quindi: SP=0180h, n=2 e m=0, mentre in
EAX viene caricato il valore 1; siccome m vale zero, la CPU
salta all'etichetta exit_proc, dove incontra l'istruzione POP bp seguita
dall'istruzione RET di tipo NEAR con operando immediato 4. La
CPU esegue quindi i seguenti passi:
- estrae da SP=0180h il valore oldBP4 e lo carica in BP
- somma due byte a SP e ottiene SP=0182h
- estrae da SP=0182h l'indirizzo di ritorno 0096h e lo carica in IP
- somma due byte a SP e ottiene SP=0184h
- somma quattro byte a SP e ottiene SP=0188h
- salta a CS:IP=CS:0096h con valore di ritorno EAX=1
All'indirizzo CS:0096h troviamo l'istruzione MOVZX, e in quel momento
abbiamo: SP=0188h, EAX=1, n=2 e m=1; la CPU esegue
quindi i seguenti passi:
- carica n=2 in EDX
- moltiplica EAX=1 per EDX=2 e ottiene EDX:EAX=2
Subito dopo la CPU incontra l'istruzione POP bp seguita dall'istruzione
RET di tipo NEAR con operando immediato 4 ed esegue i seguenti passi:
- estrae da SP=0188h il valore oldBP3 e lo carica in BP
- somma due byte a SP e ottiene SP=018Ah
- estrae da SP=018Ah l'indirizzo di ritorno 0096h e lo carica in IP
- somma due byte a SP e ottiene SP=018Ch
- somma quattro byte a SP e ottiene SP=0190h
- salta a CS:IP=CS:0096h con valore di ritorno EAX=2
All'indirizzo CS:0096h troviamo l'istruzione MOVZX, e in quel momento
abbiamo: SP=0190h, EAX=2, n=2 e m=2; la CPU esegue quindi i
seguenti passi:
- carica n=2 in EDX
- moltiplica EAX=2 per EDX=2 e ottiene EDX:EAX=4
Subito dopo la CPU incontra l'istruzione POP bp seguita dall'istruzione
RET di tipo NEAR con operando immediato 4 ed esegue i seguenti passi:
- estrae da SP=0190h il valore oldBP2 e lo carica in BP
- somma due byte a SP e ottiene SP=0192h
- estrae da SP=0192h l'indirizzo di ritorno 0096h e lo carica in IP
- somma due byte a SP e ottiene SP=0194h
- somma quattro byte a SP e ottiene SP=0198h
- salta a CS:IP=CS:0096h con valore di ritorno EAX=4
All'indirizzo CS:0096h troviamo l'istruzione MOVZX, e in quel momento
abbiamo: SP=0198h, EAX=4, n=2 e m=3; la CPU esegue quindi i
seguenti passi:
- carica n=2 in EDX
- moltiplica EAX=4 per EDX=2 e ottiene EDX:EAX=8
Subito dopo la CPU incontra l'istruzione POP bp seguita dall'istruzione
RET di tipo NEAR con operando immediato 4 ed esegue i seguenti passi:
- estrae da SP=0198h il valore oldBP1 e lo carica in BP
- somma due byte a SP e ottiene SP=019Ah
- estrae da SP=019Ah l'indirizzo di ritorno 0033h e lo carica in IP
- somma due byte a SP e ottiene SP=019Ch
- somma quattro byte a SP e ottiene SP=0200h
- salta a CS:IP=CS:0033h con valore di ritorno EAX=8
Il programma principale riottiene il controllo e trova in EAX il valore 8
che è proprio il risultato di 23; osserviamo che per ottenere un risultato
valido, nm non deve superare il massimo valore gestibile da EAX e
cioè 4294967295 = 232-1).
A differenza di quanto accade con il C, il Pascal non supporta gli interi
senza segno a 32 bit, ma solo gli interi con segno a 32 bit (Longint);
il Pascal dispone, invece, del tipo comp che è un intero con segno a 64
bit, che può essere restituito da una procedura attraverso la cima dello stack (ST(0))
della FPU.
Dall'esempio appena svolto, possiamo dedurre alcune considerazioni importanti. Prima di
tutto osserviamo che le strutture dati di tipo LIFO (Last In First Out) come lo
stack, si prestano in modo perfetto per le procedure ricorsive; l'uso dello stack, quindi,
permette di implementare in modo semplice ed efficiente gli algoritmi ricorsivi.
Affinché il meccanismo appena descritto funzioni in modo perfetto è fondamentale che il
contenuto originario del registro BP venga rigorosamente preservato; non ci vuole
molto a capire che in caso contrario, il programma andrebbe immediatamente in crash.
26.3 Vantaggi e svantaggi delle procedure ricorsive
Attraverso i vari esempi illustrati in precedenza, possiamo analizzare i vantaggi e gli
svantaggi delle procedure ricorsive.
Il vantaggio fondamentale della ricorsione è rappresentato dall'estrema compattezza del
codice scritto con questa tecnica; tale compattezza diventa particolarmente evidente nel
caso di algoritmi di complessità medio alta (come viene mostrato più avanti).
Abbiamo anche visto che un algoritmo ricorsivo, esteriormente ha una struttura molto più
semplice e comprensibile del corrispondente algoritmo non ricorsivo; è chiaro, però, che
per riuscire a risolvere ricorsivamente problemi molto complessi, bisognerebbe essere dei
geni della matematica. Questo non significa che gli algoritmi non ricorsivi siano più
semplici da individuare rispetto a quelli ricorsivi; anzi, nella maggior parte dei casi è
vero il contrario.
In generale, possiamo dire che gli svantaggi presentati dalla ricorsione superano
abbondantemente i (pochissimi) vantaggi; di fatto, la possibilità di scrivere codice
molto compatto è l'unico vantaggio degno di nota della ricorsione.
Analizzando i vari esempi di questo capitolo, si nota subito che le procedure in versione
non ricorsiva permettono di spingere al massimo la sequenzialità del codice; come già
sappiamo, più il codice è sequenziale, più sarà eseguito velocemente dalla CPU.
Le corrispondenti procedure in versione ricorsiva, invece, chiamano ripetutamente se
stesse con sensibili ripercussioni sulle prestazioni dovute al tempo necessario per il
trasferimento del controllo; la conseguenza di tutto ciò è data dal fatto che in generale,
la versione non ricorsiva di una procedura è molto più veloce della corrispondente versione
ricorsiva.
Il principale difetto delle procedure ricorsive è rappresentato sicuramente dal notevolissimo
consumo di spazio nello stack; si tratta di un aspetto piuttosto delicato che nei casi
estremi può portare alla saturazione di tutto il segmento di stack del nostro programma.
In una situazione del genere, ulteriori chiamate ricorsive comportano la scrittura di dati
in aree della memoria che non appartengono allo stack; nei casi estremi vengono sovrascritte
aree della memoria riservate al sistema operativo, con conseguente blocco del computer!
Per evidenziare questo aspetto, possiamo osservare quello che accade nel caso della procedura
Potenza; la versione non ricorsiva di questa procedura richiede uno spazio minimo
nello stack, pari a 8 byte. Questi 8 byte sono necessari per contenere
l'indirizzo di ritorno (2 byte), il parametro n (2 byte), il parametro
m (2 byte) e il contenuto originario di BP (2 byte); se si
decide, inoltre, di preservare i vari registri generali utilizzati dalla procedura, si può
arrivare a circa 16 byte di spazio necessario.
Per la versione ricorsiva di Potenza, la situazione cambia radicalmente; tale versione,
infatti, ha bisogno di almeno 8 byte per ogni chiamata ricorsiva, più 8 byte
per la prima chiamata effettuata dal caller.
Nel caso, ad esempio, di m=3, possiamo notare che sono necessari almeno:
8 * (3 + 1) = 8 * 4 = 32 byte
Nel caso di m=20 si arriva ad una richiesta minima di spazio nello stack pari a:
8 * (20 + 1) = 8 * 21 = 168 byte
In generale, la procedura ricorsiva Potenza, chiamata con l'esponente m,
richiede un minimo di:
8 * (m + 1) byte di spazio nello stack
Si tenga anche presente che Potenza è una procedura estremamente semplice; nel caso
di procedure molto complesse, la situazione può facilmente degenerare nella completa
saturazione dello stack.
Proprio questa caratteristica molto pericolosa, spinse i progettisti della IBM a
non supportare la ricorsione nel linguaggio FORTRAN77; si tratta di un linguaggio
di programmazione nato intorno al 1954 e destinato alla risoluzione dei problemi
matematici con il computer. La sigla FORTRAN, infatti, è la contrazione di IBM
Mathematical Formula Translation System; del resto, le disponibilità di memoria dei
computer dell'epoca non permettevano certo l'implementazione di procedure ricorsive.
26.4 Le Torri di Hanoi
Per evidenziare ulteriormente le caratteristiche positive e negative della ricorsione,
analizziamo in dettaglio un celebre algoritmo che permette di risolvere ricorsivamente
un famoso gioco chiamato: Le Torri di Hanoi; si tratta di un antico gioco
dell'estremo oriente, praticato dai monaci buddisti.
La Figura 26.5 mostra una rappresentazione schematica di questo gioco; l'obiettivo è
quello di trasferire tutti i dischi dal palo A al palo C servendosi del
palo B.
Le regole del gioco sono le seguenti:
- si può spostare un solo disco alla volta
- non si può sistemare un disco sopra un altro di diametro inferiore
- non si può sistemare alcun disco fuori dai tre pali
La risoluzione di questo problema con un algoritmo non ricorsivo si presenta estremamente
difficile; viceversa, il problema può essere risolto con un algoritmo ricorsivo
caratterizzato da una sorprendente semplicità!
Cominciamo con due soli dischi indicati con 1 e 2 e osserviamo che, in
questo caso, il gioco si risolve in modo molto intuitivo con le seguenti tre mosse:
- Spostare il disco 1 da A a B
- Spostare il disco 2 da A a C
- Spostare il disco 1 da B a C
In pratica, spostiamo il disco 1 dal palo di partenza al palo intermedio; poi
spostiamo l'unico disco rimasto (il numero 2), dal palo di partenza al palo di
arrivo e infine spostiamo il disco 1 dal palo intermedio al palo di arrivo.
Queste tre mosse sono molto importanti perché rappresentano la condizione alla quale
dobbiamo cercare di pervenire nel caso di 3 o più dischi; nel caso di 2
dischi, la sequenza delle tre mosse può essere generalizzata in questo modo:
- Spostare 2-1 dischi (1 disco) dal palo di partenza al palo intermedio servendosi
del palo di arrivo
- Spostare direttamente il disco rimasto, dal palo di partenza al palo di arrivo
- Spostare 2-1 dischi (1 disco) dal palo intermedio al palo di arrivo servendosi
del palo di partenza
Vediamo come si può applicare in pratica questo algoritmo; nel caso di 3 dischi
possiamo scrivere le tre mosse:
- Spostare 3-1 dischi (2 dischi) dal palo A al palo B, servendosi del palo C
- Spostare direttamente il disco rimasto, dal palo A al palo C
- Spostare 3-1 dischi (2 dischi) dal palo B al palo C, servendosi del palo A
Siccome non siamo in grado di effettuare le mosse 1 e 3, dobbiamo cercare
di scomporle in due o più mosse, sino a pervenire al caso (che sappiamo risolvere) di
due soli dischi; osserviamo subito che la mossa:
1. Spostare 3-1 dischi (2 dischi) dal palo A al palo B, servendosi del palo C
può essere scomposta nelle seguenti mosse:
- 1a. Spostare 2-1 dischi (1 disco) dal palo A al palo C, servendosi del palo B
- 2a. Spostare direttamente il disco rimasto, dal palo A al palo B
- 3a. Spostare 2-1 dischi (1 disco) dal palo C al palo B, servendosi del palo A
Analogamente, la mossa:
3. Spostare 3-1 dischi (2 dischi) dal palo B al palo C, servendosi del palo A
può essere scomposta nelle seguenti mosse:
- 1b. Spostare 2-1 dischi (1 disco) dal palo B al palo A, servendosi del palo C
- 2b. Spostare direttamente il disco rimasto, dal palo B al palo C
- 3b. Spostare 2-1 dischi (1 disco) dal palo A al palo C, servendosi del palo B
A questo punto possiamo affermare che nel caso di 3 dischi, il gioco può essere
risolto con la seguente sequenza di 7 mosse:
- Spostare un disco dal palo A al palo C
- Spostare un disco dal palo A al palo B
- Spostare un disco dal palo C al palo B
- Spostare un disco dal palo A al palo C
- Spostare un disco dal palo B al palo A
- Spostare un disco dal palo B al palo C
- Spostare un disco dal palo A al palo C
Nel caso di 4 dischi possiamo scrivere le tre mosse:
- Spostare 4-1 dischi (3 dischi) dal palo A al palo B, servendosi del palo C
- Spostare direttamente il disco rimasto, dal palo A al palo C
- Spostare 4-1 dischi (3 dischi) dal palo B al palo C, servendosi del palo A
Siccome non siamo in grado di effettuare le mosse 1 e 3, dobbiamo cercare
di scomporle in due o più mosse, sino a pervenire al caso (che sappiamo risolvere) di
due soli dischi; osserviamo subito che la mossa:
1. Spostare 4-1 dischi (3 dischi) dal palo A al palo B, servendosi del palo C
può essere scomposta nelle seguenti mosse:
- 1a. Spostare 3-1 dischi (2 dischi) dal palo A al palo C, servendosi del palo B
- 2a. Spostare direttamente il disco rimasto, dal palo A al palo B
- 3a. Spostare 3-1 dischi (2 dischi) dal palo C al palo B, servendosi del palo A
Analogamente, la mossa:
3. Spostare 4-1 dischi (3 dischi) dal palo B al palo C, servendosi del palo A
può essere scomposta nelle seguenti mosse:
- 1b. Spostare 3-1 dischi (2 dischi) dal palo B al palo A, servendosi del palo C
- 2b. Spostare direttamente il disco rimasto, dal palo B al palo C
- 3b. Spostare 3-1 dischi (2 dischi) dal palo A al palo C, servendosi del palo B
Le mosse 1a, 3a, 1b e 3b possono essere risolte con il metodo
già spiegato per il caso di 3 dischi; alla fine, per il caso di 4 dischi,
si ottiene la soluzione rappresentata dalla seguente sequenza di 15 mosse:
- Spostare un disco dal palo A al palo B
- Spostare un disco dal palo A al palo C
- Spostare un disco dal palo B al palo C
- Spostare un disco dal palo A al palo B
- Spostare un disco dal palo C al palo A
- Spostare un disco dal palo C al palo B
- Spostare un disco dal palo A al palo B
- Spostare un disco dal palo A al palo C
- Spostare un disco dal palo B al palo C
- Spostare un disco dal palo B al palo A
- Spostare un disco dal palo C al palo A
- Spostare un disco dal palo B al palo C
- Spostare un disco dal palo A al palo B
- Spostare un disco dal palo A al palo C
- Spostare un disco dal palo B al palo C
A questo punto appare evidente che nel caso generale di N dischi possiamo scrivere
le tre mosse:
- Spostare N-1 dischi dal palo A al palo B, servendosi del palo C
- Spostare direttamente il disco rimasto, dal palo A al palo C
- Spostare N-1 dischi dal palo B al palo C, servendosi del palo A
Questo algoritmo afferma che per spostare N dischi dal palo A al palo
C, dobbiamo prima spostare N-1 dischi dal palo A al palo B
attraverso il palo C; subito dopo dobbiamo spostare direttamente il disco rimasto,
dal palo A al palo C e infine dobbiamo spostare N-1 dischi dal palo
B al palo C attraverso il palo A.
Appare evidente il fatto che questo è un classico caso di algoritmo ricorsivo; indicando
quindi con N il numero di dischi, con A il palo di partenza, con C
il palo di arrivo e con B il palo intermedio, possiamo schematizzare questo
algoritmo con il seguente pseudo-codice:
La condizione di uscita dalla ricorsione è rappresentata da N=0; infatti, quando
N diventa zero vuol dire che non ci sono più dischi da spostare.
Può sembrare incredibile, ma questo (apparentemente) insignificante algoritmo funziona
in modo perfetto; in Figura 26.6 vediamo una implementazione reale scritta in Linguaggio
C.
Come si può notare, grazie alla sintassi "evoluta" dei linguaggi di alto livello, la
struttura della funzione hanoi rispecchia fedelmente la formulazione matematica
dell'algoritmo ricorsivo; tale funzione è formata da appena 5 linee di codice
C ed evidenzia ulteriormente l'estrema compattezza degli algoritmi ricorsivi,
anche nel caso di problemi complessi.
La Figura 26.7 mostra la versione Assembly del programma che risolve ricorsivamente
il gioco delle Torri di Hanoi.
Il programma HANOI.ASM è dettagliatamente commentato e dovrebbe risultare
abbastanza chiaro e comprensibile; come si può notare, vengono utilizzate diverse
procedure della libreria EXELIB (che può essere scaricata dalla sezione
Librerie di supporto per il corso assembly dell’
Area Downloads di questo sito).
Il numero di dischi da spostare viene inserito attraverso la procedura
readUdec16; inserendo il valore zero, si provoca la terminazione del programma.
La procedura hanoi viene chiamata secondo le convenzioni C per il passaggio
degli argomenti e per la pulizia dello stack; naturalmente si può modificare facilmente
la procedura stessa in modo che segua la convenzione Pascal.
Le varie mosse da compiere vengono visualizzate attraverso la stringa messaggio;
come si può notare, questa stringa termina con i tre codici
ASCII 10 (avanzamento
linea), 13 (ritorno carrello) e '$' che indica la fine di una stringa
DOS.
La stringa messaggio ha queste caratteristiche in quanto viene visualizzata
attraverso il servizio 09h (Display String) dell'INT 21h (servizi
DOS) descritto in un precedente capitolo; questa scelta si rende necessaria in
quanto, inserendo un numero relativamente alto di dischi da spostare, vengono visualizzate
moltissime mosse che vanno a riempire tutto lo schermo scomparendo poi nella parte
inferiore. Per poterle visualizzare tutte, possiamo usare appunto il servizio Display
String che tratta lo schermo come se fosse il foglio di una macchina da scrivere;
ogni volta che il cursore oltrepassa l'ultima linea visualizzabile, il servizio Display
String fa scorrere automaticamente lo schermo verso l'alto. Anche la printf del
C e la WriteLn del Pascal utilizzano questo servizio del DOS;
purtroppo, come accade per tutti i servizi gestiti attraverso le interruzioni software,
anche Display String presenta una estrema lentezza che condiziona tutto il programma.
La procedura writeString della libreria EXELIB è infinitamente più veloce di
Display String in quanto scrive direttamente nella memoria video del PC;
writeString, però, è in grado di sfruttare solo la parte visibile dello schermo.
Tutti questi aspetti vengono illustrati in dettaglio nella sezione Assembly Avanzato.
Se si ha la necessità di memorizzare le mosse svolte dal programma, si può utilizzare
la concatenazione dei file attraverso un comando del tipo:
hanoi > mosse.txt
Questo comando dice al DOS che tutto l'output di HANOI.EXE deve essere
inviato, non allo schermo, ma al file MOSSE.TXT; attraverso un qualunque editor
di testo è possibile poi visualizzare il contenuto dello stesso file MOSSE.TXT.
Indubbiamente, si può restare impressionati dall'estrema compattezza e semplicità
dell'algoritmo che risolve ricorsivamente il gioco delle Torri di Hanoi; per
poter esprimere, però, una valutazione più completa su questo algoritmo, bisogna
verificare anche altri aspetti, come la velocità e la richiesta di spazio nello stack.
Una caratteristica piuttosto evidente della procedura hanoi è data dal fatto
che, al crescere del numero di dischi da spostare, cresce in modo impressionante il
numero delle mosse necessarie; parallelamente si registra un sensibile calo della
velocità di esecuzione, legato evidentemente alla notevole crescita del numero di
chiamate ricorsive.
Per analizzare in dettaglio questi aspetti, possiamo cercare prima di tutto di
individuare la relazione che esiste tra il numero N di dischi da spostare e il
numero M di mosse necessarie; in termini matematici, si tratta di determinare
la funzione:
M = f(N)
Eseguendo il programma HANOI, possiamo ricavare questa funzione in modo empirico;
osserviamo subito che:
Analizzando questi risultati possiamo ricavare la relazione generale che esiste
tra N e M; infatti, risulta evidente che:
M = 2N - 1
Questa relazione ci dice che al crescere del numero N di dischi, il numero
M di mosse cresce in modo direttamente proporzionale alla potenza con esponente
N di 2; tutto ciò giustifica il fatto che quando si va oltre i 10
dischi, si comincia a notare un sensibile rallentamento del programma, anche con
CPU piuttosto potenti.
Nel caso, ad esempio, di N=16, sono necessarie ben:
216 - 1 = 65535 mosse!
Queste considerazioni sono piuttosto preoccupanti se pensiamo che per ciascuna chiamata
della procedura hanoi sono necessari almeno 12 byte di stack (2 per
il parametro n, 2 per dalPalo, 2 per alPalo, 2
per viaPalo, 2 per l'indirizzo di ritorno e 2 per BP); questo
significa che se il numero di chiamate ricorsive fosse pari al numero di mosse, con
N=16 avremmo bisogno di ben:
65535 * 12 = 786420 byte di stack!
Fortunatamente, però, le cose non stanno esattamente così; per rendercene conto,
possiamo provare a seguire il percorso delle varie chiamate ricorsive del programma
hanoi. La Figura 26.8 mostra lo schema di chiamata di hanoi nel caso di
N=3; le linee rosse indicano i percorsi di andata, mentre le linee blu indicano
i percorsi di ritorno.
- Il programma principale (CALLER) chiama hanoi(3,A,C,B) con
inserimento nello stack di 12 byte
- hanoi(3,A,C,B) chiama hanoi(2,A,B,C) con inserimento nello
stack di altri 12 byte per un totale di 24 byte
- hanoi(2,A,B,C) chiama hanoi(1,A,C,B) con inserimento nello
stack di altri 12 byte per un totale di 36 byte
- hanoi(1,A,C,B) chiama hanoi(0,A,B,C) con inserimento nello
stack di altri 12 byte per un totale di 48 byte
- hanoi(0,A,B,C) termina (N=0) liberando 12 byte nello
stack, per un totale di 36 byte
- Viene stampata la stringa "da A a C"
- Viene chiamata hanoi(0,B,C,A) con inserimento nello stack di altri
12 byte per un totale di 48 byte
- hanoi(0,B,C,A) termina (N=0) liberando 12 byte nello stack,
per un totale di 36 byte, e restituisce il controllo a hanoi(1,A,C,B)
- hanoi(1,A,C,B) termina liberando 12 byte nello stack, per un
totale di 24 byte
- Viene stampata la stringa "da A a B"
- Viene chiamata hanoi(1,C,B,A) con inserimento nello stack di altri
12 byte per un totale di 36 byte
- hanoi(1,C,B,A) chiama hanoi(0,C,A,B) con inserimento nello
stack di altri 12 byte per un totale di 48 byte
- hanoi(0,C,A,B) termina (N=0) liberando 12 byte nello
stack, per un totale di 36 byte
- Viene stampata la stringa "da C a B"
- Viene chiamata hanoi(0,A,B,C) con inserimento nello stack di altri
12 byte per un totale di 48 byte
- hanoi(0,A,B,C) termina (N=0) liberando 12 byte nello stack,
per un totale di 36 byte, e restituisce il controllo a hanoi(1,C,B,A)
- hanoi(1,C,B,A) termina liberando 12 byte nello stack, per un
totale di 24 byte, e restituisce il controllo a hanoi(2,A,B,C)
- hanoi(2,A,B,C) termina liberando 12 byte nello stack, per un
totale di 12 byte
- Viene stampata la stringa "da A a C"
- Viene chiamata hanoi(2,B,C,A) con inserimento nello stack di altri
12 byte per un totale di 24 byte
- hanoi(2,B,C,A) chiama hanoi(1,B,A,C) con inserimento nello stack
di altri 12 byte per un totale di 36 byte
A questo punto è perfettamente inutile continuare in quanto si capisce subito che con
N=3, non verranno mai superati i 48 byte di spazio necessario nello stack;
questi 48 byte sono relativi quindi alla prima chiamata di hanoi effettuata
dal CALLER e ai tre livelli di ricorsione indicati in Figura 26.8 con
LIVELLO 1, LIVELLO 2 e LIVELLO 3. In pratica, la massima occupazione
di spazio nello stack verrà raggiunta quando N diventa zero.
Ripetendo lo schema di Figura 26.8 per diversi valori di N, si può constatare che:
- con N=1 sono necessari 24 byte di stack e cioè:
12 * (1 + 1) con 1 livello di ricorsione
- con N=2 sono necessari 36 byte di stack e cioè:
12 * (2 + 1) con 2 livelli di ricorsione
- con N=3 sono necessari 48 byte di stack e cioè:
12 * (3 + 1) con 3 livelli di ricorsione
- con N=4 sono necessari 60 byte di stack e cioè:
12 * (4 + 1) con 4 livelli di ricorsione
e così via.
Generalizzando quindi, possiamo dire che se vogliamo spostare N dischi, abbiamo
bisogno come minimo di:
12 * (N + 1) byte di spazio nello stack.
Nel caso, ad esempio, di N=16, ci servono "solamente" 204 byte e non
786420.
L'esempio delle Torri di Hanoi ci fa capire ulteriormente quanto sia importante
l'analisi dettagliata del comportamento di un determinato algoritmo ricorsivo; in questo
modo è possibile evitare spiacevoli sorprese in fase di esecuzione di quei programmi
che fanno uso della ricorsione.
Si tenga presente che lo stack overflow (superamento della capacità massima del
segmento di stack) è uno dei bug più pericolosi e più difficili da scovare; proprio per
questo motivo, i compilatori dei linguaggi di alto livello offrono al programmatore la
possibilità di inserire automaticamente nei programmi il codice necessario per la
verifica dello stack overflow.
Gli algoritmi ricorsivi rappresentano un settore della matematica che, con l'avvento del
computer, ha accresciuto enormemente la sua importanza; infatti, moltissimi problemi
matematici possono essere formulati in modo ricorsivo. La ricorsione viene largamente
applicata anche nei programmi che sfidano gli esseri umani nel gioco degli scacchi; per
ulteriori approfondimenti si può fare riferimento ai numerosi libri che trattano questo
argomento.