Capitolo 8 - Le funzioni - organizzazione

 

 

 

 

            8.1 - Analisi strutturale di un algoritmo

 

 

                Abbiamo già incontrato diversi esempi di analisi di algoritmi, sufficientemente significativi, anche se presentati in modo molto informale, cioè senza ricorso ad un generale inquadramento teorico, che esula da questo contesto.

 

                L’analisi tende principalmente a mettere l’accento sul flusso logico di operazioni necessarie per la soluzione di un problema, ossia ad individuare gli elementi strutturali, che, almeno per la parte operativa, sono sequenze lineari, condizionali e cicli, ed eventualmente salti espliciti; vale a dire, esattamente (e non casualmente) le stesse risorse linguistiche di un linguaggio di programmazione, che abbiamo definito ormai completamente per il C.

 

                Si é specificato ‘parte operativa’ perché i problemi posti dai dati debbono pure essere esaminati in termini di struttura, ed una analisi completa deve occuparsi di entrambe le parti, che si condizionano a vicenda.

 

                Da questo punto di vista la natura dettagliata delle operazioni interessa poco e può anzi essere un ostacolo per la comprensione della logica generale del loro flusso; su questo analisi e stesura del programma possono divergere notevolmente, perché nel secondo caso la descrizione rivolta alla macchina non ammette indeterminazione: si pensi ad una frase come:

 

  (8.1.1)                   inizializza l’array con le potenze di 2

 

accettabile nell’analisi, ma non certo nel programma.

 

                Nel corso dell’analisi si é invece liberi di indicare con un nome di fantasia procedimenti operativi anche molto complessi, considerandoli come unica unità funzionale e con una buona scelta del nome ciò non solo semplifica la logica dell’analisi, ma aiuta ad informare sui suoi scopi e significati; abbiamo già osservato questa possibilità di autodocumentazione nel testo di un programma.

 

                Naturalmente, i dettagli del procedimento operativo etichettato con un nome funzionale non possono essere ignorati per sempre, perché tali risorse non possono ‘nascere da sé’; essi vengono invece trattati in un contesto diverso, come se si trattasse di un problema separato, in modo da non fare interferire la logica del particolare con quella generale.

 

                Questa impostazione dell’analisi viene detta top-down, o dall’alto in basso, e tende a raggruppare al massimo livello gerarchico, o macroanalisi, il minore numero possibile di descrizioni funzionali;  per esse si procede successivamente ad ulteriori analisi, nelle quali viene applicato il medesimo criterio, fino ad isolare unità sufficientemente semplici da non ammettere ulteriore suddivisione.

 

                E’ lasciato al giudizio dell’analista dove questa ‘discesa di livelli’ debba essere arrestata, poiché essa potrebbe giungere, in teoria, fino alla frammentazione in operazioni elementari, che coincide sostanzialmente con la scrittura delle singole istruzioni del linguaggio di programmazione (o addirittura di macchina).

 

                Tutto ciò può sembrare troppo teorico, ma si tratta in realtà solo di dare una veste sistematica a ciò che si potrebbe chiamare ‘formalizzazione del buonsenso’, poiché i medesimi principi vengono applicati più o meno intuitivamente in molte attività quotidiane, non solo in tema di algoritmi.

 

                Per dare corpo a tutte queste considerazioni, consideriamo un esempio di analisi di algoritmo. Utilizzeremo descrizioni simili alla sintassi del C, ma questo non é una limitazione, perché le considerazioni svolte sono applicabili a qualunque altro linguaggio di programmazione, modificandone eventualmente in parte l’aspetto estetico.


 

            8.2 - Esempio di analisi: lista di numeri primi

 

 

                 Il problema é:

 

  (8.2.1)                   dato un intero positivo n, costruire la lista dei numeri primi minori o uguali ad n.

 

                Per quanto riguarda i dati, il termine lista ed il fatto che n sia generico suggerisce un array lineare (vettore) per memorizzare il primo, secondo, ecc. numero primo determinato; le limitazioni della memoria e del calcolo intero (valori non superiori a 232-1) impongono vincoli di massimo per il problema. Ecco due esempi in cui elementi strutturali dei dati condizionano la logica dell’algoritmo.

 

                Utilizzando un indice come valore della prima posizione libera dell’array, il primo livello di analisi si presenta ad esempio come:

 

  (8.2.2)                   dati: n, indice, lista[MAX], con MAX da definire a parte ;

 

                               rilevare n, verificandone la validità  1 < n < MAX ;

                               porre lista[1] = 2 ed indice = 1 ;

                               per k da 3 ad n,

                                     se k é primo, incrementare indice di 1 e porre lista[indice] = k ;

 

                Ciò costituisce una definizione completa del flusso logico di operazioni funzionali, in cui sono ben riconoscibili il ciclo ed il condizionale necessari alla soluzione.

 

                Abbiamo liberamente mescolato almeno due tipi di descrizioni. Le prime sono simili ad istruzioni nella sintassi di un linguaggio di programmazione (dichiarazione dei dati, assegnazione, per .., se ..), quindi già ben definite in termini operativi e direttamente ‘traducibili’, ad esempio in istruzioni C.

 

                Delle seconde di hanno due esempi:

 

  (8.2.3)                   rilevare .. verificandone ..

 

  (8.2.4)                   essere numero primo

 

