Capitolo 2 - Struttura di un programma

 

 

 

 

            2.1- I linguaggi di programmazione

 

 

                Ogni attività della macchina, senza eccezione, é il risultato dell'esecuzione di azioni elementari, organizzate in sequenze logiche dette programmi; la definizione delle regole per la scrittura di un programma ne ha evidenziato la natura di strumento di comunicazione per l'interazione tra uomo e macchina, da cui la naturale dizione di linguaggi di programmazione.

 

                Come nei linguaggi naturali, l'insieme delle regole viene detto sintassi del linguaggio; esse possono essere scelte in modo largamente arbitrario, ma ogni regola, come tutto l’insieme, deve rispondere a criteri di coerenza e non ambiguità, tenendo conto che per almeno uno dei due ‘interlocutori’ (la macchina) esse debbono essere completamente certe ed univoche e che per l'altro (l'uomo) è perlomeno opportuno che esse siano semplici ed omogenee.

 

                In ordine di tempo, il C é il più recente tra i linguaggi ‘classici’, termine spesso impiegato per Fortran, Cobol, Basic, PL/1 e Pascal, oltre che per il C stesso; nello stadio attuale di sviluppo sono molto sensibili gli effetti di una larga osmosi nell’evoluzione parallela che ognuno di essi ha subito con il tempo e le relative differenze sono assai minori di quanto fossero alle origini. Hanno invece perduto importanza e sono rimasti, per così dire, rami secchi del cammino evolutivo, altri linguaggi quali l’Algol e l’APL, per citarne solo due particolarmente interessanti e ricchi di idee originali.

 

                Accanto ai 'classici' esistono anche linguaggi specifici nati in contesti particolari: ad esempio il linguaggio di programmazione interno di strumenti complessi quali Dbase, Excel, Maple, Matlab, ecc.; essi sono però limitati a contesti e problemi specifici e sono parti di uno strumento orientato alla soluzione di una ben definita classe di problemi che li condiziona, al di fuori dei quali non hanno applicazione

 

                Quelli citati in precedenza, invece, hanno invece impiego universale, per cui sono detti general purpose; vale a dire essi sono stati progettati per risolvere problemi non specializzati, permettendo di organizzare nel dettaglio voluto le operazioni per (quasi) qualunque scopo.

 

                Fatta questa premessa, iniziamo con l’osservare che ogni azione della macchina può essere vista come un caso particolare di esecuzione di un comando basato sul prototipo:

 

  (2.1.1)                   opera nel modo X sull'insieme di dati Y

 

ed un programma é appunto la descrizione completa dell’insieme ordinato (non necessariamente in stretta sequenza lineare) dei comandi necessari per finalizzare il flusso delle azioni corrispondenti al raggiungimento di uno scopo definito, comunque complesso, che può venire inteso come la soluzione del problema per ottenere la quale il programma é stato scritto.

 

                Si è preferita la formulazione (2.1.1), il cui grado di generalità é massimo, anche se in molti casi l’insieme dei dati é ridotto ad uno soltanto, ed in certi addirittura a nessuno, poiché per gradi successivi la (2.1.1) conduce in modo omogeneo dalle operazioni elementari di macchina alle formulazioni linguistiche aggregate, o espressioni, ed alla sintesi strutturale espressa in unità funzionali di livello superiore.

 

                E' subito evidente che le regole adatte all’esecuzione automatica di operazioni espresse come in (2.1.1) debbono comprendere almeno gli elementi che seguono, nell'ordine in cui essi sono presentati:


  (2.1.2)                   (a) - descrizione dei dati su cui la macchina deve operare;

                               (b) - descrizione delle operazioni, e tipi di dati per cui esse hanno senso;

                               (c) - descrizione dell'organizzazione del flusso delle operazioni.

 

                In particolare in (b) ci si deve occupare della definizione delle procedure di Input/Output (indicate con I/O), cioè quelle operazioni che riguardano scambio di dati tra il programma e l’ambiente esterno alla macchina: tastiera, video, stampante, dischi, ecc. Fatta eccezione per il Cobol, nato come linguaggio ‘commerciale’ e quindi particolarmente curato nella parte di I/O, tra quelli detti ‘scientifici’ il Fortran é probabilmente quello che tratta l’argomento nel modo più flessibile ed efficiente, mentre il C gli può essere giudicato di poco inferiore.

 

 

 

 

 

 

            2.2.- La definizione dei dati

 

 

                Per quanto riguarda la (a) in (2.1.2), si deve iniziare dalla dalla classificazione dei tipi di dati,  cioè dei generi di oggetti logici che é possibile memorizzare ed elaborare, generi che dipendono dalla tecnologia costruttiva e dalla organizzazione della macchine. Questa non é una esigenza ‘assoluta’, poiché é perfettamente ragionevole (anzi, da un punto di vista puramente teorico, è preferibile) non dipendere da quanto una particolare generazione di macchine può offrire, ma stabilire principi generali.

 

                E' però anche vero che ogni particolare linguaggio é progettato per risolvere problemi reali in modo reale, ed é in vista di questo fatto che ne vengono definite le regole; una certa comprensione delle esigenze di macchina facilita quindi l'apprendimento delle convenzioni linguistiche, riconoscendole come necessità logiche e non come schemi astratti del tutto arbitrari, di cui male si coglierebbe il significato.

 

                E' abbastanza sorprendente che i tipi di dati iniziali, detti semplici o elementari, quelli per i quali cioè la macchina dispone di circuiti appositi per operare su di essi, non siano molti e si riducano sostanzialmente ad unità di uno, due, quattro od otto byte.

 

                Il primo tipo (un byte=aggregato di 8 bit consecutivi) è l’unità minima che può essere oggetto di una singola operazione di macchina, quindi delle operazioni sui dati trattati da un linguaggio, anche se in apparenza si può disporre di operazioni sui singoli bit. Esso viene generalmente utilizzato per la trattazione di dati simbolici, o caratteri, mentre le maggiori unità di aggregazione sono usati generalmente per scopi aritmetici, nei quali si deve distinguere bene tra numeri interi e non interi, che nel Fortran vengono detti impropriamente reali di diversi gradi di precisione.

 

                Il termine 'generalmente' utilizzato sopra sta ad indicare che i confini non sono del tutto netti, potendo anche operare sui singoli byte con calcoli numerici o logici e sui loro aggregati per la trattazione di ‘pacchetti’ di informazione simbolica. Questo é particolarmente vero per il C, che permette di trattare i singoli byte come ‘piccoli numeri interi’, ad esempio al fine delle operazioni aritmetiche.

 

                Per il calcolo, infine, nel caso dei numeri interi é possibile che le operazioni aritmetiche debbano utilizzare il segno, oppure no; la seconda scelta, ad esempio, può essere adottata per raddoppiare la capacità di rappresentazione di valori nell'aritmetica finita dei numeri interi quando il segno non interessa.

 

                I dati possono essere presenti in un programma in due forme: costante e variabile, come accade già nella più semplice delle richieste:

 

  (2.2.1)                   assegna all'oggetto indicato simbolicamente con il nome  a  il valore 3

 

o, come di fatto si scrive in C, con il simbolo ‘;’ parte integrante della scrittura:


 

  (2.2.2)                   a = 3 ;

 

                Un altro esempio è:

 

  (2.2.3)                   carattere = ‘B’ ;

 

in cui l’elemento costante a secondo membro non é un valore numerico, ma simbolico.

 

                Le costanti si autodescrivono: è cioè possibile rilevarne in modo del tutto automatico la natura di oggetto logico: carattere, intero o non intero e nel terzo caso il grado di precisione (cifre decimali esatte);  per le variabili, invece, non si può dedurne la natura dal solo nome; ciò potrebbe anche essere vero per esempi semplici come quelli in (2.2.2) e (2.2.3), ma non lo è certamente in casi appena più complessi, come la ‘frase’ o ‘istruzione’ C di significato aritmetico ovvio:

 

  (2.2.4)                   primo = secondo + ( terzo / quarto ) ;

 

in cui, se da un lato é chiaro che le variabili possono essere trattare in espressioni simili a quelle algebriche (attenzione, possono, non devono), e dall’altro che diversamente dall'abitudine algebrica si preferiscono spesso nomi ‘lunghi’ (per ragioni di chiarezza espositiva), é però altrettanto evidente che nulla si può dedurre sulla natura intera o non intera degli oggetti interessati.

 

                Una esatta informazione sul tipo di oggetto trattato (intero, non intero, ecc.) è essenziale per permettere alla macchina di determinare senza ambiguità come gli oggetti in questione debbano essere memorizzati, in modo che la interpretazione delle sequenze di bit in memoria sia univoca, e quali operazioni debbano (o possano) essere eseguite su di essi.

 

                Ad esempio l'esecuzione di una divisione tra interi ed una tra non interi sono operazioni ben distinte sia logicamente che fisicamente, tanto che nei due casi il valore della variabile a primo membro in (2.2.4) può essere diverso, a parità ‘intuitiva’ di valori, da quelle a secondo membro, ed é certamente sempre diverso nella rappresentazione interna in memoria.

 

                Bastano i semplici esempi appena considerati per descrivere come elementi iniziali della definizione della sintassi del linguaggio quelli dello schema:

 

  (2.2.5)                   [1] - i tipi di dati elementari che il linguaggio può trattare;

                               [2] - come li descrive;

                               [3] - la descrizione delle organizzazioni di dati non elementari;

                               [4] - le regole per la formazione dei nomi di variabili;

                               [5] - le operazioni ammesse per ogni tipo di dato;

                               [6] - le regole per le espressioni pseudo-algebriche.

 

                La sintassi di un linguaggio é sostanzialmente l’insieme delle risposte alle domande implicite in questo schema.

 

 

 

               

 

            2.3 - Le operazioni su dati.

 

                Nel caso del C la classificazione dei dati elementari comprende:

 

  (2.3.1)                   caratteri

                               interi di due specie, 'corti' e 'lunghi'

                               reali di due tipi, 'semplici' e 'doppi'


 

che nei casi di due specie (o gradi di precisione) diverse permettono di eseguire calcoli su due livelli di cifre decimali esatte; i caratteri sono in realtà trattati nelle elaborazioni come piccoli interi, e per tutti i tipi interi é ammessa una ulteriore diversificazione in numeri dotati, oppure privi di segno.

 

                Accanto ai tipi di dati ‘ordinari’, comuni a tutti i linguaggi di programmazione, il C permette la definizione e la trattazione esplicita di un altro genere di dato:

 

  (2.3.2)                   puntatori, ossia oggetti capaci di rappresentare indirizzi di memoria

 

che sono presenti anche nel Pascal, ma in forma molto più ristretta. Per questo aspetto il C è unico, poiché questo tipo di dato può essere oggetto di operazioni esplicite di tipo aritmetico, caratteristica che é uno dei motivi principali della oramai netta prevalenza del C sui ‘concorrenti’.

 

                Definiti i dati come richiesto al punto (a) della (2.1.2), si deve passare al (b), cioè a definire le operazioni che il linguaggio ammette per ogni tipo, o gruppo di tipi di dati.

 

                Per il C tutti i tipi di dati hanno natura numerica, anche se le proprietà aritmetiche possono essere in qualche caso piuttosto singolari, come per i puntatori. Le operazioni di gran lunga prevalenti sono quindi quelle aritmetiche, che richiedono la definizione di simboli operativi, od operatori, per i quali ovviamente si scelgono i quattro simboli +, -, *, / per somma, differenza, moltiplicazione e divisione dell’aritmetica ordinaria; si noti che non si é incluso un operatore specifico per l’elevazione a potenza, come invece avviene, ad esempio, in Fortran o in Basic.

 

                A differenza del Fortran e del Pascal, in cui il tipo ‘logico’ denota oggetti operativamente diversi da quelli numerici, il C utilizza invece per il medesimo scopo i valori numerici, identificando zero con falso e non-zero con vero. Esiste però una classe di operatori derivata dall’algebra della logica; i suoi simboli definiscono le operazioni di congiunzione, disgiunzione e negazione, oltre alle relazioni di diseguaglianza stretta ed attenuata, di eguaglianza e non eguaglianza (sei casi in tutto), che applicate ad una coppia di valori hanno comunque come risultato i valori vero o falso, in questo caso rispettivamente uno e zero.

 

                Esistono anzi due tipi diversi di notazione (e di interpretazione) per ogni operatore logico, di cui il secondo da eseguire bit a bit; questa caratteristica permette al linguaggio C di operare sui singoli bit di un byte (pure interessando l’operazione l’intero byte), e ciò come sintassi standard del linguaggio e non come estensioni delle librerie, come accade per altri linguaggi.

 

                Utilizzando gli operatori su insiemi di dati che possono essere variabili o costanti con le consuete regole algebriche per la precedenza gerarchica e l’eventuale impiego di parentesi per superarla, si formano espressioni, che sono dette aritmetiche, logiche o di caratteri, a seconda della scelta del tipo di dati e di operatori.

 

                Il risultato di una espressione é comunque un valore, appartenente ad un tipo di dato ben definito; nella maggior parte dei casi le espressioni figurano a secondo membro di una istruzione detta assegnazione, che ha la forma (si badi bene, solo la forma) di un’eguaglianza aritmetica in cui a primo membro compare il nome di una variabile.

 

                La (2.2.4) é appunto un esempio di assegnazione, in questo caso aritmetica, in cui si é fatto ricorso ad una espressione propria; ne sono però esempi ‘minimali’ anche la (2.2.2) e (2.2.3).

 

                Il significato operativo di una assegnazione é descrivibile come:

 

  (2.3.2)                   assegna alla variabile che compare a primo membro, ossia colloca nella posizione di

                               memoria ad essa riservata, il valore derivante dal calcolo dell’espressione che figura

                               a secondo membro.


 

                La scelta del simbolo = per l’assegnazione può generare un equivoco, trattandosi del medesimo utilizzato nella notazioni matematiche per indicare la relazione di identità; nel caso attuale esso deve però essere inteso in modo assai diverso, ossia solo con il significato operativo della (2.3.2), che comporta che la relazione stabilita tra primo e secondo membro non é simmetrica, quindi scritture come:

 

  (2.3.3)                   3 = a ;

                               x + y = z ;

 

sono incoerenti con la (2.3.2), quindi necessariamente scorrette, perché in entrambi i casi al primo membro non corrisponde una posizione della memoria ove memorizzare il valore.

 

                Spesso accade che si scriva un’espressione in contesti diversi dall'assegnazione, cioè senza memorizzarne il valore sotto il nome di una variabile, ma solo come dato da utilizzare immediatamente per qualche altro fine; ciò accade principalmente quando si deve distinguere tra alternative diverse, oppure per costruire parametri da utilizzare nella richiesta di attivazione di un sottoprogramma.

 

                Le espressioni costituiscono una delle due categorie operative che in ogni linguaggio sono utilizzate per la determinazione del valore di un dato; la seconda ha natura più complessa e riguarda le operazioni di Input/Output.

 

                Il C non possiede istruzioni di I/O al livello della sintassi del linguaggio, come invece accade per il Fortran, che dispone di :

 

  (2.3.4)                   open, read, write, format, close,

 

che corrispondono alle fasi logiche necessarie per una trasmissione di dati tra la memoria centrale ed un dispositivo esterno alla memoria stessa:

 

  (2.3.5)                   stabilire, o aprire il collegamento logico/fisico con il dispositivo;

                               immettere/emettere, o leggere/scrivere dati secondo forme prestabilite

                               interrompere, o chiudere il canale di comunicazione.

 

                La mancanza di elementi di sintassi come le (2.3.4) non significa che il C possa evitare il problema posto dallo scambio di informazioni memoria/esterno indicato dalla sigla I/O, poiché le azioni descrivibili nella forma generale:

 

(2.3.6)                     leggi/scrivi :        la tale lista di valori o variabili                      ( cosa ? )

                                                               sulla/dalla tale unità logica di I/O ( dove ? )

                                                               nella tale forma                                                  ( come ? )

 

sono tutte evidentemente necessarie per specificare completamente l’azione richiesta alla macchina, in modo che le domande in (2.3.6) abbiano risposte complete e non ambigue in ogni dettaglio, da cui la complessità delle istruzioni di I/O.

 

                La trattazione dello I/O in C é completamente delegata alla libreria standard di funzioni predefinite, con soluzioni che nella pratica operativa sono molto simili nella struttura a quelle del Fortran.

 

                Citiamo per completezza le funzioni più importanti:

 

  (2.3.7)                   printf, scanf, putch, getch,

 

tutte dedicate allo scambio di base tra la memoria centrale ed i due dispositivi più comuni, la tastiera ed il video, rispettivamente canale preferenziale per input ed output. Per scambi con il disco, invece, tra le più comuni sono:

 

  (2.3.8)                   fopen, fprintf, fscanf, fclose.


 

 

            2.4.- Il flusso di esecuzione delle operazioni

 

                Con le risorse logiche acquisite fin qui, un programma si configura come una entità che contiene due tipi di frasi descrittive::

 

  (2.4.1)                   dichiarative, o di definizione dei dati ed eventualmente di altre risorse;

                               operative, o di descrizione delle sequenze di operazioni.

 

                Nella prima riga si é preferito citare i dati come particolari risorse di cui il programma dispone per le proprie operazioni, in quanto fino dalle prime fasi il C ne chiama in causa almeno un altro tipo, dato dalle funzioni. 

 

                Le descrizioni della natura dei dati vengono utilizzate dal compilatore per tradurre nella giusta forma le richieste operative; l'esecuzione avviene poi proprio come ci si può attendere sul piano intuitivo:

 

  (2.4.2)                   si inizia con l'eseguire la prima operazione della lista;

                               terminata con successo una operazione, si passa ad eseguire la successiva;

                               si procede così fino alla richiesta esplicita di arresto del programma.

 

                La formulazione molto naturale della (2.4.2) nasconde in realtà un'ambiguità: cosa si deve intendere infatti per istruzione successiva ad una data? La risposta più naturale sembra essere quella fisicamente successiva, ossia scritta nella riga che segue immediatamente nel testo del programma quella la cui esecuzione é appena terminata.

 

                Quando questo accade si dice che si percorre il programma in un semplice flusso sequenziale, ogni elemento del quale é una assegnazione oppure una istruzione di I/O; ciò é molto simile, ad esempio, al modo di leggere le righe successive di un libro.

 

                Se un linguaggio permettesse soltanto la descrizione di flussi sequenziali di operazioni la sua utilità sarebbe pressoché nulla, poiché anche gli algoritmi necessari per risolvere i problemi più semplici necessitano di elementi strutturali più complessi, che si riducono sostanzialmente a due: il controllo delle alternative e quello delle ripetizioni, o cicli.

 

 

 

            2.4.a - Le alternative

 

                Come esempio, consideriamo la soluzione completa di una equazione di secondo grado; la necessità logica che insorge nel punto critico dell'algoritmo si può descrivere come segue:

 

  (2.4.3)                   se il discriminante d é positivo o nullo, operare nel modo A

                               altrimenti operare nel modo B,

 

ove in questo caso il procedimento A impiega la radice quadrata di d, mentre B quella di -d: le due operazioni si escludono a vicenda, cosa resa bene dallo schema logico, espresso in termini di linguaggio corrente e non di sintassi formale del linguaggio di programmazione:

 

  (2.4.4)                   se X allora A,  altrimenti B .

 

                Volendo essere più precisi, si dovrebbe osservare che nell'esempio appena considerato le alternative mutuamente esclusive sono tre, per discriminante positivo, nullo e negativo, con tre distinte descrizioni delle operazioni; ne deriva uno schema logico più complesso, descrivibile come:

 

  (2.4.5)                   se X, allora A,

                               altrimenti se Y, allora B,

                               altrimenti C.


 

                Il ‘metodo di ragionamento’ (2.4.5) può naturalmente essere ampliato, fino a comprendere tante alternative quante si vuole (ovviamente in numero finito), tutte descritte da altrimenti se, salvo eventualmente l'ultima, in cui la particella  se  può essere assente.

 

 

 

            2.4.b - I cicli

 

                Iniziamo ancora con un esempio: sia dato un intero positivo n, di sui si desidera determinare se è un numero primo, o nel caso che non lo sia determinarne un fattore proprio;in questo caso si può utilizzare direttamente la definizione di numero primo:

 

  (2.4.6)                   n é primo se non ammette divisori propri, cioè diversi da sé stesso e dall'unità.

 

                Si tratta quindi di organizzare una successione di prove sui ‘candidati divisori’: k = 2, 3, 4, ..., n-1, tutte strutturalmente simili tra loro, e diverse tra loro solo per il valore di almeno un dato, poiché in ogni caso si tratta di determinare il resto della divisione di n per k, traendone le opportune deduzioni.

 

                Ciò si esprime molto bene utilizzando ancora il solo linguaggio corrente:

 

  (2.4.7)                   per k che varia da 2 ad n-1, ogni volta incrementato di uno,

                                     se il resto della divisione di n per k é nullo, arresto per 'numero non primo',

                                     altrimenti si proceda con il prossimo k;

                               arresto per 'numero primo'.

 

                Lo schema (2.4.7) richiederebbe maggiori specificazioni formali, ma per il momento l’interpretazione intuitiva dell’allineamento su colonne rientrate (indentatura) e del simbolo di punto e virgola per chiudere il contesto regolato dalla particella per può bastare.

 

                Osserviamo per completezza che il metodo più ovvio di determinazione della proprietà voluta, suggerito dalla stessa definizione (2.4.6) conduce ad un algoritmo corretto, tale cioè da garantire il risultato richiesto, ma non certamente ad un buon algoritmo, ad esempio ai fini della minimizzazione del numero di operazioni, poiché é evidente che l’esecuzione acritica di quanto previsto in (2.4.7) comporta l’esecuzione di un numero di prove inutili potenzialmente molto grande.

 

                Una presentazione più astratta della forma di un ciclo di cui il (2.4.7) può essere un esempio é:

 

  (2.4.8)                   per n da x ad y con passo p ripeti A

 

in cui A indica una generica sequenza di operazioni, e la variabile n ha l'evidente ruolo di contatore delle ripetizioni, che iniziano con il valore x e si concludono quando n è maggiore o uguale ad y, essendo ad ogni ripetizione stato incrementato di p, non escludendo eventualmente incrementi negativi, che implicherebbero x > y perché il ciclo possa essere percorso almeno una volta.

 

                In un ciclo operativo descritto dalla particella per il controllo delle ripetizioni é basato su un conteggio esplicito: la combinazione di valore iniziale, valore finale e passo permette di decidere quante volte il ciclo viene percorso e con quali valori della variabile.

 

                Non é però sempre possibile determinare a priori il conto esatto delle ripetizioni richieste da un algoritmo specifico;  lo schema (2.4.7) ne fornisce un esempio, perché la presenza della condizione di arresto per un resto nullo (numero non primo) prova che non é vero che il ciclo verrà percorso esattamente il numero di volte prestabilito, in quanto le operazioni potrebbero arrestarsi prima del raggiungimento del valore finale che limita il ciclo, n-1.

 

                Una formulazione più corretta della (2.4.7) é quindi:


 

  (2.4.9)                   posto inizialmente k = 2 ;

                               ripeti :   calcola il resto intero r della divisione n / k ;

                                               se il resto é nullo notifica ‘n non primo’

                                               altrimenti incrementa k di 1 ;

                               finché  k é minore di n ed il resto non é nullo ;

                               se k ha raggiunto n, notifica ‘n primo’ ;

 

                E’ evidente la differenza: le ripetizioni, o iterazioni della sequenza di operazioni che compongono il ciclo non sono determinate da un conteggio, ma da una condizione logica, vale a dire si deve iterare finché la condizione data al fondo risulta vera; nel caso specifico essa ha la forma della congiunzione di due relazioni aritmetiche.

 

                La formulazione più astratta dello schema é:

 

  (2.4.10) ripeti  A  finché vale la condizione  B ;

 

che comporta implicitamente che le operazioni descritte come A vengano percorse almeno una volta; in altri casi può essere più adatta la forma:

 

  (2.4.11) finché vale la condizione B ripeti A ;

 

che implica invece che se inizialmente la condizione logica descritta in B é falsa, le operazioni in A non vengono mai percorse.