MindView Inc.

 

[ Suggerimenti ] [ Soluzioni degli Esercizi] [ Volume 2 ] [ Newsletter Gratuita ]
[
Seminari ] [ Seminari su CD ROM ] [ Consulenza]

Pensare in C++, seconda ed. Volume 1

©2000 by Bruce Eckel

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

traduzione italiana e adattamento a cura di Cesare Bertoletti

Capitolo 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 è un grande blocco di memoria che viene usato per allocare runtime quantità più piccole di 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:

new unsigned char[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:

cp = (char*)fetch(&stringStash, i++)

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 dichiarazione in un file header che dice void func(float), il compilatore sa che se si chiama la funzione func con un argomento int, dovrà convertire int in float e passarlo come argomento (questa operazione è detta promotion). Senza la dichiarazione di funzione, il compilatore C assumerebbe semplicemente l’esistenza di una funzione func(int), non verrebbe fatta la promotion e il dato errato verrebbe tranquillamente passato a func().

Per ogni unità di traslazione, il compilatore crea un file oggetto, con estensione .o oppure .obj o qualcosa di simile. I file oggetto, insieme al codice necessario per l’avvio, devono essere riuniti dal linker in un file programma eseguibile, e tutti i riferimenti esterni dovranno essere risolti durante l’operazione di linking. Per esempio, nel file CLibTest.cpp sono state dichiarate (cioè al compilatore è stato detto a cosa assomigliano) e usate funzioni  come initialize() e fetch(), ma queste funzioni non sono state definite, lo sono altrove, in CLib.cpp. Quindi le chiamate in CLib.cpp sono riferimenti esterni. Il linker, quando mette insieme tutti i file oggetto, prendere i riferimenti esterni non risolti e li risolve trovando gli indirizzi a cui essi effettivamente si riferiscono, e gli poi indirizzi vengono messi nel programma eseguibile per sostituire i riferimenti esterni.

E’ importante rendersi conto che, in C, i riferimenti esterni che il linker cerca, sono semplicemente nomi di funzioni, generalmente con anteposto il carattere underscore (‘_’). Quindi, tutto quello che il linker deve fare è  associare il nome di una funzione dove è chiamata, con il corpo della funzione stessa nel file oggetto. Se, accidentalmente, un programmatore fa una chiamata che il compilatore interpreta come func(int), e in qualche altro file oggetto c’è un corpo di funzione per func(float), il linker vedrà _func in un posto e _func nell’altro, e riterrà essere tutto OK.  La funzione func(), nel luogo dove viene invocata, spingerà un int sullo stack, ma lì il corpo di funzione di func() si aspetta che ci sia un float sullo stack. Se la funzione compie solo una lettura del valore e non tenta di fare un assegnamento, allora lo stack non scoppierà. In tal caso, infatti, il valore float che legge dallo stack potrebbe anche essere sensato, il che è addirittura peggio, perché così è ancora più difficile trovare il baco.

Cosa c’è di sbagliato?

Noi siamo straordinariamente adattabili, anche in situazioni nelle quali, forse, non vorremmo adattarci. Lo stile della libreria CStash è stata un fattore di base per i programmatori C, ma se la si guarda per un momento, si può notare come sia piuttosto, come dire?… goffa. Quando la si usa, si deve passare l’indirizzo della struttura ad ogni singola funzione della libreria. Quando si legge il codice, il meccanismo della libreria si mescola con il significato delle chiamate di funzione, provocando confusione quando si prova a capire cosa sta succedendo.

Comunque, uno dei più grandi ostacoli nell’uso delle librerie in C, è il problema del conflitto di nomi (name clashes). Il C ha un singolo spazio di nomi per la funzioni, cioè, quando il linker cerca un nome di funzione, lo cerca in una singola lista master. In più, quando il compilatore sta lavorando su una unità di traslazione, lo fa lavorando solo con una singola funzione con un dato nome.

Ora, supponiamo di aver comprato due librerie da due differenti aziende, e che ogni libreria abbia una struttura che deve essere inizializzata e pulita, ed entrambe le ditte costruttrici decidono che initialize() e cleanup() sono ottimi nomi per le relative funzioni. Se si includono entrambi i file header in un singolo file di traslazione, cosa fa il compilatore C? Fortunatamente, il C dà un errore, dicendo che c’è un errato accoppiamento di tipi (type mismatch) nelle due differenti liste di argomenti delle funzioni dichiarate. Ma anche se i file header non vengono inclusi negli stessi file di traslazione, lo stesso si avranno problemi nell’attività del linker. Un buon linker rileverà la presenza di un conflitto di nomi, ma altri linker prendono il primo nome di funzione che trovano, cercando lungo la lista di file oggetto  nell’ordine a loro dato nella lista di link.

In entrambi i casi, non è possibile usare due librerie C che contengano funzioni con nomi identici. Per risolvere il problema, i venditori spesso aggiungono una sequenza di caratteri specifica all’inizio di tutti i nomi di funzione. Così, initialize() diventa CStash_initialize () e così via, che è una pensata sensata.

Adesso, è venuto il momento per fare il primo passo verso la creazione di classi in C++.

I nomi delle variabili all’interno di una struttura non sono in conflitto con i nomi delle variabili globali; e così, perché non trarne vantaggio per i nomi di funzione, quando quelle funzioni operano su una particolare struttura? Cioè, perché non costruire funzioni membro di una struttura?

L’oggetto base.

Il primo passo è proprio questo: le funzioni C++ possono essere messe all’interno di strutture come ”funzioni membro”. Di seguito viene mostrato il codice per una struttura  CStash di tipo C convertita a una CStash di tipo C++:

// C04:CppLib.h
// La libreria in stile C convertita in C++

struct Stash {
  int size;      // Dimensione di ogni elemento
  intquantity; // Numero di elementi allocati
  int next;      // Indice del primo elemento vuoto
// Array di byte allocati dinamicamente:
unsigned char* storage;
 // Le funzioni membro!
void initialize(int size);
  void cleanup();
  int add(const void* element);
  void* fetch(int index);
  int count();
  void inflate(int increase);
}; ///:~

Per prima cosa, si noti che non ci sono typedef. Invece di usare typedef, il compilatore C++ trasforma il nome della struttura in un nuovo nome di tipo valido all’interno del programma (proprio come int, char, float e double sono nomi di tipo).

Tutti i dati membro sono esattamente come prima, ma ora le funzioni sono all’interno del corpo della struttura. In più, si noti che è stato rimosso il primo argomento presente in tutte le funzioni della versione C della libreria, cioè il puntatore a CStash. In C++, invece di costringere il programmatore a passare l’indirizzo della struttura come primo argomento di tutte le funzioni che operano sulla struttura, l’operazione viene fatta  dal compilatore in modo del tutto trasparente. Ora, i soli argomenti passati alle funzioni, sono relativi a quello che la funzione fa, non al meccanismo interno della funzione.

E’ importante rendersi conto che il codice della funzione è effettivamente lo stesso della precedente versione C della libreria. Il numero di argomenti è lo stesso (anche se non è più esplicitato, il passaggio dell’indirizzo della struttura c’è ancora), e c’è un solo corpo di funzione per ogni funzione. Cioè, solo perché si dice :

Stash A, B, C;

ciò non significa avere differenti funzioni add() per ogni variabile.

Così, il codice che è stato generato è quasi identico a quello che si avrebbe scritto per la versione C della libreria. Curiosamente, questo include i suffissi che si sarebbero fatti per produrre Stash_initialize(), Stash_cleanup(), e così via.

Quando il nome di funzione è all’interno della struttura, il compilatore fa effettivamente la stessa cosa. Perciò, initialize() all’interno della struttura Stash non colliderà con una funzione chiamata initialize() all’interno di qualsiasi altra struttura, oppure con una funzione globale con lo stesso nome. Sebbene la maggior parte delle volte non serva, qualche volta si ha la necessità di poter precisare che questa specifica initialize() appartiene alla struttura Stash, e non a qualche altra struttura, e in particolare questo serve quando si deve definire la funzione, perché in tal caso si necessita di specificare pienamente cosa essa sia. Per ottenere questa specificazione completa, il C++ ha un operatore (::) chiamato operatore di risoluzione della visibilità (scope resolution operator), così chiamato perché adesso i nomi possono avere differenti visibilità: visibilità globale o all’interno di una struttura. Per esempio, per specificare la funzione membro initialize(), che appartiene a Stash, si dice:

Stash::initialize(int size).

Vediamo come si usa l’operatore di risoluzione della visibilità nelle definizioni di funzione:

//: C04:CppLib.cpp {O}
// Libreria C convertita in C++
// Dichiariamo strutture e funzioni::
#include "CppLib.h"
#include <iostream>
#include <cassert>
using namespace std;
// Quantità di elementi da aggiungere
// quando viene incrementata la allocazione:
const int increment = 100;

void Stash::initialize(int sz) {
  size = sz;
  quantity = 0;
  storage = 0;
  next = 0;
}

int Stash::add(const void* element) {
  if(next >= quantity)// E’ rimasto spazio sufficiente?
    inflate(increment);
 // Copia dell’elemento nell’allocazione,
 // partendo dal primo spazio vuoto:
int startBytes = next * size;
  unsigned char* e = (unsigned char*)element;
  for(int i = 0; i < size; i++)
    storage[startBytes + i] = e[i];
  next++;
  return(next - 1); // numero d’indice
}

void* Stash::fetch(int index) {
  // Controllo dei limiti dell’indice:
  assert(0 <= index);
  if(index >= next)
    return 0; // Per indicare la fine
 // produciamo un puntatore all’elemento desiderato:
  return &(storage[index * size]);
}

int Stash::count() {
  return next; // Numero di elementi in CStash
}

void Stash::inflate(int increase) {
  assert(increase > 0);
  int newQuantity = quantity + increase;
  int newBytes = newQuantity * size;
  int oldBytes = quantity * size;
  unsigned char* b = new unsigned char[newBytes];
  for(int i = 0; i < oldBytes; i++)
    b[i] = storage[i]; // Copia della vecchia allocazione nella nuova
  delete []storage; // Vecchia allocazione
  storage = b; // Puntiamo alla nuova memoria
  quantity = newQuantity;
}

void Stash::cleanup() {
  if(storage != 0) {
    cout << "freeing storage" << endl;
    delete []storage;
  }
} ///:~

Ci sono diverse altre differenze tra il C e il C++. Primo, le dichiarazioni nei file header sono esplicitamente richieste dal compilatore. In C++ non si può chiamare una funzione senza prima dichiararla, e in caso contrario il compilatore emetterà un messaggio di errore. Questo è un modo importante per essere sicuri che le chiamate di funzione siano consistenti tra il punto dove sono chiamate e il punto dove sono definite. Rendendo obbligatoria la dichiarazione della funzione prima di poterla chiamare, il compilatore C++ si assicura virtualmente che l’utente lo faccia includendo il file header. Se lo stesso file header viene incluso anche nel file dove le funzioni sono definite, il compilatore controlla che la dichiarazione nel file header e la definizione di funzione combacino. Questo significa che il file header diventa un luogo ottimale per le dichiarazioni di funzione e assicura che le funzioni siano usate in modo consistente in tutte le unità di traslazione del progetto.

Naturalmente, le funzioni globali possono sempre essere dichiarate a mano in ogni luogo dove sono definite e usate (cosa così tediosa da diventare molto spiacevole.) Comunque, le strutture devono essere sempre dichiarate prima che siano definite e usate, e il luogo più conveniente dove mettere la definizione di una struttura è in un file header, tranne quelle che sono intenzionalmente nascoste in un file.

Si noti che tutte le funzioni membro sembrano uguali a quando erano funzioni C, tranne per la risoluzione della visibilità e per il fatto che il primo argomento che si trovava nella versione C della libreria, non è più esplicito (c’è ancora, naturalmente, perché la funzione deve essere in grado di lavorare su una particolare variabile struct). Notiamo, però, all’interno della funzione membro, che anche la selezione del membro se n’è andata! E così, invece di dire s–>size = sz; si dice size = sz; e si elimina la tediosa scrittura s–>, che in ogni caso non aggiunge in realtà nulla al significato di quello che si sta facendo. Il compilatore C++ lo fa chiaramente per noi. Infatti, prende il primo argomento “segreto” (l’indirizzo della struttura che in precedenza gli veniva passata a mano) e applica il selettore di membro ogniqualvolta ci si riferisce ad uno dei dati membro (incluse anche le altre funzioni membro) dando semplicemente il suo nome. Il compilatore cercherà prima tra i nomi della struttura locale e poi, se non lo trova, nella versione globale di quel nome. Questa caratteristica significa non solo codice più facile da scrivere, ma anche parecchio più facile da leggere.

Ma cosa avverrebbe se, per qualche ragione, si volesse essere in grado di mettere le mani sull’indirizzo della struttura? Nella versione C della libreria sarebbe stato piuttosto facile, perché ciascun primo argomento della funzione era un puntatore s a CStash (CStash*). In C++ le cose sono ancora più conformi: c’è una parola chiave (parola riservata) speciale, chiamata this, la quale produce l’indirizzo di struct  ed è l’equivalente di s nella versione C della libreria. Così si può tornare al solito comportamento in stile C, dicendo:

this->size = Size;

Il codice generato dal compilatore, è esattamente lo stesso, senza che si abbia più bisogno di fare questo uso di this; occasionalmente, si può vedere del codice dove la gente usa esplicitamente this-> dovunque, ma ciò non aggiunge nulla al significato del codice e indica solo un programmatore inesperto. Normalmente, non si usa spesso this, ma quando se ne ha bisogno, è presente (come si vedrà in alcuni degli esempi illustrati in questo libro).

C’è un’ultima cosa da dire: in C,  si potrebbe assegnare un void* ad ogni altro puntatore in questo modo:

int i = 10;
void* vp = &i; // corretto sia in C che in C++
int* ip = vp; // Accettabile solo in C

senza che il compilatore protesti. Ma, in C++, quest’affermazione non è permessa. Perché? Perché il C non è molto rigoroso circa le informazioni sul tipo, e permette quindi di assegnare un puntatore  di un tipo non specificato, ad un puntatore di tipo specificato. Non così il C++. Il tipo è un aspetto critico del C++, e il compilatore pesta i piedi quando c’è una qualsiasi violazione nelle informazioni sui tipi. Questa è sempre stata una questione importante, ma lo è specialmente in C++ perché nelle structs ci sono funzioni membro. Se in C++ si potessero passare impunemente puntatori a structs, allora si finirebbe per fare chiamate a funzioni membro per una struttura che non esiste logicamente per quella struct! Un’ottima ricetta per compiere disastri! Perciò, mentre il C++ permette l’assegnamento di ogni tipo di puntatore  a un void* (questo era lo scopo originale di void*, che si voleva grande a sufficienza per contenere un puntatore di qualsiasi tipo), non permetterà di assegnare un puntatore void ad altri tipi di puntatori. In C++ viene sempre richiesto un cast per dire sia al lettore, che al compilatore, che si vuole veramente trattare l’oggetto come il tipo di destinazione specificato.

E qui si solleva un’interessante questione. Una delle mete importanti del C++, è compilare la quantità maggiore di codice C esistente, per permettere una facile transizione al nuovo linguaggio. Questo comunque non significa che qualsiasi frammento di codice permesso in C lo sia anche in C++. Un compilatore C lascia passare parecchie cose pericolose che possono portare a errori. Per queste situazioni, invece, il compilatore C++ genera avvertimenti e messaggi di errore, e questo è molto spesso più un vantaggio che un impiccio; infatti, vi sono molte situazioni nelle quali in C si tenta inutilmente di rintracciare un errore, e non appena si ricompia il codice in C++, il compilatore identifica subito il problema! In C capita di compilare apparentemente in modo corretto un programma che poi non funziona, mentre in C++, quando un programma è stato compilato correttamente, è molto probabile che funzioni! e ciò è dovuto al fatto che è un linguaggio più restrittivo nell’uso dei tipi.

 Nel seguente programma di test, è possibile vedere come, nella versione C++ di Cstash,  ci siano parecchie cose nuove:

//: C04:CppLibTest.cpp
//{L} CppLib
// Test della libreria C++<
#include "CppLib.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main() {
  Stash intStash;
  intStash.initialize(sizeof(int));
  for(int i = 0; i < 100; i++)
    intStash.add(&i);
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash.fetch(" << j << ") = "
    << *(int*)intStash.fetch(j)
         << endl;
  // Creiamo una stringa di 80 caratteri:
  Stash stringStash;
  const int bufsize = 80;
  stringStash.initialize(sizeof(char) * bufsize);
  ifstream in("CppLibTest.cpp");
  assure(in, "CppLibTest.cpp");
  string line;
  while(getline(in, line))
    stringStash.add(line.c_str());
  int k = 0;
  char* cp;
  while((cp =(char*)stringStash.fetch(k++)) != 0)
    cout << "stringStash.fetch("<< k << ") = "
    << cp << endl;
  intStash.cleanup();
  stringStash.cleanup();
} ///:~

 