che non sono certamente riconducibili ad operazioni immediate e richiedono almeno due altre analisi di sottoproblemi, o problemi di livello più basso.

 

                Possiamo trascurare in parte il primo, che, mancando al momento strumenti più efficaci possiamo trattare in modo simile agli esempi di rilevazione diretta di tasti numerici (7.4.3) e seguenti, cui si deve aggiungere un controllo di validità, con richiesta di riassegnazione del dato.

 

                Il secondo caso é più interessante, ed é facile riconoscervi un problema trattabile come caso a sé:

 

  (8.2.5)                   dato un numero inter n > 2, stabilire se esso é un numero primo

 

di cui, cioè, si può effettuare l’analisi in modo completamente indipendente dal contesto (8.2.1) in cui lo abbiamo incontrato. Solo dopo averlo risolto ci occuperemo di creare una connessione tra i due.

 

                Il metodo più semplice (non necessariamente il migliore) per affrontare questo problema é fare riferimento alla definizione di numero primo:

 

  (8.2.6)                   un intero positivo n é primo se non ammette divisori propri, cioè diversi da sé stesso e da uno

 

da cui é immediato derivare l’analisi: ad esempio:


 

  (8.2.7)                   dati: n, k, primo interi, con n > 1 ;

 

                               poni primo = vero ;

                               per k da 2 ad n-1:

                                    se il resto della divisione di n per k é nullo:

                                        poni primo = falso ed interrompi il ciclo ;

 

