Introduzione alle API Win32

Capitolo 27: l'interprete: l'interfaccia

Affrontiamo subito il punto centrale dei tre in cui, alla fine del capitolo scorso, abbiamo visto che si scomponeva il problema di "disegnare il grafico di una funzione":

"data" una funzione, immessa dall'utente sotto forma di stringa, come posso ottenere il valore della funzione in vari punti (ad esempio, i punti che mi servono per disegnare un grafico)?

Questo problema coinvolge inevitabilmente un interprete per la stringa immessa dall'utente, che, quindi, dovrà essere in un qualche ben definito linguaggio; vorremo sicuramente includere in questo linguaggio le quattro operazioni aritmetiche, e sarebbe certo meglio che comprendesse anche funzioni trascendenti come seno, coseno, tangente, e così via.

 

Riflettendo, vedremo che quella "funzione" che l'utente immetterà sarà una "espressione", nel senso comune del termine; dovrà poi essere una espressione in una variabile, che, per seguire le normali convenzioni, chiameremo X, e, nel grafico, rappresenteremo in ascissa (i valori della funzione, invece, andranno, altrettanto convenzionalmente, in ordinata). L'utente potrà dunque immettere stringhe come:

X che dovremmo rappresentare con un segmento di retta diagonale, idealmente a 45 gradi di inclinazione, o X*X*0.3 - X*0.6 + 2.3 (supponendo di usare, come è comune a tanti linguaggi per elaboratore, l'asterisco per la moltiplicazione), che dovremo mostrare come un opportuno segmento di parabola, e così via.

Già a questo stadio della riflessione è chiaro che, nella "funzione" così immessa dall'utente, non sono impliciti i limiti entro cui egli vuole che la disegnamo -- e, visto che lo schermo è finito, non possiamo certo disegnarla "tutta".

I limiti, ad esempio sotto forma di valori minimi e massimi per X, dovranno dunque anch'essi venire immessi dall'utente; i valori limite per le ordinate possiamo, o pensare che li debba egualmente immettere l'utente, ovvero pensare di trovarli implicitamente noi, sulla base dei valori che la funzione effettivamente prende nell'intervallo specificato (sarebbe bello dare all'utente entrambe le opzioni, ad esempio attraverso una apposita checkbox).

Questi sono elementi importanti dell'analisi del nostro problema generale, ma non ci portano più vicini alla soluzione del "nocciolo" per ora identificato: come valutare la funzione (che, ricordiamolo, riceviamo solo come stringa) nei punti (=valori di X) necessari?

Dovendo, come già detto, necessariamente usare un interprete, le possibilità sono sostanzialmente due: o ce lo scriviamo noi, o troviamo un qualche metodo furbo per utilizzare un interprete già scritto da qualcun altro, per un linguaggio del tipo di quello che ci interessa. Riutilizzare un interprete già esistente, naturalmente, è una prospettiva molto più allettante che non doverne scrivere uno "ab ovo"; naturalmente, non è detto che tutto ciò che sarebbe magari bello, sia poi anche fattibile.

Possiamo fruttuosamente rimandare questa parte del problema, decidendo, innanzitutto, come vorremo incapsulare l'interprete (sia che esso venga scritto da noi stessi, sia che venga invece "riutilizzato" un interprete altrui).

In altri termini: pensiamo, prima di tutto, ad una interfaccia, astratta, alle funzionalità che dovremo avere; solo in una fase ulteriore ci dovremo poi preoccupare di implementarle -- grazie a questa "separazione delle nostre preoccupazioni", potremo comunque procedere a progettare il "codice cliente", la parte, cioè, che usa queste funzionalità, in modo del tutto indipendente dalla parte che le implementa.

La "implementazione" potrà così cambiare, senza che ciò danneggi minimamente il "codice cliente", purchè abbiamo ben progettato la interfaccia, in modo che essa resti stabile; d'altra parte, se l'interfaccia è inoltre ben progettata anche nel senso di offrire la quantità giusta di flessibilità e generalità (non solo, cioè, per l'immediato uso che avevamo in mente progettandola), potremo scrivere poi diversi clienti che usano le funzionalità che la interfaccia "incapsula ed espone".

Quindi, sul "giusto" progetto dell'interfaccia si imperniano due assi indipendenti di riusabilità del nostro codice: riusabilità delle implementazioni per diversi clienti, riusabilità del codice cliente con diverse implementazioni.

 

Vediamo, dunque, cosa dovremo poter fare con il nostro "interprete di espressioni" (che chiameremo, tanto per rallegrarci con una sigla strana, IN. di ES., ovvero "IndiEs").

Anzitutto, di certo, dovremo inizializzarlo, ovvero comunicargli di "cominciare". Ci sarà, dunque, una funzione che potremmo chiamare IndiEs_Init. Che parametri vorremo passare a questa funzione? Non ci è ancora chiaro se e quali parametri, ovvero opzioni, potranno essere appropriati, o necessari. Per tenerci una notevole flessibilità, supponiamo di avere allora due parametri, come segue:

int IndiEs_Init( const char* sOpzioni, // opzioni-stringa unsigned long lOpzioni // opzioni-intero }; e, per il momento, specifichiamo che entrambi sono "riservati -- devono essere zero"; così, se nel corso del progetto rileveremo una opportunità di avere opzioni, le potremo introdurre (a patto di avere default pur sempre ragionevoli in loro mancanza!) senza nessun disturbo per il codice-cliente esistente.

Il valore int ritornato possiamo pensarlo come indicatore d'errore; per ora, basterà pensare a 0 per dire "errore", e un valore != 0 per dire "nessun errore", usandolo, quindi, come i BOOL che sono spesso tornati dalle API di Windows.

Ciò che inizia, deve prima o poi anche terminare; avremo dunque di certo anche una "funzione di chiusura", cui probabilmente non serviranno parametri, quindi:

BOOL IndiEs_Finis(void); anch'essa potrà tornare FALSE per dire che ha avuto qualche problema.

 

Curati così l'alfa e l'omega del nostro IndiEs, veniamo al "centro" dell'alfabeto. Vorremo, di certo, impostare la "espressione corrente" una volta, e poi valutarla per molteplici punti; non sarebbe, dunque, molto furbo mantenere legate le due sotto-funzionalità. Meglio, di certo, dividerle:

BOOL IndiEs_Espr(const char* espressione); qui, il BOOL ritornato è fondamentale, in quanto dovrà, in particolare, indicarci tutti gli "errori di sintassi" possibili nell'espressione (dove c'è un linguaggio, ci sono possibili errori di sintassi...!).

Infine, per la parte "valutazione dell'espressione in N punti", potremmo impostarla in vari modi:

Le eventuali possibilità di ottimizzazione nei vari casi non sono chiare, e nei casi poco chiari è meglio optare per la semplicità; useremo, dunque, la prima scelta -- altre funzioni possono pur sempre essere aggiunte più avanti, se ciò dovesse rivelarsi utile.

Anche la funzione "valuta l'espressione in un punto" deve poter tornare una indicazione di errore -- ad esempio, nei casi in cui IndiEs è in grado di identificare divisioni per zero, arcsin di valori con abs(x)>1, e così via; per uniformità con le altre funzioni IndiEs_ecc, pare conveniente avere l'indicazione d'errore come valore di ritorno, e restituire quindi il valore di Y attraverso un puntatore:

BOOL IndiEs_Valuta(double X, double* pY);

 

Così impostata una semplice interfaccia astratta per le funzionalità del nostro interprete di espressioni IndiEs, possiamo pensare a due diverse direzioni in cui procedere:

  1. (strategia "stub"): scrivere una versione semplicissima, estremamente "povera", di IndiEs, in grado di riconoscere e valutare una minima varietà di espressioni, e procedere con la scrittura del "vero" codice cliente basato su questo "stub" (dovremo, naturalmente, metterlo alla prova consci dei limiti dello stub di IndiEs!); infine, potremo scrivere una vera implementazione di IndiEs per interagire con il cliente che è stato sviluppato;
  2. (strategia "skeleton"): scrivere un cliente semplicissimo, estremamente "povero", e appena appena in grado di mettere alla prova le funzionalità di IndiEs, e procedere alla scrittura del "vero" IndiEs, mettendolo alla prova con questo "scheletro"; infine, potremo scrivere un "vero" cliente per interagire con l'IndiEs che è già stato sviluppato.

Con "stub" e "skeleton", le due classiche strategie di sviluppo imperniate sulla partizione di un problema grazie ad una interfaccia astratta, si arriva, alla fine, ad eguali risultati, ma si può scegliere quale strada prendere verso la meta. Naturalmente, si può anche procedere alternativamente verso entrambi gli obiettivi, iniziando con stub e skeleton, e man mano "arricchendo" entrambi -- questo può spesso richiedere un lavoro complessivo leggermente maggiore, ma offre in cambio la possibilità di controlli più dettagliati e frequenti sullo stato di avanzamento dei lavori, e riduce i rischi di schedulazione connessi ad errate valutazioni sulla effettiva complessità di sviluppo del "codice cliente" di un'interfaccia, ovvero della implementazione dell'interfaccia stessa.

Supponiamo, dunque, di partire con "stub" e "scheletro", e proseguire poi "raffinando" ciascuno dei due "iterativamente" verso il risultato cercato. Proseguiremo questo sviluppo al prossimo capitolo.


Capitolo 26: interazioni con gli EDIT
Capitolo 28: l'interprete: stub+scheletro
Elenco dei capitoli