[ 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 Antonio Cuni

7: Overloading di funzioni e argomenti di default

Una delle più importanti caratteristiche in ogni linguaggio di programmazione è l'uso pratico dei nomi

Quando si crea un oggetto (una variabile), si assegna un nome ad una regione di memoria. Una funzione è il nome di un'azione. Nella ricerca di nomi per descrivere il sistema in uso si crea un programma che è più facile da capire e da cambiare. È un po' come scrivere in prosa - lo scopo è di comunicare con i lettori. Quando però si cerca di rappresentare il concetto di sfumatura del linguaggio umano in un linguaggio di programmazione si solleva un problema. Spesso la stessa parola esprime molteplici significati, dipendenti dal contesto. Quando una singola parola ha più di un significato possiamo parlare di overloading di tale parola. Questo è molto utile, specialmente quando le differenze sono molto piccole. Diciamo "lava la maglia, lava l'auto". Sarebbe sciocco dover dire forzatamente "lava_maglia la maglia e lava_auto l'auto", visto che l'ascoltatore non deve fare alcuna distinzione circa l'azione svolta. I linguaggi umani hanno una ridondanza intrinseca, quindi anche se si dimenticano alcune parole si può ancora capire il significato. Noi non abbiamo bisogno di identificatori univoci - possiamo dedurre il significato dal contesto.

La maggior parte dei linguaggi di programmazione, tuttavia, obbliga ad avere un identificatore unico per ogni funzione. Se si hanno tre tipi di dati differenti da stampare, int, char, e float , normalmente bisogna creare tre funzioni con nomi differenti, ad esempio print_int(), print_char() e print_float() . Questo comporta uno sforzo maggiore per chi deve scrivere il programma e per chi tenta di capirlo.

In C++ c’è un altro fattore che forza ad adottare l’overloading dei nomi delle funzioni: i costruttori. Poiché il nome del costruttore è predeterminato dal nome della classe, potrebbe sembrare che si possa essere un solo costruttore. Ma cosa fare se si vuole creare un oggetto in più di un modo? Ad esempio, immaginiamo di scrivere una classe che può essere inizializzata in modo standard e anche leggendo le informazioni da un file. Abbiamo bisogno di due costruttori, uno senza argomenti (costruttore di default) e uno che accetta un argomento di tipo string , nel quale è memorizzato il nome del file con il quale inizializzare l’oggetto. Entrambi sono costruttori, quindi devono avere lo stesso nome, cioè quello della classe. Perciò l’overloading delle funzioni è essenziale per permettere di usare lo stesso nome di funzione – il costruttore in questo case – di essere usato con parametri di tipo differente.

Sebbene l’overloading delle funzioni sia una necessità per i costruttori esso è anche di utilità generale e può essere usato con qualunque funzione, non solo membri di classi. Oltre a ciò, questo significa che se si hanno due librerie che contengono funzioni con lo stesso nome esse non entreranno in conflitto se hanno insiemi di parametri diversi. Tratteremo tutti questi fattori in dettaglio nel proseguo del capitolo.

L’argomento di questo capitolo è l’uso pratico dei nomi di funzione. L’overloading delle funzioni permette di usare lo stesso nome per funzioni differenti, ma c’è un altro modo di rendere più conveniente la chiamata ad una funzione. Cosa dire se si vuole chiamare la stessa funzione in modi diversi? Quando le funzioni hanno lunghe liste di parametri può diventare noioso (e difficile da leggere) scrivere le chiamate alla funzione quando la maggior parte degli argomenti ha lo stesso valore per tutte le chiamate. Una caratteristica molto usata del C++ è quella degli argomenti di default . Un argomento di default è quello che il compilatore inserisce automaticamente se non è specificato nella chiamata alla funzione. Quindi le chiamate f("salve"), f("ciao", 1) e f("buongiorno", 2, ‘c’) possono tutte riferirsi alla stessa funzione. Potrebbero anche essere tre funzioni che hanno subito l’overloading, ma quando le liste di parametri sono simili si preferisce di solito un tale comportamento che chiama una singola funzione.

In realtà l’overloading delle funzioni e gli argomenti di default non sono molto complicati. Entro la fine del capitolo si vedrà quando usarli e i meccanismi sottostanti che li implementano durante la compilazione ed il linkaggio.

Ancora sul name mangling

Nel capitolo 4 è stato introdotto il concetto di name mangling . Nel codice

void f();
class X { void f(); };

la funzione f() nel campo di visibilità di class X non collide con la versione globale di f . Il compilatore riesce a fare questo usando internamente nomi diversi per la versione globale di f() e X::f() . Nel capitolo 4 era stato suggerito che i nomi interni sono semplicementi realizzati a partire dal nome della funzione e "decorati" con il nome della classe; quindi i nomi usati internamente dal compilatore potrebbero essere _f e _X_f . Tuttavia è evidente che la "decorazione" del nome della funzione non coinvolge solamente il nome della classe.

Ecco il perché. Si supponga di voler effettuare l'overloading di due funzioni

void print(char);
void print(float);

Non importa se sono entrambe nello scope di una classe o in quello globale. Il compilatore non può generare due identificatori interni unici usando solo il nome dello scope delle funzioni. Dovrebbe utilizzare _print in entrambi i casi. L'idea alla base dell'overloading è di usare lo stesso nome per funzioni con parametri diversi. Quindi per supportare l'overloading il compilatore deve decorare il nome della funzione con i nomi dei tipi degli argomenti. Le funzioni suddette, definite nello scope globale, potrebbero avere nomi interni del tipo _print_char e _print_float . È importante notare che non è definito alcuno standard per il modo in cui il compilatore deve generare i nomi interni, quindi si vedranno risultati molto differenti a seconda dei compilator. (È possibile vedere come vengono generati chiedendo al compilatore di generare codice assembly come output). Questo, naturalmente, crea problemi se si vuole comprare librerie compilate per compilatori e linker specifici - ma anche se il name mangling fosse standardizzato ci sarebbero altri ostacoli da superare, a causa dei diversi modi in cui i compilatori generano il codice.

Questo è in definitiva tutto quello che bisogna sapere per poter usare l'overloading: si può usare lo stesso nome per funzioni diverse, basta che esse abbiano le liste di parametri differenti. Il compilatore genera i nomi interni delle funzioni, usati da sè stesso e dal linker, a partire dal nome della funzione, dall'insieme dei parametri e dallo scope.

Overloading dei valori di ritorno

È normale meravigliarsi: "Perché solo lo scope e la lista di parametri? perché non i valori di ritorno?". All'inizio potrebbe sembrare sensato usare anche i valori di ritorno per generare il nome interno. In questo modo si potrebbe fare un cosa del genere:

void f();
int f();

Questo codice sarebbe eseguito correttamente quando il compilatore potrebbe determinare automaticamente il significato dal contesto, come ad esempio

int x = f();

Come può il compilatore determinare qual'è il significato della chiamata in questo caso? Forse anche maggiore è la difficoltà che il lettore incontra nel capire quale funzione è chiamata. L'overloading dei soli tipi di ritorno è troppo debole, quindi il C++ effetti collaterali (side effects) non la permette.

Linkage type-safe

C'è anche un altro vantaggio portato dal name mangling. Il C si presenta un problema particolarmente fastidioso quando il programmatore client sbaglia nel dichiarare una funzione o, peggio, una funzione è chiamata senza averla dichiarata e il compilatore deduce la sua dichiarazione dal modo in cui è chiamata. Alcune volte la dichiarazione è corretta, ma quando non lo è può essere un bug difficile da scovare.

Siccome in C++ tutte le funzioni devono essere dichiarate prima di essere usate, le possibilità che si presenti questo problema sono diminuite sensibilmente. Il compilatore C++ rifiuta di dichiarare una funzione automaticamente, quindi è conveniente includere l'header appropriato. Con tutto ciò, se per qualche ragione si riuscisse a sbagliare la dichiarazione di una funzione, sia perché dichiarata a mano sia perché è stato incluso l'header errato (ad esempio una versione superata), il name mangling fornisce un'ancora di salvataggio che è spesso chiamata linkage type-safe .

Si consideri la seguente situazione. In un file è definita una funzione:

//: C07:Def.cpp {O}
// Definizione di funzione
void f(int) { }
///:~

Nel secondo file la funzione è dichiarata in maniera errata e in seguito chiamata:

//: C07:Use.cpp
//{L} Def
// Dichiarazione errata di funzione
void f(char);

int main() {
//!  f(1); // Causa un errore di link
} ///:~

Anche se è possibile vedere che la funzione è in realtà f(int) il compilatore non lo può sapere poiché gli è stato detto - attraverso una dichiarazione esplicita - che la funzione è f(char) .In questo modo la compilazione avviene correttamente. In C anche la fase di link avrebbe successo, ma non in C++. poiché il compilatore effettua il name mangling dei nomi, la definizione diventa qualcosa come f_int , mentre in realtà è usata f_char . Quando il linker tenta di risolvere la chiamata a f_char , può solo trovare f_int e restituisce un messagio di errore. Questo è il linkage type-safe. benché il problema non intervenga troppo spesso, quando succede può essere incredibilmente difficoltoso da trovare, specialmente nei grossi progetti. Questo è uno dei casi nei quali potete facilmente trovare un errore difficoltoso in un programma C semplicemente compilandolo con un compilatore C++.

Esempi di overloading

Possiamo ora modificare gli esempi precedenti per usare l'overloading delle funzioni. Come stabilito prima, una prima applicazione molto utile dell'overloading è nei costruttori. Si può vederlo nella seguente versione della classe Stash :

//: C07:Stash3.h
// Overloading di funzioni
#ifndef STASH3_H
#define STASH3_H

class Stash {
  int size;      // Dimensione di ogni spazio
  int quantity;  // Numero degli spazi di memorizzazione
  int next;      // Prossimo spazio vuoto
  // Array di byte allocato dinamicamente
  unsigned char* storage;
  void inflate(int increase);
public:
  Stash(int size); // Quantità iniziale zero 
  Stash(int size, int initQuantity);
  ~Stash();
  int add(void* element);
  void* fetch(int index);
  int count();
};
#endif // STASH3_H ///:~

Il primo costruttore Stash() è lo stesso di prima, ma il secondo ha un argomento quantity che indica il numero iniziale di spazi di memorizzazione da allocare. Nella definizione, è possibile vedere che il valore interno di quantity è posto uguale a zero, insieme al puntatore storage . Nel secondo costruttore, la chiamata a inflate(initQuantity) aumenta quantity fino alla dimensione allocata:

//: C07:Stash3.cpp {O}
// Overloading di funzioni
#include "Stash3.h"
#include "../require.h"
#include #include using namespace std;
const int increment = 100;

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

Stash::Stash(int sz, int initQuantity) {
  size = sz;
  quantity = 0;
  next = 0;
  storage = 0;
  inflate(initQuantity);
}

Stash::~Stash() {
  if(storage != 0) {
    cout << "deallocazione di storage" << endl;
    delete []storage;
  }
}

int Stash::add(void* element) {
  if(next >= quantity) // Abbastanza spazio a disposizione?
    inflate(increment);
  // Copia un elemento in storage,
  // iniziando dal prossimo 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); // Indice
}

void* Stash::fetch(int index) {
  require(0 <= index, "Stash::fetch (-)index");
  if(index >= next)
    return 0; // Indica la fine
  // Trova il 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);
  if(increase == 0) return;
  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]; // Copy old to new
  delete [](storage); // Release old storage
  storage = b; // Point to new memory
  quantity = newQuantity; // Adjust the size
} ///:~

