Traccia della lezione Lez07: Lucido 2: La dichiarazione di tipo indica: - al programmatore i valori leciti e le operazioni lecite - al compilatore il formato in linguaggio binario del dato, lo spazio da riservare in memoria, le operazioni lecite e il modo di eseguirle. Quindi la dichiarazione fornisce a entrambi le informazioni necessarie a ciascuno, senza confondere il programmatore con dettagli inutili. Lucido 4: I dati appartengono sempre a tipi (insiemi) finiti, perché vengono rappresentati da sequenze finite di 0 e 1. Una sequenza di n bit può rappresentare al massimo 2^n diversi valori. Quindi: - a ogni tipo è associata una dimensione n - tipi infiniti, cioè insiemi infiniti di valori, vengono rappresentati solo parzialmente Lucido 5: Con n bit si possono rappresentare 2^n valori diversi. Trattandosi di numeri interi, si è scelto di rappresentare un insieme simmetrico attorno allo zero, quindi da -2^(n-1) a 2^(n-1)-1. Andiamo a vedere nel file LIMITS.H che valori usa il nostro compilatore. Sono quelli tipici delle macchine a 32 bit. Nella versione di Dev-C++, compare anche un tipo long long int, che non è standard. A che serve distinguere questi tipi? A risparmiare memoria, usando il tipo più piccolo strettamente necessario. Se la memoria non è un problema, conviene usare il tipo più ampio. Lucido 7: I numeri reali vengono rappresentati anzi tutto fissando un intervallo di definizione: quelli che eccedono, non sono rappresentati. I numeri compresi nell'intervallo vengono aggregati in sottointervalli (in generale non di pari ampiezza), ognuno dei quali ha un rappresentante: tutti i numeri del sottointervallo sono considerati identici fra loro e al rappresentante. La distinzione tra float, double e long double sta nell'ampiezza dell'intervallo di definizione e dei sottointervalli: diversi numeri double corrispondono allo stesso numero float. Lucido 8: ASCII sta per American Standard Code for Information Interchange. Il fatto che si possano applicare tutti gli operatori di relazione e quelli aritmetici ai caratteri implica che ha senso scrivere: for (ch = 'a'; ch <= 'z'; ch++) { } Le lettere sono ordinate secondo l'alfabeto, le cifre secondo l'ordine tradizionale. Quando si esce da questi sottoinsiemi, tutti i caratteri sono ordinati, ma l'ordine dipende dallo standard usato. Quindi non si può farvi conto. Lucido 11: Le sequenze di escape sono particolari costanti di tipo carattere, che non hanno la classica forma costituita dal carattere stesso racchiuso fra apici, - o perché il carattere non è rappresentabile - o perché è usato anche per altri scopi e quindi il suo uso sarebbe ambiguo. Il codice ESCAPE.C stampa con un ciclo i numeri naturali da 1 a n, ma non in sequenza, bensì sovrascrivendo ogni volta il numero precedente. Lo fa stampando un numero di backspace uguale al numero di cifre del valore precedente. Si noti l'uso della sequenza di escape "\n" come argomento della funzione StampaStringa() oppure entro stringhe più complicate per sostituire la funzione ACapo(), che nel resto del corso non useremo più. Lucido 12: Il codice OVERFLOW.C riporta qualche esempio dei problemi indotti dall'overflow e dalla precisione finita. Overflow: Si considera l'assegnamento del numero -1000 a una variabile short, a una intera e a una naturale. Per le prime due, nessun problema; per la terza il valore viene interpretato in aritmetica modulare e diventa 2^n-1000 (nel caso del mio portatile, 2^32-1000). Calcolando il quadrato di -1000: - se il dato è conservato in una variabile int, va tutto bene - se il dato è conservato in una variabile short, si ottiene un valore errato a causa dell'overflow (a me viene 16960, che è 1000000-15*2^16, quindi un valore ottenuto con aritmetica modulare, ma questo non è imposto dallo standard) - se il dato è conservato in una variabile unsigned int, il risultato è corretto, ma lo è solo perché (2^32-n)^2 = 2^64 - 2n*2^32 + n^2 = n^2 + (2^32 - 2n) 2^32, che in aritmetica modulare è ancora n^2. Precisione finita: Si considerino le due costanti: 0.9999999999999999 0.99999999999999999 che sono implicitamente definite di tipo double. Esse risultano diverse, mentre le costanti: 0.9999999999999999f 0.99999999999999999f che sono esplicitamente definite di tipo float dalla "f" finale, risultano uguali. Lucido 13: Quando il processore esegue un'operazione, i suoi operandi devono essere rigorosamente dello stesso tipo. Se è necessario, per ottenere questo risultato, l'applicazione dell'operatore viene preceduta dalla conversione degli operandi. Se non c'è una conversione esplicita, si applica una regola di conversione implicita. Da notare le relazioni fra i quattro casi in cui può avvenire una conversione implicita: - il secondo caso (assegnamento) è un caso particolare del primo (espressioni miste), ma ha una regola di conversione diversa - il terzo e il quarto caso sono anch'essi degli assegnamenti, ma riguardano rispettivamente il passaggio dei dati a una funzione e il recupero dei risultati dalla funzione: la funzione specifica il tipo dei dati e dei risultati, ma è possibile che i dati passati o il risultato calcolato violino tali specifiche. Lucido 14: 1) gli operandi carattere si convertono in int char ch; Per valutare (ch + 1) si converte ch e l'espressione diventa intera. 2) operandi tutti interi o naturali oppure tutti reali: si converte il tipo più "limitato" nel tipo più "ampio" Con più "ampio" si intende quello che contiene un insieme più ampio di valori (tipicamente, l'intervallo di definizione e/o la precisione sono superiori). - int i; per valutare (i + 100L) si converte i in long; l'espressione è di tipo long - double r; float s; per valutare (r + s) si converte s in double; l'espressione è di tipo double - char ch; per valutare (ch + 1L), si converte prima ch in int (regola 1), e poi in long (regola 2); l'espressione è di tipo long 3) almeno un operando reale: si convertono tutti gli operandi in reali - int i; float r; per valutare (i * r), si converte i in float; l'espressione è di tipo float - l'espressione (2 + 4.56) è di tipo double (per motivi storici, le costanti reali sono di tipo double anche se di valore basso) Lucido 15: Come già accennato, la regola di conversione per gli assegnamenti è diversa da quella che vale per le espressioni miste in generale: la differenza è che non vince il tipo più grosso, ma vince il tipo a sinistra. Il motivo, banale, è che il risultato va scritto nella variabile, e quindi deve adeguarsi al tipo della variabile, anche se questo è più limitato. Lucido 16: Il codice CONVERT.C fa alcuni esempi sui problemi provocati dalle conversioni implicite nel valutare espressioni logiche o assegnamenti. Si possono assegnare numeri interi a variabili di tipo carattere, ma devono essere numeri piccoli, altrimenti si creano problemi di overflow. char c1, c2; c1 = 100; c2 = c1 * c1; Il risultato è 16 sul mio portatile perché 100*100 = 10000 = 39*256 + 16, ma l'uso dell'aritmetica modulare non è garantito dallo standard. Assegnando il valore 1000000 a una variabile di tipo short, il compilatore emette un avvertimento ("warning"), ma non segnala errori. è un'ulteriore conferma che non conviene mai ignorare gli avvertimenti. In effetti, il valore è troppo grosso. Il risultato è 16960, che è 1000000-15*2^16, cioe' viene ottenuto con aritmetica modulare. Ancora una volta, pero', questo non è imposto dallo standard. short s1; s1 = 1000000; unsigned int u; int i; i = -5; u = 1; L'espressione (i < u) risulta falsa, perché i viene convertito in numero naturale per poterlo confrontare con u. int i, j; float f; i = 7; j = 2; f = i/j + 0.5; Al termine, f vale 3.5, perché la divisione 7/2 viene eseguita per prima e ha operandi interi, dunque è la divisione intera, con risultato 3. Per fare la divisione reale, si potrebbe scrivere: int i, j; float f; i = 7; j = 2; f = i; f /= j; f += 0.5; Assegnando le due costanti 0.9999999999999999 0.99999999999999999 a variabili double, esse risultano diverse, come già visto. Ma assegnandole a variabili float prima del confronto, risultano uguali, perché vengono convertite prima. Quando un valore reale viene assegnato a una variabile intera, viene troncato alla parte intera. int i; i = 3.5; Il valore assunto da i è 3 i = -2.5; Il valore assunto da i è -2 Quando si esegue una divisione fra numeri interi, la divisione è intera, anche se il risultato viene assegnato a una variabile reale. int i1; double d; i = 1000000; d = 1/i; 1/i vale 0, e quindi d vale 0.000000 d = 1.0/i; 1.0/i vale 0.000001 e d vale lo stesso Lucido 17: int a, b; double r; a = 5; b = 2; r = a/b; r vale 2.0 (di tipo double) r = (double) a / b; r vale 2.5 (di tipo double) r = a / (double) b; r vale 2.5 (di tipo double) r = (double) a / (double) b; r vale 2.5 (di tipo double) Quindi nell'esempio precedente 1/i può dare il valore corretto se si scrive 1/(double) i. Lucido 18: long i; short j; j = 1000; Secondo lo standard descritto nei libri e nel lucido, il primo e il terzo caso, cioè i = j * j; i = (long) (j * j); dovrebbero eseguire il prodotto nell'insieme short, dunque eccedendo il limite. In aritmetica modulare 1000 * 1000 farebbe 16960, che verrebbe convertito in long implicitamente (nel primo caso) o esplicitamente (nel terzo). Nel secondo caso, invece i = (long) j * j; la conversione esplicita di j da short a long viene eseguita prima del prodotto, e il prodotto agisce su un long e uno short, obbligando alla conversione anche del secondo short. Il risultato è long. Quindi si otterrebbe 1000000. In realtà, entrambi i compilatori che ho sotto mano (Dev-C e Visual Studio Express) producono codice che restituisce 1000000, come se la conversione in long avvenisse subito. Non ho spiegazioni: si può solo commentare che il problema va tenuto presente. Lucido 19: Differenze fra la definizione di un tipo fatta con l'istruzione typedef e con la direttiva #define: 1) la typedef è trattata dal compilatore, la #define dal precompilatore; 2) località: la typedef rende la definizione vale solo nel blocco, la #define nell'intero file; 3) la #define è una brutale sostituzione di stringhe, e quindi può portare errori nel caso di costrutti complessi che ancora non abbiamo trattato; ad esempio: #define pointer int * typedef int * pointer; sono completamente diverse, perché - nel primo caso, "pointer p, q;" diventa "int *p, q;" per cui p è un puntatore, ma q no - nel secondo caso, "pointer p, q;" equivale a int *p, *q; per cui p e q sono entrambi puntatori Lucido 20: Le dichiarazioni globali paiono molto comode al programmatore inesperto, perché rendono disponibili le variabili a tutte le funzioni di un file senza doverle inserire come parametri nelle dichiarazioni delle funzioni stesse, e quindi semplificano le dichiarazioni. In linea di principio, sono però da evitare, proprio perché nascondono il fatto che una funzione usa quelle variabili, e quindi: - il risultato della funzione dipende dal loro valore; - l'esecuzione della funzione modifica il loro valore. Usando le dichiarazioni globali, diventa molto difficile: - sapere quali variabili sono usate una funzione - sapere quali funzioni modificano una variabile - spostare una funzione da un file a un altro (la variabile diventa invisibile e il codice non compila più!) Quindi è meglio non dichiarare mai variabili globali. Al contrario, usare una definizione globale per un tipo spesso ha senso, perché le variabili di quel tipo saranno comunque definite localmente. Inoltre, se occorre, il tipo definito globalmente potrà essere modificato accedendo a un solo punto, anche se viene usato in molti punti del programma. Lucido 24: Il codice ENUM1.C illustra la simulazione di variabili di tipo seme attraverso interi e costanti introdotte con la direttiva #define. Le variabili s1 e s2 sono intere e possono assumere i valori dei quattro semi, ma anche generici valori interi. Se si lancia con l'opzione -E il solo precompilatore, nel risultato si può notare la scomparsa delle costanti simboliche PICCHE, CUORI, QUADRI, FIORI e seme. Il codice ENUM2.c illustra la definizione di variabili di tipo seme come tipo enumerativo. La variabile s2, benché definita come tipo enumerativo, funziona come un intero generico e può assumere valori esterni all'insieme enumerato, senza messaggi di errore da parte del compilatore. Lo stesso vale per la variabile s1, di tipo seme. Nel codice precompilato persistono le costanti simboliche PICCHE, CUORI, QUADRI e FIORI e il nome del tipo seme. Rispetto alla direttiva #define, però, l'uso di un tipo enumerativo chiarisce meglio il significato delle variabili e fa spiccare meglio all'occhio le espressioni che possono generare overflow, anche se non lo evita in alcun modo. Domanda sottile: le variabili s1 e s2 nel codice ENUM2.C sono dello stesso tipo oppure no? E si possono assegnare una all'altra? La risposta è che non sono dello stesso tipo, anche se la loro definizione è identica. C'è di più: persino la definizione enum {PICCHE, CUORI, QUADRI, FIORI} s1; enum {PICCHE, CUORI, QUADRI, FIORI} s2; produrrebbe due variabili di tipo diverso. Questo perché riconoscere la loro equivalenza richiederebbe al compilatore di analizzare le dichiarazioni e riconoscerne l'identità, un compito potenzialmente molto gravoso. In pratica, però, è possibile assegnare una variabile all'altra grazie a una conversione implicita: il compilatore tratta tutti i tipi enumerativi come numeri interi. Vedremo in seguito che per le strutture (un particolare tipo strutturato definito da utente) ciò non è invece possibile. Esercizio: Si provi a definire e usare il tipo enumerativo "seme" con una funzione typedef e con un tag di enumerazione. Lucido 25: Esercizio: farsi stampare le dimensioni dei tipi visti a lezione e di alcune costanti, variabili o espressioni a piacere. Poiché sizeof() restituisce un oggetto di tipo size_t, per poterlo assegnare a una variabile o stampare bisogna fare una conversione esplicita con l'operatore cast. dim = (long) sizeof(...);