Una delle cose che si notano, è che le variabili sono tutte definite “al volo” (vedi capitolo precedente). Cioè, sono definite in un punto qualsiasi permesso alla loro visibilità (scope), piuttosto che con la restrizione del C di metterle all’inizio dello scope.

Il codice è del tutto simile a CLibTest.cpp, ma quando viene chiamata una funzione membro, la chiamata avviene usando l’operatore ‘.’ di selezione del membro, preceduto dal nome della variabile. Questa sintassi risulta conveniente poiché imita la selezione di un membro dati della struttura; la differenza è che questa è una funzione membro, e quindi ha una lista di argomenti.

Naturalmente, la chiamata che il compilatore effettivamente genera, assomiglia molto di più alla funzione della libreria C originale, così, considerando  i suffissi del nome (name decoration) e il passaggio di this, la chiamata di funzione C++ intStash.initialize(sizeof(int), 100) diventa qualcosa di simile a Stash_initialize(&intStash, sizeof(int), 100). Se vi siete chiesti cosa mai ci sia sotto lo strato superficiale, ricordate che il compilatore C++ originale (“cfront” della AT&T) , produceva come suo output codice C, il quale poi veniva compilato dal compilatore C sottostante. Questo approccio significava che cfront poteva essere velocemente portato su ogni macchina che aveva un compilatore C: un grande contributo a diffondere rapidamente la tecnologia dei compilatori C++. Ma, poiché il compilatore C++ doveva generare codice C, è chiaro che si doveva in qualche modo rappresentare la sintassi C++ in C (alcuni compilatori C++ permettono tuttora di produrre codice C).

