Programmazione ad oggetti in C++
La programmazione ad oggetti (OOP=Object Oriented Programming) è basata sull'idea di costruire una serie di oggetti (software) in grado di interagire vicendevolmente scambiandosi messaggi, ma rimanendo ognuno il proprio stato e i propri dati così come avviene nel mondo reale. Un esempio pratico può permettere di comprendere in modo più chiaro ed immediato i concetti basilari della programmazione ad oggetti.
Prendiamo in considerazione un televisore. Di questo oggetto conosciamo le caratteristiche, ma poche persone conoscono quali componenti elettronici o quali complessi dispositivi sono alla base del suo corretto funzionamento.. La programmazione ad oggetti nasce dallo stesso principio: quello che importa non è l'implementazione interna del codice (che corrisponde ai componenti elettronici del televisore) ma, piuttosto, le caratteristiche e le azioni che ciascun componente software è in grado di svolgere e di mettere a disposizione agli altri componenti. Dunque:
Un programma orientato agli oggetti è costituito da un numero variabile di componenti, denominati oggetti che interagiscono tra loro attraverso lo scambio di messaggi.
Tornando all'esempio dell'orologio, supponendo di voler implementare un
software che ne costituisca un modello, dobbiamo per prima cosa definirne
le caratteristiche. Ad esempio:
● La marca.
● Lo stato (acceso/spento).
● Il canale.
● La modalità (normale/muto).
Poi dovremmo considerare le azioni che è possibile svolgere:
● Cambiare canale.
● Aumentare o diminuire il volume
● Accendere o spegnere il dispositivo.
Le azioni non sono altro che le operazioni che un oggetto è in grado di
eseguire, mentre le caratteristiche rappresentano i dati che le azioni stesse
possono utilizzare per svolgere il proprio compito.
Nella programmazione ad oggetti le caratteristiche di un oggetto vengono denominate attributi e le azioni sono dette metodi (o member function).
Questo nuovo approccio alla programmazione è piuttosto distante dalla tradizionale tecnica procedurale topdown, che inpone sin dall'inizio, partendo dal main(), di creare tutte le procedure necessarie. E', invece, necessario partire identificando tutte le entità logiche che sono coinvolte nel problema e che devono essere rappresentate in termini di oggetti per poi associare a loro metodi ed attributi opportuni.
Classi
Nella OOP, gli oggetti vengono raggruppati in categorie, o classi, in base ai loro comuni attributi e metodi. Possiamo dire che:
Una classe rappresenta una categoria particolare di oggetti per i quali, dal punto di vista della informatico funge da tipo (di dato).
In sostanza un oggetto è un dato e la classe a cui appartiene è il suo tipo, così come per i dati primitivi sono gli interi e o le stringhe. Dobbiamo sottolineare che una classe non è un oggetto ma solo una sua definizione astratta e come tale non impegna un'area di memoria.
Per avere un oggetto, occorre creare un'istanza della classe specifica cui esso appartiene. Un'istanza della classe televisore sarà costituita da un oggetto di tale classe, ossia un oggetto di una certa marca, acceso o spento .. in grado di cambiare canale e di regolare il volume. Per creare un oggetto, ossia un'istanza di una classe, dobbiamo procedere alla sua dichiarazione; nel caso del televisore scriveremo ad esempio:
televisore tv1
La dichiarazione rappresenta la creazione di un oggetto di tipo (classe) televisore attraverso l'istanza dell'identificatore tv1.
Ovvviamente le le proprietà (attributi) di un oggetto possono cambiare nel tempo, ad esempio l'utente potrebbe aumentare la regolazione audio andando in questo modo a modificare il valore dell'attributo volume.
Si definisce stato di un oggetto l'insieme dei valori dei suoi attributi in un determinato istante di tempo.
Se cambia anche un solo attributo il suo stato cambia, di conseguenza.
Si definisce comportamento di un oggetto l'insieme dei metodi che esso è in grado di eseguire.
Un oggetto, rappresenta dunque, un'entità a se stante ben definita, che, nel corso dell'elaborazione, viene creata, utilizzata ed infine distrutta.
Attributi e metodi
Gli attributi (anche chiamati variabili di istanza) specificano le caratteristiche di una classe di oggetti, determinandone l'aspetto, lo stato, le altre qualità. E' possibile pensare agli attributi come degli aggettivi che descrivono gli oggetti. Esempi di proprietà possono quindi essere l'altezza, il colore, il nome etc..
I metodi (anche chiamati funzioni membro) specificano invece le funzionalità che la classe offe, cioè le operazioni ciascun oggetto di quella classe è in grado di compiere e che corrispondono ai loro comportamenti. E' utile pensare ai metodi come ai verbi che esprimono le azioni da compiere. Esempi di metodi possono essere 'stampa', 'ordina', 'muovi'
Un programma OOP scritto in C++ è solitamente composto da più classi. Ogni classe deve essere dichiarata specificando i suoi attributi ed i suoi metodi. La struttura base della dichiarazione di una classe è la seguente:
class <nomeClasse>
{
private:
<listaAttributi>
<listaMetodi>
public:
<listaAttributi>
<listaMetodi>
};
La parola chiave class serve per iniziare la definizione di una classe ed è seguita dal nome della classe stessa. Le parentesi graffe delimitano il corpo della classe. Una classe può essere scritta banalmente in modo del tutto analogo alla definizione di una struttura record, come si vede nello scritto sottostante dove si concepisce una classe veicolo caratterizzata da due soli attributi a (autonomia) ed l (volume del sebatoio).
#include
class V {// classe veicolo
public: //dichiarazione degli attributi
int a;//autonomia (km)
int l;//volume del serbatoio(lt)
};
main(){
V v1;//istanzio l'oggetto v1 di tipo V
v1.a=300;
v1.l=50;
cout<<"il veicolo ha un consumo:" << v1.a/v1.l << "km/l";
}//__________fine main
Uno scritto di questo tipo sarebbe sufficiente per descrivere un veicolo.
Ogni classe è dotata di attributi (membri
dati) che sono privati per impostazione predefinita.
Noi abbiamo fatto un'eccezione: li abbiamo dichiarati esplicitamente pubblici,
altrimenti non avremmo potuto accedervi.
In questo caso i due attributi, a ed l
pur facendo parte dell'oggetto v1, sono accessibili dal mondo esterno.
Raggiungibili con le invocazioni:
v1.a
v1.l
La filosofia di usare variabili locali e di minimizzare le variabili globali
all'interno del codice è sempre stata raccomandata fin da quando la programmazione
strutturata, organizzata in funzioni e procedure, ha soppiantato quella
sequenziale, caratterizzata da salti e rinvii fra i vari punti del programma
(istruzione goto).
Nella programmazione ad oggetti (OOP) questa tendenza è stata inasprita,
proprio per evitare l'utilizzo di variabili globali, dato che esse, essendo
visibili da tutti i punti del programma, possono influenzare, talvolta in
modo incontrollato, l'esecuzione dello stesso, nonché, rendono difficile
la gestione del codice, la sua manutenzione e il suo sviluppo .
La OOP è basata su tre principi fondamentali:
● Incapsulamento.
● Ereditarietà.
● Polimorfismo.
Il programma cha abbiamo scritto contravviene, dunque, il primo principio
della OOP: l'incapsulamento o information hiding
(occultamento dell'informazione) che consiste nel tenere il più possibile
nascosto lo stato dell'oggetto. La regola dell'information hiding afferma
che l'unico modo per modificare o interrogare lo stato di un oggetto è quello
di servirsi dei suoi metodi. Infatti è prevista la presenza di attributi
privati interni alle classi, invisibili all'esterno di queste, e che possono
essere resi accessibili solo attraverso opportuni metodi pubblici interni
alle classe stesse che possono essere invocati dall'esterno di esse. Lo
scopo dell'information hiding è quello di aumentare il livello di sicurezza
e di robustezza del software.
Incapsulamento
Una classe è una struttura di dati che contiene tutto il necessario per
memorizzare e manipolare dati.
Ogni variabile definita all'interno di una classe è denominata attributo,
si usa questo termine per distinguerla da una normale variabile.
All'interno della classe vengono implementate anche le funzioni che manipolano
gli attributi, esse sono denominate metodi.
Dovrebbe essere possibile manipolare gli attributi di una classe solo attraverso
i metodi della classe stessa, non come abbiamo fatto noi, sopra, dove il
main può accedere liberamente ai metodi e farne quello che vuole.
In linea di principio, attributi e metodi, sono strettamente racchiusi
all'interno di un'unica entità chiamata classe. Questo comportamento è denominato
incapsulamento. Per rendere accessibili parti di una classe ad altre parti
del programma è necessario dichiararle dopo la parola chiave public. Tutti
gli attributi e i metodi, dichiarati dopo public sono accessibili a tutte
le altre funzioni del programma. E' preferibile limitare o abolire l'uso
di attributi pubblici, si dovrebbe piuttosto rendere privati tutti i dati
e controllarne l'accesso tramite metodi pubblici. E' presto fatto:
#include < iostream >
using namespace std;
class V {//(veicolo)
int a;//autonomia (km)
int l;//volume del serbatoio(lt)
public://metodi pubblici
void seta(int x){a=x;}
int geta(){return a;}
void setl(int y){l=y;}
int getl(){return l;}
};
main(){
V v1;//istanzio l'oggetto
v1 di tipo V
v1.seta(300);
v1.setl(50);
cout << "il veicolo ha un consumo:" << v1.geta()/v1.getl() << "km/l";
}//__________fine main
All'interno del programma un'eventuale chiamata del tipo:
cout << v1.a;
genera un errore di compilazione dato che l'attributo a è privato e dunque
inaccessibile.
Sono, invece, stati implementati 4 metodi pubblici: seta
e setl per impostare i valori degli attributi privati
a ed l e geta
e getl che servono per restituire al programma chiamante
i valori di a ed l.
In quest'ultimo caso gli atributi a ed l
sono privati ed irraggiungibili dall'esterno.
La loro accessibilità è garantita dai 4 metodi pubblici.
Operatore di visibilità ::
Quella appena scritta non è l'unica versione possibile per scrivere la classe veicolo. Talvolta, per migliorare la leggibilità dello scritto è opportuno ricorrere all'operatore di visibilità :: , lasciando all'interno della classe solo i prototipi delle funzioni (metodi) e implementando queste ultime all'esterno del corpo della classe come si vede qui di seguito:
#include < iostream >
using namespace std;
class V {//(veicolo)
int a;//autonomia (km)
int l;//volume del serbatoio(lt)
public:
void seta(int x);//metodi
pubblici
int geta();
void setl(int y);
int getl();
};
void V::seta(int x){
a=x;
}
int V::geta(){
return a;
}
void V::setl(int y){
l=y;
}
int V::getl(){
return l;
}
main(){
V v1;//istanzio l'oggetto
v1 di tipo V
v1.seta(300);
v1.setl(50);
cout << "il veicolo ha un consumo:" << v1.geta()/v1.getl() << "km/l";
}//__________fine main
Costruttori e distruttori
Il costruttore permette all'oggetto appena creato di autoinizializzarsi. Un costruttore è un metodo speciale che appartiene alla classe e ha il medesimo nome della classe.
#include < iostream >
using namespace std;
class V {//(veicolo)
int a;//autonomia (km)
int l;//volume del serbatoio(lt)
public:
void seta(int x);//metodi
void setl(int y);
float consumo();
V(int x,int y);//costruttore
~V();//distruttore
}; //fine classe - implementazione dei metodi
void V::seta(int x){
a=x;
}
void V::setl(int y){
l=y;
}
float V::consumo(){
return (float)a/l;
}
V::V(int x,int y){//implementazione del costruttore
a=x; l=y;
}
V::~V(){//implementazione del distruttore
cout<<"\noggetto distrutto";
}
main(){
V v1(300,40);//istanzio l'oggetto v1 di tipo
V
cout< <"il veicolo ha un consumo:"<< v1.consumo()<<
"km/l";
}//__________fine main
Nel listato precedente sono state eliminati i metodi geta()
e getl() perché ci interessa conoscere solo il consumo
che ci verrà restituito tramite un apposito metodo pubblico che ci restituisce
il rapporto autonomia/litri del veicolo.
Inoltre si nota la dichiarazione e l'implementazione del costruttore. I
costruttori non possono restituire valori.
Il costruttore di un oggetto, è invocato al momento della creazione dell'oggetto
medesimo.
Ciò significa che è invocato quando viene eseguita la dichiarazione dell'oggetto.l'output
del programma è il seguente:
il veicolo ha
un consumo:7.5km/l
oggetto distrutto
Il complemento costruttore è il distruttore; in numerose circostanze, un
oggetto nel momento in cui viene distrutto deve eseguire una o più azioni
(oggetti locali sono creati all'entrata del relativo blocco di codice e
distrutti all'uscita).
Lo scopo principale del distruttore, rimane comunque la deallocazione della
memoria precedentemente richiesta all'atto dell'istanziazione da parte del
costruttore.
Ricordiamoci che una classe può avere più di un costruttore.
I diversi costruttori si devono differenziare per la firma (segnatura) cioè
per il numero e l'ordine dei parametri di inizializzazione passati.
Ereditarietà
Il maggior punto di forza della programmazione ad oggetti consiste nel facilitare la riusabilità del software. Scrivere un programma riusabile significa scrivere un programma facilmente modificabile e ampliabile da qualunque altro programmatore e non solo da chi lo ha concepito.
La riusabilità del software è permessa dell'ereditarietà (secondo principio della OOP). L'ereditarietà è un meccanismo, che in fase di definizione di una classe, permette solo di specificare le differenze rispetto ad una classe già esistente detta superclasse. Tutte le altre caratteristiche e i comportamenti della classe derivata che si sta definendo saranno gli stessi definiti dalla superclasse.
Supponiamo di voler creare degli oggetti rappresentativi la figura geometrica di un rettangolo di cui si voglia valutare l'area, protremmo avvalerci di uno scritto come il seguente
#include<iostream>
using namespace std;
class R {
private: int b;//base
int h;//altezza
int area(){return h*b;};
public://metodi pubblici
R(int x,int y){b=x;h=y;};//costruttore
int getB(){return b;};
int getH(){return h;};
int getA(){return area();};
};//fine classe R
main(){
R r(3,2);//istanzio l'oggetto r di tipo R
cout<<r.getA();
}//_____fine main
Stavolta notiamo la presenza del metodo privato area() che può essere accessibile
solo dall'interno della classe R.
Secondo la rappresentazione UML (Unified Modeling Language) la classe può
essere rappresentata da una tabella che ha come intestazione il nome della
classe, seguita dall'elenco degli attributi e da quello dei metodi (eccetto
il costruttore che è implicito). Qui sotto un esempio della classe R (rettangolo)
Si vedono contrassegnati col prefisso "-" metodi e gli attributi privati, mentre col simbolo "+" sono contrassegnati i metodi pubblici. Oltre al nome (identificatore) dell'attributo o del metodo viene poi specificato il tipo di dato di appartenenza dell'attributo o del tipo di dato restituito dal metodo.
Dal punto di vista logico potremmo valutare anche l'eventualità che la figura geometrica in questione possa essere anche un quadrato. Un quadrato, infatti, può essere concepito come un caso particolare di rettangolo. Una prima soluzione sarebbe costituita dall'implementazione di un secondo costruttore (si può, come abbiamo già detto), come si vede di seguito
R(int x, int y) {
b=x;h= y;
area();
}//costruttore1
R(int x) {
b=x;h=x;
area();
}//costruttore2
si tratterebbe di usare due tipi diversi di invocazione
R r (3, 2);
R r(5);
Una seconda soluzione consiste nello scrivere una seconda classe Q (quadrato) derivata dalla classe precedente R (rettangolo)
#include <iostream>
using namespace std;
class R {
private:
int b;//base
int h;//altezza
int area(){return h*b;};
public://metodi pubblici
R(int x,int y){b=x;h=y;};//costruttore
int getB(){return b;};
int getH(){return h;};
int getA(){return area();};
};//fine R
class Q: public R{
private:
int lato;//attributo privato
public://metodi pubblici
Q(int l):R(l,l){lato=l;};//costruttore
int getL(){return lato;};
};//___fine Q
main(){
R r(3,2);//istanzio l'oggetto r di tipo R
cout<<"area rettangolo:\n"<<r.getA();
Q q(5);//istanzio l'oggetto q di tipo Q
cout<<"\narea quadrato:\n"<<q.getA();
}//__________fine main
La classe padre (R) è definita come superclasse.
La classe figlio (Q) è definita come sottoclasse.
Si vede come il meccanismo dell'ereditarietà sia implementato direttamente nella sottoclasse dalla sintassi
class <sottoclasse> : public <superclasse> {
<corpo della sottoclasse>
} ;
La sottoclasse eredita dalla superclasse tutto: attributi e metodi. Se la superclasse è dotata di un costruttore con parametri, il costruttore della sottoclasse lo deve invocare e questo è evidente dall'istruzione
Q(int l):R(l,l){lato=l;};//costruttore
Come si vede dall'esecuzione, la classe Q può tranquillamente usare gli
attributi ed i metodi della classe padre R.
La rappresentazione in UML deve essere fatta nel modo seguente , con una
freccia che parte dalla sottoclasse diretta verso la superclasse.
Il meccanismo di ereditarietà è stato creato per risolvere l' esigenza
spesso ricorrente nella progettazione del software che è il riutilizzo del
codice stesso. Quando occorre modificare un programma, anche per aggiungere
nuove funzionalità, spesso è necessario riscrivere intere parti di codice.
A volte l'operazione è così complessa che diventa più semplice riscrivere
l'intero programma da zero.
L'ereditarietà e il polimorfismo costituiscono tuttavia due validi strumenti
che rendono notevolmente più semplice questo compito. L'ereditarietà permette
di creare nuove classi estendendo le caratteristiche di quelle già esistenti.
In questo modo si possono creare classi imparentate tra loro da relazioni
di gerarchia, i cui oggetti saranno in grado di svolgere in modo personalizzato
delle azioni comuni.
Polimorfismo
L'ereditarietà tra classi si realizza secondo due diverse modalità: per estensione e per ridefinizione.
Nel caso dell'estensione, la sottoclasse eredita proprietà e metodi della superclasse e ne aggiunge di propri più specializzati mentre nel caso della ridefinizione, la sottoclasse eredita le proprietà e i metodi della superclasse, ma i metodi vengono ridefiniti in modo da cambiare forma. Dunque:
Per polimorfismo, si intende la possibilità che un metodo possa cambiare forma attraverso una ridefinizione del suo comportamento.
Ad esempio, se tra le figure geometriche che stiamo considerando volessimo aggiungere i triangoli rettangoli. Sappiamo che il valore dell'area per il rettangolo è b*h ma per un triangolo rettangolo l'area ha valore b*h/2. Stavolta aggiungeremo l'attributo sup nella superclasse R per contenere il valore dell'area della figura geometrica, se anche la sottoclasse deve usare questo attributo esso deve essere dichiarato protected; in tal modo l'attributo sarà visibile ed accessibile solo alla sottoclasse TR (triangolo rettangolo).
#include <iostream>
using namespace std;
class R {
private:
int b;//base
int h;//altezza
int area(){return h*b;};
protected:
float sup;//superficie=area
public://metodi pubblici
R(int x,int y){
b=x;h=y;
sup=area();
};//costruttore
int getB(){return b;};
int getH(){return h;};
float getA(){return sup;};
};//fine R
class TR: public R{
private:
int altezza;
int larghezza;
float area(){return (float)larghezza*altezza/2;};
public://metodi pubblici
TR(int la,int al):R(la,al){
larghezza=la;
altezza=al;
sup=area();
};//costruttore
};//___fine TR
main(){
R r(3,2);//istanzio l'oggetto r di tipo R
cout<<"area rettangolo:\n"<<r.getA();
TR tr(5,3);//istanzio l'oggetto tr di tipo TR
cout<<"\narea triangolo rettangolo:\n"<<tr.getA();
cout<<endl<<"altezza triangolo:"<<tr.getH();
cout<<endl<<"larghezza triangolo:"<<tr.getB();
}//__________fine main
La funzione area() viene riscritta (ridefinita)
all'interno della classe TR.
int area(){return larghezza*altezza/2;};
Sotto questo aspetto avremmo potuto dichiarare protected
anche gli altri due attributi b ed h
della classe R, ridefinendo all'interno della
classe TR il calcolo dell'area come
int area(){return b*h/2;};
eliminando i due attributi privati larghezza e altezza
della classe TR.
Si nota in ogni caso che la classe derivata TR mantiene
il pieno possesso dei metodi getB(), getH()
e getA() ereditati dalla superclasse R.
Nel diagramma ULM per il codice appena scritto si vede che il modificatore di visibilità protected deve essere contrassegnato col simbolo #.
Il polimorfismo appena visto viene definito di overriding (sovrascrittura) consiste nell'avere metodi con lo stesso prototipo in classi differenti (prototipo=nome del metodo + segnatura del metodo).
In particolare, l'overriding consiste nel riscrivere in una sottoclasse un metodo della superclasse con la stessa segnatura (cioè con lo stesso nome e gli stessi parametri di ingresso).Più in generale si possono avere metodi con lo stesso prototipo in classi differenti.
Il polimorfismo può essere implementato anche tramite la tecnica dell'overload (sovraccarico): l'overload dei metodi consiste nel creare più metodi con lo stesso nome ma con segnatura diversa ( numero e ordine dei parametri passati ). Sarà eventualmente il compilatore a decidere in fase di run-time (late-binding) quale metodo applicare a secondo del tipo di parametri ricevuti in input.
Un tipico esempio di polimorfismo per overload è fornito dall'esercizio 7 dove la superclasse (O) implementa due metodi somma() con lo stesso nome ma con segnatura diversa che effettuano la somma di due operandi se si tratta di numeri oppure la concatenazione se gli operandi sono stringhe.