lunedì 19 novembre 2018

C e C++: Appunti di una riscoperta 6

Ok, stiamo imparando tante cose, però avere a che fare solo con letterali stringa è limitante e inizia ad essere anche un po' noioso. E' tempo di iniziare a parlare di variabili. Ma cosa è una variabile? Possiamo immaginare una variabile come una scatola all'interno della quale depositare un'informazione. 

Avremo quindi scatole che possono contenere dei numeri, variabili che possono contenere un carattere (alfabetico, numerico o simbolo che sia) o possono contenere dati definiti dal programmatore. Dato che C è un linguaggio dichiarativo ossia occorre dichiarare tutto prima di poterlo utilizzare, occorre anche dichiarare, presa una scatola, cosa potrà contenere. Un numero, un carattere o altro. Questo sarà il tipo della variabile. In più per distinguere una scatola dall'altra occorre definire un nome simbolico, un token o identificatore o nome della variabile.

Quindi per dichiarare una variabile in C, ed essendo un'istruzione, occorre come minimo

tipo nome;

Calando questa cosa sul PC, il nome della variabile non è altro che un riferimento o indirizzo di memoria rappresentato in modo simbolico dal token, piuttosto che da freddi, anonimi e incomprensibili numeri (come un civico nella via della RAM), a partire dal quale prelevare un certo numero di byte a seconda del tipo di dato. Questo insieme di byte è l'informazione memorizzata dietro l'identificativo.

Partiamo con i tipi numerici. Possiamo avere numeri interi e numeri reali detti in virgola mobile. Si utilizza
  • int per i numeri interi
  • float per i numeri in virgola mobile
Quindi se vogliamo dichiarare una variabile intera e una in virgola mobile potremo scrivere

int valoreIntero;
float valoreReale;

E' anche possibile, e assolutamente consigliabile, fornire un valore iniziale alla variabile. E' possibile farlo assegnandole una costante numerica o il valore di un'altra variabile, già dichiarata e inizializzata con un valore, in fase di dichiarazione.

int valoreIntero = 100;
int valoreIntero2 = valoreIntero;
float valoreReale = 1.2;

Problema! Ma come può la memoria di un computer contenere un numero di dimensione potenzialmente infinita come un intero? Ossia dato un intero diverso e maggiore di 0 è sempre possibile aggiungere una cifra alla sua destra per ottenere un intero più grande del precedente. La risposta è ... non può. La memoria dei computer è una spazio di archiviazione finito e in quanto tale si esaurisce. Raggiunto il limite non è possibile memorizzare valori più grandi. Quindi int è il tipo intero per il computer, ma è un sottoinsieme finito dell'insieme dei numeri interi I.

