Conversione di Tipi in Linguaggio C

Nel linguaggio di programmazione C, la conversione dei tipi è un concetto fondamentale che permette di trasformare una variabile di un tipo in un'altra. Questo processo è essenziale per garantire che le operazioni aritmetiche e le assegnazioni tra variabili di tipi diversi avvengano correttamente. Le conversioni di tipi possono essere implicite, quando il compilatore le esegue automaticamente, o esplicite, quando il programmatore le specifica manualmente.

In questo articolo, esploreremo le regole di conversione implicita nel linguaggio C, con particolare attenzione alle conversioni aritmetiche e alle conversioni in fase di assegnamento. Inoltre, discuteremo le specifiche regole di conversione introdotte con lo standard C99. Comprendere queste regole è cruciale per scrivere codice C robusto ed efficiente, evitando errori comuni legati alla gestione dei tipi.

Conversioni implicite dei Tipi

I Computer hanno delle regole molto stringenti per quanto riguarda le operazioni aritmetiche.

Quando un processore deve effettuare un'operazione aritmetica, gli operandi devono essere della stessa dimensione, quindi avere lo stesso numero di bit, e devono essere memorizzati (o meglio codificati) allo stesso modo.

Un processore può, ad esempio, sommare direttamente due interi con segno a 16 bit. Ma non può sommare in maniera diretta un intero senza segno a 16 bit con un intero con segno a 16 bit, oppure un intero a 16 bit con un intero a 32 bit. O peggio ancora, non può sommare direttamente un intero a 32 bit con un numero in virgola mobile a 32 bit.

In linguaggio C, invece, abbiamo visto che possiamo mescolare nelle espressioni variabili di tipi diversi. Possiamo, infatti, combinare variabili intere, variabili in virgola mobile e variabili di tipo carattere in una singola espressione. Ad esempio:

int a = 5;
float b = 3.14;
char c = 'A';

float d = a + b;
int e = a + c;

Ma come riesce il compilatore C a gestire queste espressioni miste?

La risposta è che il compilatore, quando incontra un'espressione con operandi di tipi diversi, inserisce delle istruzioni di conversione per convertire gli operandi in un formato comune. In altre parole, senza che noi dobbiamo indicarlo esplicitamente, il compilatore C genera delle istruzioni di conversione in maniera tale che, successivamente, il processore possa eseguire l'operazione aritmetica.

Ad esempio, se proviamo a sommare un intero short a 16 bit con un intero int a 32, il compilatore C inserirà delle istruzioni di conversione per convertire l'intero short in un intero int a 32 bit. In questo modo, il processore potrà sommare i due interi senza problemi.

Analogamente, se proviamo a sommare un int ed un float, il compilatore C inserirà delle istruzioni di conversione per convertire l'intero in un numero in virgola mobile. In questo caso le istruzioni di conversione saranno molto più complesse in quanto la rappresentazione di un numero in virgola mobile è molto diversa da quella di un intero. Ma il compilatore C si occuperà di tutto ciò in maniera trasparente per noi.

Dal momento che il compilatore gestisce queste conversioni senza il coinvolgimento del programmatore e in modo del tutto trasparente, queste conversioni vengono chiamate conversioni implicite.

Definizione

Conversione Implicita di tipo

In linguaggio C, la conversione implicita è una conversione del tipo di dati che avviene automaticamente da parte del compilatore senza che il programmatore debba esplicitamente richiederla.

Esiste anche un altro tipo di conversione, chiamata conversione esplicita, che vedremo nella prossima lezione. Si chiama così perché il programmatore deve esplicitamente indicare al compilatore che vuole convertire un tipo di dato in un altro.

Le regole di conversione implicita sono alquanto complesse purtroppo dato che il linguaggio C ha svariati tipi numerici. In questa lezione vedremo come funzionano.

Situazioni in cui si verificano le conversioni implicite