C’è un’altra differenza da ClibTest.cpp, che è l’introduzione del file header require.h, un file header creato appositamente per questo libro per effettuare un controllo degli errori più sofisticato di quello dato dalla funzione assert(). Questo file contiene diverse funzioni, incluse quella che qui è usata, assure(), usata per i file: controlla se un file è stato aperto con successo, e se non lo è, comunica allo standard error che il file potrebbe non essere aperto (quindi è necessario il nome del file come secondo argomento) ed esce dal programma. Le funzioni di require.h rimpiazzano il codice di controllo degli errori di tipo distracting e repetitive, e in più forniscono utili messaggi di errore. Queste funzioni saranno completamente spiegate più tardi nel libro.

Cos’è un oggetto?

Ora che si è visto un esempio iniziale, è tempo di fare un passo indietro e dare un’occhiata a un po’ di terminologia.

Il portare funzioni all’interno di una struttura, è il fondamento strutturale che il C++ aggiunge al C; introduce un nuovo modo di pensare le strutture: come concetti. In C, una struttura è un agglomerato di dati, un modo per impacchettare dati così che possano essere trattati in un blocco. Ma è difficile pensarlo come un qualcosa che non sia altro che una conveniente modalità di programmazione. Le funzioni che operano su quella struttura possono essere ovunque. Con le funzioni all’interno del pacchetto, la struttura diventa una nuova creatura, capace di descrivere sia caratteristiche (coma fa una struttura C) che comportamento. Il concetto di un oggetto, una entità a sé, chiusa, che può ricordare e agire, suggerisce, propone, sé stesso.

In C++, un oggetto è appunto una variabile, e la più pura definizione è:  “una zona di memoria” (che è un modo più specifico di dire, “un oggetto deve avere un identificatore univoco”, il quale, nel caso del C++, è un indirizzo di memoria univoco).  E’ un luogo dove è possibile memorizzare dati, ed è sottointeso che lì ci siano anche operazioni che possono agire su questi dati.

Sfortunatamente, quando si giunge a questo livello di terminologia, non c’è un completo accordo tra i linguaggi, anche se sono accettati abbastanza bene. Si possono anche trovare posizioni discordanti su cosa sia un linguaggio orientato agli oggetti. Ci sono alcuni linguaggi che sono basati sugli oggetti (object-based), cioè hanno oggetti come le “strutture con funzioni” che si sono viste fin qui, ma questa è solo una parte della faccenda che investe un linguaggio orientato agli oggetti; i linguaggi che si fermano all’impacchettamento delle funzioni all’interno delle strutture dati, sono solo linguaggi basati sugli oggetti, e non orientati agli oggetti.