ed é ovvio che se il ciclo viene percorso fino all’ultima iterazione, il valore logico di primo non é stato alterato.

 

                L’analisi in (8.2.7) é certamente corretta nei termini in cui tale proprietà é definita per gli algoritmi, in quanto:

 

  (8.2.8)                   (a) - é ben definito l’inizio delle operazioni ;

                               (b) - per ogni operazione é ben definita la successiva ;

                               (c) - é ben definito il criterio di arresto;

                               (d) - l’arresto viene raggiunto con un numero finito di operazioni.

 

                Essa é però altrettanto certamente stupida, perché non si vede come definire in altro modo un procedimento che, dopo avere esaminato il ‘candidato divisore’ 2, ripete la stessa operazione per 4, 6, ..., oltre che per i multipli di 3, ecc.;  é ovvio che le uniche divisioni necessarie sarebbero quelle per numeri primi, ma questo pone il problema di come ottenerne la lista, la cui generazione é proprio l’obiettivo di (8.2.1) di cui, almeno per il momento, (8.2.6) é una parte, che abbiamo però deciso di trattare come problema indipendente. Ciò pone una questione interessante, che riprenderemo con il listato del programma.

 

                Il maggiore motivo di inefficienza é però un altro: se diciamo m la parte intera della radice quadrata di n (ad esempio, per n = 99, m = 9), si dimostra subito che se p é un divisore di n, con p>m, allora ne esiste un altro, q, con q<m, perché in caso contrario il prodotto p*q supererebbe n.

 

                Ne deriva che non si può trovare il divisore p senza avere già trovato q in precedenza, in altre parole che non é necessario eseguire tentativi di divisione oltre m. La riduzione che se ne ottiene é assai più consistente di quello realizzabile limitando le prove ai soli numeri primi, ed essa é anche semplice da realizzare, perché in (8.2.7) basta aggiungere una variabile - l’equivalente di m - e calcolare la radice quadrata.

 

                E’ ovvio che l’ottimizzazione completa consisterebbe nell’utilizzare i soli numeri primi minori di m, cioè solo una piccola parte della lista che dovrebbe essere già acquisita quando si opera su n.

 

                Queste considerazioni, pure valide di per sé, hanno spostato l’attenzione dal punto principale, che é la relazione tra il problema ed il sottoproblema trattati in (8.2.2) e (8.2.7) rispettivamente.

 

 

 

 

 

            8.3 - Definizione delle funzioni

 

 

                Eseguite analisi come quelle in (8.2.2) ed (8.2.7), oltre che per (8.2.3), di cui abbiamo trascurato il dettaglio, si tratta di concludere, assemblandole per ottenere un programma completo.

 

                Il modo più ovvio é di ‘trapiantare’ le analisi di livello inferiore, come la (8.2.7), in quelle di livello superiore, scrivendo le istruzioni di programma che ne derivano direttamente al posto in cui si era inizialmente inserito il nome simbolico della procedura ancora da analizzare ed ottenendo un unico flusso fisico di istruzioni, come in tutti gli esempi esaminati finora.

 

                In un programma C questo significa rendere (molto) più consistente il testo compreso tra le parentesi graffe di main, che perde quindi la semplicità di controllo presente nelle analisi (8.2.3) ed (8.2.7).


 

                Si rendono quindi opportune convenzioni che permettano di trasferire al programma i medesimi schemi logici: si tratta delle regole per la notazione e la definizione delle funzioni, che abbiamo già incontrato diverse volte utilizzando quelle della libreria standard, delle quali sin dall’inizio di é rimarcato che l’elemento essenziale per distinguerle é la presenza delle parentesi tonde in contesti diversi da quelli delle precedenze nelle espressioni.

 

                Tale differenza potrebbe essere rimossa, considerando anche le parentesi delle espressioni come funzioni senza nome, cosa che permette di leggere la tabella delle precedenze (4.4.6) senza dovere fare eccezioni.

 

                Le parentesi potrebbero essere vuote, ma nella maggior parte dei casi esse contengono i dati necessari al procedimento operativo per operare correttamente;  con quest’ultima osservazione si hanno elementi sufficienti per estendere la notazione alle funzioni create dall’utente.

 

                Si deve però fare un’ultima premessa: le funzioni sono l’elemento più flessibile e di maggiore portata applicativa presente in un linguaggio di programmazione; si potrebbe dire, l’arma più potente nelle mani dell’analista e del programmatore, quindi anche lo strumento più pericoloso, quello dal cui impiego ci si debbono attendere le  maggiori probabilità di errori logici di natura complessa. Per questo motivo la definizione delle funzioni viene curata con maggiore dettaglio rispetto alle altre componenti del linguaggio.

 

  (8.3.1)                   Si dice funzione ogni procedimento operativo individuato con un identificatore seguito da

                               una coppia di parentesi tonde, all’interno delle quali possono figurare dati detti parametri.

 

                Una funzione viene attivata, o chiamata, nel momento in cui il flusso esecutivo del programma la incontra; si ha cioè una sospensione delle operazioni nel contesto in cui avviene la chiamata, in attesa di riprendere quando le operazioni della funzione sono concluse. Nella chiamata, i dati presenti in parentesi sono detti parametri attuali; essi possono essere costanti, identificazioni di variabili o puntatori, o più in generale espressioni.

 

                Una funzione può produrre un valore come risultato delle proprie operazioni, oppure nessun valore affatto e nel primo caso si dice che la funzione ritorna o restituisce, o rende disponibile, tale valore al contesto in cui é stata chiamata; se tale valore esiste, esso può appartenere a qualunque tipo di dato ed é utilizzabile senza limitazioni in ogni contesto per cui quel tipo di dato é legittimo.

 

                Se la funzione é creata dall’utente, la parte di testo che si occupa dei suoi dettagli deve essere scritta al di fuori del blocco di parentesi graffe che delimitano il programma in cui viene attivata. La descrizione delle operazioni della funzione, o sua definizione, deve essere fatta nella forma:

 

  (8.3.2)                   tipo nome ( [ par_1 ] [, [ ... par_n ] ] )  { istruzioni }

 