Le conversioni implicite effettuate dal C vengono applicate in queste quattro situazioni:

  1. Quando gli operandi di un'operazione aritmetica non sono dello stesso tipo.

    Ad esempio, quando sommiamo un intero ed un numero in virgola mobile. In questo caso, il compilatore C effettua le cosiddette conversioni aritmetiche.

  2. Quando il tipo di un'espressione a destra di un'assegnazione è diverso dal tipo della variabile a sinistra dell'assegnazione.

    Ad esempio, quando assegniamo un numero in virgola mobile ad una variabile intera. In questo caso, il compilatore C effettua le cosiddette conversioni in fase di assegnamento.

  3. Quando passiamo un argomento ad una funzione di un tipo diverso dal tipo del parametro della funzione.

    Ad esempio, quando passiamo un intero ad una funzione che accetta un numero in virgola mobile. In questo caso, il compilatore C effettua le cosiddette conversioni in fase di chiamata di funzione. Studieremo questa situazione nelle prossime lezioni quando andremo a studiare le funzioni in linguaggio C.

  4. Quando restituiamo un valore da una funzione di un tipo diverso dal tipo di ritorno della funzione.

    Ad esempio, quando restituiamo un numero in virgola mobile da una funzione che dovrebbe restituire un intero. In questo caso, il compilatore C effettua le cosiddette conversioni in fase di ritorno di funzione. Anche in questo caso, studieremo questa situazione nelle prossime lezioni.

Per il momento, ci concentreremo sulle prime due situazioni, ovvero le conversioni aritmetiche e le conversioni in fase di assegnamento.

Conversioni aritmetiche

Le conversioni aritmetiche in C sono applicate, tipicamente, quando si adoperano operatori binari tra operandi di tipo diverso. Ciò si applica a tutti i tipi binari, siano essi aritmetici, logici o relazionali.

Per capire la logica dietro le conversioni implicite aritmetiche, prendiamo un esempio:

int a = 5;
float b = 3.14;

a + b;

Abbiamo, in questo esempio, la somma tra un intero int ed un numero in virgola mobile float. Il compilatore C si trova di fronte alla prima situazione vista sopra: gli operandi di un'operazione aritmetica non sono dello stesso tipo.

Cosa fa il compilatore in questo caso?

Ci sono due possibili soluzioni:

  1. Convertire il tipo float in int, ottenendo due interi e sommandoli tra di loro.

    Questa è una soluzione possibile ma non è quella migliore. Infatti, se convertiamo il float in int si possono verificare due problemi:

    • In primo luogo, andremmo a perdere la parte decimale del numero in virgola mobile. In questo caso, il numero 3.14 verrebbe convertito in 3, e quindi il risultato della somma sarebbe 5 + 3 = 8 ottenendo una catastrofica perdita di precisione.
    • In secondo luogo, non è detto che un float sia rappresentabile con un numero intero. Prendiamo ad esempio il numero 2.5e56. Si tratta di un numero talmente grande che non può essere rappresentato con un intero a 32 bit. In questo caso, la conversione da float a int sarebbe impossibile.
  2. Convertire il tipo int in float, ottenendo due numeri in virgola mobile e sommandoli tra di loro.

    Questa è la soluzione migliore. Infatti, convertendo l'intero in un numero in virgola mobile, non andiamo a perdere nessuna informazione. Inoltre, un numero intero può essere sempre convertito in float a meno di una leggera perdita di cifre significative nel caso in cui l'intero sia troppo grande.

Il compilatore C, quindi, sceglie la seconda soluzione e converte l'intero int in un numero in virgola mobile float. In questo modo, il processore può sommare i due numeri in virgola mobile senza problemi.

Da ciò possiamo capire quale sia la strategia generale per la conversione implicita dei tipi aritmetici in C:

Definizione

Strategia di Conversione Aritmetica Implicita

In linguaggio C, quando un operatore binario viene applicato a due operandi di tipo differente, il compilatore C converte gli operandi al tipo più piccolo in grado di contenerli entrambe.

In altre parole, il compilatore sceglie il tipo con il minor numero di byte in grado di rappresentare entrambe gli operandi con la minima perdita di informazione.

In generale, nella maggior parte dei casi, la conversione implicita si traduce in una estensione ad un tipo simile ma che occupa più byte. Ad esempio, sommare uno short ad un int consiste semplicemente nell'estendere lo short aggiungendo byte a sinistra fino a raggiungere la dimensione di un int. Del resto sia short che int sono tipi interi ed adoperano, a meno del numero di bit, la stessa rappresentazione in complemento a due.

Questo tipo di conversione prende il nome di Promozione dei Tipi:

Definizione

Promozione dei Tipi

La promozione dei tipi è una conversione implicita che consiste nell'estendere un tipo di dato ad un tipo che occupa più byte ma che adopera la stessa rappresentazione.

Da notare che si parla di promozione quando i tipi sono simili, ovvero entrambi interi o entrambi in virgola mobile. Non si parla di promozione quando convertiamo un intero in un numero in virgola mobile, o viceversa, in quanto i due tipi sono molto diversi.