Tipi di dati astratti

 La capacità di impacchettare dati insieme a funzioni, permette la creazione di un nuovo tipo di dati. Questa capacità è spesso chiamata incapsulamento [33]. Un tipo di dato già esistente, può avere diversi blocchi di dati impacchettati assieme. Per esempio, un float è costituito da un esponente, una mantissa e un bit di segno, e gli si può dire di compiere delle cose: aggiungere un altro float o un int, e così via: risulta quindi avere già un insieme sia di caratteristiche che di comportamenti.

La definizione di Stash crea un nuovo tipo di dato, su cui è possibile effettuare operazioni tramite add(), fetch(), e inflate (). Per creare un’istanza di Stash, si scrive Stash s, proprio come per creare un float si scrive float f. Un tipo Stash possiede sia delle caratteristiche che dei comportamenti. Anche Stash si comporta come un vero tipo di dato built-in, ci si riferisce ad esso come ad un tipo di dato astratto, forse perché ci permette di astrarre un concetto dallo spazio del problema allo spazio della soluzione. In più, il compilatore C++ lo tratta come un nuovo tipo di dato, e se si dice che una funzione si aspetta un dato di tipo Stash, il compilatore si assicura che si passi uno Stash a quella funzione. Così, come per i tipi built-in definiti dall’utente, anche con i tipi di dati astratti c’è lo stesso livello di controllo di tipo. Qui si vede immediatamente una differenza nel modo in cui si compiono le operazioni sugli oggetti. Si scrive: object.memberFunction(arglist), e significa “richiedere una funzione membro di un oggetto”. Ma nel gergo object-oriented, significa anche “spedire un messaggio a un oggetto”. Così. Per un s di tipo Stash, l’affermazione s.add(&i) “spedisci un messaggio a s” dice, “add() (aggiungi) questo a te stesso”. E infatti, la programmazione orientata agli oggetti può essere riassunta in una singola frase: la programmazione orientata agli oggetti è lo spedire messaggi a oggetti. In effetti, questo è tutto ciò che si fa: creare un gruppo di oggetti e spedire loro dei messaggi. Il trucco, naturalmente, è riuscire a capire cosa sono quegli oggetti e quei messaggi, ma una volta fatto questo lavoro, l’implementazione in C++ è sorprendentemente semplice.

Gli oggetti in dettaglio

Una domanda che spesso viene fatta nei seminari, è : “Quanto è grande un oggetto, e a cosa assomiglia?”. La risposta è : “circa quello che ci si aspetta da una struttura di tipo C”. Infatti, il codice che viene prodotto dal codice C per una struttura C (senza parti C++), sembra esattamente lo stesso del codice prodotto da un compilatore C++.  Questo fatto risulta rassicurante per quei programmatori per i quali è importante il dettaglio della dimensione e della implementazione nel proprio codice, e per qualche ragione accedono direttamente alla struttura di bytes invece di usare gli identificatori (il dipendere da particolari dimensioni e implementazioni di una struttura, è una attività che non è portabile su altre piattaforme).

La dimensione di una struttura è la somma delle dimensioni di tutti i suoi membri. Talvolta, quando il compilatore implementa una struct, aggiunge dei bytes extra per fare in modo che i confini fra le zone di memoria assegnate ai dati siano netti, aumentando l’efficienza dell’esecuzione del programma. Nel cap. 15 si vedrà come, in alcuni casi, vengano aggiunti alla struttura dei puntatori “segreti”, ma ora non è il caso di preoccuparsene.

Si può determinare la dimensione di una struttura usando l’operatore sizeof.Un semplice esempio:

//: C04:Sizeof.cpp
// Dimensioni di  struct
#include "CLib.h"
#include "CppLib.h"
#include <iostream>
using namespace std;

struct A {
  int i[100];
};

struct B {
  void f();
};

void B::f() {}

int main() {
  cout << "sizeof struct A = " << sizeof(A)
       << " bytes" << endl;
  cout << "sizeof struct B = " << sizeof(B)
       << " bytes" << endl;
  cout << "sizeof CStash in C = " 
       << sizeof(CStash) << " bytes" << endl;
  cout << "sizeof Stash in C++ = " 
       << sizeof(Stash) << " bytes" << endl;
} ///:~

Tenendo presente che il risultato dipende dalla macchina su cui gira il programma, la prima istruzione di print produce 200, poiché ogni int occupa due bytes. La struct B è qualcosa di anomalo, perché è una struct senza membri dati; in C è un’operazione illegale, ma in C++ è permesso perché si ha la necessità di creare una struttura il cui unico compito sia la visibilità dei nomi delle funzioni. Ancora, il risultato prodotto dal secondo print, è un qualcosa che sorprendentemente ha un valore diverso da zero. Nelle prime versioni del linguaggio, la dimensione era zero, ma quando si creano tali oggetti si crea una situazione imbarazzante: hanno lo stesso indirizzo dell’oggetto creato direttamente dopo di loro, e quindi non sono distinti. Una delle regole fondamentali nella trattazione degli oggetti, è che ogni oggetto deve avere un indirizzo univoco; quindi le strutture senza membri dati avranno sempre una qualche dimensione minima diversa da zero.

Gli ultimi due sizeof, mostrano che la dimensione della struttura in C++ è la stessa della dimensione della versione equivalente in C. Il C++ cerca di non aggiungere nessun sovrappiù che non sia strettamente necessario.

Etichetta di comportamento per i file header

Quando si crea una struttura che contiene funzioni membro, si sta creando un nuovo tipo di dato e, in generale, si vuole che questo tipo sia facilmente accessibile a chi scrive il programma e anche agli altri. Inoltre, si vuole separare l’interfaccia (la dichiarazione) dall’implementazione (la definizione delle funzioni membro), così che l’implementazione possa essere cambiata senza essere obbligati a una ricompilazione  dell’intero sistema. Si raggiunge questo fine mettendo la dichiarazione di un nuovo tipo in un file header.

Quando ho imparato a programmare in C, i file header erano per me un mistero, e in molti libri dedicati al C non c’era molto interesse al riguardo; inoltre, il compilatore C non imponeva le dichiarazioni di funzione, cosicché per molto tempo i file header sono stati considerati un’utilità opzionale, tranne nel caso in cui venissero dichiarate delle strutture. In C++, invece, l’uso dei file header diventa chiaro in modo cristallino; sono virtualmente obbligatori per un sviluppo comodo di un programma; lì, è possibile inserire informazioni molto specifiche: le dichiarazioni. Il file header dice al compilatore cosa è disponibile nella libreria. Si può usare la libreria anche se si possiede solo il file header insieme al file oggetto o al file libreria; non si ha bisogno del file .cpp del codice sorgente. Il file header è il luogo dove sono contenute le specifiche di interfaccia.

Sebbene non si sia obbligati dal compilatore, il miglior modo per costruire grandi progetti in C è di usare le librerie: collezioni di funzioni tra loro associate, poste all’interno dello stesso modulo oggetto o libreria, e usare poi un file header per contenere tutte le dichiarazioni per le funzioni. Questo metodo è di rigore in C++. E’ possibile mettere qualsiasi funzione in una libreria C, ma in C++, il tipo di dato astratto determina le funzioni che vengono associate in forza del loro comune accesso ai dati in una struct. Ogni funzione membro deve essere dichiarata nella dichiarazione della struct: non è possibile metterla altrove.

