Classi e data hiding

 


 

Analogia fra classi e strutture

 

In C++ le classi sono identiche alle strutture, con l'unica differenza formale di essere introdotte dalla parola-chiave class anziché struct.

In realtà la principale differenza fra classi e strutture è di natura "storica": le strutture sono nate in C, con alcune proprietà (descritte nel capitolo: "Tipi definiti dall'utente"); le classi sono nate in C++, con le stesse proprietà delle strutture e molte altre proprietà in più. Successivamente si è pensato di attribuire alle strutture le stesse proprietà delle classi. Pertanto le strutture  C++ sono molto diverse dalle strutture C, essendo invece identiche alle classi (a parte una sola differenza sostanziale, di cui parleremo fra poco). Per questo motivo, d'ora in poi tratteremo solo di classi, sottintendendo che, in C++, quanto detto vale anche per le strutture.

Esempio di definizione di una classe:

class point
{ double x; double y; double z; } ;

ogni istanza della classe point rappresenta un punto nello spazio e i suoi membri sono le coordinate cartesiane del punto.

 


 

Specificatori di accesso

 

In C++, nel blocco di definizione di una classe, é possibile utilizzare dei nuovi specificatori, detti specificatori di accesso, che sono i seguenti:

private:    protected:     public:

gli specificatori private: e protected: hanno significato analogo: la loro differenza riguarda esclusivamente le classi ereditate, di cui parleremo più avanti; per il momento, useremo soltanto lo specificatore private: .

Questi specificatori possono essere inseriti più volte all'interno della definizione di una classe: private: fa sì che tutti i membri dichiarati da quel punto in poi (fino al termine della definizione della classe o fino a un nuovo specificatore) acquisiscano la connotazione di membri privati (in che senso? ... vedremo più avanti); public: fa sì che tutti i membri successivamente dichiarati siano pubblici.

L'unica differenza sostanziale fra classe e struttura consiste nel fatto che i membri di una struttura sono, di default, pubblici, mentre quelli di una classe sono, di default, privati.

 


 

Data hiding

 

Il "data hiding" (occultamento dei dati) consiste nel rendere certe aree del programma invisibili ad altre aree del programma. I suoi vantaggi sono evidenti: favorisce la programmazione modulare, rende più agevoli le operazioni di manutenzione del software e, in ultima analisi, permette un modo di programmare più efficiente.

Introducendo i namespace, abbiamo detto che il data hiding si realizza sostanzialmente racchiudendo i nomi all'interno di ambiti di visibilità e definendo dei canali di comunicazione, ben circoscritti e controllati, come uniche vie di accesso ai nomi di ambiti diversi. Se tutto quello che serve è la protezione dei nomi degli oggetti, i namespace sono sufficienti a questo scopo.

D'altra parte, questo livello di protezione, limitato ai soli oggetti, può rivelarsi inadeguato, se gli oggetti sono istanze di strutture o classi, cioè possiedono membri. E' sorto quindi il problema di proteggere, non solo un oggetto, ma anche i suoi membri, facendo in modo che, anche quando l'oggetto é visibile, l'accesso ai suoi membri sia rigorosamente controllato.

Il C++ ha realizzato questo obiettivo, estendendo il data hiding anche ai membri degli oggetti. L'istanza di una classe é regolarmente visibile all'interno del proprio ambito, ma i suoi membri privati non lo sono: non é possibile, da programma, accedere direttamente ai membri privati di una classe.

Es.:         class Persona   {
      int soldi ;
   public:
      char telefono[20] ;
      char indirizzo[30] ;
 } ;
Persona  Giuseppe ;         (istanza della classe Persona) 

il programma può accedere a Giuseppe.telefono e Giuseppe.indirizzo, ma non a Giuseppe.soldi!

 


 

Funzioni membro

 

A questo punto, la domanda d'obbligo é: se i membri privati di una classe sono inaccessibili, a che cosa servono ?

In realtà i membri privati sono inaccessibili direttamente, ma possono essere raggiunti indirettamente, tramite le cosiddette funzioni-membro.

Infatti il C++ ammette che i membri di una classe possano essere costituiti non solo da dati, ma anche da funzioni. Queste funzioni possono essere, come ogni altro membro, pubbliche o private, ma, in ogni caso, possono accedere a qualunque altro membro della classe, anche ai membri privati. D'altra parte, mentre una funzione-membro privata può essere chiamata solo da un'altra funzione-membro, una funzione-membro pubblica può anche essere chiamata dall'esterno, e pertanto costituisce l'unico tramite fra il programma e i membri della classe.

