Reimpiego di codice


La programmazione orientata agli oggetti e` nata con lo scopo di risolvere il problema di sempre del modo dell'informatica: rendere economicamente possibile e facile il reimpiego di codice gia` scritto. Due sono sostanzialmente le tecniche di reimpiego del codice offerte: reimpiego per composizione e reimpiego per ereditarieta`; il C++ ha poi offerto anche il meccanismo dei Template che puo` essere utilizzato anche in combinazione con quelli classici della OOP.
Per adesso rimanderemo la trattazione dei template ad un apposito capitolo, concentrando la nostra attenzione prima sulla composizione di oggetti e poi sull'ereditarieta` il secondo pilastro (dopo l'incapsulazione di dati e codice) della programmazione a oggetti.



Reimpiego per composizione

Benche` non sia stato esplicitamente mostrato, non c'e` alcun limite alla complessita` di un membro dato di un oggetto; un attributo puo` avere sia tipo elementare che tipo definito dall'utente, in particolare un attributo puo` a sua volta essere un oggetto.


  class Lavoro {
    public:
      Lavoro(/* Parametri */);

      /* ... */

    private:
      /* ... */
  };

  class Lavoratore {
    public:
      Lavoratore(Lavoro* occupazione);
      /* ... */

    private:
      Lavoro* Occupazione;
      /* ... */
  };


L'esempio mostrato suggerisce un modo di reimpiegare codice gia` pronto quando si e` di fronte ad una relazione di tipo Has-a, in cui una entita` piu` piccola e` effettivamente parte di una piu` grossa. In questo caso il reimpiego e` servito per modellare una proprieta` della classe Lavoratore, ma sono possibili casi ancora piu` complessi:


  class Complex {
    public:
      Complex(float Real=0, float Immag=0);
      Complex operator+(Complex &);
      Complex operator-(Complex &);
      /* ... */

    private:
      float Re, Im;
  };

  class Matrix {
    public:
      Matrix();
      Matrix operator+(Matrix &);
      /* ... */

    private:
      Complex Data[10][10];
  };


In questo secondo esempio invece il reimpiego della classe Complex ci consente anche di definire le operazioni sulla classe Matrix in termini delle operazioni su Complex (un approccio matematicamente corretto).
Tuttavia la composizione puo` essere utilizzata anche per modellare una relazione di tipo Is-a, in cui invece una istanza di un certo tipo puo` essere vista anche come istanza di un tipo piu` "piccolo":


  class Person {
    public:
      Person(const char* name, unsigned age);
      void PrintName();
      /* ... */

    private:
      const char* Name;
      unsiggned int Age;
  };

  class Student {
    public:
      Student(const char  name, unsigned age,
              const unsigned code);
      void PrintName();
      /* ... */

    private:
      Person Self;
      const unsigned int IdCode;  // numero di matricola
      /* ... */
  };

  Student::Student(const char* name, unsigned age,
                   const unsigned code)
         : Self(name, age), IdCode(code) {}

  void Student::PrintName() {
    Self.PrintName();
  }

  /* ... */


In sostanza la composizione puo` essere utilizzata anche quando vogliamo semplicemente estendere le funzionalita` di una classe realizzata in precedenza (esistono tecnologie basate su questo approccio).

Esistono due tecniche di composizione:

  • Contenimento diretto;
  • Contenimento tramite puntatori.
Nel primo caso un oggetto viene effettivamente inglobato all'interno di un altro (come negli esempi visti), nel secondo invece l'oggetto contenitore in realta` contiene un puntatore. Le due tecniche offrono vantaggi e svantaggi differenti.
Nel caso del contenimento tramite puntatori:
  • L'uso di puntatori permette di modellare relazioni 1-n, altrimenti non modellabili se non stabilendo un valore massimo per n;
  • Non e` necessario conoscere il modo in cui va costruito una componente nel momento in cui l'oggetto che la contiene viene istanziato;
  • E` possibile che piu` oggetti contenitori condividano la stessa componente;
  • Il contenimento tramite puntatori puo` essere utilizzato insieme all'ereditarieta` e al polimorfismo per realizzare classi di oggetti che non sono completamente definiti fino al momento in cui il tutto (compreso le parti accessibili tramite puntatori) non e` totalmente costruito.