Inoltre, in alcuni casi promozione avviene sempre. Ad esempio, quando un operando è applicato a due short o due char, questi ultimi vengono sempre prima convertiti in int e poi si applica l'operatore. Questo perché il processore lavora più velocemente con interi a 32 bit che con interi a 16 o 8 bit. Questo tipo di promozione prende il nome di Promozione Integrale.

Definizione

Promozione Integrale

La promozione integrale è una conversione implicita che consiste nell'estendere un tipo di dato ad un tipo intero a 32 bit.

Essa viene sempre applicata quando si adoperano due tipi di dati char o short in un'espressione aritmetica.

Regole di Conversione Aritmetica

Adesso analizziamo le regole di conversione aritmetica applicate ad un'espressione composta da un operatore binario e due operandi di tipo diverso.

Possiamo dividere tali regole in due gruppi:

  1. Quando uno o entrambi gli operandi sono in virgola mobile:

    In tal caso si adopera lo schema che segue:

    Regole di Conversione implicita per i tipi in virgola mobile
    Figura 1: Regole di Conversione implicita per i tipi in virgola mobile

    In altre parole, si ragiona così:

    1. Se un operando è di tipo long double, l'altro operando viene convertito in long double.
    2. Altrimenti, se un operando è di tipo double, l'altro operando viene convertito in double.
    3. Altrimenti, se un operando è di tipo float, l'altro operando viene convertito in float.

    Queste tre regole coprono anche il caso misto. Infatti, se uno dei due operandi è di tipo double ma l'altro è di tipo intero, il compilatore converte l'intero in double e poi somma i due numeri in virgola mobile.

  2. Quando entrambi gli operandi sono interi:

    In tal caso si adopera lo schema che segue:

    Regole di Conversione implicita per i tipi interi
    Figura 2: Regole di Conversione implicita per i tipi interi

    Per prima cosa, si applica, se possibile, la promozione integrale vista sopra. In questo modo abbiamo la garanzia che nessuno dei due operandi sia di tipo char o short.

    Successivamente, si sceglie il tipo di intero più piccolo in grado di contenere entrambi gli operandi.

Conversione Aritmetica e tipi interi Senza Segno

Quando, in un'espressione aritmetica, si combinano tipi interi con segno e tipi senza segno, le regole si complicano leggermente.

Prendiamo l'esempio che segue:

unsigned int a = 5;
int b = 3;

a + b;

In questo caso, abbiamo un intero senza segno unsigned int e un intero con segno int. Entrambe usano lo stesso numero di bit. Quali regole di conversione vengono applicate in questo caso?

In tal caso accade qualcosa che, a prima vista, può sembrare contro-intuitivo. Infatti, il compilatore C converte l'intero con segno int in un intero senza segno unsigned int. In questo modo, il processore può sommare i due interi senza problemi.

Questa regola si applica però solo nel caso in cui i due tipi usino lo stesso numero di bit. Infatti, se sommiamo un unsigned int e un long int, il compilatore converte l'unsigned int in un long int e non viceversa in quanto l'unsigned int è più piccolo del long int.

Definizione

Regola di Conversione Aritmetica per tipi interi Senza Segno

Quando, in un'espressione aritmetica, si combinano un intero con segno e un intero senza segno, il compilatore C converte l'intero con segno in un intero senza segno se i due tipi usano lo stesso numero di bit.

Nel caso in cui il tipo con segno abbia un numero di bit maggiore del tipo senza segno, il compilatore converte l'intero senza segno nell'intero con segno.

Questa conversione, però, può provocare errori molto difficili da scovare.

Infatti, se l'intero con segno è positivo, la conversione non provoca problemi. Ma se l'intero con segno è negativo, la conversione in un intero senza segno può portare a risultati inaspettati.

Per chiarire, prendiamo un esempio:

unsigned int a = 10;
int b = -5;

if (a > b) {
    printf("a è maggiore di b\n");
} else {
    printf("b è maggiore di a\n");
}

A prima vista, se osserviamo il codice scritto sopra potremmo pensare che l'output del programma sia sempre a è maggiore di b.

Ma non accade questo.

Infatti, il compilatore converte l'intero con segno b in un unsigned int. Nel fare questo, però, quello che accade è che a b viene sommato il valore 2^n dove n è il numero di bit.

