Appendice B
Introduzione alla OOP
Nel corso degli anni sono stati proposti diversi paradigmi di
programmazione, ovvero diversi modi di vedere e modellare la realta`
(paradigma imperativo, funzionale, logico...).
Obiettivo comune di tutti era la risoluzione dei problemi legati alla
manutenzione e al reimpiego di codice . Ciascun paradigma ha poi avuto
un impatto differente dagli altri, con conseguenze anch'esse diverse.
Assunzioni apparentemente corrette, si sono rivelate dei veri boomerang,
basti pensare alla crisi del software avutasi tra la fine degli anni
'60 e l'inizio degli anni '70.
In verita` comunque la colpa dei fallimenti non era in generale dovuta
solo al paradigma, ma spesso erano le cattive abitudini del programmatore,
favorite dalla implementazione del linguaggio, ad essere la vera causa
dei problemi. L'evoluzione dei linguaggi e la nascita e lo sviluppo di nuovi
paradigmi mira dunque a eliminare le cause dei problemi e a guidare
il programmatore verso un modo "ideale" di vedere e concepire le cose
impedendo (per quanto possibile e sempre relativamente al linguaggio)
"cattivi comportamenti".
Di tutti i paradigmi proposti, uno di quelli piu` attuali e su cui si
basano linguaggi nuovissimi come Java o Eiffel (e linguaggi derivati da
altri come l'Object Pascal di Delphi e lo stesso C++), e` sicuramente
il paradigma object oriented.
Ad essere precisi quello object oriented non e` un vero e proprio paradigma,
ma un metaparadigma. La differenza sta nel fatto che un paradigma definisce
un modello di computazione (ad esempio quello funzionale modella un programma
come una funzione matematica), mentre un metaparadigma generalmente si limita
a imporre una visione del mondo reale non legata ad un modello
computazionale. Di fatto esistono implementazioni del metaparadigma object
oriented basate sul modello imperativo (C++, Object Pascal, Java) o
su modelli funzionali (CLOS ovvero la versione object oriented del
Lisp).
Nel seguito, parleremo di paradigma ad oggetti (anche se il termine e`
improprio) e faremo riferimento sostanzialmente al modello fornito dal
C++; ma sia chiaro fin d'ora che non esiste un unico modello object
oriented e non esiste neanche una terminologia universalmente accettata.
Il paradigma ad oggetti tende a modellare una certa situazione (realta`)
tramite un insieme di entita` attive (che cioe` svolgono azioni)
piu` o meno indipendenti l'una dall'altra, con funzioni generalmente
differenti, ma cooperanti per l'espletamento di un compito complessivo.
Tipico esempio potrebbe essere rappresentato dal modello doc/view
in cui un editor viene visto come costituito piu` o meno da una
coppia: un gestore di documenti il cui compito e` occuparsi di tutto
cio` che attiene all'accesso ai dati e ad eseguire le varie possibili
operazioni su di essi, ed un modulo preposto alla visualizzazione dei dati
ed alla interazione con chi usa tali dati (mediando cosi` tra
utente e gestore dei documenti).
Possiamo tentare un parallelo tra gli oggetti della OOP (Object
Oriented Programming) e le persone che lavorano in una certa industria...
ci saranno diverse tipologie di addetti ai lavori con mansioni diverse:
operai piu` o meno specializzati in certi compiti, capi reparto,
responsabili e dirigenti ai vari livelli. Svalgono tutti compiti diversi,
ma insieme lavorano per realizzare certi prodotti ognuno occupandosi
di problemi diversi direttamente connessi alla produzione, altri col compito
di coordinare le attivita` (interazioni).
Comunque sia chiaro che gli oggetti della OOP sono in generale diversi
da quelli del mondo reale (siano esse persone, animali o cose).
Le entita` attive della OOP (Object Oriented Programming) sono dette
oggetti. Un oggetto e` una entita` software dotata di stato, comportamento
e identita`. Lo stato viene generalmente modellato tramite un insieme
di attributi (contenitori di valori), il comportamento e` costituito dalle
azioni (metodi) che l'oggetto puo` compiere e infine l'identita` e` unica,
immutabile e indipendente dallo stato, puo` essere pensata in prima
approssimazione come all'indirizzo fisico di memoria in cui l'oggetto si
trova (in realta` e` improprio identificare identita` e indirizzo, perche`
generalmente l'indirizzo dell'oggetto e l'indirizzo del suo stato, mentre
altre informazioni e caratteristiche dell'oggetto generalmente stanno
altrove).
Vediamo come tutto questo si traduca in C++:
|
class TObject {
public:
void Foo();
long double Foo2(int i);
private:
const int f;
float g;
};
|
In questo esempio lo stato e` modellato dalle variabili f, g
.
Il comportamento e` invece modellato dalle funzioni Foo() e
Foo2(int).
Gli oggetti cooperano tra loro scambiandosi messaggi (richieste per
certe operazioni e risposte alle richieste). Ad esempio un certo oggetto A puo` occuparsi di ricevere ordini relativi all'esecuzione di certe
operazioni aritmetiche su certi dati, per l'espletamento di tale compito
puo` affidarsi ad un altro oggetto Calcolatrice fornendo il
tipo dell'operazione da realizzare e gli operandi; l'oggetto Calcolatrice a sua volta puo` smistare le varie richieste a
oggetti specializzati per le moltiplicazioni o le addizioni.
L'insieme dei messaggi cui un oggetto risponde e` detto interfaccia
ed il meccanismo utilizzato per inviare messaggi e ricevere risposte e`
quello della chiamata di procedura; nell'esempio di prima, l'interfaccia e`
data dai metodi void Foo() e
long double Foo2(int).
Ogni oggetto e` caratterizzato da un tipo; un tipo in generale
e` una definizione astratta (un modello) per un generico oggetto.
Non esiste accordo su cosa debba essere un tipo, ma in generale e accettata
l'idea secondo cui un tipo debba definire almeno l'interfaccia di un
oggetto.
In C++ il tipo di un generico oggetto si definisce tramite la realizzazione
di una classe. Una classe (termine impropriamente utilizzato dal C++
come sinonimo di tipo) in C++ non definisce solo l'interfaccia di un
oggetto, ma anche la struttura del suo stato (vedi esempio precedente)
e l'insieme dei valori ammissibili.
Ogni tipo (non solo in C++) deve inoltre fornire dei metodi speciali il cui
compito e` quello di occuparsi della corretta costruzione e
inizializzazione delle singole istanze (costruttori) e della loro
distruzione quando esse non servono piu` (distruttori).
Quando lo stato di un oggetto non e` direttamente accessibile dall'esterno,
si dice che l'oggetto incapsula lo stato, taluni linguaggi (come il C++)
non costringono a incapsulare lo stato, in questi casi gli attributi
accessibili dall'esterno divengono parte dell'interfaccia.
L'incapsulamento ha diverse importanti conseguenze, in particolare forza
il programmatore a pensare e realizzare codice in modo tale che gli
oggetti siano in sostanza delle unita` di elaborazione che ricevono
dati in input (i messaggi) e generano altri messaggi (generalmente diretti
ad altri oggetti) in output che rappresentano il risultato della loro
elaborazione. In tal modo un applicativo assume la forma di un insieme di
oggetti che comunicando tra loro risolvono un certo problema.
Altro punto fondamentale del paradigma ad oggetti e` l'esplicita presenza
di strumenti atti a conseguire un facile reimpiego di codice
precedentemente prodotto. L'obiettivo puo` essere raggiunto in diversi
modi, ciascuna modalita` e` spesso legata a caratteristiche intrinseche di
un certo modello di programmazione object oriented. In particolare
attualmente le metodologie su cui si discute sono:
- Reimpiego per composizione, distinguendo tra
- contenimento diretto
- contenimento indiretto
- Reimpiego per ereditarieta`, distinguendo tra:
- ereditarieta` di interfaccia
- ereditarieta` di implementazione
- Delegation
ciascuna con i suoi vantaggi e suoi svantaggi.
Nel reimpiego per composizione, quando si desidera estendere o
specializzare le caratteristiche di un oggetto, si crea un nuovo tipo
che contiene al suo interno una istanza del tipo di partenza (o in
generale piu` oggetti di tipi anche diversi tra loro).
L'oggetto composto fornisce alcune o tutte le funzionalita` della sua
componente facendo da tramite tra questa e il mondo esterno, mentre le
nuove funzionalita` sono implementate per mezzo di metodi e attributi propri
dell'oggetto composto.
Un oggetto composto puo` contenere l'oggetto (e in generale gli
oggetti) piu` piccolo direttamente (ovvero tramite un attributo del tipo
dell'oggetto contenuto) oppure tramite puntatori (contenimento
indiretto):
|
class Lavoro {
public:
Lavoro(/* Parametri */);
/* ... */
private:
/* ... */
};
class Lavoratore {
public:
/* ... */
private:
Lavoro Occupazione; // contenimento diretto
/* ... */
};
class LavoratoreAlternativo {
public:
/* ... */
private:
Lavoro* Occupazione; // contenimento indiretto
/* ... */
};
|
Il contenimento diretto e` in generale piu` efficiente per diversi motivi:
- Non si passa attraverso puntatori ogni qual volta si debba accedere
alla componente;
- Nessuna operazione di allocazione o deallocazione da gestire e
semplificazione di problematiche legate alla corretta creazione
e distruzione delle istanze;
- Il tipo della componente e` completamente noto e sono possibili
tutta una serie di ottimizzazioni altrimenti non fattibili.
Il contenimento per puntatori per contro ha i seguenti vantaggi:
- La costruzione di un oggetto composto puo` avvenire per gradi,
costruendo le sottocomponenti in tempi diversi;
- Una componente puo` essere condivisa da piu` oggetti;
- Come vedremo utilizzando puntatori possiamo riferire a tutto
un insieme di tipi per quella componente, ed utilizzare di
volta in volta il tipo che piu` ci fa comodo (anche cambiando
a run time la componente stessa);
- In linguaggi come il C++ in cui un puntatore e` molto simile
ad un array, possiamo realizzare relazioni in cui un oggetto
puo` avere da 0 a n componenti, con n
determinabile a run time (la composizione diretta
richiederebbe di fissare il valore massimo per n).
Concettualmente la composizione permette di modellare facilmente una
relazione Has-a in cui un oggetto piu` grande possiede
uno o piu` oggetti tramite i quali espleta determinate funzioni (il
caso dell'esempio del Lavoratore che possiede un
Lavoro). Tuttavia e` anche possibili simulare una relazione
di tipo Is-a:
|
class Persona {
public:
void Presentati();
/* ... */
};
class Lavoratore {
public:
void Presentati();
/* ... */
private:
Persona Io;
char* DatoreLavoro;
/* ... */
};
void Lavoratore::Presentati() {
Io.Presentati();
cout << "Impiegato presso " << DatoreLavoro << endl;
}
|
Molte tecnologie ad oggetti (ma non tutte) forniscono un altro meccanismo
per il reimpiego di codice: l'ereditarieta`.
L'idea di base e` quella di fornire uno strumento che permetta di
dire che un certo tipo (detto sottotipo o tipo derivato) risponde
agli stessi messaggi di un altro (supertipo o tipo base) piu` un
insieme (eventualmente vuoto) di nuovi messaggi.
Quando si eredita solo l'interfaccia di un tipo (ma non la sua
implementazione, ne l'implementazione dello stato e/o di altre
caratteristiche del supertipo) si parla di ereditarieta`
di interfaccia:
|
class Interface {
public:
void Foo();
double Sum(int a, double b);
};
class Derived: public Interface {
public:
void Foo();
double Sum(int a, double b);
void Foo2();
};
void Derived::Foo() {
/* ... */
}
double Derived::Sum(int a, double b) {
/* ... */
}
void Derived::Foo2() {
/* ... */
}
|
Si noti che quando si e` in presenza di ereditarieta` di interfaccia, la
classe derivata ha l'obbligo di implementare tutto cio` che eredita
(a meno che non si voglia derivare una nuova interfaccia),
poiche` l'unica cosa che si eredita e` un insieme di nomi (identificatori
di messaggi) cui non e` associata alcuna gestione. Infine (almeno in C++)
per (ri)definire un metodo dichiarato in una classe base, la classe
derivata deve ripetere la dichiarazione (ma cio` potrebbe non essere vero
in altri linguaggi).
Alcuni modelli di OOP consentono l'ereditarieta`
dell'implementazione (es. il C++), che puo` essere vista come caso
generale in cui si eredita tutto cio` che definiva il supertipo; dunque non
solo l'interfaccia ma anche la gestione dei messaggi che la costituiscono e
pure l'implementazione dello stato del supertipo. Il vantaggio
dell'ereditarieta` di implementazione viene fuori in quelle situazioni in
cui il sottotipo esegue sostanzialmente gli stessi compiti del supertipo
allo stesso modo (cambiano al piu` poche cose). Qualora il sottotipo
dovesse gestire un messaggio in modo differente, viene comunque data la
possibilita` di ridefinirne la politica di gestione:
|
class Base {
public:
void Foo() { return; }
double Sum(int a, double b) { return a+b; }
/* ... */
private:
/* ... */
};
class Derived: public Base {
public:
void Foo();
double Sum(int a, double b);
void Foo2();
};
void Derived::Foo() {
Base::Foo();
/* ... */
}
void Derived::Foo2() {
/* ... */
}
|
Nell'esempio appena visto la classe Derived eredita da
Base tutto cio` che a quest'ultima apparteneva (interfaccia,
stato, implementazione dell'interfaccia); Derived aggiunge
nuove funzionalita` (Foo2()) e ridefinisce alcune
di quelle ereditate (ridefinizione di Foo()), mentre
altre funzionalita` vanno bene cosi` come sono (Sum())
e dunque la classe non le ridefinisce.
In alcuni sistemi potrebbe essere fornita la sola ereditarieta` di
interfaccia, cosi` che le sole possibilita` sono ereditare da una
interfaccia per definire una nuova interfaccia, oppure utilizzare le
interfacce per definire le operazioni che possono essere compiute su
un certo oggetto (in questo caso si definisce la struttura di un
certo insieme di oggetti dicendo che essi rispondono a quella
interfaccia utilizzando una certa implementazione).
L'ereditarieta` modella in generale una relazione di tipo
Is-a poiche` un sottotipo rispondendo ai messaggi del
supertipo potrebbe essere utilizzato in sostituzione di
quest'ultimo. La sostituzione di un supertipo con un sottotipo comunque
non e` di per se garantita dalla ereditarieta`, perche` cio`
avvenga deve valere il principio di sostituibilita` di Liskov.
Tale principio afferma che la sostituibilita` e` legata non (solo) all'interfaccia dell'oggetto, ma al comportamento;
nulla infatti vieta in molti linguaggi OO (C++ compreso) di fare in modo
che un sottotipo risponda ad un messaggio con un comportamento non
coerente a quello del supertipo (ad esempio il metodo
Presentati() del tipo Lavoratore potrebbe
fare qualcosa totalmente diversa dalla versione del tipo
Persona come visualizzare il risultato di una somma).
E` anche possibile utilizzare l'ereditarieta` per modellare relazioni
Has-a, ma si tratta spesso (praticamente sempre) di un
grave errore e quindi tale caso non verra` preso in esame poiche` un
linguaggio (o una tecnologia) OO fornisce sempre almeno il
contenimento (o una qualche sua espressione).
Infine la delegation e` un meccanismo che tenta di mediare
composizione e ereditarieta`. L'idea di base e` quella di consentire
ad un oggetto di delegare dinamicamente certi compiti a altri oggetti.
Esprimere tale possibilita` in C++ non e` semplice, perche` dovremmo
ricorrere comunque al contenimento per implementare tale meccanismo:
|
class TRectangle {
public:
int GetArea();
/* ... */
};
class TSquare {
public:
int GetArea() {
return RectanglePtr -> GetArea();
}
private:
TRectangle* RectanglePtr;
};
|
Tuttavia in un linguaggio con delegation potrebbero essere
forniti strumenti opportuni per gestire dinamicamente problemi di
delega e probabilmente essere soggetti a vincoli di natura diversa
da quelli imposti dal C++.
In presenza di ereditarieta` (sia essa di interfaccia che di
implementazione), viene spesso fornito un meccanismo che permette di
lavorare uniformemente con tutta una gerarchia di classi astraendo dai
dettagli specifici della generica classe e sfruttando solo una interfaccia
comune (quella della classe base da cui deriva la gerarchia). Tale
meccanismo viene indicato con il termine polimorfismo e viene
implementato fornendo un meccanismo di late binding, ovvero
ritardando a tempo di esecuzione il collegamento tra un generico oggetto
della gerarchia e i suoi membri.
Per una piu` approfondita discussione sul polimorfismo si rimanda
al capitolo IX, paragrafo 9.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|