Quando viene usato il primo costruttore nessuna memoria è allocata per storage . L'allocazione avviene la prima volta che si tenta di aggiungere con add() un oggetto e il blocco corrente di memoria è troppo piccolo.

Entrambi i costruttori vengono testati nel seguente programma:

//: C07:Stash3Test.cpp
//{L} Stash3
// Overloading di funzioni
#include "Stash3.h"
#include "../require.h"
#include #include #include using namespace std;

int main() {
  Stash intStash(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;
  const int bufsize = 80;
  Stash stringStash(sizeof(char) * bufsize, 100);
  ifstream in("Stash3Test.cpp");
  assure(in, "Stash3Test.cpp");
  string line;
  while(getline(in, line))
    stringStash.add((char*)line.c_str());
  int k = 0;
  char* cp;
  while((cp = (char*)stringStash.fetch(k++))!=0)
    cout << "stringStash.fetch(" << k << ") = "
         << cp << endl;
} ///:~

La chiamata al costruttore per stringStash usa un secondo argomento; presumibilmente le caratteristiche dello specifico problema che si sta affrontando saranno note, permettendo di scegliere una dimensioni inziziale per Stash .

Unioni

Come abbiamo visto, la sola differenza tra struct e class in C++ è che i membri di una struct sono di default public e quelli di una class di default private . Una struct può anche avere costruttori e distruttori, come ci si potrebbe aspettare. Ma anche una union può avere costruttori, distruttori, funzioni membro e anche controllo di accesso. Si possono vedere ancora una volta gli usi e i vantaggi dell'overloading nel seguente esempio:

//: C07:UnionClass.cpp
// Unioni con costruttori e funzioni membro
#includeusing namespace std;

union U {
private: // Anche il controllo di accesso!
  int i;
  float f;
public:  
  U(int a);
  U(float b);
  ~U();
  int read_int();
  float read_float();
};

U::U(int a) { i = a; }

U::U(float b) { f = b;}

U::~U() { cout << "U::~U()\n"; }

int U::read_int() { return i; }

float U::read_float() { return f; }

int main() {
  U X(12), Y(1.9F);
  cout << X.read_int() << endl;
  cout << Y.read_float() << endl;
} ///:~

Si potrebbe pensare che nel codice sopra la sola differenza tra una union e una class sia il modo in cui le variabili sono memorizzate (infatti le variabili int e float sono sovrapposte nella stessa zona di memoria). Tuttavia, una union non può essere usata come classe base di una classe derivata, e ciò è a dir poco limitante dal punto di vista dell'object oriented design (l'ereditarietà sarò trattata nel capitolo 14). Nonostante le funzioni membro rendano più ordinato l'accesso alla union , non c'è alcun modo di prevenire il programmatore client dal selezionare l'elemento sbagliato dopo che la union è stata inizializzata. Nell'esempio sopra, si potrebbe chiamare X.read_float() , anche se è inappropriato. Tuttavia una union "sicura" può esser incapsulata in una classe. Nel seguente esempio, si noti come la enum chiarifichi il codice e come l'overloading sia utile con i costruttori:

