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:
-
se il right-operand è un
dato-membro (per esempio in
un'operazione tipo
ogg.x)
il programma accede effettivamente alla memoria in cui è localizzato
il membro
x
dell'oggetto
ogg;
-
se il right-operand è una
funzione-membro (per esempio in
ogg.init()),
il programma esegue la funzione
init (che
è unica per tutta la
classe), aggiungendo, come primo
argomento della
funzione,
l'indirizzo
dell'oggetto
ogg.
[p44]