in cui tipo é il tipo di dato restituito al contesto chiamante, oppure void, nel caso che le operazioni della funzione non si concludano con la restituzione di un valore, mentre  nome é il medesimo identificatore utilizzato per la chiamata, creato con le medesime regole che si applicano ai nomi delle variabili.

 

                In parentesi, gli elementi opzionali par_1 ... par_n sono detti parametri formali della funzione; la loro forma, nella sintassi corrente, é la simile a quella della dichiarazione delle variabili (mancano i simboli ‘;’):

 

  (8.3.3)                   tipo identificatore

 

ma esiste anche una forma obsoleta, limitata all’identificatore, che deve però poi essere dichiarato nella funzione.

 

                Per quanto riguarda istruzioni, questa parte é inclusa un una coppia di parentesi graffe che delimitato il contesto operativo della funzione, esattamente come accade per main. In questa parte é ammesso l’impiego di tutte le risorse che il linguaggio mette a disposizione, vale a dire una funzione é a tutti gli effetti un programma, con la differenza che esso viene progettato per essere “messo al servizio” di un altro programma.

 

                I parametri evidenziano uno degli aspetti più importanti delle funzioni, la loro flessibilità: il medesimo procedimento operativo, cioè la parte istruzioni in (8.3.2) viene scritto una volta per tutte, ma può essere utilizzato “alimentandolo” con dati ogni volta diversi; si pensi ad esempio a funzioni di libreria come sqrt.


 

 

            8.4 - Principali norme per le funzioni in C

 

 

                Ci occupiamo ora delle “regole per l’uso” che, per i motivi appena esposti, per le funzioni sono più complesse e dettagliate che per altri elementi del linguaggio.

 

  (8.4.1)                   Non é ammessa la definizione di una funzione all’intero di un’altra funzione;

                               ciò vale, in particolare, anche per la funzione predefinita main.

 

                Non ha invece importanza se nel testo del programma la definizione della funzione viene prima o dopo il contesto della chiamata.

 

                Può anche accadere, e questa é anzi la norma per programmi ‘reali’ che risolvono problemi ‘reali’, che sia necessario o anche solo opportuno dividere il testo logico del programma in un certo numero di testi (file) separati, per esempio per aggregare le funzioni rispetto a certi criteri di omogeneità.

 

                Il questo caso la funzione main deve essere presente in uno solo di essi, ma per il resto il modo di raggruppare le funzioni dipende dalle decisioni dell’utente; ci si deve occupare a parte delle conseguenze che una divisione di questo tipo comporta per compilazione e link, per i quali ci si deve attendere un minimo di complicazione.

 

 

                Un programma in linguaggio C deve quindi essere visto come un insieme di funzioni senza relazioni gerarchiche, di cui almeno una deve essere sempre presente, la funzione predefinita main. E’ importante rilevarne esplicitamente una conseguenza solo apparentemente ovvia:

 

 

  (8.4.2)                   In ogni punto di un programma C, o si é nel contesto (all’interno) di una funzione,

                               o si é fuori dal contesto di tutte le funzioni che compongono il programma. Nel primo

                               caso il contesto viene detto locale, nel secondo globale.

 

                La distinzione locale / globale é di importanza cruciale per tutto ciò che riguarda le risorse che una funzione può legittimamente utilizzare ed é la premessa per la trattazione delle classi di memoria.

 

 

  (8.4.3)                   Tra parametri attuali (della chiamata) e parametri formali (della definizione)  vi deve

                               essere corrispondenza biunivoca in numero e tipo di elementi. Se é ammessa la chiamata

                               di una funzione con un numero variabile di parametri, la corrispondenza é tra l’intera

                               lista dei parametri attuali ed l’inizio della lista di quelli formali.

 

                La norma é quella della prima parte, cioè la corrispondenza deve essere strettamente biunivoca, condizione senza la quale non é possibile il transito ordinato delle informazioni che il programma chiamante passa alla funzione per avviarne le operazioni.

 

                Se il compilatore ammette chiamate con un numero variabile di parametri, la variabilità deve riguardare solo la coda della lista, mentre deve esserne univocamente definita una prima parte, in ruolo di nucleo fisso. Questa tecnica é però difficile da padroneggiare, ed utilizzarla toglie al compilatore la possibilità di eseguire alcuni controlli automatici sulle liste dei parametri; essa dovrebbe quindi essere utilizzata solo in casi molto speciali e con grande cautela.

 

 

  (8.4.4)                   Se il tipo della funzione non é void, ossia se essa genera un valore, al suo interno

                               deve esistere almeno una istruzione della forma:

 

  (8.4.5)                                  return espressione ;


 

                La parola chiave return, che può comparire anche più di una volta, individua la conclusione logica delle operazioni della funzione, mentre espressione determina il valore da restituire al contesto della chiamata. Per funzioni di tipo void é ammesso l’impiego della parola return senza specificazione dell’espressione.

 

 

                Con (8.4.1) si é stabilito che l’ordine di presentazione delle funzioni nel testo non é importante, in particolare che una funzione può essere utilizzata in una chiamata prima di essere definita nella forma richiesta da (8.3.2).

 

                Ciò é in evidente contrasto con il principio per cui nessuna risorsa creata dall’utente - e le funzioni lo sono - può essere utilizzata in un programma se il tipo di oggetto logico che essa rappresenta non é stato preventivamente identificato tramite una dichiarazione esplicita. Per le funzioni della libreria standard, questo é appunto uno degli scopi dei file di testata.

 

                Se il compilatore incontra per la prima volta nel testo del programma l’identificatore di una funzione nel contesto di una chiamata, cataloga il nome tra quelli delle funzioni ed é pure in grado di stabilire numero e tipo dei sui parametri, ma non é possibile dedurre nulla sul tipo di valore che essa restituisce (o non restituisce).

 

                Poiché la definizione delle funzioni dopo la loro utilizzazione é la forma più naturale in una struttura top-down, e d’altra parte la maggior parte delle funzioni restituiscono un valore intero, fin dalla formulazione originale del C si é deciso che in condizioni come quelle dette alla funzione viene automaticamente associato il tipo intero.

 

                Questa decisione predefinita é comunque insufficiente, perché se tale ipotesi implicita non é vera, al momento della effettiva definizione della funzione si verifica una contraddizione, magari risolvibile in qualche modo, ma che costituisce egualmente un problema logico ed una carenza formale nella struttura del linguaggio.

 

                Per ovviare all’inconveniente é stata introdotta una speciale forma di dichiarazione, detta prototipo della funzione, da scrivere prima della prima chiamata; essa non é (ancora) un obbligo per la sintassi, ma é consigliabile utilizzarla sempre. La forma della dichiarazione é:

 