//: C07:SuperVar.cpp
// Una super-variable
#include using namespace std;

class SuperVar {
  enum {
    character,
    integer,
    floating_point
  } vartype;  // Definisce una variabile
  union {  // Unione anonima
    char c;
    int i;
    float f;
  };
public:
  SuperVar(char ch);
  SuperVar(int ii);
  SuperVar(float ff);
  void print();
};

SuperVar::SuperVar(char ch) {
  vartype = character;
  c = ch;
}

SuperVar::SuperVar(int ii) {
  vartype = integer;
  i = ii;
}

SuperVar::SuperVar(float ff) {
  vartype = floating_point;
  f = ff;
}

void SuperVar::print() {
  switch (vartype) {
    case character:
      cout << "character: " << c << endl;
      break;
    case integer:
      cout << "integer: " << i << endl;
      break;
    case floating_point:
      cout << "float: " << f << endl;
      break;
  }
}

int main() {
  SuperVar A('c'), B(12), C(1.44F);
  A.print();
  B.print();
  C.print();
} ///:~

Nel codice sopra, la enum non ha nessun nome di tipo (enumerazione "senza etichetta"). Questo è accettabile se si sta per definire immediatamente una istanza della enum , come in questo esempio. Non c'è nessun bisogno di riferirsi al nome del tipo della enum nel futuro, quindi esso è opzionale.

