• Non ci sono risultati.

Astrazione dei dati

Nel documento Pensare in C++, seconda ed. Volume 1 (pagine 149-174)

Fase 5: Evoluzione

4: Astrazione dei dati

Il C++ è uno strumento di miglioramento della produttività. Perché mai si dovrebbe fare lo sforzo (ed è uno sforzo, al di là di quanto facilmente si tenti di effettuare la transizione

per cambiare un certo linguaggio che già si conosce e che è produttivo, con un nuovo linguaggio con la prospettiva immediata che per un certo tempo sia meno produttivo, fino a che non ci si è fatta la mano? Perché si è giunti al convincimento che si avranno grandi vantaggi usando questo nuovo strumento.

In termini di programmazione, maggiore produttività significa che meno persone possono produrre programmi più grandi, più complessi e in meno tempo. Ci sono certamente altri problemi quando si va verso la scelta di un nuovo linguaggio, come l’efficienza (la natura di questo linguaggio causerà ritardi e un codice “gonfio”?), la sicurezza (il linguaggio ci

aiuterà ad essere sicuri che il nostro programma faccia sempre quello che abbiamo

progettato e gestirà gli errori in modo decente?) e la manutenzione (il linguaggio ci aiuterà a creare codice facile da capire, modificare ed estendere?). Questi sono dei fattori

certamente importanti e che verranno esaminati in questo libro.

Ma produttività grezza significa anche che per scrivere un programma, prima si dovevano occupare tre persone per una settimana, e ora se ne occupa una per un giorno o due. Questo aspetto tocca diversi livelli di economie: noi programmatori siamo contenti perché ci arriva una botta di energia quando si costruisce qualcosa, il nostro cliente (o il nostro capo) è contento perché il prodotto è fatto più velocemente e con meno persone, e l’utente finale è contento perché il prodotto è più economico. La sola via per ottenere un massiccio incremento di produttività è sfruttare il codice di altri programmatori, cioè usare librerie. Una libreria è semplicemente del codice che qualcun altro ha scritto e impacchettato

assieme. Spesso, il pacchetto minimo è costituito da un singolo file con un’estensione .lib e uno o più file header che dicono al compilatore cosa c’è nella libreria (per esempio, nomi di funzioni). Il linker sa come cercare nel file di libreria e come estrarre l’appropriato codice. Ma questo è solo uno dei modi per consegnare una libreria. Su piattaforme che occupano diverse architetture, come Linux/Unix, spesso il solo modo sensato di consegnare una libreria è con il codice sorgente, cosicché esso possa essere riconfigurato e ricompilato sul nuovo target.

Perciò, le librerie sono probabilmente il modo più importante per migliorare la

produttività, ed una delle mete primarie del C++ è quella di renderne più facile l’uso. Ciò sottointende che c’è qualcosa di abbastanza complicato nell’uso delle librerie scritte in C. La comprensione di questo fattore vi farà capire a fondo il modello del C++ e come usarlo.

Una piccola libreria in stile C

Normalmente, una libreria parte come un insieme di funzioni, ma chi ha già usato librerie C di terze parti, sa che vi si trova molto di più: oltre a comportamento, azioni e funzioni di un oggetto, ci sono anche le sue caratteristiche ( peso, materiale,ecc.. ), che vengono rappresentate dai dati. E quando si deve gestire un insieme di caratteristiche in C, è molto conveniente raggrupparle in una struttura struct, specialmente se volete rappresentare

più oggetti simili fra loro nel vostro spazio del problema. Quindi, potete costruire una istanza di questa struttura per ognuna di tali oggetti.

La maggior parte delle librerie C si presenta, quindi, come un insieme di strutture e un insieme di funzioni che agiscono su quelle strutture. Come esempio di un tale sistema, consideriamo un tool di programmazione che agisce come un array, ma le cui dimensioni possono essere stabilite runtime, cioè quando viene creato. Chiameremo la struttura

Cstash. Sebbene sia scritta in C++, ha lo stesso stile che avrebbe se fosse scritta in C.

//: C04:CLib.h

// Header file per una libreria in stile C // Un'entità tipo array tipo creata a runtime typedef struct CStashTag {

int size; // dimensione di ogni elemento int quantity; // numero di elementi allocati

int next; // indice del primo elemento vuoto

// array di byte allocato dinamicamente:

unsigned char* storage; } CStash;