Con i numeri reali è ancora peggio. Perché già tra 0 e 1 ci sono infiniti numeri, basta dopo la virgola aggiungere una cifra per ottenere sempre un nuovo numero diverso da quello di origine compreso fra ]0..1[, e quindi? Quindi il numero reale nel computer è approssimato ossia il computer non è in grado di distinguere due valori diversi per un determinato epsilon molto piccolo, ossia esiste per il tipo di dato float un epsilon tale che x è uguale a x+a con a<epsilon. Per numeri che hanno molte cifre decimali, solo una certa quantità sarà corretta, dopo di che il valore è approssimato. Anche la parte intera, per i motivi già detti, non potrà essere infinita. Il float va bene nella maggior parte dei casi, ma se si desidera maggiore precisione è possibile utilizzare il tipo double, a doppia precisione. In genere un float è preciso fino alla sesta cifra decimale, mentre un double fino alla quindicesima.

Ecco un buon motivo per cui, quando si deve realizzare un programma, a tavolino lo si analizza per individuare quali debbano essere i dati in ingresso e i loro tipi, quali variabili potrebbero essere necessarie in corso di elaborazione e quali sono gli output prodotti. Queste scelte influenzeranno, specie in elaborati ricchi di calcoli, il livello di affidabilità del risultato ottenuto mettendo in luce eventuali criticità.

Generalmente però gli errori di approssimazione sono talmente piccoli da essere trascurabili ma è bene tenere presente che ci sono.

Vediamo quindi quali sono i tipi di dati definiti dal linguaggio, anche detti di base o primitivi.
  • char permette di memorizzare un carattere (lettera, cifra, simbolo o caratteri speciali)
  • int permette di memorizza numeri interi 
  • float permette di memorizzare numeri in virgola mobile
  • double permette di memorizzare numeri in virgola mobile a doppia precisione
esistono dei modificatori di tipo, che possono precedere il tipo di dato nella dichiarazione, che alterano le caratteristiche del tipo cui sono applicati.
  • short può occupare meno spazio in memoria di un int, ma riduce l'intervallo dei valori disponibili
  • signed utilizza la memoria per archiviare sia valori positivi che degativi (in assenza di unsigned s'intende implicitamente signed)
  • unsigned utilizza la memoria solo per valori positivi fornendo a parità di tipo un intervallo più ampio per questi a scapito dei negativi
  • long può avere una maggiore occupazione di memoria e fornire un intervallo maggiore di valori disponibili.
La lista quindi dei possibili tipi di dato primitivi, base o implementati dal linguaggio è

char               unCarattereASCII;
unsigned char      unCarattereASCIIEsteso;
short int          unInteroCorto;
unsigned short int unInteroCortoSenzaSegno;
int                unIntero;
unsigned int       unInteroSenzaSegno;
long int           unInteroLungo;
unsigned long int  unInteroLungoSenzaSegno;
float              unNumeroInVirgolaMobile32bit;
double             unNumeroInVirgolaMobileDoppiaPrecisione64bit;
long double        unNumeroInVirgolaMobile80bit;
 
il compilatore Microsoft segnala se si prova ad utilizzare un long float, ma lo tratterà come un bouble. In alcuni compilatori è ammesso anche il long bouble (un float dovrebbe essere 32 bit, un bouble 64 bit e i long double 80 bit), ma in Microsoft Visual Studio è trattato come un double.

Ormai gli identificatori iniziano a presentarsi prepotentemente. Ma un identificatore deve rispettare delle regole per essere valido? Certo che si. Può avere lunghezza arbitraria, ma non è detto che il compilatore distingua due identificatori utilizzando tutti i caratteri che lo compongono. Nel caso di identificatori esterni, lo standard impone non meno di 6 caratteri. Quindi Paperino1 e Paperino2 potrebbero dar luogo a un errore dato che il compilatore utilizza per distinguerli fino a Paperi. Per identificatori interni, quelli che fino ad ora abbiamo visto, si hanno 31 caratteri utili per distinguerli. Microsoft in particolare mette a disposizione 247 caratteri. Il primo carattere deve essere una lettera. E' consentito anche l'underscore, ma è meglio lasciaro agli usi specifici del C. Possono seguire lettere e numeri e underscore. Il C come già detto distingue tra maiuscole e minuscole. Utilizzare coerentemente maiuscole e minuscole, ed utilizzare nomi che hanno un senso aiuta notevolmente nella lettura del codice che diviene auto descrittivo. Il commento comunque non va mai tralasciato.
Una nota sul char. Se char è un carattere, che senso ha unsigned char? In realtà il computer non capisce le lettere. Il linguaggio C dedica un byte al tipo char, ed un byte è una sequenza binaria di 8 bit. Praticamente un numero. Quindi char è un intero con segno memorizzabile in un byte [-128..127], mentre unsigned char è un intero senza segno memorizzabile in un byte [0..255]. Quindi cosè
char c = 'A';

Iniziamo con l'osservare che una costante carattere è racchiusa tra apici singoli e non tra doppi apici, dedicato ai letterali stringa e poi scopriremo perchè. In realtà il compilatore non sta memorizzando la lettera A ma il valore ASCII ad essa corrispondente ossia 65. Ecco la tavola ASCII per valori da 0 a 127.

Quindi il tipo char riesce a mappare la tavola ASCII. Esiste poi la ASCII estesa che aggiunge una serie di altri caratteri, simboli e grafica.
In fase di stampa a video il char può essere trattato come un numero e quindi se gli era stato assegnato 'A' o 65 visualizzerà il valore 65 o come carattere e se gli era stato assegnato 'A' o 65 visualizzerà il carattere A.

E' il caso di concludere con un po' di pratica. Creare quindi un nuovo progetto e nel nostro file sorgente copiare e incollare il listato seguente. provate a leggere il codice per capire che fa.

#include "stdio.h"
#include "float.h"
#include "limits.h"

#define INTESTAZIONE "%-15s -> %-5s\n"
#define FORMATO "%-15s -> %3d\n"

main() {
    /*********************************
     * Inizializzazione di variabili *
     *********************************/

    char c0 = 'A';
    char c1 = '\t';
    unsigned char c2 = 253;
    short int si = 10;
    int i = 100;
    long int li= 1000L;
    float f = 1.2F;
    double d = 1.2;

    /* Prove di visualizzazione del tipo char */
    printf("%c   - %d \n", c0, c0);
    printf("%c   - %d \n", c2, c2);
    printf("tab - %d \n", c1);
    printf("\n\n");

    /* Dimensione della memoria occupata dai tipi primitivi */
    printf("Memoria occupata da ogni tipo \n\n");

    printf(INTESTAZIONE, "TIPO", "Byte");
    printf(FORMATO, "char",          sizeof(char));
    printf(FORMATO, "short int",     sizeof(short int));
    printf(FORMATO, "int",           sizeof(int));
    printf(FORMATO, "long int",      sizeof(long int));
    printf(FORMATO, "long long int", sizeof(long long int));
    printf(FORMATO, "float",         sizeof(float));
    printf(FORMATO, "double",        sizeof(long double));   
}

Iniziamo con il notare che sono stati aggiunti due file di intestazioni appartenenti alla libreria standard del C. Sono limits.h e float.h. Dopo la compilazione del progetto, questi due file compariranno nella cartellina Dipendenze esterne di Esplora soluzioni. Potremo quindi aprirli con un doppio clic

Facendo doppio clic su float.h vedremo che nel file sono definite tutta una serie di costanti, per mezzo della direttiva #define, relative ai float (FLT_) e ai double (DBL_). Il commento accanto, in stile C++, spiega il significato della costante.


inserendo quindi questo file d'intestazione abbiamo la possibilità di accedere a costanti qui definite come valore massimo e minimo, valore minimo epsilon, numero di cifre precise dopo la virgola e varie altre. C'è anche la sezione LDBL_ per i long double ma sono gli stessi valori del double.

C'è poi limits.h che è come float.h ma dedicato ai numeri interi.

Sono definite un paio di costanti per la stringa di formato da passare a printf (perché printf fa più che visualizzare letterali stringa e lo vedremo presto). E iniziamo ad apprezzare la comodità delle costanti macro. Infatti se devo apportare una correzione, basta farlo a FORMATO e non a ogni singola printf.

C'è quindi il main in cui nella sezione dichiarativa ho definito e inizializzo una serie di variabili. Ebbene sì, esiste una sezione dichiarativa anche dentro le funzioni o meglio subito dopo il token { di apertura di un blocco istruzioni è possibile dichiarare delle variabili la cui visibilità e utilizzo è limitato al blocco in cui sono dichiarate e ai blocchi istruzioni in esso contenuti. Se fossero state dichiarate all'esterno della funzione, subito dopo le direttive al compilatore, la loro visibilità sarebbe stata a livello di file, quindi ogni funzione nel file sorgente avrebbe avuto potenzialmente accesso a quelle variabili compreso il main. Notiamo anche come  in

char c1 = '\t';

\t sia interpretato dal compilatore come un solo carattere. Ciò perchè in fase di escaping il valore letterale carattere Horizontal Tab è convertito nel valore numerico 9, come indicato dalla tavola ASCII precedente.

printf("%c   - %d \n", c0, c0);

Con la printf precedente vediamo come la stessa variabile c0 è trattata come carattere se nella stringa di formato c'è %c e come numero se c'è %d.

Per finire un elenco di printf che producono una tabellina con la dimensione in byte occupata da ogni tipo. L'istruzione sizeof, sebbene abbia l'aspetto di una fuzione è in realtà una parola chiave del linguaggio, quindi esiste a prescindere dalle librerie caricate. Il suo scopo è fornire la dimensione in byte del tipo passato come argomento. Quindi per esempio, nel caso del long int scopriamo che una variabile di questo tipo è un nome simbolico o token facente riferimento a una locazione di memoria nella RAM a partire dalla quale occorre prelevare 4 byte per avere l'informa. Quando accediamo al contenuto della variabile ovviamente questa attività di prelevamento dei 4 byte è assolutamente trasparente al programmatore che si occupa e preoccupa solo di operare con il valore contenuto nella variabile.

L'output del programma è il seguente:


Quindi, riepilogando ora sappiamo
  • quali sono i tipi di dato primitivi del linguaggio C
  • come e dove dichiarare una variabile
  • cosa è una varriabile
  • come inizializzare una variabile di tipo primitivo in fase di dichiarazione
  • come creare identificatori validi
  • che una variabile esiste ed è utilizzabile (scope o visibilità delle variabili) all'interno del blocco istruzioni in cui è dichiarata e nei suoi blocchi istruzioni annidati, ossia a livello di file se dichiarata fuori da ogni blocco istruzioni
  • cosa si nasconde dietro il tipo char che rappresenta i caratteri
  • cosa sono le costanti carattere e che vanno racchiuse tra apici singoli
  • che i tipi numerici sono finiti e non infiniti
  • che in float.h e limits.h sono contenute costanti che definiscono i limiti fisici imposti dal compilatore in uso rispetto ai numeri in virgola mobile e interi.
  • che sizeof è una funzione speciale, parola chiave del linguaggio, che ci permette di conoscere lo spazio occupato in memoria da variabili di un certo tipo passato come argomento
  • che la printf fa più che stampare letterali stringa
Non resta che vedere bene la funzione printf e vedere le costanti numeri nelle loro varianti.