La union non ha nè nome di tipo nè nome di variabile. Essa è chiamata unione anonima , e alloca la memoria per la union senza richiedere l'accesso ai suoi elementi attravarso il nome di una variabile e l'operatore punto. Per esempio, se la una union anonima è:

//: C07:AnonymousUnion.cpp
int main() {
  union { 
    int i; 
    float f; 
  };
  // Accede ai membri senza usare qualificatori:
  i = 12;
  f = 1.22;
} ///:~

Notare che si accede ai membri di un unione anonima come se fossero variabili ordinarie. La sola differenza è che entrambe le variabili occupano la stessa regione di memoria. Se un'unione anonima ha "file scope" (fuori da tutte le funzioni e classi) essa deve essere dichiarata static , in moto da avere linkage interno.

Nonostante SuperVar è ora sicura, la sua utilità è dubbia, in quanto la ragione per usare una union nel primo esempio è per risparmiare memoria e l'aggiunta di vartype occupa alcuni bit relativi ai dati nella union , quindi il risparmio è in effetti eliminato. Ci sono un paio di alternative per rendere questo schema funzionante. Se vartpye controllasse più di una istanza dell'unione - se fossero tutte dello stesso tipo - allora si avrebbe bisogno solo di una per tutto il gruppo e ciò non occuperebbe più memoria. un approccio più utile è di porre delle #ifdef attorno al codice che usa vartype , che potrebbero garantire un corretto uso durante la fase di sviluppo e testing. Per la versione definitiva l'overhead in termini di tempo e memoria potrebbe essere eliminato.