(8.4.6)                     tipo nome ( [ tipo_1 [ ident_1 ] ] [, [ ... tipo_n [ ident_n ] ] ] )  ;

 

in parte simile all’enunciato di definizione (8.3.2), da cui differisce per due aspetti:

 

·       é presente il simbolo di terminatore di istruzione ‘;’ immediatamente dopo la chiusura della parentesi, dove cioè la definizione della funzione contiene descrizione di dettaglio della funzione stessa;

 

·       nella parentesi non sono presenti i parametri, ma solo i tipi di dato cui essi appartengono, al fine di determinarne numero e genere; é ammesso accompagnare ogni descrizione di tipo con un identificatore, che si deve però intendere solo come commento per la documentazione.

 

 

  (8.4.7)                   Non vi sono limitazioni alla chiamata di una funzione da parte di un’altra funzione;

                               in particolare, una funzione può chiamare sé stessa, direttamente, oppure indirettamente

                               tramite un’altra funzione. In questi casi si dice che la funzione é ricorsiva.

 

                La ricorsività si incontra in generale in algoritmi matematici, soprattutto in argomenti quali la teoria dei numeri o quella dei grafi. In schemi quali:

 

una funzione f chiama sé stessa ,

una funzione f chiama una funzione g, che a sua volta chiama f ,

 