Ciò accade perché, l'ultimo bit di b che viene adoperato per rappresentare il segno, quando viene convertito in un intero senza segno, viene interpretato come un bit di peso maggiore. In questo modo, il valore di b viene interpretato come 2^n + b, dove n è il numero di bit dell'intero.

Se un int è rappresentato con 32 bit, allora ciò che accade è che nella conversione b diventa:

b = -10 + 2^{32} = 4294967286

Quindi, nel confronto tra a e b, b risulterà maggiore di a!

Nota

Evitare di combinare tipi con segno e senza segno in un'espressione aritmetica

Il consiglio che diamo è di evitare di combinare tipi con segno e senza segno in un'espressione aritmetica. Questo perché le conversioni implicite possono portare a risultati inaspettati.

Alcuni compilatori segnalano questa situazione come un warning. Ad esempio, il compilatore gcc segnala:

warning: comparison between signed and unsigned

Se si riceve un warning di questo tipo, è bene rivedere il codice e cercare di evitare di combinare tipi con segno e senza segno.

Esempio riassuntivo di Conversione Aritmetica

Ricapitoliamo le regole di conversione aritmetica con il seguente esempio riassuntivo:

char c;
short int s;
int i;
unsigned int u;
long int l;
unsigned long int ul;
float f;
double d;
long double ld;

/* ... */

i = i + c;          /* c è convertito in int */
i = i + s;          /* s è convertito in int */
u = u + i;          /* i è convertito in unsigned int */
l = l + u;          /* u è convertito in long int */
ul = ul + l;        /* l è convertito in unsigned long int */
f = f + ul;         /* ul è convertito in float */
d = d + f;          /* f è convertito in double */
ld = ld + d;        /* d è convertito in long double */

In questo esempio, abbiamo una serie di variabili di tipi diversi. In ogni riga, abbiamo un'assegnazione di un'espressione aritmetica ad una variabile. In ogni espressione aritmetica, il compilatore C converte gli operandi in modo da poter eseguire l'operazione aritmetica.

Conversioni in fase di Assegnamento

Le regole viste sopra per convertire implicitamente i tipi aritmetici in un'espressione non si applicano durante l'assegnamento.

Il linguaggio C, invece, segue la seguente semplice regola:

Definizione

Regola di Conversione in fase di Assegnamento

In un assegnamento in linguaggio C, l'espressione a destra dell'operatore di assegnamento viene convertita nel tipo della variabile a sinistra dell'assegnamento.

tipo x = espressione

L'espressione viene convertita nel tipo tipo prima di essere assegnata alla variabile x.

Si possono verificare, a questo punto, due casi:

  1. La variabile è di un tipo in grado di contenere il risultato:

    In tal caso la conversione avviene senza problemi. Ad esempio:

    char c;
    int i;
    float f;
    double d;
    
    i = c;      /* c viene convertito in int */
    f = i;      /* i viene convertito in float */
    d = f;      /* f viene convertito in double */
    

    Nell'esempio di sopra, in ogni assegnamento la variabile di destinazione è sempre più grande del risultato. Ossia, int è più grande di char, float è più grande di int e double è più grande di float. Quindi le conversioni implicite non hanno ripercussioni.

  2. La variabile non è in grado di contenere il risultato:

    Prendiamo un esempio in cui assegniamo un valore floating point ad un intero:

    int i = 3.14;
    

    In questo caso, il compilatore C converte il numero in virgola mobile 3.14 in un intero rimuovendo la parte frazionaria. Per cui, la variabile i varrà 3.

    Attenzione che non si tratta di un'arrotondamento. Si tratta di un troncamento puro e semplice. Per cui:

    int i = 8.9999;
    

    La variabile i varrà 8 e non sarà arrotondata a 9.

    Inoltre, si possono verificare situazioni in cui l'assegnamento è privo di significato oppure, nel caso peggiore, il risultato è al di fuori dell'intervallo rappresentabile da quel tipo.

    Ad esempio:

    char c = 10000;
    

    In questo caso, il numero 10000 è troppo grande per essere rappresentato con un char che è in grado di rappresentare numeri da 0 a 255. Il risultato pertanto, sarà un troncamento di cifre significative e la variabile c varrà 16.

    Questo perché in binario il numero 10000 è:

    10000_{10} = 10011100010000_2

    Ma il tipo char può contenere solo le prime 8 cifre binarie meno significative:

    10000_{10} = 100111\mathbf{00010000}_2 \rightarrow 00010000_{2} = 16_{10}

    Abbiamo ottenuto un numero completamente diverso!