Argomenti di default

Si esaminino i due costruttori di Stash() in Stash3.h . Non sembrano molto differenti, vero? Infatti, il primo costruttore sembra essere un caso particolare del secondo con size iniziale uguale a zero. Creare e mantenere due differenti versioni di una funione simile è uno spreco di energie.

Per rimediare a questo il C++ offre gli argomenti di default . Un argomento di default è un valore posto nella dichiarazione di una funzione che il compilatore inserisce automaticamente se non si specifica un'altro valore nella chiamata. Nell'esempio Stash , possiamo sostituire le due funzioni:

Stash(int size); // Quantità iniziale zero
Stash(int size, int initQuantity);

con la singola funzione:

Stash(int size, int initQuantity = 0);

La definizione di Stash(int) è semplicemente rimossa - solo la definizione dell'unica Stash(int,int) è necessaria.

A questo punto le due definizioni di oggetti

Stash A(100), B(100, 0);

produrranno esattamente lo stesso risultato. In entrambi i casi sarà chiamato lo stesso costruttore, ma per A il secondo argomento è inserito automaticamente dal compilatore quando vede che il primo argomento è un int e non c'è alcun secondo argomento. Il compilatore ha visto l'argomento di default, quindi sa di poter ancora effettuare la chiamata se inserisce il secondo argomento, e questo è ciò che gli è stato detto di fare rendendolo un argomento di default.

Sia gli argomenti di default che l'overloading di funzioni sono convenienti. Entrambe queste caratteristiche permettono di usare un singolo nome di funzione in situazioni differenti. La differenza è che usando gli argomenti di default il compilatore li sostituisce automaticamente quando non si vuole inserirli a mano. L'esempio precedente è una buona dimostrazione dell'uso degli argomenti di default invece dell'overloading; altrimenti ci si sarebbe ritrovati con due o più funzioni con dichiarazioni e comportamenti simili. Se le funzioni hanno comporamenti molto differenti, solitamente non ha senso usare gli argomenti di default (in questo caso, ci si dovrebbe chiedere se due funzioni molto diverse debbano avere lo stesso nome).

Quando si usano gli argomenti di default bisogna conoscere due regole. La prima è che solo gli argomenti finali possono essere resi di default. Qundi non si può porre un argomento di default seguito da uno non di default. La seconda è che, una volta che si è usato un argomento di default in una particolare chiamata a funzione, tutti gli argomenti seguenti devono essere lasciati di default (questo regola deriva dalla prima).

Gli argomenti di default sono posti solo nella dichiarazione di una funzione (solitamente in un header). Il compilatore deve infatti sapere quale valore usare. A volte i programmatori inseriscono i valori di default, in forma di commenti, anche nella definizione della funzione, a scopo documentativo

