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 mensionato, non c'e` alcun limite alla complessita` di un membro dato di un oggetto; un dato attributo puo` avere sia tipo elementare che tipo definito dall'utente, in particolare un attributo puo` a sua volta essere un oggetto.
Vediamo un esempio che mostra come definire una matrice 10x10 di numeri 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];
  };
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; 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, unsigned code);
      void PrintName();
      /* ... */

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

  Student::Student(const char * name, unsigned age, 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 due tecniche di composizione: 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'ultimo punto e` probabilmente il piu` difficile da capire e richiede la conoscenza dei principi della OOP. 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. 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: 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. Implicitamente 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.h >
  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;
  };
    
  void main() {
    BigObj Test(15);
    BigObj Test2;
  }
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).
Il costruttore poteva anche essere scritto 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, 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.


Pagina precedente - Pagina successiva



C++, una panoramica sul linguaggio - © Copyright 1997, Paolo Marotta