void initialize(CStash* s, int size);

void cleanup(CStash* s);

int add(CStash* s, const void* element);

void* fetch(CStash* s, int index);

int count(CStash* s);

void inflate(CStash* s, int increase);

///:~

Un nome di variabile come CstashTag, generalmente è usato per una struttura nel caso in cui si abbia bisogno di riferirsi all’interno della struttura stessa. Per esempio, quando si crea una lista concatenata (ogni elemento nella lista contiene un puntatore all’elemento successivo), si necessita di un puntatore alla successiva struttura, così si ha bisogno di un modo per identificare il tipo di quel puntatore all’interno del corpo della struttura. Vedrete anche che, quasi universalmente, per ogni struttura in una libreria C, il simbolo typedef viene usato come sopra, in modo tale da poter trattare una struttura come se fosse un nuovo tipo e poter quindi definire istanze di quella struttura:

CStash A, B, C;

Il puntatore storage è un unsigned char*. Un unsigned char* è il più piccolo pezzo di memoria che un compilatore C può gestire: la sua dimensione è dipendente

dall’implementazione della macchina che state usando, spesso ha le dimensioni di un byte, ma può essere anche più grande. Si potrebbe pensare che, poiché Cstash è progettata per contenere ogni tipo di variabile, sarebbe più appropriato un void*. Comunque, lo scopo non è quello di trattare questa memoria come un blocco di un qualche tipo sconosciuto, ma piuttosto come un blocco contiguo di byte.

Il codice sorgente contenuto nel file di implementazione (il quale, se comprate una libreria commerciale, normalmente non viene dato, – avreste solo un compilato obj o lib o dll) assomiglia a questo:

//: C04:CLib.cpp {O}

// Implementazione dell’esempio di libreria in stile C // Dichiarazione della struttura e delle funzioni:

#include "CLib.h" #include <iostream> #include <cassert>

using namespace std;

// Quantità di elementi da aggiungere

// quando viene incrementata la allocazione:

const int increment = 100;

void initialize(CStash* s, int sz) { s->size = sz;

s->quantity = 0; s->storage = 0; s->next = 0; }

int add(CStash* s, const void* element) {

if(s->next >= s->quantity) // E’ rimasto spazio sufficiente?

inflate(s, increment);

// Copia dell’elemento nell’allocazione, // partendo dal primo elemento vuoto:

int startBytes = s->next * s->size;

unsigned char* e = (unsigned char*)element; for(int i = 0; i < s->size; i++)

s->storage[startBytes + i] = e[i]; s->next++;

return(s->next - 1);

// numero d’indice

}

void* fetch(CStash* s, int index) {

// Controllo dei limiti dell’indice:

assert(0 <= index); if(index >= s->next)

return 0; // per indicare la fine

// produciamo un puntatore all’elemento desiderato: return &(s->storage[index * s->size]);

}

int count(CStash* s) {

return s->next; // Elementi in CStash

}

void inflate(CStash* s, int increase) { assert(increase > 0);

int newQuantity = s->quantity + increase; int newBytes = newQuantity * s->size; int oldBytes = s->quantity * s->size;

unsigned char* b = new unsigned char[newBytes]; for(int i = 0; i < oldBytes; i++)

b[i] = s->storage[i]; // Copia della vecchia allocazione nella nuova delete [](s->storage); // Vecchia allocazione

s->storage = b; //Puntiamo alla nuova memoria

s->quantity = newQuantity; }

void cleanup(CStash* s) { if(s->storage != 0) {

cout << "freeing storage" << endl; delete []s->storage;

} } ///:~

La funzione initialize() compie le inizializzazioni necessarie per la struttura CStash impostando le variabili interne con valori appropriati. Inizialmente, il puntatore storage è impostato a zero, cioè: inizialmente, nessuno spazio di memoria allocato.

La funzione add() inserisce un elemento in CStash nella successiva locazione di memoria disponibile. Per prima cosa effettua un controllo per vedere se c’è dello spazio residuo utilizzabile, e se non c’è espande lo spazio di memoria utilizzando la funzione inflate() descritta più sotto.

Poiché il compilatore non conosce il tipo specifico della variabile che deve essere immagazzinata (la funzione tratta un void*), non è possibile effettuare solo un

assegnamento, che sarebbe certamente la cosa conveniente da fare, invece si deve copiare la variabile byte per byte. Il modo più diretto per fare la copia, è tramite un array

