Programmazione a oggetti


I costrutti analizzati fin'ora costituiscono gia` un linguaggio che ci consente di realizzare anche programmi complessi e di fatto, salvo alcuni dettagli, quanto visto costituisce il linguaggio C. Tuttavia il C++ e` molto di piu` e offre caratteristiche nuove che estendono e migliorano il C, programmazione a oggetti, RTTI (Run Time Type Information), programmazione generica, gestione delle eccezioni sono solo alcune delle caratteristiche che rendono il C++ diverso dal C e migliore di quest'ultimo sotto molti aspetti. Si potrebbe apparentemente dire che si tratta solo di qualche aggiunta, in realta` nessun'altra affermazione potrebbe essere piu` errata: le eccezioni semplificano la gestione di situazioni anomale a run time (un compito gia` di per se` complesso), mentre il supporto alla programmazione ad oggetti e alla programmazione generica (e cio` che ruota attorno ad esse) rivoluzionano addirittura il modo di concepire e realizzare codice e caratterizzano il linguaggio fino a influenzare il codice prodotto in fase di compilazione (notevolmente diverso da quello prodotto dal compilatore C).
Inizieremo ora a discutere dei meccanismi offerti dal C++ per la programmazione orientata agli oggetti, cercando contemporaneamente di esporre i principi alla base di tale metodologia di codifica. E` bene sottolineare subito che non esiste un unico modello di programmazione orientata agli oggetti, ma esistono differenti formulazioni spesso differenti in pochi dettagli che hanno pero` una influenza notevole, quanto segue riferira` unicamente al modello offerto dal C++.



L'idea di base

La programmazione orientata agli oggetti (OOP) impone una nuova visione di concetti quali "Tipo di dato" e "Operazioni sui dati".
In contrapposizione al paradigma procedurale dove si distingue tra entita` passive (Dati) e entita` attive (operazioni sui dati), la OOP vede queste due categorie come due aspetti di una unica realta`. In ottica procedurale volendo realizzare una libreria per la matematica sui complessi, sarremmo portati a scrivere


  #include 
  #include 
  using namespace std;

  struct Complex {
    double Re;
    double Im;
  };

  void Print(Complex& Val) {
    cout << Val.Re << " + i" << Val.Im;
    cout << endl;
  }

  double Abs(Complex& Val) {
    return sqrt(Val.Re*Val.Re + Val.Im*Val.Im);
  }

  int main() {
    Complex C;
    C.Re = 0.5;
    C.Im = 2.3;
    Print(C);
    cout << Abs(C) << endl;
    return 0;
  }


Tutto cio` e` corretto, ma perche` separare la rappresentazione di un Complex dalle operazioni definite su di esso (Print e Abs). In particolare il problema insito nella visione procedurale e` che si puo` continuare ad accedere direttamente alla rappresentazione dei dati eventualmente per scavalcare le operazioni definite su di essa:


  int main() {
    Complex C;

    // Le seguenti 4 linee di codice non
    // sono una buona pratica;
    C.Re = 0.5;
    C.Im = 2.3;    
    cout << Val.Re << " + i" << Val.Im;
    cout << endl;
  
    cout << Abs(C) << endl;
    return 0;
  }


Si tratta di un comportamento abbastanza comune in chi non ha molta esperienza nella nanutenzione del codice. Tale comportamento nasconde infatti due pericoli:

  • Maggiore possibilita` di errore;
  • Difficolta` nella modifica del codice;
Nel primo caso ogni qual volta si replica del codice si rischia di introdurre nuovi errori, utilizzando invece direttamente le funzioni previste ogni errore non puo` che essere localizzato nella funzione stessa.
Nel secondo caso, la modifica della rappresentazione di un Complex e` resa difficile dal fatto che bisogna cercare nel programma tutti i punti in cui si opera direttamente sulla rappresentazione (se si fossero utilizzate solo e direttamente le funzioni previste, tale problema non esisterebbe).
Tutti questi problemi sono risolti dalla OOP fondendo insieme dati e operazioni sui dati secondo delle regole ben precise. Nelle applicazioni object oriented non ci sono piu` entita` attive (procedure) che operano sui dati, ma unicamente entita` attive (oggetti) che cooperano tra loro. Se il motto della programmazione procedurale e` "Strutture dati + algoritmi = programmi", quello della OOP non puo` che essere "Oggetti + cooperazione = programmi".



Strutture e campi funzione

Come precedentemente detto, l'idea di partenza e` quella di fondere in una unica entita` la rappresentazione dei dati e le operazioni definite su questi. La soluzione del C++ (e sostanzialmente di tutti i linguaggi object oriented) e` quella di consentire la presenza di campi funzione all'interno delle strutture:


  struct Complex {
    double Re;
    double Im;

    // Ora nelle strutture possiamo avere
    // dei campi di tipo funzione;
    void Print();
    double Abs();
  };


I campi di tipo funzione sono detti funzioni membro oppure metodi, i restanti campi della struttura vengono denominati membri dato o attributi.
La seconda cosa che si puo` notare e` la scomparsa del parametro di tipo Complex. Questo parametro altri non sarebbe che il dato su cui si vuole eseguire l'operazione, e che ora viene specificato in altro modo:


  Complex A;
  Complex* C;
  
  /* ... */
  
  A.Print();
  C = new Complex;
  C -> Print();
  float FloatVar = C -> Abs();


Nella OOP non ci sono piu` procedure eseguite su certi dati, ma messaggi inviati ad oggetti. Gli oggetti sono le istanze di una struttura; i messaggi sono le operazioni che possono essere eseguiti su di essi ("Print, Abs").
Un messaggio viene inviato ad un certo oggetto utilizzando sempre il meccanismo di chiamata di funzione, il legame tra messaggio e oggetto destinatario viene realizzato con la notazione del punto ("A.Print()") o, se si dispone di un puntatore a oggetto, tramite l'operatore -> ("C -> Print()"). Non e` possibile invocare un metodo (inviare un messaggio) senza associarvi un oggetto:


  Complex A;
  
  /* ... */
  
  Print();        // Errore!


Un messaggio deve sempre avere un destinatario, ovvero una richiesta di operazione deve sempre specificare chi deve eseguire quel compito.
Il compilatore traduce la notazione vista prima con una normale chiamata di funzione, invocando il metodo selezionato e passandogli un parametro nascosto che altri non e` che l'indirizzo dell'oggetto stesso, ma questo lo riprenderemo in seguito.
Quello che ora e` importante notare e` che siamo ancora in grado di accedere direttamente agli attributi di un oggetto esattamente come si fa con le normali strutture:


  // Con riferimento agli esempi riportati sopra:

  A.Re = 10;        // Ok!
  A.Im = .5;        // ancora Ok!
  
  // anzicche` A.Print()...
  cout << A.Re << " + i" << A.Im;
  cout << endl;

A questo punto ci si potra` chiedere quali sono in vantaggi di un tale modo di procedere, se poi i problemi precedentemente esposti non sono stati risolti; in fondo tutto cio` e` solo una nuova notazione sintattica.
Il problema e` che le strutture violano un concetto cardine della OOP, l'incapsulamento. In sostanza il problema e` che non c'e` alcun modo di impedire l'accesso agli attributi. Tutto e` visibile all'esterno della definizione della struttura, compresi i campi Re e Im.
Il concetto di incapsulamento dice in sostanza che gli attributi di un oggetto non devono essere accessibili se non ai soli metodi dell'oggetto stesso.



Sintassi della classe

Il problema viene risolto introducendo una nuova sintassi per la dichiarazione di un tipo oggetto.
Un tipo oggetto viene dichiarato tramite una dichiarazione di classe, che differisce dalla dichiarazione di struttura sostanzialmente per i meccanismi di protezione offerti; per il resto tutto cio` che si applica alle classi si applica allo stesso modo alla dichiarazione di struttura (e vicevera) senza alcuna differenza.
Vediamo dunque come sarebbe stato dichiarato il tipo Complex tramite la sintassi della classe:


  class Complex {
    public:
      void Print();     // definizione eseguita altrove!

      /* altre funzioni membro */ 

    private:
      float Re;         // Parte reale
      float Im;         // Parte immaginaria
  };


La differenza e` data dalle keyword public e private che consentono di specificare i diritti di accesso alle dichiarazioni che le seguono:

  • public: le dichiarazioni che seguono questa keyword sono visibili sia alla classe che a cio` che sta fuori della classe e l'invocazione (selezione) di uno di questi campi e` sempre possibile;

  • private: tutto cio` che segue e` visibile solo alla classe stessa, l'accesso ad uno di questi campi e` possibile solo dai metodi della classe stessa;
come mostra il seguente esempio:


  Complex A;
  Complex * C;
    
  A.Re = 10.2;             // Errore!
  C -> Im = 0.5;           // Ancora errore!
  A.Print();               // Ok!
  C -> Print()             // Ok!


Ovviamente le due keyword sono mutuamente esclusive, nel senso che alla dichiarazione di un metodo o di un attributo si applica la prima keyword che si incontra risalendo in su; se la dichiarazione non e` preceduta da nessuna di queste keyword, il default e` private:


  class Complex {
      float Re;           // private per
      float Im;           // default
    public:
      void Print();
      
      /* altre funzioni membro*/
  };


In effetti e` possibile applicare gli specificatori di accesso (public, private e come vedremo protected) anche alle strutture, ma per le strutture il default e public (per compatibilita` col C).
Esiste anche una terza classe di visibilita` specificata dalla keyword protected, ma analizzaremo questo punto solo in seguito parlando di ereditarieta`.
La sintassi per la dichiarazione di classe e` dunque:

  class <NomeClasse> {
     public:
        <membri pubblici>
     protected:
        <membri protetti>
     private:
        <membri privati>
  };                                           // notare il punto e virgola finale!
Non ci sono limitazioni al tipo di dichiarazioni possibili dentro una delle tre sezioni di visibilita`: definizioni di variabili o costanti (attributi), funzioni (metodi) oppure dichiarazioni di tipi (enumerazioni, unioni, strutture e anche classi), l'importante e` prestare attenzione a evitare di dichiarare private (o protected) cio` che deve essere visibile anche all'esterno della classe, in particolare le definizioni dei tipi di parametri e valori di ritorno dei metodi public.