dovrebbe essere evidente una analogia con un problema già incontrato: condizioni come queste possono facilmente provocare la perdita di controllo del programma generando cicli senza uscita. Ne esistono però anche altri, ed alle funzioni ricorsive si deve dedicare una attenzione particolare, trattandole come un argomento a sé stante.


 

            8.5 - Esempio di applicazione: lista di numeri primi

 

                Presentiamo il listato completo di un programma per il problema proposto in (8.2.1), completo della parte di rilevazione del dato intero, ricavata dagli esempi visti al riguardo.

 

  (8.5.1)

       /*  LISPRIMI.C - generazione di una lista di numeri primi

       */

       #include <stdio.h>

       #define  MAX_N  1000

 

             int    rileva_num   ( int minimo, int massimo ) ;                  /* <1> */

             int    num_primo    ( int ) ;

 

             int    lista [ MAX_N / 2 ] ;                                       /* <2> */

 

       main ( )

       {   int n, k, idx ;

 

             printf ( "\n LISTA DI NUMERI PRIMI \n" ) ;

             n = rileva_num ( 3, MAX_N ) ;                                      /* <3> */

             idx = 1 ; lista[idx] = 2 ;

             for ( k = 3 ; k <= n ; k++ )

                if ( num_primo ( k ) )  lista[idx++] = k ;               /* <4> */

             printf ( "\n lista dei numeri primi fino a %d :\n ", n ) ;

             r ( k = 1 ; k < idx ; k++ )  printf ( "%4d", lista[k] ) ;

       }

 

       /*--------------------------------------------------------*/

       /*  R I L E V A _ N U M - input controllato di un intero  */       /* <5> */

       /*--------------------------------------------------------*/

       #include <conio.h>                                                        /* <6> */

       #include <ctype.h>

 

       int     rileva_num ( int min, int max )                            /* <7> */

       {

          int dato, ok ;  char c ;

 

             do { printf ( "\n assegnare n tra %d e %d :  ", min, max ) ;

                  dato = 0 ;

                  do if ( ok = isdigit ( c = getche ( ) ) )

                       dato = dato * 10 + ( c - '0' ) ;

                  while ( ok ) ;

                } while ( dato < min || dato > max ) ;

             return ( dato ) ;                                                  /* <8> */

       }

 

       /*-------------------------------------------------------------*/

       /*  N U M _P R I M O - determinazione della proprietà… "primo"  */

       /*-------------------------------------------------------------*/

       #include <math.h>

 

       int     num_primo ( int num )

       {

          register int k, lim, primo = 1 ;

 

             lim = (int) sqrt ( (double) num ) ;

             for ( k = 2 ; k <= lim ; k++ )

                if ( ! ( num % k ) )  { primo = 0 ;  break ; }

             return ( primo ) ;

       }


 

                Commentiamo, al solito, alcuni punti di particolare interesse.

 