L'ultimo punto e` probabilmente il piu` difficile da capire e richiede la conoscenza del concetto di ereditarieta` che sara` esaminato in seguito. Sostanzialmente possiamo dire che poiche` il contenimento avviene tramite puntatori, in effetti non possiamo conoscere l'esatto tipo del componente, ma solo una sua interfaccia generica (classe base) costituita dai messaggi cui l'oggetto puntato sicuramente risponde. Questo rende il contenimento tramite puntatori piu` flessibile e potente (espressivo) del contenimento diretto, potendo realizzare oggetti il cui comportamento puo` cambiare dinamicamente nel corso dell'esecuzione del programma (con il contenimento diretto invece oltre all'interfaccia viene fissato anche il comportamento ovvero l'implementazione del componente). Pensate al caso di una classe che modelli un'auto: utilizzando un puntatore per accedere alla componente motore, se vogliamo testare il comportamento dell'auto con un nuovo motore non dobbiamo fare altro che fare in modo che il puntatore punti ad un nuovo motore. Con il contenimento diretto la struttura del motore (corrispondente ai membri privati della componente) sarebbe stata limitata e non avremmo potuto testare l'auto con un motore di nuova concezione (ad esempio uno a propulsione anzicche` a scoppio). Come vedremo invece il polimorfismo consente di superare tale limite. Tutto cio` sara` comunque piu` chiaro in seguito.
Consideriamo ora i principali vantaggi e svantaggi del contenimento diretto:
  • L'accesso ai componenti non deve passare tramite puntatori;
  • La struttura di una classe e` nota gia` in fase di compilazione, si conosce subito l'esatto tipo del componente e il compilatore puo` effettuare molte ottimizzazioni (e controlli) altrimenti impossibili (tipo espansione delle funzioni inline dei componenti);
  • Non e` necessario eseguire operazioni di allocazione e deallocazione per costruire le componenti, ma e` necessario conoscere il modo in cui costruirle gia` quando si istanzia (costruisce) l'oggetto contenitore.
Se da una parte queste caratteristice rendono il contenimento diretto meno flessibile ed espressivo di quello tramite puntatore e anche vero che lo rendono piu` efficente, non tanto perche` non e` necessario passare tramite i puntatori, ma quanto per gli ultimi due punti.



Costruttori per oggetti composti

L'inizializzazione di un ogggetto composto richiede che siano inizializzate tutte le sue componenti. Abbiamo visto che un attributo non puo` essere inizializzato mentre lo si dichiara (infatti gli attributi static vanno inizializzati fuori dalla dichiarazione di classe (vedi capitolo VIII, paragrafo 6); la stessa cosa vale per gli attributi di tipo oggetto:


  class Composed {
    public:
      /* ... */

    private:
      unsigned int Attr = 5;    // Errore!
      Component Elem(10, 5);    // Errore!
      /* ... */
  };


Il motivo e` ovvio, eseguendo l'inizializzazione nel modo appena mostrato il programmatore sarebbe costretto ad inizializzare la componente sempre nello stesso modo; nel caso si desiderasse una inizializzazione alternativa, saremmo costretti a eseguire altre operazioni (e avremmo aggiunto overhead inutile).
La creazione di un oggetto che contiene istanze di altre classi richiede che vengano prima chiamati i costruttori per le componenti e poi quello per l'oggetto stesso; analogamente ma in senso contrario, quando l'oggetto viene distrutto, viene prima chiamato il distruttore per l'oggetto composto, e poi vengono eseguiti i distruttori per le singole componenti.
Il processo puo` sembrare molto complesso, ma fortunatamente e` il compilatore che si occupa di tutta la faccenda, il programmatore deve occuparsi solo dell'oggetto con cui lavora, non delle sue componenti. Al piu` puo` capitare che si voglia avere il controllo sui costruttori da utilizzare per le componenti; l'operazione puo` essere eseguita utilizzando la lista di inizializzazione, come mostra l'esempio seguente:


  #include < iostream >
  using namespace std;

  class SmallObj {
    public:
      SmallObj() {
        cout << "Costruttore SmallObj()" << endl;
      }
      SmallObj(int a, int b) : A1(a), A2(b) {
        cout << "Costruttore SmallObj(int, int)" << endl;
      }
      ~SmallObj() {
         cout << "Distruttore ~SmallObj()" << endl;
      }

    private:
      int A1, A2;
  };

  class BigObj {
    public:
      BigObj() {
        cout << "Costruttore BigObj()" << endl;
      }
      BigObj(char c, int a = 0, int b = 1) 
       : Obj(a, b), B(c) {
        cout << "Costruttore BigObj(char, int, int)"
             << endl;
      }
      ~BigObj() {
         cout << "Distruttore ~BigObj()" << endl;
      }

    private:
      SmallObj Obj;
      char B;
  };
    
  int main(int, char* []) {
    BigObj Test(15);
    BigObj Test2;
    return 0;
  }