Questo tipo di architettura del C++ costituisce la base fondamentale della programmazione a oggetti: ogni istanza di una classe è caratterizzata dalle sue proprietà (dati-membro) e dai suoi comportamenti (funzioni-membro), detti anche metodi della classe. Con proprietà e metodi, un oggetto diviene un'entità attiva e autosufficiente, che comunica con il programma in modo rigorosamente controllato. L'azione di chiamare dall'esterno una funzione-membro pubblica di una classe viene riferita con il termine: "inviare un messaggio a un oggetto", per evidenziare il fatto che il programma si limita a dire all'oggetto cosa vuole, ma in realtà é l'oggetto stesso ad eseguire l'operazione, tramite i suoi metodi e agendo sulle sue proprietà (si dice anche che le funzioni-membro sono incapsulate negli oggetti).

Nella definizione di una funzione-membro, gli altri membri della sua stessa classe vanno indicati esclusivamente con il loro nome (senza operatori . o ->). Il C++, ogni volta che incontra una variabile non dichiarata nella funzione, cerca, prima di segnalare l'errore, di identificare il suo nome con quello di un membro della classe  (esattamente come accade per i membri di un namespace, utilizzati in una funzione membro dello stesso namespace).

I metodi possono essere inseriti nella definizione di una classe in due diversi modi: o come funzioni inline, cioè con il loro codice (ma la parola-chiave inline può essere omessa in quanto all'interno della definizione di una classe è di default), oppure con la sola dichiarazione separata dal codice, che viene scritto in altra parte del programma. Riprendendo l'esempio della  classe point (che, per semplicità, riduciamo a due dimensioni):

Esempio del primo modo       Esempio del secondo modo
class point  { class point  {
           double x;            double x;
           double y;            double y;
     public:      public:
           void set(double x0, double y0)            void set(double, double ) ;
                    { x=x0 ; y=y0 ; }                         } ;
                        } ;

Se la definizione della funzione-membro set  non è inserita nell'ambito della definizione della classe point (secondo modo), il suo nome dovrà essere qualificato con il nome della classe (come vedremo fra poco).

Seguendo l'esempio, definiamo ora l'oggetto p come istanza della classe point:
                       point p;
il programma, che non può accedere alle proprietà private p.x e p.y, può però accedere a un metodo pubblico dello stesso oggetto, con l'istruzione:
                       p.
set(x0,y0) ;
e quindi agire sull'oggetto nel solo modo che gli sia consentito.

Nel caso che una variabile venga definita come puntatore a una classe, valgono le stesse regole, con la differenza che bisogna usare (per le funzioni come per i dati) l'operatore ->
Tornando all'esempio:
                    point *  ptr = new point;
ptr->set(1.5, 0.9) ;

 


 

Risoluzione della visibilità

 

Se il codice di un metodo si trova all'esterno della definizione della classe a cui appartiene, bisogna "qualificare" il nome della funzione associandogli il nome classe, tramite l'operatore :: di risoluzione di visibilità. Seguitando nell'esempio precedente, la definizione esterna della funzione-membro set  è:

                              void point::set(double x0, double y0)
{
         x = x0 ;
         y = y0 ;
}

notiamo che questa regola è la stessa che abbiamo visto per i namespace; in realtà si tratta di una regola generale che si applica ogni volta che si deve accedere dall'esterno a un nome dichiarato in un certo ambito di visibilità, e lo stesso ambito di visibilità è identificato da un nome (come sono appunto sia i namespace che le classi).

La scelta se un metodo debba essere scritto in forma inline o meno è arbitraria: se è inline, l'esecuzione è più veloce, se non lo è, la definizione della classe appare in una forma più "leggibile". Per esempio, si potrebbero lasciare inline solo i metodi privati. E' anche possibile scrivere il codice esternamente alla definizione della classe, ma specificare esplicitamente che la funzione deve essere trattata come inline, con la seguente istruzione (riprendendo il solito esempio):
               inline
void point::set(double x0, double y0)
in ogni caso il compilatore separa automaticamente il codice se la funzione è troppo lunga.

Quando,  nella definizione di una classe, si lasciano solo i prototipi dei metodi, si suole dire che viene creata un'intestazione di classe. La consuetudine prevalente dei programmatori in C++ è quella di creare librerie di classi, separando in due gruppi distinti, le intestazioni, distribuite in header-files, dal codice delle funzioni, compilate separatamente e distribuite in librerie in formato binario; infatti ai programmatori che utilizzano le classi non interessa sapere come sono fatte le funzioni di accesso, ma solo come usarle.

 


 

Funzioni-membro di sola lettura

 

Quando un metodo ha il solo compito di riportare informazioni su un oggetto, senza modificarne il contenuto, si può, per evitare errori, imporre tale condizione a priori, inserendo lo specificatore const dopo la lista degli argomenti della funzione (sia nella dichiarazione che nella definizione). Riprendendo l'esempio della classe point, aggiungiamo la funzione-membro get:

                              void point::get(double& x0, double& y0) const
{
         x0 = x ;
         y0 = y ;
}

la funzione-membro get non può modificare i membri della sua classe.

[p43][p43] [p43]

 


 

Classi membro

 

Una classe può anche essere definita all'interno di un'altra classe (oppure semplicemente dichiarata, e poi definita esternamente, nel qual caso però il suo nome deve essere qualificato con il nome della classe di appartenenza). Esempio di definizione di un metodo f di una classe B, definita all'interno di un'altra classe A:
                        void A::B::f( ) {......}

Le classi definite all'interno delle altre classi sono dette: classi-membro o classi annidate. A parte i problemi inerenti all'ambito di visibilità e alla conseguente necessità di qualificare i loro nomi, queste classi si comportano esattamente come se fossero indipendenti. Se però sono collocate nella sezione privata della classe di appartenenza, possono essere istanziate solo dai metodi di detta classe. In sostanza, annidare una classe dentro un'altra classe permette di controllare la creazione dei suoi oggetti. L'accesso ai suoi membri, invece, non dipende dalla collocazione nella classe di appartenenza, ma solo da come sono dichiarati gli stessi membri al suo interno (cioè se pubblici o privati).

 


 

Polimorfismo

 

Per una programmazione efficiente, anche la scelta dei nomi delle funzioni ha la sua importanza. In particolare é utile che funzioni che svolgono la stessa azione abbiano lo stesso nome.

Il C++ consente questa possibilità: non solo i metodi di una classe possono agire su istanze diverse della stessa classe, ma sono anche ammessi metodi di classi diverse con lo stesso nome e gli stessi argomenti (non confondere con l'overload, che implica funzioni con lo stesso nome, ma con diverse liste di argomenti). Il C++ é in grado di riconoscere in esecuzione l'oggetto a cui il metodo é applicato e di selezionare ogni volta la funzione che gli compete. Questa attitudine del linguaggio di rispondere in modo diverso allo stesso messaggio si chiama "polimorfismo": risponde all'esigenza del C++ di modellarsi il più possibile sui concetti della vita reale e, in questo modo, rendere la programmazione più facile ed efficiente che in altri linguaggi. L'importanza del polimorfismo si comprenderà a pieno quando parleremo dell'eredità e delle funzioni virtuali.

 


 

Puntatore nascosto this

 

Ci potremmo chiedere, a questo punto, come fa il C++ ad attuare il polimorfismo: in programmi in formato eseguibile, i nomi degli oggetti e delle funzioni sono spariti, e sono rimasti solo indirizzi e istruzioni. In altre parole, come fa il programma a sapere, in esecuzione, su quale oggetto applicare una funzione?

In realtà il compilatore trasforma il codice sorgente, introducendo un puntatore costante "nascosto" (identificato dalla parola-chiave this) ogni volta che incontra la chiamata di una funzione-membro, e inserendo lo stesso puntatore come primo argomento nella funzione.

Chiariamo quanto detto con il seguente esempio, in cui ogg è un'istanza di una certa classe myclass e init() è una funzione-membro che utilizza un dato-membro x, entrambi  della stessa classe myclass:
    la definizione della funzione:     void myclass::init()  {.....  x = .....}
viene trasformata in: void init(myclass*  const this)  {.....  this->x = .....}
e quindi .....
l'istruzione di chiamata della funzione: ogg.init( ) ;
viene tradotta in: init(&ogg) ;

Come si può notare dall'esempio, il puntatore nascosto this punta all'oggetto utilizzato dalla funzione. Il programmatore non é tenuto a conoscerlo, tuttavia, se vuole, può utilizzarlo in sola lettura (per esempio, in una funzione che deve restituire l'oggetto stesso, può usare l'istruzione return *this; ).

Nel caso che la funzione abbia degli argomenti, il puntatore this viene inserito per primo, e gli altri argomenti vengono spostati in avanti di una posizione.

Se la funzione è un metodo in sola lettura, il compilatore trasforma la sua definizione nel seguente modo (per esempio):
        int myclass::get( ) const  ----------> int get(const myclass*  const this)
cioè this diventa un puntatore costante a costante. Questo fa sì che si possano definire due metodi identici, l'uno const e l'altro no, perchè in realtà i tipi del primo argomento sono diversi (e quindi l'overload è ammissibile).

L'introduzione del puntatore this spiega l'apparente "stranezza" di istruzioni come ogg.init() (in realtà il codice della funzione in memoria é uno solo, cioè non ne esiste uno per ogni oggetto come per i dati-membro). Pertanto, le operazioni di accesso ai membri di un oggetto (con gli operatori  . e ->), producono risultati diversi se il right-operand è un dato-membro o una funzione-membro:

[p44]

 


 

Torna all'Indice