·         <1>: Vi sono due esempi di prototipi di funzioni, di cui il primo utilizza come commento due pseudo-variabili per indicare lo scopo dei parametri.

 

·         <2>: Questa forma di dichiarazione del dato é nuova, perché non si trova in una funzione, cioè al livello che in (8.4.2) abbiamo detto locale, ma fuori delle funzioni, al livello globale. Ciò non é conseguenza della strutturazione in funzioni, ma delle limitazioni dello spazio disponibile per le variabili a livello locale, che bloccherebbe il programma in esecuzione, se la costante MAX_N venisse aumentata a sufficienza; l’argomento verrà approfondito trattando della speciale zona di memoria disponibile per questo scopo, detta stack. Al proposito di MAX_N, di deve osservare ancora che l’array lista é certamente sovradimensionato, ma non potendo conoscerne in anticipo la dimensione esatta, e non volendo complicare troppo il programma, si é preferita questa “parametrizzazione” di larga sicurezza.

 

·         <3>: Ecco il primo esempio di richiamo di una funzione definita dall’utente, rileva_num, dotata di due parametri da utilizzare nelle operazioni della funzione; il valore restituito dalla funzione viene utilizzato in una assegnazione.

 

·         <4>: Altro caso di chiamata di funzione, il cui valore viene stavolta utilizzato come condizione logica per stabilire se memorizzare il valore nella lista, oppure no; si osservi il modo di incrementare l’indice idx, che individua sempre la prossima posizione libera nella lista.

 

·         <5>: Questi commenti costituiscono un ‘titoletto’ che permette di riconoscere immediatamente i punti del testo in cui vengono definite le funzioni; é bene abituarsi subito a convenzioni come questa.

 

·         <6>: Si osservi la posizione delle due metaistruzioni richieste dalle funzioni getche ed isdigit: esse si trovano sempre a livello globale, ma sono state ‘chiamate in causa’ solo quando ne é stata riconosciuta la necessità; le convenzioni in esse stabilite sono valide da questo punto in avanti, non nella parte precedente.

 

·         <7>: Questo é il primo esempio di apertura di definizione della funzione; si osservino in particolare i due nomi di ‘variabili’ min e max, che sono quelli con cui questa funzione riconosce i valori interessati.

 