Definizione delle funzioni membro

La definizione dei metodi di una classe puo` essere eseguita o dentro la dichiarazione di classe, facendo seguire alla lista di argomenti una coppia di parentesi graffe racchiudente la sequenza di istruzioni:


  class Complex {
    public:
      /* ... */

      void Print() {
        if (Im >= 0)
          cout << Re << " + i" << Im;
        else
          cout << Re << " - i" << fabs(Im);
          // fabs restituisce il valore assoluto!
      }    

    private:
      /* ... */
  };


oppure riportando nella dichiarazione di classe solo il prototipo e definendo il metodo fuori dalla dichiarazione di classe, nel seguente modo:


  /* Questo modo di procedere richiede l'uso
  dell'operatore di risoluzione di scope e l'uso del
  nome della classe per indicare esattamente quale
  metodo si sta definendo (classi diverse possono
  avere metodi con lo stesso nome). */
    
  void Complex::Print() {
    if (Im >= 0)
      cout << Re << " + i" << Im;
    else
      cout << Re << " - i" << fabs(Im);
  }


I due metodi non sono comunque del tutto identici: nel primo caso implicitamente si richiede una espansione inline del codice della funzione, nel secondo caso se si desidera tale accorgimento bisogna utilizzare esplicitamente la keyword inline nella definizione del metodo:


  inline void Complex::Print() {
    if (Im >= 0)
      cout << Re << " + i" << Im;
    else
      cout << Re << " - i" << fabs(Im);
  }


Se la definizione del metodo Print() e` stata studiata con attenzione, il lettore avra` notato che la funzione accede ai membri dato senza ricorrere alla notazione del punto, ma semplicemente nominandoli: quando ci si vuole riferire ai campi dell'oggetto cui e` stato inviato il messaggio non bisogna adottare alcuna particolare notazione, lo si fa e basta (i nomi di tutti i membri della classe sono nello scope di tutti i metodi della stessa classe)!
La domanda corretta da porsi e` come si fa a stabilire dall'interno di un metodo qual'e` l'effettiva istanza cui ci si riferisce. Il compito di risolvere correttamente ogni riferimento viene svolto automaticamente dal compilatore: all'atto della chiamata, ciascun metodo riceve un parametro aggiuntivo, un puntatore all'oggetto a cui e` stato inviato il messaggio e tramite questo e` possibile risalire all'indirizzo corretto. Il programmatore non deve comunque preoccuparsi di cio` e` il compilatore che risolve tutti i legami tramite tale puntatore. Allo stesso modo a cui si accede agli attributi dell'oggetto, un metodo puo` anche invocare un altro metodo dell'oggetto stesso:


  class MyClass {
    public:
      void BigOp();
      void SmallOp();

    private:
      void PrivateOp();
      /* altre dichiarazioni */
  };

  /* definizione di SmallOp() e PrivateOp() */

  void MyClass::BigOp() {
    /* ... */
    SmallOp();   // questo messaggio arriva all'oggetto
                 // a cui e` stato inviato BigOp()
    /* ... */
    PrivateOp(); // anche questo!
    /* ... */
  }


Ovviamente un metodo puo` avere parametri e/o variabili locali che sono istanze della stessa classe cui appartiene (il nome della classe e` gia` visibile all'interno della stessa classe), in questo caso per riferirsi ai campi del parametro o della variabile locale si deve utilizzare la notazione del punto:


  class MyClass {
    /* ... */
    void Func(MyClass A);
  };
    
  void MyClass::Func(MyClass A, /* ... */ ) {
    /* ... */
    BigOp();    // questo messaggio arriva all'oggetto
                // cui e` stato inviato Func(MyClass)
    A.BigOp();  // questo invece arriva al parametro.
    /* ... */
  }


In alcuni rari casi puo` essere utile avere accesso al puntatore che il compilatore aggiunge tra i parametri di un metodo, l'operazione e` fattibile tramite la keyword this (che in pratica e` il nome del parametro aggiuntivo), tale pratica quando possibile e` comunque da evitare.



Pagina precedente - Pagina successiva



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