L’uso delle librerie di funzioni era incoraggiato in C, ma è istituzionalizzato in C++.

L’importanza dei file header.

Quando si usa una funzione di una libreria, il C permette di ignorare il file header e di dichiarare semplicemente la funzione a mano. Nel passato, la gente qualche volta lo faceva per accelerare un pelo il compilatore ed evitandogli così il compito di aprire e includere il file (cosa non più redditizia con i compilatori moderni). Come esempio, di seguito riporto una dichiarazione estremamente “lazzarona” per la funzione printf()  del C (da <stdio.h>):

printf(…);   

L’omissione “…” indica una lista variabile di argomenti [34], che dice: printf() ha un qualche argomento, ognuno dei quali ha un tipo, ma si ignora quale. Soltanto prendendo un determinato argomento è possibile vederlo e accettarlo. Usando questo tipo di dichiarazione, vengono sospesi tutti i controlli di errore sugli argomenti della funzione.

Questa pratica può causare problemi oscuri, subdoli. Se si dichiara una funzione a mano, in un file si può fare un errore. Siccome il compilatore vede solo la dichiarazione fatta a mano e fatta solo in quel file, può essere in grado di adattarsi all’errore. Il programma verrà quindi linkato correttamente, ma l’uso della funzione in quel singolo file fallirà. Questo tipo di errore è molto difficile da trovare, ma si può facilmente evitare usando un file header.

Se si mettono tutte le dichiarazioni di funzione in un file header e si include quel file header sia dove viene definita la funzione  che ovunque la si usi, ci si assicura una dichiarazione consistente attraverso l’intero sistema. Inoltre, ci si può assicurare che dichiarazione e definizione combacino, inserendo il file header nel file di definizione.

Se in un file C++ si dichiara una struct, è obbligatorio includere il file header ovunque si usi una struct e dove vengono definiti i membri funzione della struct. Se si prova a chiamare una funzione regolare, o a chiamare o definire una funzione membro senza che essa sia stata prima dichiarata, il compilatore C++ darà un messaggio di errore. Costringendo il programmatore ad un uso corretto dei file header, il linguaggio si assicura la consistenza nelle librerie, inoltre riduce gli errori con l’uso della stessa interfaccia ovunque sia necessaria.

Il file header è un contratto fra l’utente di una libreria e il suo programmatore, che descrive la sua struttura dati, specifica gli argomenti e i valori restituiti dalle funzioni chiamate. Dice: “Questo è ciò che la mia libreria fa”. L’utente necessita di alcune di queste informazioni per sviluppare la propria applicazione, mentre il compilatore ne ha bisogno per generare il codice appropriato. L’utente che usa struct, include semplicemente il file header, crea le istanze di struct e linka il tutto nel modulo oggetto o in libreria (cioè: compila il codice).

Il compilatore fa osservare il contratto richiedendo di dichiarare tutte le strutture e le funzioni prima che siano usate e, nel caso di funzioni membro, prima che esse siano definite. Ciò implica essere costretti a mettere le dichiarazioni nel file header e a includerlo nel file dove le funzioni membro sono definite, e nei file dove sono usate. Siccome un singolo file header che descrive la libreria è incluso lungo tutto il sistema, il compilatore è in grado di assicurare la consistenza e prevenire gli errori.

Ci sono certe questioni di cui si deve essere al corrente al fine di organizzare il codice in modo appropriato e scrivere dei file header efficaci. La prima questione concerne quello che si deve mettere all’interno di un file header. La regola base è: “solo dichiarazioni”, cioè, solo informazioni al compilatore ma niente che allochi memoria attraverso la generazione di codice o la creazione di variabili. Questo perché, tipicamente, in un progetto il file header sarà incluso in diverse unità di traslazione, e se la memoria per un identificatore viene allocata in più di un posto, il linker emetterà un errore di definizione multipla (questa è, per il C++, la “regola dell’uno per le definizioni”: E’ possibile dichiarare delle cose quante volte si vuole, ma ci può essere una sola definizione per ogni cosa dichiarata).

Questa regola non è del tutto rigida: se si definisce una variabile che è “file static” (ha visibilità solo dentro un file) all’interno di un file header, ci saranno istanze multiple di quel dato nei moduli del progetto, ma il linker non riscontrerà collisioni [35]. Fondamentalmente, non si desidera fare nulla nel file header che possa causare una ambiguità nella fase di link.

Il problema della dichiarazione multipla.

La seconda questione legata ai file header è questa: quando si mette una dichiarazione di struct in un file header, è possibile che quel file header sia incluso più di una volta se il programma è complicato. Gli Iostreams rappresentano un buon esempio. Ogni volta che una struct fa un un’operazione di I/O, può includere uno o più degli header di iostream. Se il file cpp su cui si sta lavorando usa più di un tipo di struct ( includendo tipicamente un file header per ognuna di esse), si corre il rischio di includere più di una volta lo header   <iostream> e di ridichiarare gli iostreams.

Il compilatore considera la ridichiarazione di una struttura (si parla sia di structs che di classes) come un errore, poiché se così non fosse permetterebbe l’uso dello stesso nome per tipi differenti. Per prevenire questo errore quando si includono file header multipli, si ha la necessità di costruire un qualcosa di intelligente all’interno dei file header usando il preprocessore (i file header dello Standard C++, come <iostream>, hanno già questo qualcosa di intelligente).

Sia il C che il C++ permettono di ridichiarare una funzione, purché le due dichiarazioni combacino, ma né uno né l’altro permettono la ridichiarazione di una struttura. Questa regola è particolarmente importante in C++, perché se il compilatore permettesse di ridichiarare una struttura e poi le due dichiarazioni differissero, quale userebbe?

Il problema della ridichiarazione emerge un po’ più in C++ che in C, perché in C++ ogni tipo di dato (strutture con funzioni) ha generalmente il suo proprio file header, e si deve includere un header in un altro se si vuole creare un altro tipo di dati che usa il primo.  In ogni file cpp di un progetto, è probabile che vengano inclusi diversi file che includono a loro volta lo stesso file header. Durante una singola compilazione, il compilatore può vedere lo stesso file header diverse volte. A meno che non si faccia qualcosa a riguardo, il compilatore vedrà la ridichiarazione della struttura e registrerà un errore di compilazione. Per risolvere il problema, si ha bisogno di conoscere un po’ di più il preprocessore.

Le direttive del preprocessore: #define, #ifdef, #endif

Si può usare la direttiva di preprocessore #define, per creare dei flag nel momento della compilazione del programma. Si hanno due scelte: si può semplicemente dire al preprocessore che il flag è definito, senza specificarne un valore:

#define FLAG

oppure si può dargli un valore (che è il tipico modo di definire delle costanti in C):

#define PI 3.14159

In entrambi i casi, la label può essere testata dal preprocessore per vedere se è stata definita:

#ifdef FLAG

Questo produrrà come risultato “true”, e il codice che segue la direttiva #ifdef sarà incluso nel pacchetto spedito al compilatore. Questa inclusione cessa quando il preprocessore incontra la direttiva

#endif

o

#endif // FLAG

