Vincenzo Grassi Note per il corso di Fondamenti di Informatica I a.a. 1999/2000 Facoltà di Ingegneria, Università di Roma “Tor Vergata” Capitolo 1 Rappresentazione di tipi di dato astratti 2 Introduzione Questo capitolo costituisce una integrazione delle lezioni tenute in aula durante lo svolgimento del corso, riguardanti la definizione di alcuni tipi di dato astratti e la loro codifica utilizzando i meccanismi messi a disposizione dal linguaggio C++. Il suo scopo principale è di presentare in maniera più organica e precisa quanto esposto durante le lezioni. Si rimanda quindi a queste ultime per la parte riguardante la “cornice” (il concetto di tipo di dato astratto) in cui inquadrare tale capitolo. Anche se la codifica presentata riguarda il linguaggio C++, il lettore dovrebbe essere in grado facilmente di tradurre tale codifica in un altro linguaggio, utilizzando i meccanismi (più o meno “potenti” di quelli C++) messi a disposizione. Obiettivo di questo capitolo è anche quello di far vedere come, a prescindere dal linguaggio adottato, una certa “filosofia” di progettazione, basata sul concetto di tipo di dato astratto, consenta di arrivare alla realizzazione di algoritmi risolutivi con migliori caratteristiche di modularità e riusabilità. Mi scuso per qualunque errore o svista che possa apparire in questo capitolo. Ogni commento e suggerimento sarà comunque gradito. Vorrei infine ringraziare Vittoria de Nitto Personè per gli utili suggerimenti e commenti durante la stesura di questo capitolo. 3 Il tipo di dato astratto “lista” 1. Introduzione Una “lista” rappresenta una sequenza ordinata di elementi omogenei, la cui quantità può variare dinamicamente. Operazioni tipiche effettuate su un oggetto di tipo lista sono quelle di inserzione, rimozione e modifica di un elemento. Tali operazioni possono essere eseguite qualunque sia la posizione dell’elemento nella lista. Quindi, nell’eseguire tali operazioni, è necessario specificare in qualche modo la posizione coinvolta. Nella definizione che daremo si assumerà che tale posizione è specificata esplicitamente, tramite un intero che rappresenta il numero d’ordine dell’elemento interessato, dove ad ogni elemento della lista è assegnato un numero d’ordine che va da 1 a n (numero corrente di elementi nella lista). Si noti che il numero d’ordine di un elemento (ovvero, la sua posizione nella lista) può cambiare nel tempo, in conseguenza di operazioni di inserzione o rimazione di altri elementi nella stessa lista. Graficamente, un oggetto di tipo lista può essere visualizzato come in Fig. 1, dove n è la lunghezza corrente della lista α. α = [a1 a2 a3 … an-1 an] Figura 1 2. Definizione del tipo di dato Nella letteratura si possono incontrare varie definizioni del tipo di dato astratto lista (in particolare, delle operazioni primitive definite su questo tipo di dato). Quella che adottiamo non ha nessuna pretesa di essere “migliore” di altre. E’ semplicemente una definizione “ragionevole”, ma altre definizioni lo sono altrettanto. Nella nostra definizione, il tipo di dato lista risulta caratterizzato dai seguenti domini e operazioni primitive: D = {lista, T_elem_lis, integer} F = {length, insert, remove, element} dove: T_elem_lis: insieme (generico) degli oggetti utilizzabili come elementi di una lista; lista: insieme degli oggetti di tipo lista; integer: insieme dei numeri interi. Le operazioni primitive in F hanno la seguente caratterizzazione funzionale e semantica: - length : lista → integer length(α) == n (dove n è la lunghezza della lista; se la lista è vuota, n vale 0) 4 - element : lista x integer → T_elem_lis definizione: restituisce il valore dell’i-esimo elemento di α; element(α, i) == ai precondizione: length(α)>0 and 1≤i≤length(α) - insert : lista x T_elem_lis x integer → lista definizione: inserisce un elemento nella posizione i-esima della lista; insert(α, b, i) == [a1 a2 … ai-1 b (nuovi indici degli elementi dopo l’inserzione: ai … an-1 an] 1 2 … i-1 i i+1 … n n+1) precondizione: 1≤i≤length(α)+1 se i==length(α)+1 il nuovo elemento diventa l’ultimo della lista - remove : lista x integer → lista definizione: rimuove un elemento dalla posizione i-esima della lista; remove(α, i) == [a1 a2 … ai-1 ai+1 … an-1 an] (nuovi indici degli elementi dopo la rimozione: 1 2 … i-1 i … n-2 n-1) precondizione: length(α)>0 and 1≤i≤length(α) ) 3. Codifica C++ del tipo astratto lista In questa sezione viene presentata una possibile codifica del tipo lista, usando i meccanismi disponibili nel linguaggio C++. Si noti che in tutti i casi in cui compare la scritta “… // errore: …” si intende che, in una effettiva codifica, tale frase andrà sostituita da un comando opportuno (per esempio: stampa di un messaggio di segnalazione, assegnazione di un valore ad un parametro aggiuntivo (per riferimento) usato per segnalare al chiamante l’impossibilità di eseguire l’operazione (in questo caso sarà responsabilità del chiamante prendere azioni opportune), ecc.). 3.1. Codifica astratta Usando la filosofia della programmazione a oggetti, e quindi il meccanismo delle classi del C++, possiamo definire una codifica “astratta” del tipo di dato lista. Il termine “astratta” fa riferimento alla possibilità di definire e codificare in C++ algoritmi su liste indipendentemente dal particolare modo in cui gli oggetti (e le corrispondenti operazioni primitive) del tipo di dato lista sono effettivamente realizzati. Infatti, il meccanismo delle classi del C++ ci consente di definire un’interfaccia “pubblica” indipendente dal particolare meccanismo usato per codificare oggetti di tipo lista e le corrispondenti operazioni primitive. Tale interfaccia pubblica può poi essere utilizzata nella definizione e codifica in C++ di algoritmi su liste. La codifica astratta può essere definita come segue: 5 typedef … T_elem_lis; class lista {private : //strutture dati per rappresentare una lista ... public : void init_lis(); int length(); T_elem_lis element(int i); void insert(T_elem_lis b,int i); void remove(int i); }; void lista::init_lis() {//codifica dell’ operazione init_lis() } int lista::length() {//codifica dell’ operazione length() } T_elem_lis lista::element(int i) {//codifica dell’ operazione element(…) } void lista::insert(T_elem_lis b,int i) {//codifica dell’ operazione insert(…) } void lista::remove(int i) {//codifica dell’ operazione remove(…) } In questa codifica, si noterà la presenza, a fianco delle operazioni primitive, di una operazione init_lis() il cui scopo è quello di inizializzare in modo opportuno le strutture dati utilizzate per rappresentare una lista. Tale funzione d e v e essere chiamata prima di qualunque uso di un oggetto di tipo lista. Come esempio di applicazione di quanto detto, viene presentata nel seguito una funzione che risolve il seguente problema: data una lista di valori (per esempio, numeri interi) ordinati in senso crescente, inserire un nuovo valore rispettando l’ordinamento crescente. 6 lista ins_in_ord(lista l, T_elem_lis b) {if (l.length()==0 || b<l.element(1)) l.insert(b,1); else if (b>l.element(l.length())) l.insert(b, l.length()+1); //se la lista è vuota oppure b è //più piccolo di ogni elemento //della lista, b deve essere //inserito in prima posizione; //b risulta maggiore del più //grande elemento della lista, e //quindi viene inserito in ultima //posizione else {int i; //inserimento in posizione “intermedia” i=1; while (i<=l.length() && l.element(i)<b) i++; l.insert(b,i); } return l; } Come si può notare, questa funzione restituisce come risultato la lista di partenza modificata inserendo in modo ordinato il nuovo elemento. Questa funzione potrebbe essere utilizzata in un programma così fatto: typedef int T_elem_lis; siano //si assume che gli elementi della lista //numeri interi class lista {private : //strutture dati per rappresentare una lista public : void init_lis(); int length(); T_elem_lis element(int i); void insert(T_elem_lis b,int i); void remove(int i); }; void lista::init_lis() {//codifica dell’ operazione init_lis() } int lista::length() {//codifica dell’ operazione length() } T_elem_lis lista::element(int i) {//codifica dell’ operazione element(…) } void lista::insert(T_elem_lis b,int i) {//codifica dell’ operazione insert(…) } void lista::remove(int i) {//codifica dell’ operazione remove(…) } 7 main () {int k; lista l; l.init_lis(); for (k=1;k<5;k++) l.insert(k,k); for (k=1;k<=l.length();k++) cout <<l.element(k)<<' '; cout <<'\n'; l=ins_in_ord(l,3); l=ins_in_ord(l,7); for (k=1;k<=l.length();k++) cout <<l.element(k)<<' '; cout <<'\n'; } Al lettore viene lasciato di verificare che l’uscita prodotta da questo programma è: 1 2 3 4 1 2 3 3 4 7 Come si può facilmente notare, dopo aver definito come int il generico tipo T_elem_lis, il programma principale e la funzione ins_in_ord risultano indipendenti dalla codifica del tipo lista, e quindi anche da eventuali modifiche di tale codifica che interessino solo la parte private (ovvero, il modo di rappresentare concretamente una lista e le operazioni primitive si di essa). Tale caratteristica mostra come l’adozione dell’approccio “a oggetti” nella realizzazione di soluzioni di problemi (come l’inserimento in ordine appena presentato) facilita grandemente il “riuso” di tali soluzioni all’interno di differenti situazioni, dove, per esigenze particolari, possono essere adottate codifiche differenti dello stesso tipo di dato (ovviamente, deve valere il vincolo che l’interfaccia adottata (cioè la parte public) sia la stessa in tutte le situazioni). 3.2. Codifica concreta (sequenziale) La codifica concreta che presentiamo è basata sull’idea di rappresentare una lista di M elementi usando M posizioni contigue di un array di N elementi. Dato che un array è una struttura statica nel linguaggio C++, conviene definire N>M, in modo opportuno, per consentire una eventuale “crescita” dinamica della lista. La condizione N>M implica che non tutti gli elementi dell’array saranno, in generale, occupati da elementi della lista; quindi, la codifica deve includere anche una informazione su quale parte dell’array è effettivamente utilizzata. Una possibile codifica del dominio lista è quindi la seguente: 8 class lista {private : const int N = …; T_elem_lis elem[N]; int primo,lungh; public : //parte “pubblica” }; In questa codifica, “primo” e “lungh” delimitano la porzione effettivamente utilizzata dell’array. Per usare in modo efficiente lo spazio disponibile, conviene considerare l’array come una “struttura circolare” di N elementi numerati da 0 a N-1, dove: - il successore di un elemento in posizione i è quello in posizione (i+1) % N; - il predecessore di un elemento in posizione i è quello in posizione (i-1+N) % N. Esempio 1 Se N = 5, si ha: - successore(2) = (2+1) % 5 = 3; successore(4) = (4+1) % 5 = 0; - predecessore(2) = (2-1+5) % 5 = 1; predecessore(0) = (0-1+5) % 5 = 4; 4 0 1 3 2 Figura 2 Fine esempio 1 Esempio 2 Nella Figura 3 sono mostrate due possibili configurazioni di una struttura dichiarata di tipo lista usata per rappresentare la lista di interi [4, 5, 10, 12, 7]: 9 2 5 0 primo N-2 5 primo 0 10 elem 1 12 7 lungh elem 1 2 4 2 3 5 10 3 4 5 6 lungh 4 12 5 6 7 7 7 N-2 N-2 N-1 N-1 4 5 Figura 3 Fine esempio 2 Passiamo ora a realizzare in C++ le operazioni primitive del tipo astratto lista usando questa codifica sequenziale. void lista::init_lis() {lungh=0;primo=1;} int lista::length() {return lungh;} T_elem_lis lista::element(int i) {if (lungh>0 && i<=lungh && i>=1) return elem[(primo+i-1)%N]; else … ; //errore: indice fuori dai limiti } 10 void lista::insert(T_elem_lis b, int i) {if (lungh==N) … ; //errore: array pieno else if (i>lungh+1 || i<=0) … ; //errore: indice fuori dai limiti else {if (i==1) //inserzione in prima posizione {elem[(primo-1+N)%N]=b; primo=(primo-1+N)%N; } else if (i==lungh+1) //inserzione in ultima //posizione elem[(primo+lungh)%N]=b; else //inserzione interna {int j; for (j=(primo+lungh)%N;j!=(primo+i-1)%N; j=(j-1+N)%N) elem[j]=elem[(j-1+N)%N]; elem[(primo+i-1)%N]=b; } lungh=lungh+1; } } void lista::remove(int i) {if (i>lungh || i<1) … ; //errore: indice fuori dai limiti else if (i==1) //rimozione dalla prima posizione {primo=(primo+1)%N; lungh=lungh-1; } else if (i==lungh) //rimozione dall'ultima posizione lungh=lungh-1; else //rimozione interna {int j; for (j=(primo+i-1)%N;j!=(primo+lungh-1)%N;j=(j+1)%N) elem[j]=elem[(j+1)%N]; lungh=lungh-1; } } La codifica sequenziale appena descritta presenta i seguenti inconvenienti: • ogni oggetto di tipo lista occupa sempre uno spazio proporzionale a N, anche se, ad un certo istante, può essere costituito da un numero molto più piccolo di elementi; • il costo dell’esecuzione delle operazioni di inserzione e rimozione è alto, poichè si richiede uno spostamento di elementi, per mantenere la necessaria contiguità fisica. Questi due inconvenienti possono essere superati usando codifiche degli oggetti di tipo lista che non cercano di rappresentare la “contiguità logica” tra elementi della lista (cioè il fatto che l’elemento i-esimo della lista è preceduto dall’(i-1)-esimo e seguito dall’(i+1)-esimo) mediante la “contiguità fisica” degli elementi in una struttura di tipo array. Tali codifiche non verranno presentate in questa sede. Terminiamo questa sezione presentando due diverse codifiche di una funzione che risolve il seguente problema su liste: 11 data una lista e un valore di tipo T_elem_lis, la funzione deve restituire il valore 1 se l’elemento è presente nella lista, e 0 altrimenti. (E’ da notare che, secondo la convenzione del C++, questo corrisponde a restituire i valori logici “vero” se l’elemento è presente nella lista, e “falso” altrimenti) int ricerca_1(lista l, T_elem_lis e) {if (l.length()==0) return 0; //e non è presente perchè la lista è vuota else {int i; i=1; while (i<=l.length() && l.element(i)!=e) i++; if (i>l.length()) return 0; //e non è presente perchè la lista è stata //esaminata tutta senza successo else return 1; } } int ricerca_2(lista l, T_elem_lis e) {if (lungh==0) return 0; //e non è presente perchè la lista è vuota else {int i; i=1; while (i<=lungh && element([(primo+i-1)%N]!=e) i++; if (i>lungh) return 0; //e non è presente perchè la lista è stata //esaminata tutta senza successo else return 1; } } Data la codifica del tipo lista presentata in questa sezione, la funzione ricerca_1( ) è corretta, mentre ricerca_2( ) non lo è. Perchè? In che modo andrebbe modificata la definizione della classe lista affinchè la funzione ricerca_2( ) risulti corretta? Il lettore è invitato a sperimentare concretamente possibili soluzioni, per verificarne la correttezza. 12 Il tipo di dato astratto “matrice” 1. Definizione del tipo di dato astratto Una “matrice” (a n dimensioni) rappresenta una collezione di elementi omogenei, ognuno dei quali è identificato univocamente da una n-upla particolare (indice dell’elemento). Operazioni tipiche effettuate su un oggetto di tipo matrice sono quelle di acquisizione e modifica del valore di un elemento della matrice, identificato tramite la n-upla associata. Il tipo di dato matrice risulta quindi caratterizzato dai seguenti domini e operazioni primitive: D = {matrice, T_elem_mat, indice} F = {valore, modifica} dove: - matrice: insieme degli oggetti di tipo matrice (n-dimensionale); - T_elem_mat: insieme (generico) degli oggetti utilizzabili come elementi di una matrice; - indice = indice1 x indice2 x … x indicen : insieme di n-uple di indici; Un singolo elemento di una matrice è quindi individuato da una n-upla <i1, i2 ,… , in>, i k ∈indice k , k = 1, 2, …, n. Le operazioni primitive in F hanno la seguente caratterizzazione funzionale: - valore : matrice x indice → T_elem_mat (restituisce il valore dell’elemento identificato da “indice”) - modifica: matrice x indice x T_elem_mat→ matrice (modifica la matrice rimpiazzando il valore dell’elemento identificato da “indice” con il valore dato) Esempio 1 Consideriamo il problema del calcolo del prodotto di matrici reali bidimensionali. Come sappiamo date due matrici Amxn = [aij] e Bnxp = [bij], si definisce come prodotto la matrice Cmxp = [cij] tale che: n cij = ∑ k=1 aik ·b kj Definiamo un algoritmo per il calcolo del prodotto di matrici, pensato per un “esecutore” che “conosce” il tipo astratto matrice e le operazioni aritmetiche su numeri reali. Si ha quindi: indice_1 = {1, 2, …, m} x {1, 2, …, n} indice_2 = {1, 2, …, n} x {1, 2, …, p} indice_3 = {1, 2, …, m} x {1, 2, …, p} ℜ} B ∈ matrice(indice_2, ℜ } C ∈ matrice(indice_3, ℜ } (dove ℜ rappresenta l’insieme dei numeri reali). A ∈ matrice(indice_1, 13 algoritmo: date A e B, esegui: per ogni i ∈ {1, 2, …, m} : per ogni j ∈ {1, 2, …, p} : modifica(C, (i,j), 0) //inizializza a zero l’elemento (i,j) di C per ogni k ∈ {1, 2, …, n} : modifica(C, (i,j), valore(C,(i,j))+valore(A, (i,k))·valore(B, (k,j)) fine algoritmo Fine esempio 1 2. Codifica C++ del tipo astratto matrice In questa sezione vengono presentate alcune possibili codifiche del tipo astratto matrice, usando i meccanismi messi a disposizione dal linguaggio C++. 2.1. Codifica usando il meccanismo array E’ abbastanza ovvio realizzare che il meccanismo array è stato introdotto in C++ (come anche in altri linguaggi) principalmente per codificare il tipo astratto matrice. Questo in particolare significa che il C++ mette a disposizione una sintassi “facilitata” e una realizzazione efficiente delle operazioni primitive del tipo di dato. Per semplicità, limitiamo il discorso a matrici bidimensionali, ma, ovviamente, le considerazioni fatte valgono per matrici con qualsiasi numero di dimensioni. Si ha quindi : • codifica del dominio: const int N1 = … ; const int N2 = … ; typedef … T_elem_mat; typedef T_elem_mat //cardinalità del dominio indice_1; //cardinalità del dominio indice_2; //definizione del tipo degli elementi //della matrice; matrice[N1][N2]; Si noti che in C++ gli indici di un array [N1][N2] sono vincolati ad assumere valori tra 0 e N1-1 (o N2-1); nel caso che, per il problema considerato (come nell’Esempio 1), il dominio degli indici non corrisponda a questa convenzione, bisognerà preoccuparsi di stabilire una opportuna corrispondenza tra indici “astratti” e indici “concreti”. • codifica delle operazioni primitive: dato, ad esempio, un ambiente definito dalle seguenti dichiarazioni: matrice A; int i1, i2; si ha: - “modifica(A, (I1,I2), E)” è rappresentato come: A[i1][i2] = E; (dove E è una espressione C++ di tipo T_elem_mat) - “valore(A, (I1,I2))” è rappresentato da: A[i1][i2] (usato all’interno di una opportuna espressione C++). 14 Esempio 2 Codifichiamo, usando la rappresentazione appena vista, l’algoritmo per il prodotto di matrici descritto nell’Esempio 1. Per semplicità, consideriamo il caso di matrici quadrate NxN. Si ha: const int N = … ; typedef … T_elem_mat; typedef T_elem_mat matrice[N][N]; void prodmat1(matrice a, matrice b, matrice c) {//calcolo del prodotto di matrici N*N c=a*b int i,j,k; for (i=0;i<N;i++) for (j=0;j<N;j++) {c[i][j]=0; for (k=0;k<N;k++) c[i][j]=c[i][j]+a[i][k]*b[k][j]; } } Si noti che l’algoritmo non è codificato come funzione di tipo matrice perchè in C++ una funzione non può assumere come valore un oggetto codificato tramite array; il risultato viene quindi restituito tramite il parametro c (si ricordi che in C++ tutti i parametri formali di tipo array sono implicitamente per riferimento). Fine Esempio 2 2.2. Codifica astratta del tipo matrice Come detto nella sezione precedente, il meccanismo array è pensato (e quindi ottimizzato) per la codifica di oggetti di tipo matrice. Questo, tra l’altro, implica l’uso di una sintassi (quella vista per la rappresentazione delle operazioni) strettamente dipendente dal meccanismo utilizzato. Una maggiore “astrazione” dalla codifica (con conseguente possibilità di modificare la codifica senza dover modificare la codifica degli algoritmi risolutivi) la si otterrebbe se il tipo astratto matrice venisse codificato utilizzando la filosofia della programmazione a oggetti, e quindi il meccanismo delle classi del C++. In questo modo diventa possibile definire un’interfaccia “pubblica” indipendente dal particolare meccanismo usato per codificare oggetti di tipo matrice e le corrispondenti operazioni primitive. Tale codifica “astratta” potrebbe essere definita come segue: const int N1 = … ; //cardinalità del dominio indice_1; const int N2 = … ; //cardinalità del dominio indice_2; typedef … T_elem_mat; //definizione del tipo degli elementi // della matrice; class matrice {private : //strutture dati per rappresentare una matrice public : void init_mat(T_elem_mat v); T_elem_mat valore(int i, int j); void modifica(int i, int j, T_elem_mat v); }; 15 void matrice::init_mat(T_elem_mat v) {//codifica dell’ operazione init_mat(); il valore v viene assegnato //a tutti gli elementi della matrice } T_elem_mat matrice::valore(int i, int j) {//codifica dell’ operazione valore() } void matrice::modifica(int i, int j, T_elem_mat v) {//codifica dell’ operazione modifica() } Usando questa codifica astratta, l’algoritmo per il prodotto tra due matrici può essere riscritto come segue (assumendo, per semplicità, N1 == N2): void prodmat2(matrice a, matrice b, matrice& c) {//calcolo del prodotto di matrici int i,j,k; for (i=0;i<N1;i++) for (j=0;j<N1;j++) {c.modifica(i,j,0); for (k=0;k<N1;k++) c.modifica(i,j,c.valore(i,j)+a.valore(i,k)*b.valore(k,j)); } } E’ facile notare come, rispetto alla procedura prodmat1(…) dell’Esempio 2, questa nuova funzione risulta indipendente dalla particolare codifica adottata per il tipo matrice. Si noti anche che, questa volta, il parametro formale c utilizzato per trasmettere all’esterno il risultato deve essere dichiarato esplicitamente come parametro per riferimento. L’utilità di poter scrivere algoritmi in modo indipendente dalla codifica del tipo di dato cui si fa riferimento risulterà più chiaro, quando, nel seguito, verrà presentata una nuova possibile codifica del tipo matrice, che in alcune situazioni risulta più vantaggiosa di quella tramite array. La possibilità di cambiare la codifica utilizzata senza dover cambiare la codifica degli algoritmi risolutivi comporta un indubbio vantaggio in termini di modificabilità e flessibilità. E’ bene avvertire, però, che nel caso del tipo matrice il vantaggio è controbilanciato da una pesante perdita di efficienza. Ogni operazione primitiva, in questa codifica, richiede l’esecuzione di una chiamata di funzione, mentre questo non succede nel caso della codifica della Sezione 2.1 basata sull’uso diretto del meccanismo array (ogni operazione primitiva comporta in quel caso, sostanzialmente, solo un accesso in memoria, che richiede molto meno tempo per essere eseguito). La differenza, nel caso di algoritmi complessi che richiedono l’esecuzione di un gran numero di operazioni primitive (per esempio, somme e prodotti di matrici di grandi dimensioni) è sostanziale. 2.3. Codifica concreta tramite array In questa sezione viene mostrato come la codifica tramite array di matrici può essere “inglobata” nella codifica astratta basata sul meccanismo delle classi definita nella sezione precedente. E’ bene 16 comunque rimarcare quanto detto alla fine di quella sezione: se si sceglie di adottare il meccanismo array per la codifica, e non si hanno fortissime esigenze di modificabilità della rappresentazione, la codifica della Sezione 2.1 è senz’altro da preferire, in termini di efficienza di esecuzione. La codifica di questa sezione è quindi data sostanzialmente a scopo dimostrativo. const int N1 = … ; //cardinalità del dominio indice_1; const int N2 = … ; //cardinalità del dominio indice_2; typedef … T_elem_mat; //definizione del tipo degli elementi //della matrice; class matrice {private : T_elem_mat m[N1][N2]; public : void init_mat(T_elem_mat v); T_elem_mat valore(int i, int j); void modifica(int i, int j, T_elem_mat v); }; void matrice::init_mat(T_elem_mat v) {int i,j; for (i=0;i<N1;i++) for (j=0;j<N2;j++) m[i][j] = v; } T_elem_mat matrice::valore(int i, int j) {return m[i][j]; } void matrice::modifica(int i, int j, T_elem_mat v) {m[i][j] = v; } I 3. Codifica compatta di matrici sparse Per matrice sparsa si intende una matrice in cui la “maggior parte” degli elementi hanno uno stesso valore, detto valore dominante. Esempio 4 E’ molto facile che la matrice dei coefficienti di un sistema di equazioni lineari, in molti casi reali, sia una matrice sparsa. Un tipico sistema di equazioni è, ad esempio, il seguente: n ∑ j=1 aij xj = bi, i = 1, 2, …, n > 0 se |i - j| ≤ 1 con aij altrimenti = 0 In questo caso la matrice A = [aij] risulta formata da n2 elementi di cui: - 3n-2 elementi diversi da 0; - n2- (3n-2) elementi uguali a 0. 17 Quindi, 0 è il valore dominante,e solo 3n -2 elementi risultano “significativi” ai fini della soluzione dell’equazione. Fine Esempio 4 La codifica di matrici sparse usando il meccanismo array, come visto nella Sezione 2, comporta necessariamente, per le caratteristiche intrinseche del meccanismo, uno spreco di locazioni di memoria, usate per memorizzare elementi con valore dominante che, come nell’esempio appena visto, risultano non significativi. Si pone quindi l’esigenza di ideare codifiche alternative, che consentano di ridurre lo spreco di spazio. L’idea base di queste codifiche è di provare a rappresentare solo gli elementi con valore diverso da quello dominante. Tali codifiche vengono dette “codifiche compatte”. Nel seguito vengono riportate alcune possibili codifiche. Anche in questa sezione, per semplicità, ci limitiamo al caso di matrici bidimensionali. 3.1. Codifica a lista Questa codifica si basa sull’idea di estrarre dalla matrice gli elementi significativi, e di organizzarli in una sequenza ordinata. Per identificare nella sequenza ogni elemento della matrice, al suo valore dovremo associare la corrispondente coppia di indici. Esempio 5 Consideriamo la seguente matrice 5x5: c B = d a b e g h f La sua rappresentazione compatta secondo l’idea appena descritta risulta essere la seguente: [(a,1,2) (b,1,3) (c,2,1) (d,3,1) (e,3,4) (f,3,5) (g,4,3) (h,5,3)] Fine Esempio 5 Se la rappresentazione di una matrice è quella appena descritta, si pone il problema di come eseguire le operazioni primitive; in questo caso, evidentemente, non possiamo più fare affidamento su meccanismi già realizzati nella “macchina C++” (come per la codifica della Sezione 2), ma dobbiamo programmare esplicitamente tali operazioni. Sia β la sequenza di elementi [valore, indice_riga, indice_colonna] che rappresenta in modo compatto una generica matrice B. Algoritmi che realizzano le operazioni primitive possono essere schematizzati come segue: 18 • valore(B, i, j) : scandisci β partendo dall’inizio, esaminando ogni elemento x ; se esiste x tale che indice_riga(x) = i e indice_colonna(x) = j allora restituisci valore(x) altrimenti restituisci valore dominante • modifica(B, i, j, e): scandisci β partendo dall’inizio, esaminando ogni elemento x ; se esiste x tale che indice_riga(x) = i e indice_colonna(x) = j allora se e = valore dominante allora elimina x dalla sequenza β altrimenti valore(x) ← e altrimenti se e ≠ valore dominante allora aggiungi alla sequenza β l’elemento (e, i, j) E’ facile rendersi conto che la sequenza β usata per rappresentare una matrice B può essere vista come un oggetto appartenente al tipo astratto lista, dove il tipo degli elementi è costituito dalle triple (valore, indice_riga, indice_colonna). Gli algoritmi schematizzati sopra possono quindi essere descritti in maniera più precisa come segue, facendo uso delle operazioni primitive sul tipo lista: • valore(B, i, j) : k ← 1; finchè (k ≤LENGTH(β)) e (ELEMENT(β,k).indice_riga≠i o ELEMENT(β,k).indice_colonna≠j) k ← k+1 //ricerca di un elemento di indici (i,j) se k≤ LENGTH(β) allora restituisci (ELEMENT(β, k).valore altrimenti restituisci valore dominante •modifica(B, i, j, e): k ← 1; finchè (k ≤ LENGTH(β)) e (ELEMENT(β,k).indice_riga≠i o ELEMENT(β,k).indice_colonna≠j) k ← k+1 //ricerca di un elemento di indici (i,j) se k≤ LENGTH(β) allore se e=valore dominante allora β ← REMOVE(β,k) altrimenti β ← REMOVE(β, k) β ← INSERT(β, [e,i,j], k) altrimenti se e≠valore dominante allora β ← INSERT(β, [e,i,j], LENGTH(β)+1) //il nuovo elemento viene //aggiunto come ultimo della lista Possiamo ora dare una codifica C++ della rappresentazione compatta appena descritta, conforme alla codifica astratta della Sezione 2.2 (quindi, quello che codificheremo sarà la parte “private” di quella classe e le relative operazioni primitive. Questa codifica utilizza, al suo interno, una codifica C++ del tipo astratto lista. A questo scopo può essere usata la codifica del tipo lista data nel relativo capitolo, o qualunque altra codifica che si conformi alla stessa codifica astratta. 19 • codifica della classe: const int N1 = … ; //cardinalità del dominio indice_1; const int N2 = … ; //cardinalità del dominio indice_2; typedef … T_elem_mat; //definizione del tipo degli elementi //della matrice; typedef struct {T_elem_mat val; int riga; int colonna; } T_elem_lis; class lista {private : //tipo degli elementi della lista //utilizzata per rappresentare la //matrice //una qualunque codifica del tipo lista public : void init_lis(); int length(); T_elem_lis element(int i); void insert(T_elem_lis b,int i); void remove(int i); }; class matrice {private : lista l; //contiene i valori diversi da quello dominante T_elem_mat elem_dom; //valore dell’elemento dominante public : void init_mat(T_elem_mat v); T_elem_mat valore(int i, int j); void modifica(int i, int j, T_elem_mat v); }; Come si può vedere dalla codifica data, una matrice viene rappresentata tramite una struttura a due campi, uno dei quali contiene il valore dominante, e l’altro è la lista dei valori (con relativi indici) diversi dal valore dominante. In pratica, il compattamento è stato ottenuto “collassando” tutti gli elementi con valore dominante nel singolo campo “elem_dom”. Con riferimento alla matrice dell’Esempio 4, la codifica di tale matrice risulterebbe essere quella rappresentata graficamente in Figura 1, immaginando che il valore dominante sia espresso come x.. elem_dom : l: x (a ,1,2) (b ,1,3) (c ,2,1) (d ,3,1) (e ,3,4) (f ,3,5) (g ,4,3) (h ,5,3) Figura 1 • codifica delle operazioni primitive: //è necessario premettere la codifica delle operazioni su lista, che //verranno utilizzate per realizzare le operazioni primitive su matrice void lista::init_lis() {//codifica dell’ operazione init_lis() } 20 int lista::length() {//codifica dell’ operazione length() } T_elem_lis lista::element(int i) {//codifica dell’ operazione element(…) } void lista::insert(T_elem_lis b,int i) {//codifica dell’ operazione insert(…) } void lista::remove(int i) {//codifica dell’ operazione remove(…) } void matrice::init_mat(T_elem_mat v) {elem_dom = v; l.init_lis(); } T_elem_mat matrice::valore(int i, int j) {int k; k=1; while (k<=l.length() && (l.element(k).riga!=i || l.element(k).colonna!=j)) k++; if (k<=l.length()) return (l.element(k)).val; else return elem_dom; } void matrice::modifica(int i, int j, T_elem_mat v) {T_elem_lis t; int k; k=1; while (k<=l.length() && (l.element(k).riga!=i || l.element(k).colonna!=j)) k++; if (k<=l.length()) //se vero l'elemento di indice (i,j) e' gia' presente if (v==elem_dom) l.remove(k); else {l.remove(k); t.riga=i; t.colonna=j; t.val=v; l.insert(t,k); } else //l'elemento di indice (i,j) non e' presente if (v!=elem_dom) {t.riga=i; t.colonna=j; t.val=v; l.insert(t,1); //convenzione: si inserisce in prima posizione } } Possiamo ora discutere vantaggi e svantaggi di questa codifica rispetto a quella tramite il meccanismo array della Sezione 2. Prendiamo in considerazione matrici quadrate n x n con k elementi diversi dal valore dominante: • spazio occupato: codifica con array: proporzionale a n2; codifica compatta a lista: proporzionale a 3k; 21 • complessità delle operazioni primitive: codifica con array: proporzionale a 1; codifica compatta a lista: proporzionale a k; Facciamo alcune osservazioni. Dal punto di vista dello spazio occupato, la codifica compatta è conveniente solo se k < n2/3. C’è inoltre da considerare che, per codifiche di liste come quelle viste nel relativo capitolo, l’occupazione di spazio può essere superiore a 3k (se scegliamo un N>k per avere la possibilità di aggiungere nuovi elementi). Dal punto di vista della complessità delle operazioni, quelle nel caso “array” costano 1 perchè sono indipendenti dalla dimensione della matrice (vedi modalità di accesso a una struttura array memorizzata nella memoria di una macchina C++); nel caso compatto a lista, invece, può succedere di dover scandire l’intera lista (k elementi) prima di concludere l’operazione (per esempio nel caso l’elemento di indice (i,j) non sia presente nella lista perchè ha valore dominante). C’è inoltre da considerare che, nel caso di codifica compatta, le operazioni primitive devono necessariamente essere realizzate come procedure e funzioni, con conseguente innalzamento del costo. Nella sezione seguente viene presentata una codifica compatta alternativa, con l’obiettivo di ridurre il costo delle operazioni primitive, a prezzo di un leggero aumento dello spazio occupato. 3.2. Codifica a vettore di liste L’idea alla base di questa codifica è quella di organizzare in liste separate gli elementi “significativi” (diversi dal valore dominante) di ogni riga, e di fornire punti di accesso separati per ogni lista. In questo modo, le operazioni di scansione di una lista per ricercare un elemento di indice (i, j) richiedono un numero di passi pari al più al numero di elementi significativi in una riga, e non nell’intera matrice. Esempio 5 Consideriamo di nuovo la matrice 5x5: c B = d a b e g h f La sua rappresentazione compatta secondo l’idea descritta in questa sezione risulta essere la seguente: 22 riga 1: (a, 2) (b, 3) riga 2 : (c, 1) riga 3 : (d, 1) (e, 4) (f, 5) riga 4 : (g, 3) riga 5 : (h, 3)] Fine Esempio 5 Come si può vedere anche dall’esempio, la lista che rappresenta la riga i-esima è formata da elementi ognuno dei quali è una coppia di tipo (valore, indice_colonna). La ricerca dell’elemento di indice (i, j) della matrice avviene quindi accedendo alla lista che rappresenta la riga i-esima, e scandendola finchè non si trova, eventualmente, un elemento con indice_colonna=j. Possiamo a questo punto dare direttamente una codifica C++ di questa rappresentazione. Si noti che nella codifica, i nomi di alcuni “tipi” C++ sono uguali a nomi usati nella codifica a lista unica della Sezione 3.1, ma hanno una definizione diversa in questa nuova codifica. • codifica del la classe: const int N1 = … ; //cardinalità del dominio indice_1; const int N2 = … ; //cardinalità del dominio indice_2; typedef … T_elem_mat; //definizione del tipo degli elementi //della matrice; typedef struct {T_elem_mat val; int colonna; } T_elem_lis; class lista {private : //tipo degli elementi della lista //utilizzata per rappresentare la //matrice; notare la differenza //rispetto alla definizione di //Sezione 3.1 //una qualunque codifica del tipo lista public : void init_lis(); int length(); T_elem_lis element(int i); void insert(T_elem_lis b,int i); void remove(int i); }; class matrice {private : lista l[N1]; //vettore di liste; la lista i-esima contiene //i valori della riga i-esima diversi da //quello dominante T_elem_mat elem_dom; //valore dell’elemento dominante public : void init_mat(T_elem_mat v); T_elem_mat valore(int i, int j); void modifica(int i, int j, T_elem_mat v); }; Come si può vedere dalla codifica data, una matrice viene rappresentata come una struttura a due campi, uno dei quali contiene il valore dominante, e l’altro è un vettore di liste, ognuna delle quali contiene gli elementi significativi di una riga, con il relativo indice di colonna. 23 Con riferimento alla matrice dell’Esempio 5, la codifica di tale matrice risulterebbe essere quella rappresentata graficamente in Fig. 2, immaginando anche qui che il valore dominante sia espresso come x. elem_dom : l: (a (c (d (g (h x ,2) (b ,3) ,1) ,1) ( e ,4) (f ,5) ,3) ,3) Figura 2 • codifica delle operazioni primitive: //è necessario premettere la codifica delle operazioni su lista, che //verranno utilizzate per realizzare le operazioni primitive su matrice void lista::init_lis() {//codifica dell’ operazione init_lis() } int lista::length() {//codifica dell’ operazione length() } T_elem_lis lista::element(int i) {//codifica dell’ operazione element(…) } void lista::insert(T_elem_lis b,int i) {//codifica dell’ operazione insert(…) } void lista::remove(int i) {//codifica dell’ operazione remove(…) } void matrice::init_mat(T_elem_mat v) {int k; elem_dom = v; for (k=0;k<N1;k++) l[k].init_lis(); } 24 T_elem_mat matrice::valore(int i, int j) //si noti la forte somiglianza con la realizzazione della stessa //funzione nel caso di rappresentazione a lista unica {int k; k=1; while (k<=l[i].length() && l[i].element(k).colonna!=j) k++; if (k<=l[i].length()) return (l[i].element(k)).val; else return elem_dom; } void matrice::modifica(int i, int j, T_elem_mat v) //si noti anche qui la forte somiglianza con la realizzazione della //stessa funzione nel caso di rappresentazione a lista unica {T_elem_lis t; int k; k=1; while (k<=l[i].length() && l[i].element(k).colonna!=j)) k++; if (k<=l[i].length()) //se vero l'elemento di indice (i,j) e' gia' //presente if (v==elem_dom) l[i].remove(k); else {l[i].remove(k); t.colonna=j; t.val=v; l[i].insert(t,k); } else //l'elemento di indice (i,j) non e' presente if (v!=elem_dom) {t.colonna=j; t.val=v; l[i].insert(t,1); //convenzione: si inserisce in prima posizione } } Si può facilmente verificare che il costo delle operazioni diventa proporzionale al numero di elementi significativi in una riga (che sarà minore del numero totale di elementi significativi nell’intera matrice). Il costo può essere in molti casi ulteriormente ridotto se la lista che rappresenta ogni singola riga viene ordinata per indice di colonna crescente. In questo caso il costo medio di ricerca dell’elemento di indice (i, j) diventa proporzionale a (num. elementi significativi in una riga)/2. Ovviamente, una organizzazione per indici di colonna crescenti richiede che le operazioni di inserzione nella lista siano realizzate in modo da rispettare l’ordinamento. Infine, una ultima considerazione, per rimarcare i vantaggi in termini di flessibilità e modificabilità di algoritmi risolutivi progettati secondo la filosofia della programmazione a oggetti (utilizzando il meccanismo delle classi offerto dal C++). Se si considera la procedura “prodmat2” per il prodotto di matrici data nell’Esempio 3, si può facilmente verificare che essa può essere utilizzata con una qualunque delle rappresentazioni di matrici fornite (usando classi con la stessa parte “pubblica”), a differenza della procedura “prodmat1” dell’Esempio 2 che può essere utilizzata solo con rappresentazione basata direttamente sul meccanismo array. Rimangono ovviamente valide le considerazioni sulla maggiore velocità nella esecuzione delle operazioni nel caso di quest’ultima rappresentazione. 25 Capitolo 2 Linguaggio “assembler” e architettura della macchina 26 Introduzione Anche questo capitolo non intende costituire un testo autosufficiente, ma va inteso come integrazione delle lezioni tenute regolarmente in aula durante lo svolgimento del corso. Lo scopo delle lezioni a cui questo capitolo fa riferimento è di fornire una idea più precisa di come algoritmi espressi in un formalismo di “alto livello” (come è, ad esempio, il C++) possono essere concretamente eseguiti da una macchina reale. L’approccio seguito a tale scopo è in due passi. Viene prima presentato un nuovo formalismo astratto (chiamato AS-86) di livello “più basso” rispetto al C++, e viene mostrato come i fondamentali costrutti del C++ possono essere tradotti in sequenze di operazioni semanticamente equivalenti dell’AS-86. Viene poi presentata l’architettura di una possibile macchina reale (chiamata 8086-S) e viene mostrato come algoritmi specificati in AS-86 possono essere tradotti in sequenze di operazioni semanticamente equivalenti nel linguaggio (chiamato LM) eseguibile dall’8086-S. I nomi scelti per indicare la macchina e i linguaggi non sono casuali. La macchina 8086-S e LM non sono altro che una versione notevolmente semplificata della nota famiglia di processor 80x86 e del loro linguaggio macchina, rispettivamente, mentre il linguaggio AS-86 è ispirato ai corrispondenti linguaggi “assembler”. La presentazione di questi due punti segue un ordine inverso rispetto a quello abitualmente seguito (prima l’architettura della macchina e poi il suo linguaggio “assembler”, che viene presentato come una semplice versione “simbolica” del linguaggio macchina). La presentazione “inversa” adottata intende invece mettere l’accento sulla stratificazione di “livelli virtuali” che avvolgono una macchina reale e che rendono possibile l’esecuzione di formalismi anche molto “distanti” dal linguaggio macchina. L’importanza di tale concetto di base mi è sembrato un motivo sufficiente a giustificare la “forzatura” insita nell’approccio seguito. Questo capitolo, per la sua natura, si presenta incompleto in particolare per quanto riguarda la formalizzazione di molti dei concetti espressi. In un caso (traduzione da C++ in AS-86) tale mancata formalizzazione rappresenta una scelta in gran parte obbligata, in quanto richiederebbe strumenti che verranno acquisiti in corsi più avanzati. Spero comunque di aver dato l’idea che tale traduzione è “possibile”. Negli altri casi, (specificazione della sintassi e semantica di AS-86 e LM) la formalizzazione è solo parziale. Questo è dovuto alla natura di “lavoro in corso” di tali note, e spero non sia tale da compromettere la comprensione delle idee base. Ogni commento e suggerimento sarà comunque gradito. 27 Il linguaggio AS-86 Un algoritmo può essere specificato usando differenti formalismi, ognuno dei quali può essere “eseguito” da un opportuno “automa” (si ricorda che per automa si può intendere una qualunque entità in grado di comprendere ed eseguire un algoritmo espresso in un certo formalismo; quindi un automa può essere un essere umano, una macchina astratta, una macchina concreta, …). Consideriamo come esempio, il seguente algoritmo (banale) per calcolare il massimo tra due numeri A e B. Un primo modo per esprimerlo può essere nel formalismo dei diagrammi a blocchi: leggi A leggi B si no A≥B? max <-- A max <-- B scrivi max l Figura 1 Lo stesso algoritmo può essere espresso in un altro formalismo a noi noto, il C++, eseguibile da una “macchina C++”. main () {int A, B, MAX; cin >> A >> B; if (A>=B) MAX = A; else MAX = B; cout << MAX; } Figura 2 Mettiamo in evidenza alcune caratteristiche che questo ultimo formalismo richiede per specificare ed eseguire un algoritmo: a. i dati utilizzati devono essere dichiarati esplicitamente; b. la macchina C++ esegue il programma in accordo alla seguente regola base: 1. la prima istruzione del programma è l’istruzione corrente 2. esegui l’istruzione corrente; 3. l’istruzione immediatamente successiva è la nuova istruzione corrente; 4. ritorna a 2. 28 La regola base può essere alterata usando opportuni costrutti di controllo che consentono di “saltare” (if … … else) o “iterare” (for, while, do-while) blocchi di istruzioni. In effetti, si può pensare che questi costrutti consentono di alterare il valore di una sorta di puntatore implicito che la macchina C++ usa per tenere traccia di quale è l’istruzione corrente. Esempio 1 - Se A=5, B=7, il “puntatore implicito” nel programma di Fig. 2 assume in sequenza questi valori: 1 → cin >> A >> B; if (A>=B) MAX = A; (nota come viene “saltata” l’istruzione MAX = A;) 2 → else MAX = B; 3→ cout << MAX; _____________________________ Consideriamo ora la realizzazione dello stesso algoritmo di Figura 1 e 2 in un nuovo formalismo, che chiamiamo AS-86, non ancora definito: MAX THEN: FINE: DW ? <leggi AX> <leggi BX> CMP AX,BX JGE THEN MOV MAX,BX JMP FINE MOV MAX,AX <scrivi MAX> } dichiarazione | | | > operazioni eseguibili | | Figura 3 Non essendo ancora stato definito il formalismo AS-86, questo algoritmo risulta probabilmente di difficile comprensione. Lo commentiamo qui di seguito, per dare una prima idea (informale) delle caratteristiche particolari di questo nuovo formalismo: AX, BX e MAX sono operandi; CMP, JGE, MOV, JMP sono operazioni eseguibili; THEN, FINE sono oggetti speciali, detti etichette (il loro significato verrà spiegato più avanti); Si può quindi notare: • solo uno degli operandi è dichiarato esplicitamente (MAX); 29 • le operazioni hanno un formato standard: <etichetta> : <nome operazione> |← CAMPO ETICHETTA →|← CAMPO OPERATORE <operandi> →|← CAMPO OPERANDI →| (opzionale) (uno o due; se due, il primo è anche la “destinazione” del risultato) In AS-86 la regola standard di esecuzione è analoga a quella del C++. A differenza del C++ però, il puntatore all’istruzione corrente è un puntatore esplicito , nel senso che ha un nome (PC) e il suo valore può essere modificato esplicitamente. Infatti, a differenza del C++, la regola standard viene alterata in AS-86 usando un diverso meccanismo, quello delle istruzioni di salto, mediante le quali viene assegnato un nuovo “valore” al puntatore PC. Una istruzione di salto ha il seguente formato: J<cond> <etichetta> dove <cond> è una sequenza di uno o due caratteri alfabetici che specifica una condizione logica (secondo un codice che verrà dato più avanti). Il significato di una istruzione di salto è il seguente: se <cond> è vera, allora assegna a PC il valore di puntatore alla istruzione nel cui campo etichetta compare <etichetta>; altrimenti, assegna a PC il valore di puntatore alla istruzione successiva a quella di salto. MP: condizione sempre vera (salto “incondizionato”) L: minore LE: minore o uguale G: maggiore GE: maggiore o uguale EQ: uguale NZ: diverso Tabella 1 - Codici delle condizioni di salto Quindi, nel programma di Figura 3, dopo l’istruzione CMP AX,BX il cui significato è: "confronta il valore di AX e BX", si esegue l’istruzione JGE THEN che ha il significato di salto condizionato all’esito del confronto appena fatto (in questo caso, si salta se risulta AX≥BX). Se il salto non viene eseguito (cioè se AX<BX), allora si esegue MOV MAX, BX 30 il cui effetto è di assegnare a MAX il valore di BX, e subito dopo si esegue l’istruzione di salto incondizionato JMP FINE per evitare di eseguire l’istruzione MOV MAX,AX. Dopo questa presentazione informale, presentiamo formalmente il linguaggio AS-86. Questo significa definire: • dati rappresentabili; • sintassi e semantica della dichiarazione di dati; • sintassi e semantica delle operazioni disponibili. Dati rappresentabili E’ possibile utilizzare dati appartenenti a due tipi diversi: DW: DB: interi compresi tra ± 215; { interi compresi tra ±2 7 caratteri (fino a 256 caratteri diversi) Dichiarazioni Diamo ora la sintassi della dichiarazione esplicita di dati di tipo DB o DW. <dich-cost>::= <nome> EQU <val-def> <dich-var>::= <nome> <tipo> <corpo-dich> <tipo> ::= DB | DW <corpo-dich> ::= <lista> | <sequenza> <lista> ::= <valore> | <valore>, <lista> <valore> ::= <val-def> | <val-indef> <val-indef> ::= ? <val-def> ::= -215 | … | -1 | 0 | 1 | 2 | … | 215-1 | A | B | C | … | Z <sequenza> ::= <dim-sequenza> DUP (<valore>) <dim-sequenza> ::= 1 | 2 | 3 | 4 | … Diamo ora alcuni esempi di dichiarazioni ricavabili dalla sintassi appena descritta. La semantica della dichiarazioni di costanti, variabili, “liste” e “sequenze” è analoga alla semantica delle corrispondenti dichiarazioni C++ (associazione tra nome e “oggetto” dichiarato). Gli esempi che seguono evidenziano tale analogia. Dichiarazione di costante NUM EQU 10 (associa al nome NUM il valore costante 10) CAR EQU 'A' (associa al nome CAR il valore costante 'A') 31 Dichiarazioni di variabili di tipo DB VAR DB ? (dichiarazione di var. di nome VAR; la cella di memoria associata a tale nome ha valore indefinito) NUM DB 23 (dichiarazione di var. di nome NUM; la cella di memoria associata a tale nome è inizializzata con il valore 23) LISTANUM DB 12, 7 (dichiarazione di “lista” di (due) variabili; la 1a è identificata dal nome LISTANUM, e la cella di memoria associata è inizializzata con il valore 12; la 2a (per convenzione dell’AS-86) è identificata dal “nome” LISTANUM+1, e la corrispondente cella di memoria è inizializzata con il valore 7) LISTANUM: 12 LISTANUM+1 : 7 Figura 4 LISTACAR DB 'A','B','C' (dichiarazione di “lista” di (tre) variabili; la 1a è identificata dal nome LISTACAR, e la cella di memoria associata è inizializzata con il valore 'A'; la 2a (per convenzione dell’AS-86) è identificata dal “nome” LISTACAR+1, e la corrispondente cella di memoria è inizializzata con il valore 'B'; la 3a (per convenzione dell’AS-86) è identificata dal “nome” LISTACAR+2, e la corrispondente cella di memoria è inizializzata con il valore 'C') LISTACAR : 'A' LISTACAR+1 : 'B' LISTACAR+2 : 'C' Figura 5 STR DB 10 DUP('A') (dichiarazione di sequenza di (dieci) elementi, tutti inizializzati con il valore 'A'; il 1° è identificato dal nome STR; il 2° (per convenzione dell’AS-86) è identificata dal “nome” STR+1; … ; il 10° è identificato dal “nome” STR+9) 32 STR : 'A' STR+1 : 'A' STR+2 : 'A' . . . . STR+9 : 'A' Figura 6 Dichiarazioni di variabili di tipo DW VAR DW ? (dichiarazione di var. di nome VAR; la cella di memoria associata a tale nome ha valore indefinito) NUM DW 154 (dichiarazione di var. di nome NUM; la cella di memoria associata a tale nome è inizializzata con il valore 154) LISTANUM DW 207,128,13 (dichiarazione di “lista” di (tre) variabili; la 1a è identificata dal nome LISTANUM, e la cella di memoria associata è inizializzata con il valore 207; la 2a (per convenzione dell’AS-86) è identificata dal “nome” LISTANUM+2 (nota la differenza dal caso precedente!) e la corrispondente cella di memoria è inizializzata con il valore 128; la 3 a è identificata dal “nome” LISTANUM+4 e la corrispondente cella di memoria è inizializzata con il valore 13) LISTANUM : 207 LISTANUM+2 : 128 LISTANUM+4 : 13 Figura 7 VET DW 20 DUP(?) (dichiarazione di sequenza di (venti) elementi, tutti non inizializzati; il 1° è identificato dal nome VET; il 2° (per convenzione dell’AS-86) è identificata dal “nome” VET+2; il 3° è identificato dal “nome” VET+4; … ; il 20° è identificato dal “nome” VET+38) 33 VET : VET+2 : VET+4 : . . . . VET+38 : Figura 8 Come già accennato nell’esempio presentato in precedenza, il linguaggio AS-86 consente di utilizzare, oltre ai dati (costanti, variabili,…) dichiarati esplicitamente secondo le modalità appena viste, anche alcune variabili “predichiarate”. Per variabili predichiarate si intendono dei nomi predefiniti ai quali è già associato un tipo (p.es. DB) e una cella di memoria. Ne diamo ora l’elenco completo. Variabili predichiarate AX, BX, CX, DX: di tipo DW AH, AL, BH, BL, CH, CL, DH, DL: di tipo DB SI, DI: di tipo “indice” (l’uso di questo tipo speciale verrà spiegato nel seguito) SP: di tipo “pila” (il formalismo AS-86 dispone di una pila (non è possibile definirne altre perchè il tipo “pila” non è inserito tra i tipi utilizzabili nelle dichiarazioni esplicite), il cui riferimento all’elemento di testa è SP). Operazioni La sintassi delle operazioni disponibili nel formalismo AS-86, già data informalmente in precedenza, è la seguente: <operazione> ::= <etichetta> : <operatore> <lista operandi> | <operatore> <lista operandi> <etichetta> ::= <nome> <operatore> ::= ADD | SUB | MUL | DIV | INC | DEC | CMP | MOV | PUSH | POP <lista operandi> ::= <operando> | <operando>, <operando> <operando> ::= <val-def> | <nome> | <nome con indice> | <puntatore> Questa sintassi (incompleta) ci dice che una operazione è formata da due parti fisse (<operatore> e <lista operandi>) più una opzionale (<etichetta>); a sua volta la <lista operandi> è formata da uno o due operandi, ognuno dei quali può essere specificato secondo una delle quattro modalità indicate (che spiegheremo più avanti). 34 Ovviamente, non tutte le “frasi” corrette secondo questa sintassi hanno un significato nella semantica del formalismo AS-86. Di seguito, indichiamo quali vincoli devono essere soddisfatti perchè tali “frasi” abbiano un significato, e quale è tale significato. • Operatore ADD : Frase lecita: ADD dst, src (dove src e dst sono due operandi) Semantica: dst = dst + src Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • uno dei due operandi deve essere una variabile predichiarata o una costante • Operatore SUB : Frase lecita: SUB dst, src (dove src e dst sono due operandi) Semantica: dst = dst - src Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • uno dei due operandi deve essere una variabile predichiarata o una costante • Operatore MUL : Frase lecita: MUL dst, src (dove src e dst sono due operandi) Semantica: dst = dst x src Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • uno dei due operandi deve essere una variabile predichiarata o una costante • Operatore DIV : Frase lecita: DIV dst, src (dove src e dst sono due operandi) Semantica: dst = dst / src Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • uno dei due operandi deve essere una variabile predichiarata o una costante • Operatore INC : Frase lecita: INC dst Semantica: dst = dst + 1 Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante 35 • Operatore DEC : Frase lecita: DEC dst Semantica: dst = dst - 1 Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • Operatore CMP : Frase lecita: CMP dst, src (dove src e dst sono due operandi) Semantica: confronta (e memorizza l’esito del confronto) dst e src Vincoli: • uno dei due operandi deve essere una variabile predichiarata o una costante • Operatore PUSH : Frase lecita: PUSH src Semantica: inserisce sulla pila (“push”) il valore di src • Operatore POP : Frase lecita: POP dst Semantica: estrae dalla pila (“pop”) il valore di testa e lo assegna a dst Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • Operatore MOV : Frase lecita: MOV dst, src (dove src e dst sono due operandi) Semantica: dst := src Vincoli: • l’operando dst non può essere specificato come <val-def> oppure come <nome> se <nome> è un nome di costante • uno dei due operandi deve essere una variabile predichiarata o una costante Nota: uno dei vincoli che compare spesso nelle definizioni appena date (uno dei due operandi deve essere una variabile predichiarata o una costante) può apparire del tutto “arbitrario” e privo di giustificazioni. Lo è in effetti, alla luce delle conoscenze che abbiamo a questo punto. Troverà una sua giustificazione più avanti, quando affronteremo il problema della “traduzione” di algoritmi specificati in AS-86 in algoritmi specificati in un differente formalismo (“linguaggio macchina”). Modalità di specificazione degli operandi Abbiamo visto che la sintassi per specificare operandi è: <operando> ::= <val-def> | <nome> | <nome con indice> | <puntatore> Diamo ora la semantica delle varie modalità sintattiche indicate: 36 <val-def> : il valore specificato è quello indicato esplicitamente dall’operando; <nome> : dobbiamo distinguere due casi: 1• <nome> appare in una dichiarazione di costante: il valore specificato è quello associato a <nome> nella dichiarazione; 2 • <nome> appare in una dichiarazione di variabile o è il nome di una variabile predichiarata: il valore specificato è: il valore contenuto nella cella di memoria associata a <nome>. <nome con indice> : la sintassi completa di questa modalità di specificazione degli operandi è: <nome con indice> ::= <nome> [<indice>] dove <nome> deve essere il nome di una variabile dichiarata esplicitamente e <indice> deve essere il nome di una delle due variabili predichiarate di tipo “indice” (DI o SI), oppure una costante il valore specificato è: il valore contenuto nella cella di memoria associata a <nome>+(valore di <indice>). Nota: per il significato di <nome>+(valore di <indice>) vedi nella sezione Dichiarazioni le modalità di accesso agli elementi di oggetti dichiarati come “sequenza” o “lista”. <puntatore> : la sintassi completa di questa modalità di specificazione degli operandi è: <puntatore> ::= [<indice>] | [<indice>] + <num. intero> dove <indice> deve essere il nome di una delle due variabili predichiarate di tipo “indice” (DI o SI), e <num. intero> è un valore intero positivo; il valore specificato è: il valore contenuto nella cella di memoria associata a <indice>+(valore di <num. intero>). Nota: al momento non è ancora noto come associare a una variabile di tipo indice una cella di memoria. Vedremo più avanti come questo può essere fatto. Esempio 2 Consideriamo il seguente “frammento” di programma C++: int int . . x = y = y = const x,y ; k = 10; y; k; 30; 37 Un frammento equivalente (cioè con la stessa semantica) in AS-86 è il seguente: K EQU 10 X DW ? Y DW ? . . MOV AX, Y MOV X, AX MOV Y,K MOV Y,30 oppure, usando la variabile predichiarata BX al posto di Y: K EQU 10 X DW ? . . MOV X,BX MOV BX,K MOV BX,30 ____________ Consideriamo il programma visto prima per il calcolo del massimo tra due numeri: THEN: FINE: MAX DW ? <leggi AX> <leggi BX> CMP AX,BX JGE THEN MOV MAX,BX JMP FINE MOV MAX,AX <scrivi MAX> Abbiamo visto, a suo tempo, come questo programma sia equivalente (abbia la stessa semantica) ad un programma C++ che fa uso del costrutto if … … else. Questa equivalenza può essere generalizzata secondo la seguente regola: Regola di traduzione 1: dato un comando C++ del tipo if <condizione> <comando 1> else <comando 2> l’equivalente programma AS-86 è: <test condizione > (usando CMP … , tipicamente) J<cond> THEN (salto condizionato, si deve saltare se la condizione è vera) <operazioni equivalenti a “comando 2”> JMP ENDIF 38 THEN : <operazioni equivalenti a “comando 1”> ENDIF : … _________________ Esempio 3 Calcolo del massimo in un vettore di 10 elementi. Il frammento di programma C++ che risolve questo problema è il seguente: int int … max for vet [10]; i,max ; = vet[0]; (i = 1; i<10; i++) if (max < vet[i]) max = vet[i]; … Un programma equivalente in AS-86 è il seguente: VET MAX DW 10 DUP (?) DW ? . . MOV AX, VET MOV SI, 0 CICLO : ADD SI, 2 CMP AX, VET[SI] JGE ENDIF MOV AX, VET[SI] MOV MAX, AX ENDIF : CMP SI, 18 JL CICLO … (SI funge da indice per il ciclo) (l’incremento di SI è di 2 perchè l’array è di oggetti di tipo DW) (AX ≥VET[SI]?) (salta se la condizione è vera) (realizza in due passi l’operazione MAX = VET[SI] (raggiunto l’ultimo elemento del vettore?) (salta se l’indice SI non ha raggiunto l’ultimo elemento del vettore) Nota: gli elementi dell’array VET sono individuabili come mostrato nella seguente figura. ' VET : VET+2 : VET+4 : . 10 ELEMENTI . VET+18 : Figura 9 _________________________________ L’esempio appena visto, oltre a presentare un ulteriore caso di traduzione di un costrutto if del C++ in uno equivalente in AS-86, presenta anche una traduzione di un costrutto for del C++ in uno equivalente in AS-86. Anche in questo caso possiamo definire una regola generale che fa corrispondere a un comando for C++ un equivalente programma in AS-86. La regola è la seguente: 39 Regola di traduzione 2: dato un comando C++ del tipo: for (i = k; i≤n; i++) <comando> l’equivalente programma AS-86 è: MOV <indice>, k*incr. (<indice> è una delle due variabili SI o DI) CICLO : CMP <indice>, n*incr. (se l’indice risulta > n, si deve uscire dal ciclo) JG ENDFOR <operazioni equivalenti a “comando”> ADD <indice>, incr. (incr. è un opportuno incremento dell’indice, precedente, incr.=2) JMP CICLO ENDFOR : …… nell’esempio _____________________________ Esempio 4 Dato il seguente comando C++ (dove a, i ed n sono variabili di tipo int): … for (i = 3; i≤n; i++) a = a+i; … una sua corrispondente traduzione in AS-86 è: N A … DW ? DW ? MOV SI, 3 CICLO: CMP SI, N JG ENDFOR ADD A, SI ADD SI, 1 (oppure JMP CICLO ENDFOR: … INC SI) Da notare che in questo caso l’incremento “opportuno” di SI per realizzare un comportamento equivalente al comando C++ è 1. ____________________________ Esempio 5 Dato un vettore di 5 interi, moltiplicare per due tutti quelli che precedono un elemento (se c’è) di valore uguale ad un valore “soglia”. Ad esempio, se “soglia”=8, il vettore dato verrà trasformato come segue: 40 caso a): il vettore contiene l’elemento soglia; la trasformazione si arresta all’elemento precedente; 9 18 6 12 8 8 5 5 15 15 vettore di partenza vettore trasformato caso b): il vettore non contiene l’elemento soglia; la trasformazione interessa tutto il vettore; 7 14 11 22 3 6 5 10 12 24 vettore di partenza vettore trasformato Un programma C++ che risolve questo problema è il seguente. Nel programma, per semplificare le operazioni da eseguire, si ricorre all’espediente di aggiungere in fondo al vettore originario un elemento fittizio, che assume il valore “soglia”. int vet :6] ; int soglia, i; … cin >> soglia; vet[5] = soglia; i := 0; while (vet[i] != soglia) {vet[i] = 2*vet[i]; i = i+1; } Un programma equivalente in AS-86 è il seguente: 41 VET DW 6 DUP (?) . . <memorizza in DX il valore “soglia”> MOV VET[10], DX (vet[6] = soglia) MOV SI, 0 CICLO : CMP DX, VET[SI] (soglia≠VET[SI]?) JEQ ENDWHILE (se la condizione è falsa, esci dal ciclo) MUL VET[SI], 2 (VET[SI] = 2*VET[SI]) ADD SI, 2 (incremento dell’indice) JMP CICLO ENDWHILE : … ____________________________ Anche in questo caso possiamo generalizzare l’esempio visto nella seguente regola: Regola di traduzione 3: dato un comando C++ del tipo while (<condizione>) <comando> l’equivalente programma AS-86 è il seguente: CICLO : <test condizione> Jcond ENDWHILE (tipicamente, con una istruzione CMP) (salta se la condizione del while è falsa) <operazioni equivalenti a <comando>> JMP CICLO ENDWHILE : … ____________________________ Esempio 6 Calcolo della media di un vettore di 16 interi. La versione C++ di un algoritmo che risolve questo problema è la seguente. int vet [16]; int media, i; <lettura vettore>; media = 0; for (i = 0; i<16; i++) media = media + vet[i]; media = media/16 La versione equivalente in AS-86 è la seguente. VET DW 16 DUP (?) MEDIA DW ? <lettura vettore> MOV MEDIA, 0 MOV SI, 0 42 CICLO : MOV AX, VET[SI] MEDIA, VET[SI] a causa del vincolo sul tipo di ADD MEDIA, AX ADD SI, 2 CMP SI, 30 JLE CICLO DIV MEDIA, 16 (nota che sarebbe stato scorretto scrivere direttamente ADD operandi) (incremento dell’indice) _______________________________ Esempio 7 Somma di due vettori A e B di n elementi (la somma va in A). La versione C++ di un algoritmo che risolve questo problema è la seguente. int const n = … ; int a[n]; int b[n]; int i; <lettura di a e b>; for (i = 1; i<n; i++) a[i] = a[i] + b[i]; … L’equivalente versione in AS-86 è la seguente. <lettura vettori A e B> MOV SI, 0 CICLO : MOV BX, B[SI] (nota che sarebbe stato scorretto scrivere direttamente ADD A[SI], B[SI] a causa del vincolo sul tipo di operandi) ADD A[SI], BX ADD SI, 2 CMP SI, DOUBLE_N (SI < 2*n?) JL CICLO (salta se la condizione è vera) . . DOUBLE_N EQU … (valore pari a 2*n) N EQU … A DW N DUP (?) B DW N DUP (?) Procedure Le motivazioni che portano all’introduzione del concetto di “procedura” in un linguaggio di programmazione ci sono già note. Per poter rendere praticabile l’uso di tale concetto, un linguaggio di programmazione deve mettere a disposizione: • costrutti per dichiarare che un segmento di codice è una procedura; • meccanismi di chiamata e ritorno; • meccanismi per il passaggio dei parametri e il ritorno dei risultati. Riassumiamo brevemente come il formalismo C++ risolve questi punti: A) dichiarazione: diamo per nota la sua sintassi e semantica; B) chiamata: quando un nome di procedura viene incontrato durante l’esecuzione, il “puntatore implicito” della macchina C++ si trasferisce sulla prima istruzione eseguibile della procedura; 43 C) ritorno: terminata l’ultima istruzione eseguibile di una procedura, il “puntatore implicito” ritorna automaticamente a puntare alla istruzione successiva a quella in cui era stata fatta la chiamata; D) passaggio parametri: • per valore; • per riferimento; E) ritorno risultati: • per riferimento • valore ritornato da funzioni. Nel formalismo AS-86 la soluzione data è la seguente: A) dichiarazione: sintassi: <dich. procedura> ::= <nome> PROC <comando> ENDP semantica: la sequenza di operazioni <comando> viene associata al nome <nome>; B) chiamata: sintassi: <chiamata di procedura> ::= CALL <nome> dove <nome> deve essere un nome di procedura; semantica: 1. PC assume il valore di riferimento alla prima istruzione eseguibile della procedura <nome>; 2. sulla pila (riferimento SP) viene messo il riferimento alla istruzione successiva a CALL <nome>; C) ritorno: sintassi: <ritorno da procedura> ::= RET semantica: si estrae il valore in testa alla pila e lo si assegna a PC Esempio 8 Cosideriamo questo frammento di programma AS-86: 44 . . XX : YY : … CALL PROC_1 . . PROC_1 PROC WW : … (1a istruzione di PROC_1) … ZZ : CALL PROC_2 TT : … … RET … ENDP PROC_2 PROC JJ : … (1a istruzione di PROC_2) … … RET … procedura PROC_1 procedura PROC_2 ENDP L’evoluzione del contenuto della pila e del valore di PC e SP in corrispondenza delle varie chiamate e ritorni è il seguente: SP→ SP→ YY SP→ TT YY PC : XX PC : WW (CALL PROC_1) SP→ YY PC : TT PC : JJ (CALL PROC_2) SP→ PC : (RET) YY (RET) __________________________________ D) passaggio parametri: tipicamente, avviene usando le variabili predichiarate, secondo la modalità per valore (alla variabile è associato il valore del parametro} o per riferimento (alla 45 variabile è associato un riferimento alla cella di memoria che contiene il valore del parametro); E) ritorno risultati: sempre tramite le variabili predichiarate secondo le stesse modalità del passaggio di parametri. Nota: La pila definita nel formalismo AS-86 può essere usata per trasmettere parametri e ritornare risultati. Dato però che la stessa pila viene anche usata per gestire “esplicitamente” (cioè in modo visibile a chi scrive l’algoritmo) il meccanismo di chiamata e ritorno, particolare attenzione va posta per non creare interferenze potenzialmente disastrose tra le due cose. Esempio 9 Calcolare la media aritmetica delle medie aritmetiche degli elementi di due vettori (si utilizza a tale scopo una versione leggermente modificata del programma visto nell’ esempio 5). L’algoritmo di soluzione è articolato in una procedura che riceve in ingresso un vettore e la sua dimensione e restituisce il valore della media degli elementi del vettore, e un programma principale che chiama due volte la procedura e poi calcola la media delle due medie così ottenute. La trasmissione dei parametri e dei risultati avviene usando le seguenti variabili predichiarate: SI: vettore (passaggio per riferimento) BX: dimensione del vettore (passaggio per valore); CX: media degli elementi del vettore (risultato). Programma principale: VET1 DW 10 DUP (?) VET2 DW 20 DUP (?) MEDIA_MEDIE DW ? <lettura VET1 e VET2> MOV SI, OFFSET VET1 MOV BX, 10 CALL CALCOLA_MEDIA MOV MEDIA_MEDIE, CX MOV SI, OFFSET VET2 MOV BX, 20 CALL CALCOLA_MEDIA ADD MEDIA_MEDIE, CX DIV MEDIA_MEDIE, 2 (incontriamo qui una nuova modalità di specificazione degli operandi: l’operando OFFSET <nome> assume il valore di riferimento alla cella di memoria associata a <nome>; in questo caso quindi, SI assume il valore di puntatore al primo elemento del vettore VET1) (in CX è stato posto il risultato) (preparazione parametri per la 2a chiamata della procedura) (il nuovo risultato viene sommato al precedente) (calcolo della media delle medie) 46 Procedura: CALCOLA_MEDIA PROC MOV NUM_ELEM, BX BX verrà decrementato nel ciclo) MOV CX, 0 CICLO : ADD CX, [SI] ADD SI, 2 (salva nella variabile NUM_ELEM il valore trasmesso in (azzeramento della variabile che conterrà il risultato finale) (SI viene fatto puntare all’elemento successivo del vettore) (decremento di BX) (BX ≠ 0?) (salta se la condizione è vera, cioè se la scansione del vettore non è terminata) DEC BX CMP BX, 0 JNZ CICLO NUM_ELEM DIV CX, NUM_ELEM RET DW ? ENDP → 1 [SI+2] → 2 [SI+4] → 3 [SI] BX; . . . . . . n Figura 10 _____________________________ 47 Architettura e linguaggio di programmazione di una macchina fisica (8086-S) In questa sezione presentiamo una macchina in grado di eseguire algoritmi specificati nel linguaggio (che chiameremo LM) proprio della macchina stessa, e mostriamo come algoritmi specificati in AS_86 possono essere tradotti in algoritmi equivalenti specificati in LM. I principali componenti della macchina 8086-S sono mostrati in Fig. 11. Memoria Processor I/O … I/O Figura 11 Memoria La memoria di questa macchina è costituita da una sequenza di N celle, ognuna identificata da un numero (indirizzo) progressivo, da 0 a N-1. 0 : 1 : 2 : . . . . N-1 : Ogni cella è costituita da b elementi fisici in grado di assumere due stati diversi (magnetizzato/non magnetizzato, conduttore/non conduttore, … a seconda delle tecnologie disponibili). Ogni elemento rappresenta quindi un bit (cifra binaria) di informazione, e ogni cella può quindi contenere una informazione codificata in forma binaria, usando b cifre binarie. 0 1 2 … b-1 cella i : Nell’8086-S (e nei calcolatori attuali) b = 8, e la cella viene chiamata byte. Come unità di riferimento della memoria si usa anche la parola, formata da due o quattro byte. Nell’AS-86, una parola è formata da due byte. Ogni parola è quindi individuata da un indirizzo pari. 48 0 : 2 : 4 : . . . . . . N-2 : Ogni cella può essere utilizzata per rappresentare: • un dato (o parte), numerico o non numerico • una istruzione eseguibile (o parte) Per comunicare con le altre unità, la memoria dispone di alcune celle particolari, i registri, che sono le uniche parti della memoria direttamente accessibili dall’esterno, e i cui “nomi” e funzioni sono i seguenti: RWR (2 byte): registro di lettura e scrittura; AR (2 byte): registro indirizzo; OPR (2 bit): registro operazione (00 ≡ lettura di un byte; 01 ≡ lettura di una parola; 10 ≡ scrittura di un byte; 11 ≡ scrittura di una parola); RR (1 bit): registro di “richiesta”; TR (1 bit): registro di “terminazione”. 0 : 1 : 2 : . . N-1: RWR AR OPR RR TR Figura 12 Il funzionamento della memoria è descritto dal seguente algoritmo (la memoria può quindi essere vista come una macchina che esegue fisicamente questo unico algoritmo): 49 do while (RR == 0) do nothing; switch (OPR) {case 00 : <lettura byte>; break; case 01 : <lettura parola>; break; case 10 : <scrittura byte>; break; case 11 : <scrittura parola> break; }; TR = 1; while (RR == 1) do nothing; TR = 0; for ever. <lettura byte> : RWR[0..7] = M(AR) <lettura parola> : RWR[0..15] = M(AR) <scrittura byte> : M(AR) = RWR[0..7] <scrittura parola> : M(AR) = RWR[0..15] dove: RWR[0..n] denota i bit 0..n del registro RWR, e M(AR) denota il byte (o parola) il cui indirizzo è contenuto in AR. Quindi, per leggere una parola di indirizzo X dalla memoria una unità esterna (ad esempio il processor) deve eseguire le seguenti operazioni: while (TR == 1) do nothing; AR = X; OPR = 01; RR = 1: while (TR == 0) do nothing; <preleva parola da RWR>; RR = 0; mentre, per scrivere una informazione E codificata con 16 bit (una parola) nella cella di memoria di indirizzo X una unità esterna deve eseguire le seguenti operazioni: while (TR == 1) do nothing; RWR = E; AR = X; OPR = 11; RR = 1: while (TR == 0) do nothing; RR := 0; Processor Il processor è una macchina formata da tre parti fondamentali: • UC (unità di controllo): è l’unità che controlla e comanda il funzionamento di tutta la macchina; 50 • UO (unità operativa): è l’unità che esegue, dietro comando di UC, operazioni logico/aritmetiche (somma, sottrazione, confronto, …); • Registri: dispositivi in grado di contenere informazione, accessibili direttamente da UC e UO. I dispositivi di tipo “registro” presenti nell’8086-S sono i seguenti: Registri generali: (16 bit) AX, BX, CX, DX; (8 bit) AL, AH, BL, BH, CL, CH, DL, DH. Come si nota dalla Fig. 13, i registri a 8 bit non sono altro che il primo e il secondo byte di ogni registro a 16 bit. Questi registri vengono utilizzati per contenere dati. Registri indice: (16 bit) SI, DI. Questi registri vengono utilizzati per contenere indirizzi. Registro indirizzo istruzione corrente: (16 bit) PC Utilizzato per contenere l’indirizzo dell’istruzione correntemente eseguita dal processor. Registro istruzione corrente: (32 bit) CI Utilizzato per contenere l’istruzione correntemente eseguita dal processor. Flags: (1 bit) CF: indicatore di risultato con riporto sul bit di segno ZF: indicatore di risultato nullo SF: indicatore di risultato negativo 0 7 8 15 AX AL AH BX BL BH CX CL CH DX DL DH DI SI PC CI CF ZF SF Figura 13 - registri del processor della macchina 8086-S L’esecuzione di ogni operazione logico/aritmetica da parte del processor provoca una opportuna modifica del valore dei flag. Essi vengono quindi utilizzati come indicatori del tipo di risultato 51 ottenuto, tipicamente per scopi di controllo (eventuali trabocchi, …) o per pilotare l’esecuzione di “salti condizionati” (vedi dopo). Nota: si può notare come i nomi utilizzati per identificare alcuni registri del processor dell’8086S siano uguali ai nomi delle variabili predichiarate del linguaggio AS-86. La corrispondenza non è casuale ma, come vedremo più avanti, ha lo scopo di semplificare la “traduzione” di programmi scritti nel linguaggio AS-86 in programmi scritti in un linguaggio eseguibile dalla macchina 8086-S. In altre parole, il linguaggio AS-86 è progettato in modo da poter essere “facilmente” eseguito dalla macchina 8086-S. Il funzionamento del processor è descritto dal seguente algoritmo (in particolare, l’Unità Controllo può essere vista come una macchina che esegue fisicamente questo algoritmo): do {while (TR == 1) do nothing; AR = PC; (lettura della istruzione il cui indirizzo è contenuto in PC) OPR = 01; RR = 1; while (TR == 0) do nothing; CI[15..0] = RWR; (l’istruzione letta viene messa nei primi 16 bit di CI) RR = 0; PC = PC+2; (PC ora contiene l’indirizzo della parola successiva) if (<istruzione su due parole>) (la codifica di alcune istruzioni occupa due parole consecutive nella memoria dell’8086-S; esaminando la parte già presente in CI[15..0] si può stabilire in quale caso ci si trova) {AR = PC; (lettura della seconda parte dell’istruzione) OPR = 01; RR = 1: while (TR == 0) do nothing; CI[31 .. 16] = RWR;(la seconda parte dell’istruzione viene messa in CI[31..16]) RR = 0; PC = PC+2 } <decodifica ed esecuzione istruzione in CI>; <trattamento interruzioni>; } for ever. Nell’algoritmo appena presentato, si può notare che la lettura di una parola dalla memoria avviene secondo le modalità descritte a proposito della memoria. Per quanto riguarda le due parti designate come <decodifica ed esecuzione istruzione in CI>, e <trattamento interruzioni> la prima verrà presentata più avanti, dopo aver descritto i tipi di istruzioni che possono essere eseguiti dal processor dell’8086-S, mentre la seconda non sarà oggetto della presente trattazione. Dalla presentazione dell’algoritmo di funzionamento del processor dovrebbe comunque essere chiaro come tale algoritmo consenta alla macchina 8086-S di eseguire una sequenza di istruzioni (cioè un programma) presente nella sua memoria. 52 Istruzioni eseguibili Il processor dell’8086-S è in grado di eseguire tutte le usuali operazioni logico/aritmetiche (somma, sottrazione, …). Tali operazioni (istruzioni) sono codificate in forma binaria, su una o due parole consecutive (16 o 32 bit), dove il primo byte codifica, in ogni caso, il tipo di operazione (codice operativo). 0 7 8 15 0 7 8 15 C OP I restanti byte (uno o tre) vengono usati per codificare gli operandi. Le opzioni possibili nella specificazione degli operandi sono le seguenti: • l’operando (il suo valore) è contenuto in un registro (modalità registro); • l’operando (il suo valore) è contenuto nell’istruzione stessa (modalità immediata); • l’operando (il suo valore) è contenuto in una cella di memoria; in questo caso deve essere specificato l’indirizzo della cella, che può essere fatto secondo le seguenti modalità: - l’indirizzo è contenuto nella stessa istruzione (modalità memoria); - l’indirizzo è ottenuto sommando il contenuto di un registro ad un valore contenuto nell’istruzione stessa (modalità registro indiretto (+ valore)); Le istruzioni eseguibili dal processor possono essere raggruppate in varie classi che differiscono fra loro per le modalità di specificazione degli operandi. Presentiamo ora tali classi. In questa presentazione, utilizzeremo le seguenti notazioni: COP: codice binario di una operazione logico/aritmetica R1, R2: codifica binaria del nome di un registro VALORE: codifica binaria di un valore numerico op: generico operatore logico/aritmetico M(ind.): cella di memoria di indirizzo “ind” 0 … COP Classe RR (registro-registro) 7 8 … 11 12 … 15 R1 R2 In questa classe sono codificate operazioni binarie o unarie i cui operandi sono entrambi contenuti in registri. R1 = R1 op R2; (operazione binaria) R1 = op R1; (operazione unaria) 0 … COP 7 8 … 11 12 … 15 R1 VALORE Classe RM (registro-memoria) 53 R2 In questa classe sono codificate operazioni binarie o unarie i cui operandi sono contenuti uno in un registro e l’altro in una cella di memoria. R1 = R1 op M(R2 + VALORE); M(R1 + VALORE) = M(R1 + VALORE) op R2; Da notare nell’indirizzo (Rx + VALORE) che: • se Rx non corrisponde a un nome “valido”, l’indirizzo è costituito dal solo VALORE (modalità memoria); • se VALORE == 0, l’indirizzo è costituito dal solo contenuto di Rx (modalità registro indiretto); • altrimenti, l‘indirizzo è calcolato come Rx + VALORE (modalità registro indiretto + valore). E’ facile vedere come tali modalità di specificazione dell’operando in memoria consentano di realizzare le varie modalità di specificazione degli operandi del linguaggio AS-86 (nome, nome con indice, puntatore). 0 … 7 8 … 11 12 … 15 COP R1 VAL_1 VALORE_2 Classe RI (registro-immediato) In questa classe sono codificate operazioni binarie o unarie i cui operandi sono contenuti uno in un registro o in una cella di memoria, e l’altro è codificato direttamente nella istruzione (operando “immediato”) R1 = R1 op VALORE_2; M(R1 + VAL_1) = M(R1 + VAL_1) op VALORE_2; 0 … 7 8 … 15 COP VALORE Classe SALTO In questa classe sono codificate istruzioni che modificano il contenuto del registro PC, provocando quindi un “salto”, cioè una alterazione nel normale flusso sequenziale di esecuzione delle istruzioni presenti in memoria. Di solito, PC viene modificato in relazione al valore assunto da alcuni flag. PC = VALORE; (salto incondizionato) if (f(FLAG)== true) PC = VALORE; (salto condizionato) dove f(FLAG) è una generica funzione logica del valore dei flag. Decodifica ed esecuzione istruzione Esaminiamo ora in qualche dettaglio la parte rimasta non specificata dell’algoritmo eseguito dal processor. Più esattamente, tale algoritmo, inclusa la parte che presentiamo ora, è eseguito da UC. Si ricorda che nel byte CI[0..7] di CI è contenuto il codice (COP) dell’operazione che deve essere eseguita. 54 <decodifica ed esecuzione istruzione in CI>: switch (<formato operazione in CI[0..7]>) {case RR : <esegui RR>; break; case RM : <esegui RM>; break; case RI : <esegui RI>; break; case SALTO : <esegui SALTO>;break; default <segnala errore> } <esegui RR> : {<utilizza CI[8..15] per individuare R1 e R2>; <invia COP e contenuto di R1 e R2 a UO>; R1 = <risultato proveniente da UO>; } <esegui RM> : {while (TR == 1) do nothing; AR = Rx + VALORE; (x == 1, 2) OPR = <lettura byte (00) o parola (01))>; (lettura del byte o parola di indirizzo Rx +VALORE RR = 1; while (TR == 0) do nothing; <preleva dato da RWR>; RR = 0; <invia COP, dato e contenuto di Ry (y == 2, 1) a UO>; if (x == 2) (x == 2 significa che il 1° operando è di tipo registro (quindi y == 1) R1 = <risultato proveniente da UO>; else {while (TR == 1) do nothing; RWR = <risultato proveniente da UO>; (scrittura nella cella di indirizzo AR = R1 + VALORE; R2+VALORE del risultato OPR = <scrittura byte (10) o parola (11))>; proveniente da UO) RR = 1: while (TR == 0) do nothing; RR = 0; } <esegui SALTO> : if (<salto incondizionato>)(questa condizione viene verificata controllando il COP) PC = CI[16..31]; (si modifica il valore di PC) else if (<f(FLAG)> == true) (f(FLAG) indica una opportuna condizione logica) PC = CI[16..31]; (PC viene modificato solo se la condizione è vera) Nota: Ogni cella di memoria può contenere, indifferentemente, la codifica binaria di un dato o una istruzione. Il significato da dare alla sequenza di bit contenuta in una cella dipende esclusivamente dall’uso che si fa di essa: se viene caricata in CI, viene interpretata come la codifica di una istruzione; se viene caricata in un registro generale, viene interpretata come la codifica di un dato; se viene caricata in un registro indice, viene interpretata come la codifica di un indirizzo. 55 Traduzione dal linguaggio AS-86 nel linguaggio della macchina 8086-S Ci occupiamo ora del problema della traduzione di algoritmi specificati usando il formalismo AS86 in algoritmi “equivalenti” specificati nel linguaggio della macchina 8086-S, che indichiamo con LM e che è costituito da tutte le operazioni eseguibili dalla macchina 8086-S e raggruppate nelle quattro classi descritte in precedenza. Notiamo che un algoritmo specificato in LM è equivalente a uno specificato in AS-86 se e solo se per uno stesso valore dei dati in ingresso fornisce gli stessi risultati. Ricordiamo che ogni operazione presente in un algoritmo scritto in AS-86 è caratterizzata dal seguente formato: <etichetta> : |← CAMPO ETICHETTA →|← <nome operazione> CAMPO OPERATORE <operandi> →|← CAMPO OPERANDI →| Un algoritmo equivalente in LM si ottiene generando: 1. una sequenza di operazioni (codificate in binario!) di LM corrispondente alla sequenza di operazioni in AS-86; 2. aree dati nella memoria della macchina 8086-S dimensionate e inizializzate opportunamente, corrispondenti alle variabili e costanti dichiarate nell’algoritmo scritto in AS-86. Come vedremo, la soluzione del punto 2. è immediata. Per quanto riguarda la soluzione del punto 1., notiamo innanzitutto che essa è grandemente facilitata dalla “vicinanza” tra AS-86 e LM. Questa riguarda in particolare le operazioni disponibilli, le modalità di specificazione degli operandi, il meccanismo di controllo del flusso (salti condizionati o no). Grazie a ciò, possiamo eseguire una traduzione “rigo per rigo”, generando: a) un codice operativo binario corrispondente alla operazione specificata nel campo operatore AS-86; b) codici binari corrispondenti agli operandi specificati in AS-86. Anche riguardo questi due punti la soluzione è “banale”, tranne che in un caso: quando l’operando in AS-86 è specificato usando una etichetta. Ricordiamo che in AS-86 una etichetta ha il valore di “riferimento simbolico” (a un dato o a una operazione). In LM un riferimento a un dato o istruzione non è altro che un indirizzo della cella di memoria che contiene quel dato o istruzione. Dobbiamo quindi trovare il modo di tradurre etichette AS-86 in indirizzi opportuni della memoria dell’8086-S. La soluzione a questo problema è basata sulla seguente osservazione: immaginiamo che la traduzione nel linguaggio LM dell’algoritmo in AS-86 sia memorizzata a partire dall’indirizzo 0; sia ETICH una etichetta che compare nel campo etichette dell’algoritmo in AS86; sia k-1 la lunghezza (in byte) della traduzione in LM di ciò che precede la riga di codice AS-86 che contiene ETICH nel suo campo etichetta; allora l’indirizzo da associare a ETICH è k (vedi Figura 14) 56 AS-86 ETICH : LM ................ 0 ................ 1 ................ 2 ................ . ................ . ................ k-1 xxxxxxxxx k bbbbbbbbbb (traduzione binaria di xxxxxxxxx) ................ . ................ . Figura 14 E’ da notare che una etichetta può essere incontrata nel campo operandi di una istruzione in un algoritmo scritto in AS-86 prima di incontrare la stessa etichetta nel campo etichetta di un’altra istruzione nello stesso algoritmo (vedi, Nell’Esempio 8, l’etichetta NUM_ELEM nella procedura CALCOLA_MEDIA). Dato il meccanismo appena descritto di “calcolo” dell’indirizzo da associare a una etichetta, si crea il problema, nella traduzione “rigo per rigo” da AS-86 in LM, di come affrontare situazioni di questo genere. La soluzione generale è di scomporre il processo di traduzione in due successive scansioni dell’algoritmo scritto in AS-86: 1 a scansione: si calcolano gli indirizzi corrispondenti alle etichette; 2 a scansione: si genera il codice binario (LM) che traduce l’algoritmo originario. 1 a scansione L’algoritmo corrispondente riceve come dati in ingresso: • il testo in AS-86 dell’algoritmo da tradurre; • la tabella istruzioni, che per ogni operazione AS-86 fornisce il corrispondento codice di operazione in LM (binario) e lo spazio (in byte) occupato dalla traduzione dell’intera operazione (inclusi gli operandi); notare che a uno stesso nome di operazione AS-86 possono corrispondere più codici in LM (formati diversi!); • la tabella dichiarazioni, che per ogni “dichiarazione” o “delimitatore” AS-86 specifica il nome di un sottoprogramma del traduttore che esegue le opportune operazioni di gestione; e produce in uscita: • la tabella etichette-indirizzi, che associa a ogni etichetta AS-86 il corrispondente indirizzo. 57 CODICE AS-86 FORMATO CODICE BINARIO (LM) N. BYTE ADD RR 10 00 00 00 2 ADD RM 11 00 00 00 4 ADD RI 01 00 00 00 4 SUB RR 00 10 00 00 2 . . . . . . . . Tabella istruzioni CODICE AS-86 NOME SOTTOPROGRAMMA DI GESTIONE DB ………… DW ………… ENDP ………… END ………… . . . . Tabella dichiarazioni ETICHETTA INDIRIZZO NOME_1 ………… NOME_2 ………… . . . . . . Tabella etichette/indirizzi In questo algoritmo viene inoltre utilizzato un contatore (LC, location counter) che indica, ad ogni passo dell’algoritmo, l’indirizzo (a partire da 0) in cui verrà memorizzato nella memoria della macchina 8086-S il codice binario che traduce l’istruzione (o dato) AS-86 correntemente esaminata. 58 Algorimo prima scansione LC = 0; do {<leggi un rigo del programma AS-86>; if (<istruzione>) {if <campo etichetta non vuoto> <metti in tab. etich./indir. nome etichetta e valore LC>; <accedi a tab. istruzioni in base a codice operazione e formato e ricava n. byte>; LC = LC + n. byte; } else if (<dichiarazione>) <esegui sottoprogramma relativo>; else ERRORE; } while <non raggiunta fine programma AS-86>. Sottoprogramma di gestione per dichiarazioni DB (1a scansione) if (<campo etichetta non vuoto>) <metti in tab. etich./indir. nome etichetta e valore di LC>; <determina n. byte occupati dalla codifica binaria del dato>; LC = LC + n. byte; Nota: data la definizione del dominio dei dati di tipo DB in AS-86, ognuno di tali dati può essere codificato in LM con 8 bit (un byte). Quindi, il numero di byte occupati dalla codifica in LM dei dati dichiarati in una dichiarazione di tipo DB è pari al numero di tali dati. Invece, ogni dato DW può essere codificato con 16 bit (2 byte), e quindi lo spazio occupato dalla corrispondente traduzione in LM è pari a 2x(numero di dati DW). Esempio 10 Consideriamo questa semplice procedura AS-86 che calcola la somma di due numeri (accanto a ogni rigo del programma è indicato il valore assunto da LC in quel punto). LC SOMMA NUM1 NUM2 ANS PROC MOV AX, NUM1 ADD AX, NUM2 MOV ANS, AX RET DW 15 DW 3 DW ? ENDP 0 0 4 8 12 14 16 18 20 La tabella costruita alla fine della prima scansione è: 59 ETICHETTA INDIRIZZO SOMMA 0 NUM1 14 NUM2 16 ANS 18 _________________________________ 2 a scansione L’algoritmo corrispondente riceve come dati in ingresso: • il testo in AS-86 dell’algoritmo da tradurre; • la tabella istruzioni; • la tabella dichiarazioni; • la tabella etichette-indirizzi, costruita durante la 1a scansione; • la tabella registri, che fornisce la codifica binaria del nome di ogni registro; e produce in uscita la traduzione in codice binario (LM) dell’algoritmo in AS-86. NOME REGISTRO CODICE AX 0001 BX 0010 CX 0011 DX …… AL …… SI …… DI …… Tabella registri Algoritmo seconda scansione LC = 0; do {<leggi un rigo del programma AS-86>; if (<istruzione>) {<accedi a tab. istruzioni in base a codice op. e formato>; <“assembla” la corrispondente traduzione binaria>; <memorizza istruzione tradotta>; LC = LC + n. byte>; } else if (<dichiarazione>) <esegui sottoprogramma relativo>; else ERRORE; } while <non raggiunta fine programma AS-86> 60 “assemblaggio” di una istruzione di formato RM: COP = codice binario corrispondente al nome dell’operazione AS-86 (da tab. istruz.); R1 = codice binario che identifica il primo registro (da tab. registri); (0 in caso di reg. non specificato) R2 = codice binario che identifica il secondo registro (da tab. registri); (0 in caso di reg. non specificato) if (<nome in campo operandi presente in tab. etich./indir.>) VALORE = indirizzo; (da tab. etich./indir.) else ERRORE a sottoprogramma realtivo a una dichiarazione DB (2 scansione) <determina n. dati>; <memorizza la codifica binaria dei dati>; LC = LC + n. dati; Esempio 11 La traduzione in LM del sottoprogramma di somma dell’esempio 10 (prodotta in uscita dalla 2a scansione) è la seguente (per rendere un po’ più leggibile il codice, adottiamo la notazione (OP-F)bin per indicare la codifica binaria (riportata in tab. istruzioni) in LM di una operazione AS-86 di nome OP e formato F. (MOV-RM) bin 0001 0000 (0001 è la codifica di AX) 0000 (14 è l’indirizzo corrispondente a NUM1) 0000 0000 1110 (ADD-RM) bin 0001 0000 0000 0000 0001 0000 (MOV-RM) bin 0000 0001 0000 0001 0010 0000 0000 0000 (RET- -) bin (16 è l’indirizzo corrispondente a NUM2) (18 è l’indirizzo corrispondente a ANS) 0000 0000 0000 1111 (valore numerico del dato = 15) 0000 0000 0000 0011 (valore numerico del dato = 3) 0000 0000 0000 0000 _____________________________ Nota Gli algoritmi di prima e seconda scansione presentati calcolano i valori delle etichette assumendo che il programma verrà caricato in memoria a partire dall’indirizzo 0. Nella realtà questo non succede quasi mai (gli indirizzi “bassi” sono riservati usualmente al “Sistema Operativo” della macchina). Il problema può essere risolto in due modi: 61 • se l’indirizzo iniziale è noto al momento della traduzione, basta inizializzare il contatore LC con questo valore iniziale, piuttosto che 0; • se l’indirizzo iniziale non è noto al momento della traduzione (e questo è il caso più probabile, per ragioni di flessibilità nell’uso della macchina), la traduzione verrà fatta usando come indirizzo iniziale l’indirizzo “fittizio” 0; quando il programma verrà effettivamente caricato in memoria centrale per l’esecuzione (a partire da un indirizzo iniziale k≥0), occorrerà aggiornare gli indirizzi sommando ad essi il valore k. E’ da notare che l’aggiornamento richiesto nel secondo caso può risultare molto gravoso se fatto “esplicitamente” (potenzialmente, ogni istruzione deve essere aggiornata!). Nella macchina 8086-S che abbiamo descritto non c’è altra possibilità. Una possibile soluzione più efficiente può essere quella di predisporre un apposito registro interno al processor (registro base) che viene caricato con il valore k, e il cui contenuto viene automaticamente sommato al valore del registro AR ogni volta che viene inviata una richiesta di accesso alla memoria. 62 Capitolo 3 Rappresentazione di numeri 63 Introduzione In questo capitolo viene presentata, in maniera più organica e precisa, una parte di quanto esposto durante le lezioni sull’argomento della rappresentazione di numeri. La trattazione è limitata, in queste note, alla rappresentazione posizionale di numeri interi. 64 Rappresentazione di numeri interi 1. Concetti generali Sia S = {s0 , s1 , …, sk} un insieme finito di simboli. Sia S* l’insieme di tutte le sequenze finite costruibili usando simboli appartenenti a S. Si noti che S* è un insieme infinito. Esempio S = {a} S* = {a, aa, aaa, aaaa, aaaaa, … }; oppure: S = {a, b} S* = {a, b, aa, ab, ba, bb, aaa, aab, aba, baa, abb, … } Fine esempio Sia N l’insieme dei numeri naturali {0, 1, 2, 3, … }. Una rappresentazione di N è una funzione iniettiva r : N → S* che possiede una funzione inversa (interpretazione) ℑ : S* → N tale che, data σ∈S*, se r(n) = σ n ∈N ℑ(σ) = indefinito altrimenti 2. Rappresentazione posizionale Sia B un numero intero ≥2. Una rappresentazione posizionale in base B viene definita in questo modo: S B = {s0 , s1 , …, sB-1 } rB : N → SB * è definita come: sn se0 ≤ n ≤ B-1 rB (n) = (c p-1 cp-2 … c0 )B, c i ∈SB, p ≥ 2 se n ≥ B dove la sequenza (cp-1 cp-2 … c0 )B viene interpretata come: p−1 ℑN(cp-1 cp-2 … c0 ) = ℑN(ci)·Bi i=0 Si noti che, dalla definizione di rB , si ha, banalmente, ℑN(sj) = j, per ogni sj∈S B . Nel seguito, per ∑ semplicità, scriveremo semplicemente j invece di ℑN(sj). 65 Esempio La rappresentazione in base 10 che siamo abituati ad usare non è altro che un caso particolare della rappresentazione appena definita, con B=10, S10 = {0, 1, 2, …, 9}. Infatti, data una sequenza appartenente a S 10 *, p.es. 343, questa viene interpretata come rappresentazione del valore numerico 3·102 + 4·10 1 + 3·10 0 . Fine esempio Si noti che una stessa sequenza di simboli rappresenta, in generale, valori numerici diversi al variare di B; così, la sequenza 101 rappresenta i seguenti valori numerici (espressi nella usuale notazione in base 10): se B=2, rappresenta il valore 1·22 + 0·2 1 + 1·2 0 = (5)10 se B=3, rappresenta il valore 1·32 + 0·3 1 + 1·3 0 = (10)10 se B=10, rappresenta il valore 1·102 + 0·10 1 + 1·10 0 = (101)10 . 3. Proprietà di rappresentazioni posizionali Dalla definizione di rappresentazione posizionale derivano alcune semplici proprietà. Potenza k-esima Il valore Bk in base B è rappresentato dalla sequenza (ck ck-1 ck-2 … c 0 )B , con c k=1 e c i=0, 0≤i<k (ovvero, un 1 seguito da k volte 0); infatti si ha: k k-1 ℑN(ck ck-1 ck-2 … c0 ) = ci·B i = ck·B k + ci·B i i=0 i=0 k-1 = 1·B k + 0·B i =Bk i=0 ∑ ∑ ∑ Per esempio, se B=2, il valore numerico (8)10 , pari a 2 3 , è rappresentato come (1000) 2 ; se B=3, il valore numerico (27)10 , pari a 33 , è rappresentato come (1000) 3 ; Massimo numero rappresentabile Il massimo valore numerico M rappresentabile in base B usando una sequenza σ∈S B * di lunghezza al più pari a p è dato da: M = Bp - 1 Infatti si ha: p-1 M = max{ ∑ ci·B i } = p-1 ∑ i=0 i=0 p B −1 = (B-1) = Bp - 1 B−1 max{ci}·Bi = p-1 ∑ i=0 Per esempio, con B=2 e p=4, si ha 66 (B-1)·B i = (B-1) p-1 ∑ i=0 Bi M = ℑN(1111)2 = 1·23 + 1·22 + 1·21 + 1·20 = (15)10 = 24 - 1; con B=3 e p=3, si ha M = ℑN(222) 3 = 2·32 + 2·31 + 2·30 = (26)10 = 33 - 1. 4. Rappresentazione con numero finito di cifre Supponiamo di porre un limite massimo (pari ad h≥1) alla lunghezza delle sequenze di simboli utilizzabili in una rappresentazione posizionale di N (da notare che questo è quanto succede effettivamente nelle macchine che utilizziamo per eseguire calcoli numerici). Questo limite comporta come conseguenza la possibilità di rappresentare soltanto un sottoinsieme finito dell’insieme N. Indichiamo con N B,h ⊂ N questo sottoinsieme. In base al risultato della Sezione 3, si ha: N B,h = {n | n∈N, 0 ≤ n ≤ Bh - 1} Utilizzare una rappresentazione di N usando un numero finito di cifre porta come conseguenza che non sempre gli algoritmi che utilizziamo per calcolare le operazioni aritmetiche restituiscono il risultato corretto. Ad esempio, supponiamo che AlgSomma(.,.) indichi un algoritmo per il calcolo della somma tra interi (ad esempio, potrebbe essere l’usuale algoritmo che ci è stato insegnato durante la scuola elementare, basato sulla somma cifra per cifra da destra a sinistra e sulla regola del riporto). Questo algorimo si applica a due sequenze di simboli (aq-1 a q-2 … a 0 )B e (b s-1 b s-2 … b 0 )B e restituisce come risultato una nuova sequenza di simboli (ct-1 ct-2 … c0 )B , con t≥max{q, s}, tale che ℑN(aq-1 aq-2 … a0 )B + ℑN(bs-1 bs-2 … b0 )B = ℑN(ct-1 ct-2 … c0 )B Nel caso che stiamo considerando di rappresentazione con un numero finito h di cifre, AlgSomma(.,.) deve necessariamente essere modificato, dando luogo al seguente algoritmo (si noti che, per il vincolo che ci siamo posti sulla rappresentazione, deve essere q≤h e s≤h): AlgSommaB,h (.,.): acquisisci (aq-1 aq-2 … a0 )B e (bs-1 bs-2 … b0 )B ; determina (ct-1 ct-2 … c0 )B usando AlgSomma(.,.); se t>h, elimina le cifre ct-1 ct-2 … ch ; restituisci come risultato la sequenza di cifre restanti. In altre parole, AlgSommaB,h (.,.) tronca alla h-esima cifra il risultato della somma. Questa operazione di troncamento può ovviamente portare a situazioni di errore. Più precisamente, l’errore si verifica se ℑN(aq-1 aq-2 … a0 )B + ℑN(bs-1 b s-2 … b 0 )B ∉N B,h . Questo tipo di errore viene detto errore di “trabocco” (overflow). Esempio Assumiamo B=10 e h=3; se (a2 a1 a0 )B = (230) e (b2 b1 b0 ) = (381), si ha: 67 AlgSommaB,h (230, 381) = 611 che costituisce il risultato corretto; invece, se (a2 a1 a0 )B = (621) e (b2 b1 b0 ) = (492), si ha: AlgSommaB,h (621, 492) = 113 che non è, evidentemente, il risultato corretto. Nel secondo caso, il risultato corretto sarebbe stato 1113; l’algoritmo produce un risultato scorretto perchè 1113 rappresenta un valore numerico che va al di là dei limiti della rappresentazione scelta; infatti, con B=10 e h=3 il massimo intero rappresentabile è pari a 103 - 1 = 999. Fine esempio 5. Rappresentazione di interi relativi Finora ci siamo occupati della rappresentazione di numeri naturali, ovvero interi senza segno; consideriamo ora il problema della rappresentazione degli interi relativi (insieme Z). La rappresentazione che siamo correntemente abituati ad utilizzare è la cosiddetta rappresentazione in modulo e segno, ottenuta premettendo il simbolo + o - alla sequenza di simboli che rappresenta il valore assoluto del numero. In questa sezione presentiamo una rappresentazione alternativa, detta in complemento, che può risultare vantaggiosa dal punto di vista della definizione di algoritmi per l’esecuzione di operazioni aritmetiche. In particolare, consente di utilizzare sostanzialmente il solo algoritmo di somma per realizzare l’operazione di addizione indipendentemente dal segno degli operandi; inoltre, anche l’operazione di sottrazione si riconduce sostanzialmente a quella di somma (al contrario, nella rappresentazione in modulo e segno occorre discriminare il segno deggli operandi: somma di numeri con lo stesso segno e differenza di numeri di segno opposto si calcolano usando l’algoritmo di somma; somma di numeri di segno opposto e differenza di numeri dello stesso segno si calcolano usando un differente algoritmo, quello di sottrazione, anche questo appreso, almeno per la base 10, nella scuola elementare). Questo semplifica la costruzione di “macchine” per eseguire tali operazioni. Premessa fondamentale: la rappresentazione in complemento è definibile solo nel caso si ponga un vincolo al numero massimo di cifre utilizzabili (come nella Sezione 4). Sia B la base adottata, e h la quantità massima di cifre utilizzabili. Sia rB : N → S B * la funzione di rappresentazione definita nella Sezione 2. La rappresentazione in complemento a B dell’insieme Z è data dalla funzione rc : Z → SB *, definita nel seguente modo: dato x∈Z, rB (x) rc(x) = rB (Bh - x ) se0 ≤ x < se - h B 2 Bh 2 ≤ x<0 Da questa definizione, risulta evidente che si riesce a rappresentare soltanto il sottoinsieme Bh Bh finito di Z costituito dai numeri appartenenti all’intervallo 2 ,+ 2 (questa è, di nuovo, una conseguenza dell’aver posto un limite al numero di cifre utilizzabili). In maniera informale, l’idea alla base di questa rappresentazione è quella di suddividere l’intervallo dei naturali [0, B h -1] (ovvero 68 l’insieme N B,h ) in due metà, e di utilizzarne la prima metà per rappresentare i relativi appartenenti Bh Bh , e la seconda metà per rappresentare l’intervallo dei relativi - ,-1 (vedi all’intervallo 0,+ 2 2 Figura 1). h - B 2 h B 2 0 Z h B 2 0 N h B -1 Figura 1 Esempio Consideriamo il caso B=2, h=3; si possono quindi rappresentare gli interi relativi appartenenti all’intervallo [-4, +3]; questi valori verranno rappresentati utilizzando le rappresentazioni, definite dalla funzione rB , dell’intervallo dei naturali [0, 7]. Si ha quindi (per maggiore chiarezza, utilizziamo sempre una notazione a tre cifre, incluse quindi le cifre 0 non significative (cioè quelle più a sinistra)): rB (0) = 000 rB (1) = 001 rB (2) = 010 rB (3) = 011 rB (4) = 100 rB (5) = 101 rB (6) = 110 rB (7) = 111 Delle otto sequenze di simboli così ottenute, le prime quattro (cioè 000, 001, 010, 011) vengono utilizzate per rappresentare l’intervallo [0, +3], e le seconde quattro (cioè 100, 101, 110, 111) per rappresentare l’intervallo [-4, -1], nel seguente modo: rc(0) = rB (0) = 000 rc(1) = rB (1) = 001 rc(2) = rB (2) = 010 rc(3) = rB (3) = 011 rc(-4) = rB (23 - |-4|) = 100 rc(-3) = rB (23 - |-3|) = 101 rc(-2) = rB (23 - |-2|) = 110 rc(-1) = rB (23 - |-1|) = 111 69 Analogamente, con B=10 e h=2, l’intervallo dei relativi [-50, +49] viene rappresentato tramite l’intervallo dei naturali [0, 99], nel seguente modo: rc(0) = rB (0) = 00 rc(1) = rB (1) = 01 rc(2) = rB (2) = 02 rc(3) = rB (3) = 03 … rc(47) = rB (47) = 47 rc(48) = rB (48) = 48 rc(49) = rB (49) = 49 rc(-50) = rB (10 2 - |-50|) = 50 rc(-49) = rB (10 2 - |-49|) = 51 rc(-48) = rB (10 2 - |-48|) = 52 … rc(-2) = rB (10 2 - |-2|) = 98 rc(-1) = rB (10 2 - |-1|) = 99 Fine esempio La funzione inversa di rc (ovvero la funzione di interpretazione, che fornisce il valore numerici rappresentato da una sequenza di simboli) si definisce, banalmente, nel seguente modo: ℑZ(ah-1 ah-2 … a0 )B = ℑN (ah−1ah−2 …a 0 ) - Bh − ℑN (ah − 1a h − 2 …a 0 ) Bh se0 ≤ ℑN (ah−1a h−2 …a 0 )< 2 h B se ≤ ℑN (ah−1a h−2 …a 0 ) < B h 2 dove ℑN è la funzione di interpretazione definita nella Sezione 2. ( ) Esempio Consideriamo il caso B=3, h=3. Le sequenze di simboli utilizzabili per la rappresentazione sono quindi: 000, 001, 002, 010, 011, 012, 020, 021, 022, 100, …, 212, 220, 221, 222; secondo la funzione di interpretazione ℑN, queste sequenze rappresentano l’intervallo dei naturali: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, …, 23, 24, 25, 26. Interpretate secondo alla funzione ℑZ (ovvero, come rappresentazione in complemento di numeri relativi) queste stesse sequenze rappresentano i seguenti valori numerici: ℑZ(000) = ℑN(000) = 0, ℑZ(001) = ℑN(001) = 1, …, ℑZ(111) = ℑN(111) = 13, ℑZ(112) = -(33 ℑN(112)) = -13, …, ℑZ(221) = -(33 - ℑN(221)) = -2, ℑZ(222) = -(33 - ℑN(222)) = -1. (Quindi, l’intervallo dei relativi rappresentabile è [-13, +13]). 70 Se consideriamo invece il caso B=10, h=2, le sequenze di due cifre decimali 00, 01, 02, …, 97, 98, 99 vengono interpretate come: ℑZ(00) = ℑN(00) = 0, ℑZ(01) = ℑN(01) = 1, …, ℑZ(49) = ℑN(49) = 49, ℑZ(50) = -(102 - ℑN(50)) = 50, …, ℑZ(98) = -(102 - ℑN(98)) = -2, ℑZ(99) = -(102 - ℑN(99)) = -1. (Quindi, l’intervallo dei relativi rappresentabile è [-50, +49]). Fine esempio Per quanto riguarda le operazioni aritmetiche, la rappresentazione in complemento consente di sommare o sottrarre numeri relativi, indipendentemente dal loro segno, facendo ricorso, fondamentalmente, al solo algoritmo per l’esecuzione dell’addizione. Consideriamo inizialmente l’operazione di addizione. Questa viene eseguita utilizzando lo stesso algoritmo AlgSommaB,h (.,.) definito su numeri naturali. Vediamo di seguito alcuni esempi che dovrebbero convincere della correttezza di tale affermazione. Esempio Consideriamo B=2 e h=4. L’intervallo dei relativi rappresentabile è quindi [-8, +7]. Consideriamo i seguenti numeri relativi (espressi nella usuale base 10): 2, 3, 4, 7, -3, -4, -1. Le loro rappresentazioni, nella notazione in complemento in base 2, sono: rc(2) = 0010, rc(3) = 0011, rc(4) = 0100, rc(7) = 0111, rc(-3) = 1101, rc(-4) = 1100, rc(-1) = 1111. Consideriamo ora le seguenti operazioni di somma tra i numeri sopra elencati: 2 + 4, 3 + 3, 3 + (-3), 4 + (-3), 7 + (-1), -4 + (-3) Per eseguirle utilizziamo l’algoritmo AlgSomma2,4 (.,.); il lettore è invitato a verificare che i risultati forniti nel seguito sono effettivamente quelli ottenuti applicando questo algoritmo (usando l’aritmetica in base 2!): 2 + 4 ==> AlgSomma2,4 (0010, 0100) = 0110 il numero rappresentato da 0110 è ℑZ(0110) = +6, quindi il risultato 3 + 3 ==> calcolato è corretto AlgSomma2,4 (0011, 0011) = 0110 il numero rappresentato da 0110 è ℑZ(0110) = +6, quindi il risultato 3 + (-3) ==> calcolato è corretto AlgSomma2,4 (0011, 1101) = 0000 il numero rappresentato da 0000 è ℑZ(0000) = 0, quindi il risultato 4 + (-3) ==> calcolato è corretto AlgSomma2,4 (0100, 1101) = 0001 il numero rappresentato da 0001 è ℑZ(0001) = +1, quindi il risultato 7 + (-1) ==> calcolato è corretto AlgSomma2,4 (0111, 1111) = 0110 il numero rappresentato da 0110è ℑZ(0110) = +6, quindi il risultato 71 -4 + (-3) ==> calcolato è corretto AlgSomma2,4 (1100, 1101) = 1001 il numero rappresentato da 1001 è ℑZ(1001) = -7, quindi il risultato calcolato è corretto Fine esempio Consideriamo ora la sottrazione; invece di definire un apposito algoritmo, possiamo sfruttare l’algoritmo per l’addizione in base alla semplice considerazione che, dati n,m∈Z, n-m = n+(-m). Possiamo quindi calcolare la differenza tra due numeri come somma tra il primo numero e il secondo cambiato di segno. In una rappresentazione in complemento in base B con h cifre, cambiare di segno un numero significa effettuare una operazione di complementazione sulla sua rappresentazione. Concettualmente, l’operazione di complementazione trasforma un numero n nel suo complemento rispetto a B h , cioè B h -n. Questo sembrerebbe richiedere l’esecuzione di una sottrazione, contraddicendo quanto detto sopra circa la possibilità, grazie alla rappresentazione in complemento, di eseguire l’operazione di sottrazione evitando di definire uno specifico algoritmo per la sottrazione. In effetti, non è necessario eseguire una sottrazione per calcolare Bh -n. Infatti, assumiamo che (ah-1 ah-2 … a0 ) sia la sequenza di cifre che rappresenta n in base B; si ha allora: h-1 h-1 h-1 h h i i B -n = B -1 - n + 1 = (B-1)·B ai·B + 1 = ((B-1)-ai)·Bi + 1 i=0 i=0 i=0 ∑ ∑ ∑ La complementazione si effettua quindi nel seguente modo: se (ah-1 ah-2 … a0 ) è la sequenza di cifre che rappresenta un numero n∈Z, allora la sequenza di cifre che rappresenta Bh -n è calcolata dall’algoritmo ComplB,h (ah-1 ah-2 … a0 ) = AlgSommaB,h ((bh-1 b h-2 … b0 ), (uh-1 uh-2 … u0 )), con bi = (B-1) - ai, 0≤i≤h-1 se i= 0 1 ui = 0 se 1≤ i ≤ h -1 In pratica, la complementazione (cioè il calcolo di Bh -n) si ottiene rimpiazzando ogni cifra di n con il suo complemento rispetto a B-1, e sommando alla fine 1 alla sequenza così ottenuta. Si noti che nel caso B=2, il complemento di una cifra rispetto a B-1 si calcola banalmente come: 1 se ai = 0 bi = se ai =1 0 Esempio Consideriamo il caso B=2, h=4, i seguenti numeri relativi: +5, -4, +1, e le loro rappresentazioni in complemento date da rc(5) = 0101, rc(-4) = 1100, rc(1) = 0001. Consideriamo le seguenti operazioni di sottrazione: +5 - (+1), -4 - (+1), +1- (-4) +5 - (+1) ==> è calcolabile come +5 + (-1); la rappresentazione di -1 si determina 72 complementando la rappresentazione di +1: Compl2,4 (0001) = AlgSomma2,4 (1110, 0001) = 1111; quindi +5 + (-1) si calcola come AlgSomma2,4 (0101, 1111) = 0100; il numero rappresentato da 0100 è ℑZ(0100 ) = +4, quindi il risultato calcolato è corretto; -4 - (+1) ==> è calcolabile come -4 + (-1); la rappresentazione di -1 è data da1111; quindi +4 + (-1) si calcola come AlgSomma2,4 (1100,1111) = 1011; il numero rappresentato da 1011 è ℑZ(1011) = -5, quindi il risultato calcolato è corretto; +1- (-4) ==> è calcolabile come +1 + (+4); la rappresentazione di +4 sidetermina complementando quella di -4: Compl2,4 (1100) = AlgSomma2,4 (0011, 0001) = 0100; quindi +1 + (+4) si calcola come AlgSomma2,4 (0001, 0100) = 0101; il numero rappresentato da 0101 è ℑZ(0101 ) = +5, quindi il risultato calcolato è corretto. Consideriamo ora il caso B=10, h=2, i seguenti numeri relativi: +25, -41, -12, e le loro rappresentazioni in complemento date da rc(25) = 25, rc(-41) = 59, rc(-12) = 88. Consideriamo le seguenti operazioni di sottrazione: +25 - (-12), -41 - (-12) +25 - (-12) ==> è calcolabile come +25 + (+12); la rappresentazione di +12 si determina come Compl10,2 (88) = AlgSomma10,2 (11, 01) = 12; quindi +25 + (+12) si calcola come AlgSomma10,2 (25, 12) = 37; poichè ℑZ(37 ) = +37, il risultato calcolato è corretto; -41 - (-12) ==> è calcolabile come -41 + (+12); la rappresentazione di +12 si determina come Compl10,2 (88) = AlgSomma10,2 (11, 01) = 12; quindi -41 + (+12) si calcola come AlgSomma10,2 (59, 12) = 71; poichè ℑZ(71 ) = -29, il risultato calcolato è corretto. Fine esempio Si noti che negli esempi appena fatti sulla esecuzione di operazioni aritmetiche usando la rappresentazione in complemento, il risultato era sempre corretto perchè rientrava nei limiti dell’intervallo di rappresentazione. Se invece il risultato cade al di fuori di tale intervallo (cioè se è Bh Bh <, oppure ≥ ), allora il risultato calcolato sarà ovviamente scorretto. 2 2 Esempio Consideriamo il caso B=2, h=4. L’intervallo di rappresentazione è quindi [-8, +7]. Consideriamo i seguenti numeri relativi: +5, +4, -6, -3. Questi numeri appartengono all’intervallo di rappresentazione, e le loro rappresentazioni in complemento sono date da rc(+5) = 0101, rc(+4) = 0100, rc(-6) = 1010, rc(-3) = 1101. Consideriamo le seguenti operazioni: 5 + 4, -6 + (-3), +5 - (-6) 73 5 + 4 ==> si calcola come AlgSomma2,4 (0101, 0100) = 1001; poichè stiamo utilizzando la rappresentazione in complemento questo risultato viene interpretato tramite la funzione ℑZ(.); si ha quindi ℑZ(1001) = -7, che è evidentemente errato; -6 + (-3) ==> si calcola come AlgSomma2,4 (1010, 1101) = 0111; poichè stiamo utilizzando la rappresentazione in complemento questo risultato viene interpretato tramite la funzione ℑZ(.); si ha quindi ℑZ(0111) = +7, che è evidentemente errato; +5 - (-6) ==> si calcola come AlgSomma2,4 (0101, Compl24 (1010)) = AlgSomma2,4 (0101, 0110) = 1011; poichè stiamo utilizzando la rappresentazione in complemento questo risultato vienei nterpretato tramite la funzione ℑZ(.); si ha quindi ℑZ(1011) = -5, che è evidentemente errato. Analogamente, consideriamo B=10 e h=2, e i seguenti valori numerici: +38, -43, -21. Le loro rappresentazioni in complemento sono: rc(+38) = 38, r c(-43) = 57, r c(-21) = 79. Consideriamo le seguenti operazioni aritmetiche: +38 - (-43), -43 - (-21), -43 + (-21). +38 - (-43) ==> si calcola come +38 + (+43), la rappresentazione di +43 si ottiene complementando quella di -43: Compl10,2 (57) = 43; quindi AlgSomma10,2 (38, 43) = 81; poichè stiamo utilizzando la rappresentazione in complemento questo risultato viene interpretato tramite la funzione ℑZ(.); si ha quindi ℑZ(81) = -19, che è evidentemente errato; -43 - (-21) ==> si calcola come -43 + (+21), la rappresentazione di +21 si ottiene complementando quella di -21: Compl10,2 (79) = 21; quindi AlgSomma10,2 (57, 21) = 78; poichè stiamo utilizzando la rappresentazione in complemento questo risultato viene interpretato tramite la funzione ℑZ(.); si ha quindi ℑZ(78) = -22, che è evidentemente corretto; -43 + (-21) ==> si calcola come AlgSomma10,2 (57, 79) = 36; poichè stiamo utilizzando la rappresentazione in complemento questo risultato viene interpretato tramite la funzione ℑZ(.); si ha quindi ℑZ(36) = +36, che è evidentemente errato. Fine esempio 74