·         <8>: Questa é la conclusione logica delle operazioni ed il punto di ‘ritorno’ al contesto precedente; le parentesi in cui é stato racchiuso il valore che deve essere restituito al contesto della chiamata sono superflue, ma alcuni (tra cui chi scrive) hanno l’abitudine ad utilizzarle sistematicamente per evidenziare meglio il valore finale.

 

 

 

 

 

 

            8.6 - Contesti locali e globali

 

 

                I due termini locale e globale sono già stati impiegati diverse volte in riferimento a diversi generi di risorse, utilizzandone più l’interpretazione intuitiva che una definizione precisa, di cui invece ci occupiamo ora.

 

  (8.6.1)                   In questo contesto con risorse si intendono tutti gli identificatori creati dall’utente, che

                               siano classificabili in una delle due categorie: variabili di tutti i tipi, funzioni.

 

  (8.6.2)                   Una risorsa ha validità globale se essa é dichiarata o definita a livello globale, cioè al di

                               fuori di ogni funzione. Essa é disponibile senza restrizioni in ogni contesto successivo al

                               punto del testo in cui é dichiarata o definita.

 

  (8.6.3)                   Una risorsa ha validità locale se essa é dichiarata o definita all’interno della definizione

                               di una funzione (parametri inclusi) ; al di fuori di tale contesto diviene indefinita.


 

                Variabili e funzioni non sono i soli oggetti logici per i quali l’utente crea identificatori, poiché esistono anche costanti simboliche ed etichette. In entrambi i casi si tratta però di identificatori per scopi speciali, di cui i primi debbono essere scritti in metaistruzioni, che sono sempre globali, e le seconde sono strettamente locali.

 

                Nell’esempio (8.5.1) esiste un solo caso di variabile globale, l’array lista, che in virtù di questa definizione potrebbe essere utilizzata in un contesto qualunque, ossia anche durante le operazioni delle funzioni rileva_num e num_primo, cosa che in questo caso non é stata fatta poiché non ne esiste necessità.

 

                L’osservazione é però interessante e ne deriva un principio generale.

 

  (8.6.4)                   Le variabili globali possono essere utilizzate per condividere dati tra diverse funzioni,

                               cioè come mezzo per fare circolare informazioni tra di esse al di fuori dei due canali

                               dati dalla lista dei parametri e dal valore di ritorno.

 

                Per comprendere meglio cosa questo significhi in termini operativi, si supponga di volere ottimizzare i calcoli della funzione num_primo, come osservato alla fine di 8.2, ossia eseguendo i test del resto della divisione intera solo su candidati divisori che siano a loro volta numeri primi.

 

                Poiché questi numeri sono minori di quello su cui si sta facendo il test, essi sono certamente già inclusi in lista; si tratta allora di utilizzare la variabile k del for per percorrere non la sequenza di interi consecutivi 2, 3, 4, ..., bensì come indice per percorrere gli elementi di lista fin dove necessario, cioè finché sono minori o uguali a lim.

 

                Ciò significa, in primo luogo, che la forma for può essere vantaggiosamente essere sostituita da una while, ma soprattutto che durante le operazioni é necessario ‘consultare’ l’array lista.

 

                Con la definizione globale di lista ciò é possibile senza problemi, ma questa scelta comporta una conseguenza molto importante: nella formulazione di (8.5.1) la funzione num_primo non utilizza nessuna informazione oltre quella fornita con il parametro, ed é quindi riutilizzabile senza variazioni in qualunque altro programma richieda il medesimo servizio. Se essa viene modificata in modo da utilizzare anche il dato globale, diviene dipendente dal contesto particolare in cui è stata scritta e non è riutilizzabile senza sostanziali variazioni.

 

                Una funzione di tipo generale, non legata ad un programma specifico, dovrebbe comportarsi come una ‘scatola nera’, che ricevendo certe informazioni tramite i parametri genera un certo risultato, indipendentemente dal contesto in cui essa viene attivata; ciò può essere formulato come principio generale.

 

  (8.6.5)                   Se una funzione viene progettata per impiego generale, non limitato al un solo programma,

                               essa non deve fare riferimento a risorse globali, che in un particolare contesto potrebbero

                               essere indefinite; deve cioè utilizzare solo parametri e variabili locali.

 

                Per quanto riguarda le variabili locali, invece, le osservazione più significative sono le seguenti:

 

 (8.6.6)                    Una variabile locale diviene indefinita, cioè cessa effettivamente di esistere, al di fuori del

                               contesto in cui é stata dichiarata.

 

 (8.6.7)                    Se in un contesto locale viene dichiarato un identificatore dello stesso nome di uno che già

                               esiste a livello globale, la dichiarazione locale nasconde, finché é valida, quella globale, la

                               quale riprende però a valere non appena il contesto locale viene superato, senza perdita

                               di informazione per  il dato globale.

 

                Si osservi ad esempio la variabile k di main in (8.5.1): essa non ha nulla a che fare con la sua omologa dichiarata in num_primo che, se le esigenze logiche dell’algoritmo lo avessero richiesto, avrebbe anche potuto rappresentare un tipo diverso di oggetto logico (ad esempio double).