void fn(int x /* = 0 */) { // ...

Argomenti segnaposto

Gli argomenti di una funzione possono essere dichiarati anche senza identificatori. Quando sono usati con gli argomenti di default questo può apparire un po' strambo. Si può scrivere

void f(int x, int = 0, float = 1.1);

In C++ non è obbligatorio specificare gli identificatori nella definizione:

void f(int x, int, float flt) { /* ... */ }

Nel corpo della funzione possono essere usati x e flt ma non il secondo argomento, visto che non ha nome. Le chiamate alla funzione devono tuttavia specificare un valore per il segnaposto: f(1) o f(1,2,3.0) . Questa sintassi permette di inserire l'argomento come segnaposto senza usarlo. L'idea è che si potrebbe in seguito voler cambiare la definione della funzione in modo da usare il segnaposto, senza il bisogno di cambiare tutto il codice in cui si richiama la funzione. Naturalmente si può raggiungere lo stesso risultato assegnando un nome agli argomenti non usati, ma, definendo un argomento per il corpo di una funzione senza poi usarlo, la maggior parte dei compilatori genererà un warning, credendo che ci sia un errore di concetto. Lasciando consapevolmente l'argomento senza nome si impedisce che venga generato il warning.

Ancora più importante, se si scrive una funzione che usa un argomento e in seguito si decide che non serve, si può realmente rimuoverlo senza generare warning e senza disturbare alcun codice client che chiama la versione precedente.

Scegliere tra l'overloading e gli argomenti di default

Sia l'overloading gli argomenti di defualt forniscono degli strumenti vantaggiosi per le chiamate a funzione. Tuttavia ci possono essere dei casi in cui non sapere scegliere quale tecnica usare. Ad esempio, si consideri il seguente strumento ideato per gestire i blocchi di memoria automaticamente:

//: C07:Mem.h
#ifndef MEM_H
#define MEM_H
typedef unsigned char byte;

class Mem {
  byte* mem;
  int size;
  void ensureMinSize(int minSize);
public:
  Mem();
  Mem(int sz);
  ~Mem();
  int msize();
  byte* pointer();
  byte* pointer(int minSize);
}; 
#endif // MEM_H ///:~

Un oggetto Mem possiede un blocco di byte e assicura di avere abbastanza memoria. Il costruttore di default non alloca alcuna memoria e il secondo costruttore assicura che siano disponibili sz byte di memoria nell'oggetto Mem . Il distruttore dealloca la memoria, msize() restituisce il numero di byte allocati correntemente in Mem e pointer() restituisce un puntatore all'indirizzo iniziale del blocco di memoria ( Mem è uno strumento abbastanza a basso livello). C'è anche un overloading di pointer() con il quale il programmatore client può impostare la dimensione minima minSize del blocco di byte che vuole, e la funzione membro assicura questo.

Sia il costruttore che la funzione membro pointer() usano la funzione membro private ensureMinSize() per incrementare la dimensione del blocco di memoria (notare che non è sicuro memoriazzare il risultato di pointer() se la memoria è ridimensionata).

Ecco l'implementazione della classe

//: C07:Mem.cpp {O}
#include "Mem.h"
#include using namespace std;

Mem::Mem() { mem = 0; size = 0; }

Mem::Mem(int sz) {
  mem = 0;
  size = 0;
  ensureMinSize(sz); 
}

Mem::~Mem() { delete []mem; }

int Mem::msize() { return size; }

void Mem::ensureMinSize(int minSize) {
  if(size < minSize) {
    byte* newmem = new byte[minSize];
    memset(newmem + size, 0, minSize - size);
    memcpy(newmem, mem, size);
    delete []mem;
    mem = newmem;
    size = minSize;
  }
}

byte* Mem::pointer() { return mem; }

byte* Mem::pointer(int minSize) {
  ensureMinSize(minSize);
  return mem; 
} ///:~

Si può vedere come ensureMinSize() è la sola funzione addetta ad allocare memoria e che essa è usata dal secondo costruttore e dal secondo overloading di pointer() . All'interno di ensureMinSize() non è necessario fare nulla se la dimensione size è grande abbastanza. Se è necessario allocare nuova memoria per rendere il blocco più grande (che è anche il caso in cui la dimensione del blocco è zero dopo aver richiamato il costruttore di default), la nuova porzione "extra" è posta a zero usando la funzione della libreria Standard C memset() , che è stata introdotta nel capitolo 5. Dopo di questo è chiamata la funzione della libreria Standard C memcpy() , che in questo caso copia i dati esistenti da mem a newmem (solitamente in modo molto efficiente). Infine la vecchia memoria è deallocata, mentre la nuova memoria e le dimensioni vengono assegnata ai membri appropriati.

La classe Mem è ideata per essere usata come strumento all'interno di altre classi per semplificare la gestione della memoria (potrebbe anche essere usata per nascondere un più sofisticato sistema di gestione della memoria fornito, ad esempio, dal sistema operativo). Essa è testata in modo appriopriato in questo esempio creando una semplice classe "string":

//: C07:MemTest.cpp
// Test della classe Mem
//{L} Mem
#include "Mem.h"
#include #include using namespace std;

class MyString {
  Mem* buf;
public:
  MyString();
  MyString(char* str);
  ~MyString();
  void concat(char* str);
  void print(ostream& os);
};

MyString::MyString() {  buf = 0; }

MyString::MyString(char* str) {
  buf = new Mem(strlen(str) + 1);
  strcpy((char*)buf->pointer(), str);
}

void MyString::concat(char* str) {
  if(!buf) buf = new Mem;
  strcat((char*)buf->pointer(
    buf->msize() + strlen(str) + 1), str);
}

void MyString::print(ostream& os) {
  if(!buf) return;
  os << buf->pointer() << endl;
}

MyString::~MyString() { delete buf; }

int main() {
  MyString s("My test string");
  s.print(cout);
  s.concat(" some additional stuff");
  s.print(cout);
  MyString s2;
  s2.concat("Using default constructor");
  s2.print(cout);
} ///:~

Tutto quello che si può fare con questa classe è di creare una MyString , concatenare il testo e stamparlo su un ostream . La classe contiene solo un puntatore a un oggetto Mem , ma si noti la distinzione tra il costruttore di default, che imposta il puntatore a zero, e il secondo costruttore, che crea un Mem e nel quale copia i dati. Il vantaggio del costruttore di default è che si può creare, ad esempio, un grosso array di MyString vuoti occupando pochissima memoria, visto che la dimensione di ciascun oggetto è solo quella di un puntatore e l'unico overhead del costruttore di default è un assegnamento a zero. Il peso di MyString si inizia a far sentire solo quando si concatenano due stringhe; a quel punto se necessario si crea l'oggetto Mem . Tuttavia, se si usa il costruttore di default e non si concatenano mai stringhe, il distruttore è ancora sicuro perché la chiamata a delete su zero i definita in modo da non rilasciare alcun blocco di memoria e non causa problemi.

Guardando questi due costruttori potrebbe sembrare inizialmente che siano candidati per usare gli argomenti di default. Tuttavia, cancellando il costruttore di default e scrivendo il costruttore rimanente con un argomento di default:

MyString(char* str = "");

funziona tutto correttamente, ma si perdono tutti i benefici precedenti, siccome un oggetto Mem è creato sempre e comunque. Per riavere l'efficienza precedente, bisogna modificare il costruttore:

MyString::MyString(char* str) {
  if(!*str) { // Puntatore a stringa vuota
    buf = 0;
    return;
  }
  buf = new Mem(strlen(str) + 1);
  strcpy((char*)buf->pointer(), str);
} 

Questo significa, in effetti, che il valore di default diventa un flag che esegue un blocco di codice specifico se non è usato il valore di default. Anche se sembra abbastanza fattibile con un piccolo costruttore con questo, generalmente questa usanza può causare problemi. Se è necessario verificare il valore di default piuttosto che trattarlo come un valore ordinario, questo è un indizio che state scrivendo due funzioni differenti all'interno di uno stesso corpo di funzione: una versione per i casi normali e una di default. Si potrebbe anche dividerle in due corpi di funzione distinti e lasciare che sia il compilatore a scegliere la versione giusta. Questo comporta un piccolissimo (e solitamente invisibile) aumento di efficienza, visto che l'argomento extra non è passato alla funzione e il codice per il confronto non è eseguito. più importante ancora, si mantiene il codice di due funzioni separate in due funzioni separate, piuttosto che combinarle in una sola usando gli argomenti di default; questo atteggiamento porta ad una manutenzione più facile, specialmente se le funzioni sono lunghe.

D'altra parte, si consideri la classe Mem . Se si esaminano le definizioni dei due costruttori e delle due funzioni pointer() è possibile vedere come in entrambi i casi l'uso degli argomenti di default non causa minimamente il cambiamento delle definizioni. Quindi, la classe potrebbe essere facilmente:

//: C07:Mem2.h
#ifndef MEM2_H
#define MEM2_H
typedef unsigned char byte;

class Mem {
  byte* mem;
  int size;
  void ensureMinSize(int minSize);
public:
  Mem(int sz = 0);
  ~Mem();
  int msize();
  byte* pointer(int minSize = 0);
}; 
#endif // MEM2_H ///:~

Notare che una chiamata a ensureMinSize(0) sarà sempre abbastanza efficiente

benché in entrambi i casi abbia basato alcune delle mie scelte di design sull'efficenza, bisogna stare molto attenti a non cadere nella trappola di pensare solo in termini di efficenza (anche se affascinante). Lo scopo principale del design è l'interfaccia della classe (i suoi membri public , utilizzabili dal programmatore client). Se verrà prodotta una classe facile da usare e da riusare, essa sarà un successo; si può sempre concentrarsi sull'efficienza, ma gli effetti di una classe progettata male perché il programmatore è troppo preso dalle problematiche di efficienza possono essere terribili. Notare che in MemTest.cpp l'uso di MyString non cambia se è usato un costruttore di default o se l'efficienza è alta o bassa.

Sommario

In generale si cerchi di non usare un argomento di default come un flag in base al quale eseguire codice. Piuttosto, se possibile, si dovrebbe dividere la funzione in due o più funzioni usando l'overloading. Un argomento di default dovrebbe avere un valore con cui la funzione lavora nel modo consueto. È un valore usato molto più spesso degli altri, quindi il programmatore client può generalmente ignorarlo o usarlo solo se vuole cambiarlo rispetto al valore di default.

Si usa un argomento di default per rendere le chiamate alla funzione più facili, specialmente nei casi in cui si hanno diversi valori usati molto spesso. Non solo è più facile scrivere la chiamata, ma anche leggerla, specialmente se il creatore della classe ordina gli argomenti in modo che quello modificato meno di frequente appaia in fondo alla lista.

Un uso particolarmente importatante degli argomenti di default si ha quando si inizia a scrivere una funzione con un insieme di argomenti, e dopo averla usata per un po' si scopre di avere bisogno di ulteriori argomenti. Rendendoli di default si assicura che il codice cliente che usa l'interfaccia precedente non è disturbato.

Esercizi

Le soluzioni degli esercizi selezionati possono essere trovate nel documento elettronico The Thinking in C++ Annotated Solution Guide , disponibile per una piccola somma su www.BruceEckel.com

  1. Creare una classe Text che contiene un oggetto string per memorizzare il contenuto di un file. Scrivete due costruttori: uno di default e un che prende un argomento string che contiene il nome del file da aprire. Quando è usato il secondo costruttore, aprire il file e leggerne il contenuto memorizzandolo nella variabile membro strin . Aggiungere una funzione membro contents() che ritorni una string in modo da poterla, ad esempio, stampare. In main() , aprire un file usando Text e stamparne il contenuto.
  2. Creare una classe Message con un costruttore che prende un singolo parametro string con un valore di default. Creare un membro privato string e nel costruttore assegnate semplicemente l'argomento string a questo membro. Create due overloading di una funzione membro print() : uno che non prende alcun argomento e stampa semplicemente il messaggio memorizzato nell'oggetto e uno che prende un argomento string e lo stampa assieme al messaggio interno. Ha un senso usare questo approccio invece di quello usato per i costruttori?
  3. Determinare come generare codice assembly come output del proprio compilatore e fare delle prove per dedurre la procedura usata per il name-mangling
  4. Creare una classe che contiene quattor funzioni membro, con 0, 1, 2, 3 argomenti int , rispettivamente. Creare una main() che usa un'oggetto di questa classe e chiama tutte le funzioni membro. Ora modificare la classe in modo che abbia una sola funzione con tutt gli argomenti di default. Cambia qualcosa nella main() ?
  5. Creare una funzione con due argomenti e chiamarla da main() . Ora rendere uno degli argomenti "segnaposto" (senza identificatore) e guardare se la chimata in main() cambia.
  6. Modificare Stash3.h e Stash3.cpp in modo da usare argomenti di default nel costruttore. Testare il costruttore creando due differenti versioni di un oggetto Stash .
  7. Creare una nuova versione della classe Stack (capitolo 6) che contiene il costruttore di default come la prima, e un secondo costruttore che prende come argomento un array di puntatori a oggetti e la dimensione dell'array. Questo costruttore dovrebbe dovrebbe scorrere l'array e fare il push ogni puntatore nello Stack . Testare la classe con un array di string .
  8. Modificare SuperVar in modo che ci siano delle #ifdef intorno al codice che usa vartype come descritto nella sezione sulle enum . Rendere vartype una regolare enumerazione public (senza istanza) e modificare print() in modo che richieda un argomento di tipo vartype per determinare cosa fare.
  9. Implementare Mem2.h MemTest.cpp .
  10. Usare la classe Mem per implementare Stash . Notare che, dato che l'implementazione è privata e quindi nascosta al programmatore client, il codice di test non deve essere modificato.
  11. Aggiungere alla classe Mem una funzione membro bool moved() che prende il risultato di una chiamata a pointer() e dice quando il puntatore è stato spostato (a causa della riallocazione). Scrivere una main() che testa la funzione moved() . È più sensato usare qualcosa tipo moved o semplicemente chiamare pointer() ogni volta che si deve accedere alla memoria in Mem ?
[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
Ultimo Aggiornamento:21/02/2003