Regole di Conversione implicita in C99

Le regole di conversione implicita si complicano leggermente nello standard C99.

La motivazione sta nel fatto che il C99 introduce sia il tipo booleano _Bool che il nuovo tipo intero long long. Inoltre aggiunge anche il tipo complesso che vedremo più avanti.

Per poter definire le regole di conversione, in C99 è stato introdotta la cosiddetta gerarchia di conversione dei tipi interi. Questa gerarchia definisce l'ordine di conversione tra i tipi interi ed è così definita dal livello più alto a quello più basso:

  1. long long int e unsigned long long int
  2. long int e unsigned long int
  3. int e unsigned int
  4. short int e unsigned short int
  5. char, signed char e unsigned char
  6. _Bool

La regola di Promozione Integrale viene così ridefinita in C99:

Definizione

Promozione Integrale in C99

Tutti i tipi interi che si trovano nella gerarchia di conversione dei tipi interi al di sotto del livello di int e unsigned int vengono automaticamente promossi a:

  1. int se si riesce a rappresentare il valore con un int;
  2. unsigned int altrimenti.

Detto questo, possiamo ora definire le regole di conversione implicita in C99:

  1. Se uno dei due operandi è di un tipo in virgola mobile valgono le stesse regole viste sopra:

    • Se uno dei due operandi è di tipo long double, l'altro operando viene convertito in long double.
    • Altrimenti, se uno dei due operandi è di tipo double, l'altro operando viene convertito in double.
    • Altrimenti, se uno dei due operandi è di tipo float, l'altro operando viene convertito in float.
  2. Se entrambi gli operandi sono di tipo intero si applicano le seguenti regole:

    Prima si effettua la promozione integrale se necessario.

    Se i tipi dei due operandi sono, dopo il primo passo, uguali il processo di conversione termina.

    Altrimenti, si usano le seguenti regole fermandosi alla prima che ha una corrispondenza:

    1. Se un operando è con segno mentre l'altro è senza segno, si converte l'operando con il tipo inferiore nella gerarchia nel tipo dell'operando con il tipo superiore.
    2. Se l'operando senza segno ha un tipo più alto nella gerarchia oppure è allo stesso livello dell'operando con segno, si converte l'operando con segno nel tipo dell'operando senza segno.
    3. Se il tipo dell'operando con segno può rappresentare tutti i valori del tipo dell'operando senza segno, si converte l'operando senza segno nel tipo dell'operando con segno.
    4. Altrimenti, si convertono entrambe gli operandi nel tipo senza segno corrispondente nello stesso livello della gerarchia del tipo con segno.

In Sintesi

In questa lezione abbiamo visto come il compilatore C gestisce le conversioni implicite dei tipi.

Abbiamo visto che:

  • Il compilatore C inserisce delle istruzioni di conversione per convertire gli operandi in un formato comune quando incontra un'espressione con operandi di tipi diversi.
  • Queste conversioni vengono chiamate conversioni implicite.
  • Esistono quattro situazioni in cui si verificano le conversioni implicite: le conversioni aritmetiche, le conversioni in fase di assegnamento, le conversioni in fase di chiamata di funzione e le conversioni in fase di ritorno di funzione.
  • Le conversioni aritmetiche sono applicate quando si adoperano operatori binari tra operandi di tipo diverso.
  • La strategia di conversione aritmetica implicita in C consiste nel convertire gli operandi al tipo più piccolo in grado di contenerli entrambi.
  • La promozione dei tipi è una conversione implicita che consiste nell'estendere un tipo di dato ad un tipo che occupa più byte ma che adopera la stessa rappresentazione.
  • La promozione integrale è una conversione implicita che consiste nell'estendere un tipo di dato ad un tipo intero a 32 bit.
  • Quando si combinano tipi con segno e senza segno in un'espressione aritmetica, il compilatore C converte l'intero con segno in un intero senza segno se i due tipi usano lo stesso numero di bit.
  • Le regole di conversione in fase di assegnamento in C prevedono che l'espressione a destra dell'operatore di assegnamento venga convertita nel tipo della variabile a sinistra dell'assegnamento.
  • In C99, le regole di conversione implicita si complicano leggermente a causa dell'introduzione del tipo booleano _Bool e del tipo intero long long.

Nella prossima lezione vedremo come effettuare le conversioni esplicite dei tipi in linguaggio C: questo tipo di conversione prendono il nome di casting.