il cui output sarebbe:


  Costruttore SmallObj(int, int)
  Costruttore BigObj(char, int, int)
  Costruttore SmallObj()
  Costruttore BigObj()
  Distruttore ~BigObj()
  Distruttore ~SmallObj()
  Distruttore ~BigObj()
  Distruttore ~SmallObj()


L'inizializzazione della variabile Test2 viene eseguita tramite il costruttore di default, e poiche` questo non chiama esplicitamente un costruttore per la componente SmallObj automaticamente il compilatore aggiunge una chiamata a SmallObj::SmallObj(); nel caso in cui invece desiderassimo utilizzare un particolare costruttore per SmallObj bisogna chiamarlo esplicitamente come fatto in BigObj::BigObj(char, int, int) (utilizzato per inizializzare Test).

Si poteva pensare di realizzare il costruttore nel seguente modo:


  BigObj::BigObj(char c, int a = 0, int b = 1) {
    Obj = SmallObj(a, b);
    B = c;
    cout << "Costruttore BigObj(char, int, int)" << endl;
  }


ma benche` funzionalmente equivalente al precedente, non genera lo stesso codice. Infatti poiche` un costruttore per SmallObj non e` esplicitamente chiamato nella lista di inizializzazione e poiche` per costruire un oggetto complesso bisogna prima costruire le sue componente, il compilatore esegue una chiamata a SmallObj::SmallObj() e poi passa il controllo a BigObj::BigObj(char, int, int). Conseguenza di cio` e` un maggiore overhead dovuto a due chiamate di funzione in piu`: una per SmallObj::SmallObj() (aggiunta dal compilatore) e l'altra per SmallObj::operator=(SmallObj&) (dovuta alla prima istruzione del costruttore).
Il motivo di un tale comportamento potrebbe sembrare piuttosto arbitrario, tuttavia in realta` una tale scelta e` dovuta alla necessita` di garantire sempre che un oggetto sia inizializzato prima di essere utilizzato.
Ovviamente poiche` ogni classe possiede un solo distruttore, in questo caso non esistono problemi di scelta!

In pratica possiamo riassumere quanto detto dicendo che:

  1. la costruzione di un oggetto composto richiede prima la costruzione delle sue componenti, utilizzando le eventuali specifiche presenti nella lista di inizializzazione del suo costruttore; in caso non venga specificato il costruttore da utilizzare per una componente, il compilatore utilizza quello di default. Alla fine viene eseguito il corpo del costruttore per l'oggetto composto;
  2. la distruzione di un oggetto composto avviene eseguendo prima il suo distruttore e poi il distruttore di ciascuna delle sue componenti;
In quanto detto e` sottointeso che se una componete di un oggetto e` a sua volta un oggetto composto, il procedimento viene iterato fino a che non si giunge a componenti di tipo primitivo.

Ora che e` noto il meccanismo che regola l'inizializzazione di un oggetto composto, resta chiarire come vengono esattamente generati il costruttore di default e quello di copia.
Sappiamo che il compilatore genera automaticamente un costruttore di default se il programmatore non ne definisce uno qualsiasi, in questo caso il costruttore di default fornito automaticamente, come e` facile immaginare, non fa altro che chiamare i costruttori di default delle singole componenti, generando un errore se per qualche componente non esiste un tale costruttore. Analogamente il costruttore di copia che il compilatore genera (solo se il programmatore non lo definisce esplicitamente) non fa altro che richiamare i costruttori di copia delle singole componenti.



Pagina precedente - Pagina successiva



C++, una panoramica sul linguaggio - seconda edizione
© Copyright 1996-1999, Paolo Marotta