Il concetto di classe fornisce al programmatore uno strumento per creare nuovi tipi, utilizzabili nello stesso modo dei tipi predefiniti. In teoria un tipo definito dall'utente, non dovrebbe differire dai tipi predefiniti per il modo di usarlo, ma solo per il diverso modo con cui viene creato.
Un tipo costituisce la rappresentazione concreta di un concetto. Ad esempio, il tipo predefinito float, insieme alle operazioni +, -, *, /, rappresenta una versione limitata, ma concreta, del concetto matematico di numero reale.
La realizzazione di un nuovo tipo, è necessaria per fornire una definizione concreta e specifica di un concetto che non presenta controparte semplice e diretta fra i tipi predefiniti. E' possibile, ad esempio, fornire un tipo trunk_module in un programma che riguarda i telefoni, oppure un tipo list_of_paragraphs in un programma per l'elaborazione di testi. Un programma dotato di tipi strettamente corrispondenti ai concetti dell'applicazione è generalmente più semplice da comprendere e modificare.
L'idea fondamentale per la definizione di un nuovo tipo, consiste nel separare i dettagli secondari dell'implementazione (ad esempio, la struttura dei dati impiegati per memorizzare un oggetto di quel tipo) dalle proprietà fondamentali per un uso corretto (come, ad esempio, l'elenco completo delle funzioni che possono avere accesso ai dati). Tale separazione può essere espressa incanalando ogni utilizzo della struttura dati e le routine interne di gestione attraverso un'interfaccia specifica.
Concetto di classe:
Supponiamo di voler realizzare un programma per la gestione degli studenti di una università e domandiamoci quale e' la struttura dati più idonea per la memorizzazione delle relative informazioni. Sulla base di quanto finora e' stato discusso e' immediato pensare ad un array di strutture e la scelta potrebbe risultare adeguata per un linguaggio di programmazione non ad oggetti. Poiché questo corso ha come obiettivo la programmazione ad oggetti, ci dobbiamo chiedere quali sono i punti deboli del tipo struct e qual'è l'alternativa offerta dal linguaggio C++. Sappiamo che per default i membri di una struttura sono pubblici e questo certamente non favorisce la protezione dei dati.
Qualunque funzione può liberamente accedervi e modificarne il contenuto. Ed inoltre se volessimo raggruppare gli studenti per facoltà di appartenenza, dovremmo creare una nuova struttura dati senza la possibilità del riuso del codice esistente. Il linguaggio C++ supera questi limiti attraverso l'introduzione del costrutto classe. La classe è un'astrazione che descrive le proprietà di tutti gli oggetti caratterizzati da:
- uno stesso insieme di operazioni (PROTOCOLLO o INTERFACCIA)
- una stessa struttura interna
- uno stesso comportamento
e che consente la creazione di un numero qualunque di istanze (oggetti). La parola chiave class consente di dichiarare una classe:
class contatore {
int val; // private per default
public:
void reset(); // Funzioni di interfaccia
void inc(); // oppure
void dec();
int visual(); // Protocollo
};
Abbiamo così creato un classe di nome contatore costituita da un intero di nome val e da un insieme di operazioni (protocollo o interfaccia) che rappresentano che cosa può fare un oggetto di questa classe. Nel nostro esempio un oggetto di tipo contatore può azzerarsi, incrementarsi, decrementarsi, visualizzare il valore.
Per indicare come opera un oggetto della classe, si deve scrivere il codice delle funzioni:
void contatore::reset() { val=0;}
void contatore::inc() { val++;}
void contatore::dec() { val--;}
void contatore::visual() { return val;}
Il simbolo :: posto tra il nome della classe ed il nome della funzione membro, qualifica le operazioni come appartenenti ad una certa classe. Notiamo che le funzioni membro possono accedere direttamente al campo val che è privato e perciò invisibile all'esterno, ma accessibile a chi scrive il codice. Questo implica in generale due punti di vista nella programmazione ad oggetti: il punto di vista di chi realizza la classe ed il punto di vista di chi usa la classe. Provo a chiarire il concetto con un esempio. Immaginate che la classe sia una stanza, i membri privati siano gli oggetti che si trovano all'interno e i membri pubblici le finestre.
Se ci troviamo dentro la stanza, possiamo toccare e manipolare gli oggetti che si trovano all'interno, così come possiamo manipolare i dati privati di una classe se ci troviamo al suo "interno" cioè se stiamo scrivendo il codice relativo alla classe: è il punto di vista di chi realizza la classe.
Se ci troviamo all'esterno della stanza, non ci e' consentito toccare gli oggetti al suo interno, possiamo solo vederli attraverso le finestre così come possiamo raggiungere i membri della classe solo attraverso le sue "finestre" e cioè l'interfaccia: è questo il punto di vista di chi usa la classe.
Una volta realizzata la classe, si possono definire un numero qualunque di oggetti e questi avranno la stessa rappresentazione concreta ed inoltre possono eseguire le sole operazioni descritte nella classe. Se scriviamo:
contatore c1,c2;
abbiamo definito due oggetti di tipo contatore ognuno dei quali ha la propria copia di val e può eseguire solo le operazioni reset(), inc(), dec(), visual(). Nel prossimo paragrafo vedremo come queste istanze possono essere usate.
Accesso ai membri di una classe:
Per usare un oggetto precedentemente definito, basta scrivere il suo nome seguito dal "." e dall'operazione che deve eseguire:
c1.reset();
Questa istruzione ha il significato di invio del messaggio all'oggetto c1 di eseguire l'operazione reset() che nel nostro caso pone a zero il campo val dell'oggetto c1. Un esempio completo è a questo punto utile:
// nel file contator.h
class contatore {
int val;
public:
void reset();
void inc();
void dec();
int visual();
};// nel file contator.cpp
#include"contator.h"
void contatore::reset() {
val=0;
}
void contatore::inc() {
val++;
}
void contatore::dec() {
val--;
}
int contatore::visual() {
return val;
}// nel file prova.cpp
#include<iostream.h>
#include"contator.h"
main()
{
contatore c1,c2;
c1.reset();
c2.inc();
cout<<c1.visual()<<endl;//val=0
cout<<c2.visual()<<endl;//val=1
}
Notare che il programma è stato suddiviso su tre file. In generale è consigliabile memorizzare in un file la dichiarazione della classe, in un altro file la definizione delle funzioni membro e in un altro file ancora il programma principale.
Costruttori e distruttori:
Gli oggetti si comportano come normali variabili rispetto alla visibilità ed al ciclo di vita. Questo significa che in presenza della dichiarazione:
contatore c1;
il compilatore costruirà un oggetto c1 il cui contenuto in questo esempio è indefinito, non avendolo inizializzato. Analogamente all'uscita dalla funzione in cui c1 è stato dichiarato, vi sarà la sua distruzione e ciò in perfetto accordo con il ciclo di vita delle variabili locali. In entrambi i casi, il compilatore invoca un costruttore ed un distruttore di default. Questi possono essere resi espliciti dichiarandoli nella sezione pubblica della classe:
class contatore {
int val;
public:
.......
contatore(); // costruttore senza parametri
contatore(int); // costruttore con un parametro
~contatore(); // distruttore
};
Essi hanno lo stesso nome della classe di appartenenza, non sono funzioni membro e perciò non sono operazioni eseguibili dagli oggetti, ma vengono invocati automaticamente negli istanti di creazione e distruzione dell'oggetto stesso. Quando è richiesta l'inizializzazione degli oggetti, si prevedono costruttori con uno o più parametri. In una classe possono essere presenti più costruttori ed il compilatore utilizza quello adeguato in modo automatico, mentre il distruttore è unico in ogni classe. Facciamo un esempio che metta in luce tutti gli aspetti evidenziati (il codice già scritto non viene ripetuto):
// aggiungere nel file contator.cpp
contatore::contatore() {
val=0;
}
contatore::contatore(int a) {
val=a;
}
contatore::~contatore() {
cout<<"distruttore"<<endl;
}// file prova.cpp
#include<iostream.h>
#include"contator.h"
main()
{
contatore c1; // invoca il costruttore:contatore().c1=0
contatore c2(12); // invoca il costruttore:contatore(int)
// si ottiene c2=12
c1.inc(); // si ottiene c1=1
c2.dec(); // si ottiene c2=11
cout<<"c1="<<c1.visual()<<endl;
cout<<"c2="<<c2.visual<<endl;
} // invoca il distruttore: prima viene distrutto c2 e poi c1