Qualsiasi cosa che non sia un commento, messo sulla stessa linea dopo la direttiva #endif, è illegale, anche se qualche compilatore può accettarlo. La coppia #ifdef / #endif si può annidare una nell’altra.

Il complemento di #define è #undef, il quale farà un #ifdef usando la stessa variabile e producendo “false” come risultato. La direttiva #undef causerà anche lo stop dell’uso delle macro da parte del preprocessore.

Il complemento di #ifdef è #ifndef, che dà “true” se la label non è stata definita (questo è quello che si userà nei file header).

Nel preprocessore C ci sono altre caratteristiche utili, che si possono trovare nella documentazione allegata al particolare preprocessore.

Uno standard per i file header

Quando un file header contiene una struttura, prima di includerlo in un file cpp, si dovrebbe controllare se è già stato in qualche modo incluso in questo cpp. Questo viene fatto testando un flag del preprocessore. Se il flag non è stato messo in precedenza, significa che il tipo non è già stato dichiarato; si può allora mettere il flag (così che la struttura non possa venire ridichiarata) e dichiarare la struttura, cioè includere il file. Se invece il flag è già stato messo, allora il tipo è già stato dichiarato e non lo si deve più dichiarare. Ecco come dovrebbe presentarsi un file header:  

#ifndef HEADER_FLAG
#define HEADER_FLAG
// La dichiarazione di Tipo deve essere inserita qui... 
#endif // HEADER_FLAG

Come si può vedere, la prima volta che il file header è incluso, il contenuto del file header (includendo le dichiarazioni di tipo) sarà incluso dal processore. Tutte le volte seguenti che sarà incluso – in una singola unità di compilazione – la dichiarazione di tipo sarà ignorata. Il nome HEADER_FLAG può essere qualsiasi nome univoco, ma uno standard attendibile da seguire è quello di scrivere il nome del file header con caratteri maiuscoli e sostituire i punti con underscore (gli underscore iniziali sono riservati per i nomi di sistema). Un esempio:

//: C04:Simple.h
// Un semplice header che previene la re-definizione 
#ifndef SIMPLE_H
#define SIMPLE_H

struct Simple {
  int i,j,k;
  initialize() { i = j = k = 0; }
};
#endif // SIMPLE_H ///:~
 
SIMPLE_H dopo #endif è commentato e quindi ignorato dal preprocessore, ma risulta utile per la documentazione.

Le dichiarazioni di preprocessore che prevengono la inclusioni multiple, sono spesso nominate come “include guards” (guardie degli include).

Namespaces negli header

Si sarà notato che in quasi tutti i file cpp di questo libro, sono presenti using direttive , normalmente nella forma:

using namespace std;
Poiché std è il namespace che circonda l’intera libreria Standard del C++, questa particolare direttiva d’uso permette ai nomi nella libreria Standard del C++ di essere usati senza qualificazione. Comunque, non si vedranno virtualmente mai direttive d’uso in un file header (almeno, non al di fuori dello scope). La ragione è che la direttiva d’uso elimina la protezione di quel particolare namespace, e l’effetto dura fino alla fine dell’unità di compilazione. Se si mette una direttiva d’uso (fuori dallo scope) in un file header, significa che questa perdita di “namespace protection” avverrà per ogni file che include questo header, il che significa, spesso, altri file header. Così, se si parte mettendo direttive d’uso nei file header, risulta molto facile finire per disattivare le direttive d’uso praticamente ovunque, neutralizzando, perciò, gli effetti benefici dei namespace.

In breve: non mettere le direttive using nei file header.

Uso degli header nei progetti

Quando si costruisce un progetto in C++, normalmente lo si crea mettendo assieme un po’ di tipi differenti (strutture dati con funzioni associate). Normalmente, le dichiarazioni per ogni tipo per ciascun gruppo di tipi associati, si mettono in file header separati, e poi si definiscono le funzioni relative in una unità di traslazione. Quando poi si usa quel tipo, si deve includere il file header per eseguire correttamente le dichiarazioni.

In questo libro si seguirà talvolta questo schema, ma il più delle volte gli esempi saranno molto piccoli, e quindi ogni cosa  - dichiarazioni di struttura, definizioni di funzioni, e la funzione main() – possono comparire in un singolo file. Comunque, si tenga a mente che nella pratica si dovranno usare sia file che file header separati.

Strutture annidate

Il vantaggio di estrarre nomi di dati e di funzioni dal namespace globale, si estende alle strutture. E’ possibile annidare una struttura all’interno di un’altra struttura e pertanto tenere assieme gli elementi associati. La sintassi di dichiarazione è quella che ci si aspetterebbe , come è possibile vedere nella struttura seguente, che implementa uno stack push-down come una semplice lista concatenata, così che non esaurisca mai la memoria:

//: C04:Stack.h
// Struttura annidata in una lista concatenata
#ifndef STACK_H
#define STACK_H

struct Stack {
  struct Link {
    void* data;
    Link* next;
    void initialize(void* dat, Link* nxt);
  }* head;
  void initialize();
  void push(void* dat);
  void* peek();
  void* pop();
  void cleanup();
};
#endif // STACK_H ///:~

La struct annidata è chiamata Link e contiene un puntatore al successivo elemento Link della lista, e un puntatore ai dati immagazzinati in Link. Se il puntatore next è zero, significa che si è arrivati alla fine della lista.

Si noti che il puntatore head è definito giusto dopo la dichiarazione della struct Link, invece di avere una definizione separata come Link* head.  Questa è una sintassi che viene dal C, ma qui sottolinea l’importanza del punto e virgola dopo la dichiarazione di struttura. Il punto e virgola indica la fine della lista di definizioni separate da virgola di quel tipo di struttura. (Normalmente la lista è vuota).

La struttura annidata ha la sua propria funzione initialize(), come tutte le strutture presentate fin’ora, per assicurare una corretta inizializzazione. Stack ha sia una funzione initialize () che una funzione cleanup(), come pure una funzione push(), la quale ha come argomento un puntatore ai dati che si vogliono memorizzare (assume che i dati siano allocati nello heap), e pop(), la quale restituisce il puntatore data dalla cima di Stack e rimuove l’elemento in cima. Quando si usa la pop() su un elemento, si è responsabili della distruzione dell’oggetto puntato da data. Inoltre, la funzione peek() restituisce il puntatore data dall’ elemento che è in cima, ma lascia l’elemento nello Stack.

Ecco le definizioni per le funzioni membro:

//: C04:Stack.cpp {O}
// Lista concatenata con annidamento
#include "Stack.h"
#include "../require.h"
using namespacestd;

void 
Stack::Link::initialize(void* dat, Link* nxt) {
  data = dat;
  next = nxt;
}

void Stack::initialize() { head = 0; }

void Stack::push(void* dat) {
  Link* newLink = new Link;
  newLink->initialize(dat, head);
  head = newLink;
}

void* Stack::peek() { 
  require(head != 0, "Stack empty");
  return head->data; 
}

void* Stack::pop() {
  if(head == 0) return 0;
  void* result = head->data;
  Link* oldHead = head;
  head = head->next;
  delete oldHead;
  return result;
}

void Stack::cleanup() {
  require(head == 0, "Stack not empty");
} ///:~

 