indicizzato. Tipicamente, vi sono già dati di tipo byte nella memoria allocata storage, e il riempimento è indicato dal valore di next. Per partire con il giusto offset di byte, la

variabile next viene moltiplicata per la dimensione di ogni elemento (in byte) per ottenere

startBytes. Dopodiché, l’argomento element viene castato ad un unsigned char* in

modo da poter essere indirizzato byte per byte e copiato nello spazio disponibile storage, e next viene quindi incrementato in modo da indicare sia la prossima zona di memoria utilizzabile, che il numero d’indice dove il valore è stato immagazzinato, così che possa essere recuperato usando il numero d’indice con la funzione fetch().

La funzione fetch() controlla che l’indice non ecceda i limiti e restituisce l’indirizzo della variabile desiderata, calcolata usando l’argomento index. Poiché index indica il numero di elementi di offset in CStash, per ottenere l’offset numerico in byte, si deve moltiplicare

index per il numero di byte occupati da ogni elemento. Quando questo offset viene usato

per l’indicizzazione in storage usando array indicizzati, non si ottiene l’indirizzo, bensì il byte a quell’indirizzo. Per ottenere l’indirizzo, si deve usare operatore &, “indirizzo-di”. La funzione count(), a prima vista, può sembrare strana a un esperto programmatore C. Sembra, in effetti, un grosso pasticcio fatto per fare una cosa che è più facile fare a mano. Se, per esempio, si ha una struttura CStash chiamata intStash, parrebbe molto più diretto scoprire quanti elementi ha usando intStash.next invece di fare una chiamata di funzione (che è overhead) come count(&intStash). Comunque, se in futuro si vorrà cambiare la rappresentazione interna di CStash, e quindi anche il modo con cui si calcola il conteggio, l’interfaccia della chiamata di funzione permette la necessaria flessibilità. Ma, purtroppo, la maggior parte dei programmatori non si sbatterà per scoprire il miglior design per utilizzare la vostra libreria; guarderanno com’è fatta la struct e preleveranno direttamente il valore di next, e possibilmente cambieranno anche next senza il vostro permesso. Ah, se solo ci fosse un modo per il progettista di librerie per avere un controllo migliore sopra cose come questa (Sì, è un'anticipazione!).

Allocazione dinamica della memoria.

Non si conosce mai la quantità massima di memoria di cui si ha bisogno per la struttura

CStash, così la memoria puntata da storage è allocata prendendola dallo heap. Lo heap

memoria. Si usa lo heap quando non si conoscono le dimensioni della memoria di cui si avrà bisogno mentre si sta scrivendo il programma (solo in fase di runtime si scopre, per esempio, che si ha bisogno di allocare spazio di memoria per 200 variabili aeroplano invece di 20). Nel C Standard, le funzioni di allocazione dinamica della memoria includono

malloc(), calloc(), realloc() e free(). Invece di chiamate le funzioni di libreria,

comunque, il C++ ha un accesso più sofisticato (sebbene più semplice da usare) alla

memoria dinamica, il quale è integrato nel linguaggio stesso attraverso parole chiave come

new e delete.

La funzione inflate() usa new per ottenere una quantità di spazio più grande per

CStash. In questa situazione, la memoria si potrà solo espandere e non restringere, e la

funzione assert() garantirà che alla inflate() non venga passato un numero negativo come valore del parametro increase. Il nuovo numero di elementi che può essere

contenuto (dopo che è stata completata la chiamata a inflate()), è calcolato e memorizzato in newQuantity, e poi moltiplicato per il numero di byte per elemento per ottenere

newBytes, che rappresenta il numero di byte allocati. In questo modo siamo in grado di

sapere quanti byte devono essere copiati oltre la vecchia locazione; oldBytes è calcolata usando la vecchia quantity.

L’allocazione della memoria attuale, avviene attraverso un nuovo tipo di espressione, che implica la parola chiave new:

newunsignedchar[newBytes];

L’uso generale di new, ha la seguente forma:

new Type;

dove Type descrive il tipo di variabile che si vuole allocare nello heap. Nel nostro caso, si vuole un array di unsigned char lungo newBytes, così che si presenti come Type. E’ possibile allocare anche qualcosa di semplice come un int scrivendo:

new int;

e sebbene tale allocazione venga raramente fatta, essa è tuttavia formalmente corretta. Una espressione new restituisce un puntatore ad un oggetto dell’esatto tipo richiesto. Così, se si scrive new Type, si ottiene un puntatore a Type. Se si scrive new int, viene

restituito un puntatore ad un intero, e se si vuole un array di unsigned char, verrà

restituito un puntatore al primo elemento dell’array. Il compilatore si assicurerà che venga assegnato il valore di ritorno dell’espressione new a un puntatore del tipo corretto.

Naturalmente, ogni volta che viene richiesta della memoria, è possibile che la richiesta fallisca se non c’è sufficiente disponibilità di memoria. Come si vedrà in seguito, il C++ possiede dei meccanismi che entrano in gioco se non ha successo l’operazione di

allocazione della memoria.

Una volta che la nuova memoria è allocata, i dati nella vecchia allocazione devono essere copiati nella nuova; questa operazione è realizzata di nuovo attraverso l’indicizzazione di un array, copiando in un ciclo un byte alla volta. Dopo aver copiato i dati, la vecchia allocazione di memoria deve essere rilasciata per poter essere usufruibile per usi futuri da

altre parti del programma. La parola chiave delete è il complemento di new, e deve essere usata per rilasciare ogni blocco memoria allocato in precedenza con una new (se ci si dimentica di usare delete, la zona di memoria interessata rimane non disponibile, e se questa cosiddetto meccanismo di memoria persa (memory leak) si ripete un numero sufficiente di volte, il programma esaurirà l’intera memoria disponibile). In aggiunta, quando si cancella un array si usa una sintassi speciale, ed è come se si dovesse ricordare al compilatore che quel puntatore non punta solo a un oggetto, ma ad un gruppo di oggetti: si antepone al puntatore da eliminare una coppia vuota di parentesi quadre:

delete [ ] myArray;

Una volta che la vecchia allocazione è stata eliminata, il puntatore a quella nuova può essere assegnato al puntatore che si usa per l’allocazione; la quantità viene aggiornata al nuovo valore e la funzione inflate() ha così esaurito il suo compito.

Si noti che la gestione dello heap è abbastanza primitiva, grezza. Lo heap cede blocchi di memoria e li riprende quando viene invocato l’operatore delete. Non c’è alcun servizio inerente alla compattazione dello heap, che lo comprima per liberare blocchi di memoria più grandi (un servizio di deframmentazione). Se un programma alloca e libera memoria di heap per un certo periodo, si rischia di avere uno heap frammentato che ha sì pezzi di memoria liberi, ma nessuno dei quali sufficientemente grandi da permettere di allocare lo spazio di memoria richiesto in quel momento. Un compattatore di heap complica un programma, perché sposta pezzi di memoria in giro per lo heap, e i puntatori usati dal programma non conserveranno i loro propri valori! Alcuni ambienti operativi hanno al proprio interno la compattazione dello heap, ma richiedono l’uso di handle speciali per la memoria (i quali possono essere temporaneamente convertiti in puntatori, dopo aver bloccato la memoria, in modo che il compattatore di heap non possa muoverli) al posto di veri puntatori. Non è impossibile costruire lo schema di un proprio compattatore di heap, ma questo non è un compito da prendere alla leggera.

Quando si crea una variabile sullo stack, durante la compilazione del programma, l’allocazione di memoria per la variabile viene automaticamente creata e liberata dal compilatore. Il compilatore sa esattamente quanta memoria è necessaria, e conosce anche il tempo di vita di quella variabile attraverso il suo scope. Con l’allocazione dinamica della memoria, comunque, il compilatore non sa di quanta memoria avrà bisogno e, quindi, non conosce neppure il tempo di vita di quella allocazione. Quindi, l’allocazione di memoria non può essere pulita automaticamente, e perciò il programmatore è responsabile del rilascio della memoria con la procedura di delete, la quale dice al gestore dello heap che può essere nuovamente usata alla prossima chiamata a new. Il luogo più logico dove fare la pulizia della memoria nella libreria, è la funzione cleanup(), perché è lì che viene compiuta l’intera procedura di chiusura di tutte le operazioni ausiliarie.

Per provare la libreria, sono state create due strutture di tipo CStash. La prima contiene interi e la seconda un array di 80 caratteri.

//: C04:CLibTest.cpp //{L} CLib

// Test della libreria in stile C #include "CLib.h"

#include <fstream> #include <iostream> #include <string> #include <cassert>

using namespace std;

int main() {

// Definiamo le variabili all’inizio // del blocco, come in C:

CStash intStash, stringStash; int i;

char* cp; ifstream in; string line;

const int bufsize = 80;

// Ora, ricordiamoci di inizializzare le variabili: initialize(&intStash, sizeof(int)); for(i = 0; i < 100; i++)

add(&intStash, &i);

for(i = 0; i < count(&intStash); i++)

cout << "fetch(&intStash, " << i << ") = " << *(int*)fetch(&intStash, i)

<< endl;

// (Holds) Creiamo una stringa di 80 caratteri:

initialize(&stringStash, sizeof(char)*bufsize); in.open("CLibTest.cpp");

assert(in);

while(getline(in, line))

add(&stringStash, line.c_str()); i = 0;

while((cp = (char*)fetch(&stringStash,i++))!=0) cout << "fetch(&stringStash, " << i << ") = " << cp << endl;

cleanup(&intStash); cleanup(&stringStash); } ///:~

Seguendo il formalismo del C, tutte le variabili sono create all’inizio dello scope di main(). Naturalmente, successivamente ci si deve ricordare di inizializzare le variabili CStash nel blocco chiamando initialize(). Uno dei problemi con le librerie C, è che si deve indicare accuratamente a chi userà la libreria, l’importanza delle funzioni di inizializzazione e di pulizia. Se queste funzioni non vengono chiamate, si va incontro a grossi problemi. Sfortunatamente, l’utente non si chiede sempre se l’inizializzazione e la pulizia siano obbligatori e quale sia il modo corretto di eseguirli; inoltre, anche il linguaggio C non prevede meccanismi per prevenire cattivi usi delle librerie.

La struttura intStash viene riempita con interi, mentre la stringStash con array di caratteri; questi array di caratteri sono ottenuti aprendo ClibTest.cpp, un file di codice sorgente: le linee di codice vengono lette e scritte dentro un’istanza di string chiamata

line, poi usando la funzione membro c_str() di string, si produce un puntatore alla

rappresentazione a caratteri di line.

Dopo che ogni Stash è stata caricata, viene anche visualizzata. La intStash viene

stampata usando un ciclo for, che termina quando la fetch() restituisce zero per indicare che è uscito dai limiti.

Si noti anche il cast aggiuntivo in:

a causa del controllo rigoroso dei tipi che viene effettuato in C++, il quale non permette di assegnare semplicemente void* a ogni altro tipo (mentre in C è permesso).

Cattive congetture.

C’è però un argomento più importante che deve essere capito prima di affrontare in

generale i problemi nella creazione di una libreria C. Si noti che il file header CLib.h deve essere incluso in ogni file che fa riferimento a CStash, perché il compilatore non può anche indovinare a cosa assomiglia quella struttura. Comunque il compilatore può

indovinare a cosa assomiglia una funzione; questo suona come una qualità del C, ma poi si rivela per esserne uno dei principali tranelli.

Sebbene si dovrebbero dichiarare sempre le funzioni, includendo un file header, la dichiarazione di funzione non è obbligatoria in C. In C (ma non in C++) è possibile

chiamare una funzione che non è stata dichiarata. Un buon compilatore avvisa suggerendo di dichiarare prima la funzione, ma il C Standard non obbliga a farlo. Questa è una pratica pericolosa, perché il compilatore C può assumere che una funzione che è stata chiamata con un argomento di tipo int abbia una lista di argomenti contente interi, anche se essa può in realtà contenere un float. Come si vedrà, questa pratica può produrre degli errori difficili da trovare in fase di debugging.

Ogni file separato di implementazione C (cioè con estensione .c), è una unità di

traslazione, cioè il compilatore elabora separatamente ognuna di tali unità, e mentre la

sta elaborando è conosce questa sola unità e nient’altro. Così, ogni informazione che viene fornita attraverso l’inclusione dei file header, risulta di grande importanza, poiché

determina la conoscenza del compilatore sul resto del programma. Le dichiarazioni nei file header risultano essere particolarmente importanti, perché in qualsiasi posto essi vengano inclusi, faranno in modo che lì il compilatore sappia cosa fare. Se, per esempio, si ha una

Nel documento Pensare in C++, seconda ed. Volume 1 (pagine 149-174)

Documenti correlati