La prima definizione è particolarmente interessante perché mostra come definire un membro in una struttura annidata: semplicemente, si usa un livello addizionale di risoluzione di scope, per specificare il nome della struttura racchiusa. Stack::Link::initialize( ) prende gli argomenti e li assegna ai suoi membri.

Stack::initialize() imposta head a zero, così l’oggetto sa che ha una lista vuota.

Stack::push() prende l’argomento, il quale è un puntatore alla variabile di cui si vuole tenere traccia, e lo spinge su Stack. Per prima cosa, viene usato new per allocare memoria per Link, che sarà inserito in cima; poi viene chiamata la funzione initialize() di Link per assegnare valori appropriati ai membri di Link. Si noti che il puntatore next viene assegnato al head corrente; quindi head viene assegnato al nuovo puntatore Link. Questo in realtà mette Link in cima alla lista.

Stack::pop() cattura il puntatore data che in quel momento è in cima a Stack, poi porta giù il puntatore head e elimina quello vecchio in cima allo Stack, e in fine restituisce il puntatore prelevato. Quando pop() rimuove l’ultimo elemento, allora head diventa ancora zero, e ciò significa che lo Stack è vuoto.

Stack::cleanup() in realtà non fa alcuna ripulita, ma stabilisce una linea di condotta risoluta: “il programmatore client che userà l’oggetto Stack, è responsabile per espellere tutti gli elementi di Stack e per la loro eliminazione”. La funzione require(), è usata per indicare se è avvenuto un errore di programmazione con lo Stack non vuoto.

Ma perché il distruttore di Stack non potrebbe occuparsi di distruggere tutti gli oggetti che il cliente programmatore non ha eliminato con pop()? Il problema è che lo Stack tratta puntatori void e, come si vedrà nel cap. 13, chiamando la delete per void*, non si effettua correttamente la pulizia.

Anche il soggetto di “chi è il responsabile della gestione della memoria” non è qualcosa di semplice definizione, come si vedrà negli ultimi capitoli.

Un esempio per testare Stack:

//: C04:StackTest.cpp
//{L} Stack
//{T} StackTest.cpp
// Test di una lista concatenata annidata
#include "Stack.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main(int argc, char* argv[]) {
  requireArgs(argc, 1);// Il nome del file è un argomento
  ifstream in(argv[1]);
  assure(in, argv[1]);
  Stack textlines;
  textlines.initialize();
  string line;
   // Leggiamo il file e immagazziniamo le linee di testo in Stack:
while(getline(in, line))
    textlines.push(new string(line));
 // Facciamo emergere le linee di testo da Stack e poi le stampiamo:
  string* s;
  while((s = (string*)textlines.pop()) != 0) {
    cout << *s << endl;
    delete s; 
  }
  textlines.cleanup();
} ///:~

Questo esempio è simile all’esempio precedente, ma qui spingono linee da un file (come puntatore a string) sullo Stack, per poi espellerle, il che risulta nel fatto che il file viene stampato in ordine inverso. Si noti, che la funzione membro pop() restituisce un void* e questo deve essere castato a string* prima di essere usato. Per stampare la stringa, il puntatore deve essere dereferenziato.

Non appena textlines viene riempito, il contenuto di line è “clonato” per ogni push() con il codice new string(line). Il valore restituito dall’espressione new, è un puntatore alla nuova stringa string che è stata creata e che ha copiato le informazioni da line. Se si era semplicemente passato l’indirizzo di line a push(), si finirà con uno Stack riempito con indirizzi identici, tutti che puntano a line. Nel prossimo libro, si imparerà un po’ di più riguardo questo processo di “clonazione”.

Il nome del file è preso dalla riga di comando. Per garantire che vi siano sufficienti argomenti sulla riga di comando, si veda una seconda funzione, usata dal file header requie.h: requireArgs(), la quale confronta argc con il numero di argomenti desiderati, e stampa un appropriato messaggio di errore ed esce dal programma se non vi sono argomenti sufficienti.

Risoluzione dello scope globale

L’operatore di risoluzione dello scope interviene nelle situazioni in cui il nome che il compilatore sceglie per default (il nome più “prossimo”), non è quello che si vuole. Per esempio, supponiamo di avere una struttura con un identificatore locale a, e che si voglia selezionare un identificatore globale a dall’interno di una funzione membro. Il compilatore sceglierebbe per default quello locale, così siamo costretti a dirgli di fare altrimenti. Quando si vuole specificare un nome globale usando la risoluzione di scope, si usa l’operatore con niente di fronte ad esso. L’esempio seguente illustra la risoluzione di scope globale sia per una variabile, che per una funzione:

//: C04:Scoperes.cpp
// Risoluzione di scope globale
int a;
void f() {}

struct S {
  int a;
  void f();
};

void S::f() {
  ::f();  // Altrimenti sarebbe  ricorsiva  
  ::a++; // Selezioniamo la ‘a’ globale
  a--;     // La ‘a’ nello scope della struttura 
}
int main() { S s; f(); }
 ///:~

Senza risoluzione di scope in S::f(), il compilatore selezionerebbe per default la versione membro di f() e a.

Sommario

In questo capitolo abbiamo trattato della svolta fondamentale del C++, e cioè: è possibile inserire funzioni nelle strutture. Questo nuovo tipo di struttura è chiamato tipo di dato astratto, e le variabili che si creano usando questa struttura, sono chiamate oggetti, o istanze, di quel tipo. Il chiamare una funzione membro di un oggetto, viene detto spedire un messaggio a quell’oggetto. L’azione primaria nella programmazione orientata agli oggetti, è lo spedire massaggi agli oggetti.

Sebbene l’impacchettare insieme dati e funzioni sia un significativo vantaggio per l’organizzazione del codice e renda le librerie più facili da usare (previene il conflitto sui nomi nascondendo il nome), c’è ancora parecchio che si può fare per ottenere una programmazione C++ più sicura. Nel prossimo capitolo si imparerà come proteggere alcuni membri di una struttura così che solo l’autore possa manipolarli. Questo stabilisce un chiaro confine tra cosa l’utente della struttura può modificare e cosa solo il programmatore può variare.

Esercizi

Si possono trovare le soluzioni degli esercizi 2 - 6, 9-10, 15, 17 - 20, 25 nel documento elettronico “The Thinking in C++ Annotated Solution Guide”, disponibile al sito http://www.BruceEckel.com.

  1. Nella libreria Standard del C, la funzione puts() stampa una stringa di caratteri sullo schermo  in modo tale da poter dire  puts(“ciao”). Scrivete un programma C che usi puts(), non usando però l’istruzione #include <stdio.h>, o altrimenti, dichiarando la funzione.  Compilate il programma con il vostro compilatore C. (Alcuni compilatori C++ non sono distinti dal loro compilatore C; in questo caso, potreste aver bisogno di identificare i flag necessari a una riga di comando per utilizzare la sola compilazione C.) Quindi, compilate il programma con il compilatore C++ e osservate le differenze. 
  2. Create una struct che abbia una sola funzione membro, poi scrivete la definizione per quella funzione membro. Create un oggetto appartenente al vostro nuovo tipo di dati e quindi chiamate la funzione membro.
  3. Create lo stesso programma dell’esercizio 2, ma facendo in modo che struct sia dichiarata in un suo proprio file header, sia definita in un file cpp diverso dal file in cui c’è la funzione main().
  4. Create una struct con un singolo dato membro int di tipo intero, e due funzioni globali, ciascuna delle quali ha come argomento un puntatore a quella struttura. La prima funzione ha un secondo argomento di tipo intero e imposta il dato membro int di struct al valore dell’argomento intero, la seconda mostra int leggendolo da struct. Testate le funzioni.
  5. Ripetete l’esercizio 4 in modo che le funzioni siano ora funzioni membro di struct, e testatele nuovamente.
  6. Create una classe che (in modo ridondante) esegua la selezione di un dato membro e una chiamata ad una funzione membro usando la parola chiave this ( la quale fa riferimento all’indirizzo dell’oggetto corrente).
  7. Costruite un oggetto di tipo Stash che tratti dati di tipo double. Riempite Stash con 25 valori di tipo double, e quindi stampateli a video.
  8. Ripetete l’esercizio 7 usando l’oggetto Stack.
  9. Create un file contenente una funzione f() che abbia un argomento i di tipo intero, e che lo stampi a video usando la funzione printf() di <stdio.h> con l’istruzione: printf(“%d/n”, i), dove i è l’intero che si vuole stampare.Create un file separato che contenga main(), e in questo file dichiarate f() con un argomento di tipo float. Chiamate f() all’interno di main(). Provate a compilare e a linkare il vostro programma con un compilatore C++ e vedete cosa accade. Ora compilate e linkate il programma usando il compilatore C, e vedete che accade quando fate girare il programma. Spiegate il comportamento.
  10. Trovate come produrre linguaggio assembly dai vostri compilatori C e C++. Scrivete una funzione in C e una struct con una singola funzione membro in C++. Fate produrre linguaggio assembly da entrambe e trovate i nomi delle funzioni che vengono prodotti dalla funzione C e dal membro funzione C++, così che possiate vedere quale tipo di suffisso (name decoration) viene applicato all’interno del compilatore.
  11. Scrivete un programma a compilazione condizionata in main() in modo tale che venga stampato un messaggio quando si usa una direttiva di preprocessore. Compilate questo codice provando con una #define all’interno del programma, e poi cercate di scoprire il modo in cui il compilatore prende le direttive di preprocessore dalla line di comando e fate un esperimento con quella.
  12. Scrivete un programma che usi assert() con un argomento che è sempre ‘false’ (zero) per vedere cosa accade quando lo fate girare. Poi compilatelo con l’istruzione #define NDEBUG, e fatelo girare ancora per vedere la differenza.
  13. Create un tipo di dato astratto che rappresenti una videocassetta di un negozio di videonoleggio. Provate a considerare tutti i dati e le operazioni che servono per una buona descrizione del tipo Video nel sistema di gestione del videonoleggio. Includete una funzione membro print() che mostri informazioni relative a Video.
  14. Create un oggetto Stack che tratti gli oggetti Video dell’esercizio 13. Create poi diversi oggetti Video, memorizzateli nello Stack, e quindi mostrateli usando Video::print().
  15. Scrivete un programma che stampi tutte le dimensioni dei tipi fondamentali di dati sul vostro computer usando sizeof.
  16. Modificate Stash in modo da poter usare un vector<char> come sua struttura dati di base.
  17. Usando new, create dinamicamente allocazioni di memoria dei tipi seguenti: int, long, un array di 100 char, un array di 100 float. Stampatene gli indirizzi e poi liberate la memoria usando delete.
  18. Scrivete una funzione che abbia un char* come argomento. Usando new, allocate dinamicamente un array di char con le stesse dimensioni dell’array di char che viene passato alla funzione. Usando l’indicizzazione di array, copiate i caratteri dall’argomento all’array allocato dinamicamente (non dimenticate il carattere “null” di terminazione) e restituite il puntatore alla copia. Nella vostra funzione main(), testate la funzione passandole un array statico di caratteri racchiusi tra apicetti; quindi, prendetene il risultato e passatelo di nuovo alla funzione. Stampate entrambe le stringhe ed entrambi i puntatori, in modo che possiate vedere che sono zone differenti di memoria. Usando delete, pulite tutta la memoria dinamica allocata.
  19. Mostrate un esempio di una struttura dichiarata all’interno di un’altra struttura (una struttura annidata). Dichiarate i dati membro in entrambe le strutture, e dichiarate e definite le funzioni membro in entrambe le strutture. Scrivete una funzione main() per testare il vostro nuovo tipo di dato.
  20. Quanto è grande una struttura? Scrivete del codice che stampi le dimensioni di varie strutture. Create strutture che abbiano solo dati membro, e una che abbia dati membro e funzioni membro. Poi , create una struttura che non abbia alcun membro. Stampate le dimensioni di tutte queste strutture. Infine, spiegate le ragioni del risultato relativo alla struttura senza membri.
  21. Come avete visto in questo capitolo, per una struttura, il C++ crea automaticamente l’equivalente di typedef. Il C++ fa la stessa cosa anche per le enumerazioni e per le union. Scrivete un piccolo programma per dimostrarlo.
  22. Create una Stack che tratti oggetti tipo Stash. Ogni Stash otterrà cinque linee da un file di input. Create gli Stash usando new.  Leggete un file dentro il vostro Stack, quindi stampatelo nel suo formato originale estraendolo da Stack.
  23. Modificate l’esercizio 22 creando una struttura struct che incapsuli una Stack di Stash. L’utente dovrebbe aggiungere e ricavare le linee solamente attraverso le funzioni membro, ma sotto l’invisibilità offerta dalla copertura della struttura, la struct usa uno Stack di Stash.
  24. Create una struttura struct che abbia un intero int e un puntatore ad un’altra istanza della stessa struct. Scrivete una funzione che abbia come argomenti l’indirizzo di una di queste strutture e un intero che indichi la lunghezza della lista che volete creare. Questa funzione produrrà una catena completa (intera) di queste struct (una lista concatenata) partendo dall’argomento (la testa della lista), e con ognuna di essa che punta alla successiva. Costruite le nuove struct usando new, e mettete il conteggio (cioè il numero di oggetti che c’è) nel dato int. Nell’ultima struct della lista, assegnate il valore zero al puntatore, per indicare che è terminata la lista. Scrivete una seconda funzione che prenda la testa della vostra lista e che la sposti alla fine, stampando entrambi i valori del puntatore e il valore di int per ognuno di essi.
  25. Ripetete l’esercizio 24, ma mettete le funzioni all’interno di una struct, invece di usare strutture e funzioni nude”.

[33] Questo termine può essere fonte di discussione: alcuni lo usano nel senso definito in questo libro, altri per descrivere il controllo di accesso, che sarà discusso nel capitolo seguente.

[34] Per scrivere una definizione di funzione per una funzione che prende una vera lista di argomenti di variabili, si deve usare varargs, sebbene queste ultime dovrebbero essere vietate in C++. I dettagli sull’uso di varargs si trovano nei manuali di C.

[35] Comunque, nel C++ Standard, l’uso di file static è deprecabile.

 

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]


Ultima Modifica: 13/01/2003