Prof. Roberto Cipollone Corso di informatica per la

Transcript

Prof. Roberto Cipollone Corso di informatica per la
Prof. Roberto Cipollone
Corso di informatica
per la quarta classe dell'ITIS
-Programmazione Orientata agli Oggetti-OOP-
OOP: incapsulamento, classi, oggetti, stato interno e metodi
Come si diceva, per molto tempo i linguaggi di programmazione non hanno offerto strumenti
linguistici per una rappresentazione semplice e naturale di un ADT. Questo fino agli anni 90, quando
fecero il loro ingresso sulla scena i linguaggi OOP (Object Oriented Programming, OOP in sigla, cioè
programmazione orientata agli oggetti).
Tre sono le parole chiave della OOP
incapsulamento
ereditarietà
polimorfismo
Con il termine incapsulamento si intende la possibilità di definire in un programma rappresentazioni
di oggetti del mondo reale usando strutture (le classi) che contengono al loro interno (incapsulati,
appunto) sia i dati che descrivono le caratteristiche degli oggetti sia le funzioni (metodi) che
corrispondono al comportamento degli oggetti.
In pratica viene superata la storica separazione tra strutture dati e sottoprogrammi che le usano.
class Cani
{
public string Razza;
public string Nome;
public string Verso()
{
return "bau bau!";
}
}
Ecco qui a lato come si potrebbe definire una classe per rappresentare il concetto di cane in C#.
All’inizio sono elencate le caratteristiche (Razza e Nome) che definiscono il cosiddetto stato interno
di un oggetto) e di seguito i sottoprogrammi (li chiameremo metodi) che potranno essere usati con
un oggetto di tipo Cani.
La classe introduce di fatto un nuovo tipo : il programmatore potrà dichiarare variabili di tipo Cani.
Public davanti alle dichiarazioni delle variabili e dei metodi li rende visibili all’esterno.
Il programmatore rispetto ai linguaggi non OOP può chiaramente distinguere nel codice la parte che
corrisponde alla rappresentazione del cane. Il compilatore da parte sua potrà controllare ‘abusi’ come
tentare di usare con un cane sottoprogrammi che non sono della classe dei cani (se un
sottoprogramma non appare incluso nel blocco della classe, non potrà essere richiamato con un
oggetto di tipo Cani):
…
Cani Trovatello = new Cani();
Trovatello.Razza=”bastardino”;
Trovatello.Nome=”Boby”;
La prima istruzione crea un nuovo oggetto (new) di tipo Cani: Trovatello; Trovatello ha una razza ed
un nome; poiché queste caratteristiche sono public le possiamo manipolare direttamente. Allo stesso
modo potremo invocare solo i metodi public:
Console.WriteLine( Trovatello.Verso() );
In questo caso abbiamo invocato il metodo Verso che restituirà la stringa “bau bau” (si può anche dire
che abbiamo inviato all’oggetto il messaggio Verso; di nuovo, lo possiamo fare perché il metodo è
public.
Non è possibile sbagliare ed invocare un metodo come in:
Console.WriteLine (Trovatello.Miagola() );
… perché semplicemente non esiste un metodo Miagola nei cani! Il compilatore rifiuta il codice e
segnala un errore. Senza classi il compilatore potrebbe in tante situazioni accettare cose senza senso
ma corrette da un punto di vista sintattico (per chiamare un sottoprogramma senza errori da parte del
compilatore di un linguaggio non OOP è sufficiente rispettare il numero ed il tipo dei parametri
previsti).
DEFINIZIONE. Una classe è un modello per creare oggetti descritti dalle stesse caratteristiche (che
possono però assumere valori diversi) e che mostrano comportamenti identici se sollecitati con gli
stessi stimoli a partire dalle stesse condizioni iniziali (cioè stato interno identico).
Il concetto non è del tutto estraneo ai programmatori tradizionali: voi stessi siete già abituati ad usare
delle classi ‘predefinite’. Ad esempio potremmo dire che il tipo Integer è la classe per definire le
variabili integer (gli oggetti) con cui rappresentiamo i numeri interi. Allo stesso modo sono già definiti
dei ‘comportamenti’ per oggetti di questo tipo: le variabili intere ‘sanno’ sommarsi tra loro, sottrarsi
ecc.
Una classe da sola non serve a niente, come del resto non servirebbe a niente il solo tipo integer. E’
necessario creare esemplari di oggetti di una classe, allo stesso modo in cui è necessario definire
variabili integer per lavorare con numeri interi.
DEFINIZIONE. Un oggetto è un ben preciso esemplare (istanza) di una classe.
Il programmatore definisce una classe e crea sulla base di essa tutte le istanze che ritiene necessarie.
Definisce ad esempio la classe ‘Cane’ e crea (istanzia) tanti oggetti di quella classe quanti sono i cani
di cui ha bisogno nel suo programma. Quindi non confondete: la classe è il modello, gli oggetti sono
gli esemplari che si basano su quel modello.
‘Razza’ e ‘Nome’ sono le caratteristiche che avranno tutti gli oggetti creati a partire da questo modello
‘Cani’ (ovviamente ciascun oggetto avrà il suo nome e la sua razza). L’insieme delle variabili che
descrivono un oggetto è chiamato stato interno.
Nella terminologia OOP i sottoprogrammi di una classe sono chiamati metodi. La funzione ‘Verso’ è
quindi l’unico metodo definito per la classe ‘Cani’. Metodi di classi diverse potrebbero avere lo stesso
nome. Ad esempio potremmo aggiungere nel programma la classe dei Gatti ed anche per questi ultimi
decidere di scrivere un metodo chiamato Verso. Il compilatore non farebbe confusione: dal tipo
dell’oggetto saprebbe quale metodo mandare in esecuzione.
In generale quando ci si riferisce alle variabili dello stato interno od ai metodi si parla di membri della
classe.
I metodi sono condivisi tra tutti gli oggetti di una classe: non viene cioè mantenuta una copia del
codice per ogni oggetto. Ogni metodo invocato opera però sullo stato interno dell’oggetto per il quale
è stato invocato.
Ad esempio, se cane1 e cane2 fossero due oggetti della classe Cani , e se vi fosse un metodo float
Peso() che restituisce il peso di un cane, i comandi cane1.Peso() e
cane2.Peso()
attiverebbero lo stesso metodo (Peso) ma funzionante sugli stati interni rispettivamente di cane1 e
cane2 e nel primo caso avremmo il peso di cane1 e nel secondo caso il peso di cane2.
Variabili reference
Cani ilMioCane=null;
Con la precedente istruzione viene solo dichiarata una variabile di tipo Cani. ATTENZIONE: l’oggetto
non esiste ancora. La variabile ilMioCane non è l’oggetto ma un riferimento (reference) ad esso. Altri
linguaggi (C, C++) chiamano queste variabili puntatori, nel senso che ‘puntano’ cioè individuano l’area
in memoria dove VERRA’ creato l’oggetto.
L’istruzione di creazione è la seguente: ilMioCane = new Cani(); la parola chiave new chiarisce in
modo inequivocabile che si vuole un nuovo oggetto richiamando il costruttore dei Cani. Il valore
restituito da new è l’indirizzo dell’oggetto in memoria (l’indirizzo del suo primo byte): il punto in cui
sarà memorizzata la coppia di valori Razza/Nome per questo oggetto:
PRIMA DEL COMANDO NEW
ilMioCane
DOPO IL COMANDO
ilMioCane = new Cani();
ilMioCane
DOPO IL COMANDO
ilMioCane = new Cani (“Bichon Frisè”, “Tea”);
NOTA: in questo ultimo esempio sto immaginando che per la classe Cani esista un
secondo costruttore (ricordate il discorso sugli overload??) che accetta da subito la razza
ed il nome del cane.
ilMioCane
Questa gestione della memoria è detta dinamica in contrapposizione a quella statica che avete usato
fino ad ora: con la dichiarazione int x; la variabile x è da subito utilizzabile perché l’area di memoria
per contenere il suo valore è allocata dall’inizio alla fine dell’esecuzione del programma. La memoria
dinamica viene occupata invece a comando (quando l’oggetto viene creato) e restituita a comando o
in automatico quando l’oggetto viene distrutto. In automatico significa che periodicamente viene
avviata una procedura (garbage collection, letteralmente ‘raccolta della spazzatura’) volta ad
individuare gli oggetti non raggiunti (referenziati) da una variabile reference.
Ribadisco che è quindi un errore MOLTO grave tentare di usare un oggetto prima di averlo creato: ad
esempio tentare di accedere al valore di una delle variabili dello stato interno o peggio tentare di
modificarne una; allo stesso modo tentare di invocare un metodo. La creazione avviene invocando il
cosiddetto costruttore, un metodo ‘speciale’.
E’ speciale per vari motivi:
come nome ha lo stesso della classe
anche se non ne viene definito uno esplicitamente (non lo abbiamo fatto per i Cani … ma abbiamo in
effetti potuto creare un cane) la classe può contare sulla presenza di un costruttore standard (che si
preoccupa almeno di allocare la memoria per le variabili dello stato interno)
non può restituire valori
il programmatore può (anzi è auspicabile!) scrivere personalmente il costruttore (addirittura può
aggiungere diverse versioni del costruttore per dare la possibilità di creare un oggetto di quella classe
in diversi modi) e non c’è modo di creare un oggetto evitando il costruttore: è quindi un ottimo punto
in cui mettere codice importante che deve essere eseguito prima che qualcuno inizi ad interagire con
l'oggetto; ad esempio le inizializzazioni delle variabili dello stato interno, eventualmente con parametri
inviati al costruttore stesso
Errori da evitare
Cani.Nome = “Diablo” è un uso sbagliato: infatti Cani è il nome della classe e non di un oggetto
creato con new.
Nome = “Diablo”
Anche usare il nome di una variabile dello stato interno senza mettere davanti
l’identificatore dell’oggetto è sbagliato: non si saprebbe a che istanza (a quale cane) si vuole
assegnare il nome.
Costruttori e distruttori definiti dal programmatore, overloading dei metodi
class Cani
{
string Razza="";
string Nome="";
public Cani(string Razza, string Nome)
{
this.Razza = Razza;
this.Nome = Nome;
}
public string FaiIlverso()
{
return "bau bau!";
}
}
Il costruttore standard (quello non scritto da noi ma reso disponibile in automatico) si limita a fare
spazio in memoria per le variabili dello stato interno ma non ci costringe ad indicare il loro valore. Il
programmatore può per fortuna aggiungere altri costruttori che si preoccupano di dare un valore ad
alcune (eventualmente tutte ma di solito solo quelle più importanti) le variabili dello stato interno.
Naturalmente non siamo limitati a questo: possiamo far compiere al costruttore qualsiasi cosa. Ad
esempio potremmo decidere che come ultima operazione il costruttore visualizzi sullo schermo
l’immagine del cane.
NOTA
I costruttori devono essere dichiarati di pubblico accesso: public Cani().
Diversamente verrebbero considerati private (livello di accessibilità di default) e non potremmo
richiamarli dall’esterno !
Il valore private di default è la scelta più aderente ai principi dell’information hiding …
Ho evidenziato in grassetto il costruttore. Il suo compito in questo programma è solamente quello, si
diceva, di copiare i parametri inviati da chi sta chiedendo di creare l’oggetto nelle corrispondenti
variabili dello stato interno. L’istruzione
this.Razza = Razza
richiede una spiegazione. Poiché è spontaneo dare al parametro lo stesso nome della variabile dello
stato interno (rappresentano la stessa cosa, parametro Razza e variabile dello stato interno Razza) ci
si ritroverebbe a comandare Razza=Razza per copiare il parametro. Ma è ovvio che la scrittura è
ambigua: non si distingue il parametro dalla variabile interna. In questi casi si può ricorrere al
reference speciale this che significa ‘di questa istanza della classe in cui si sta scrivendo il codice’.
Quindi this.Razza significa la variabile Razza dello stato interno dell’istanza dell’oggetto che si sta
usando, non il parametro. Ambiguità risolta.
Ovviamente sarebbe altrettanto valido usare un nome diverso per il parametro e non usare this:
public Cani(string _Razza, string _Nome)
{
Razza = _Razza;
Nome = _Nome;
}
NOTA: nel momento in cui si aggiunge anche un solo costruttore ad una classe, quello standard che
non prevede parametri non è più disponibile. Se si vuole lasciare la possibilità di creare un oggetto
senza parametri bisogna aggiungere esplicitamente un secondo costruttore senza parametri: public
Cani() { }
Overloading
Notiamo innanzitutto che anche questo secondo costruttore si chiama Cani. Questa è una possibilitàò
che ci dà il c++ e la possiamo vedere in azione in tutta la sua convenienza proprio con i costruttori.
Costruttori che richiamano altri costruttori
L’idea è quella di scrivere molto meno codice quando si vogliono realizzare versioni dei costruttori con
meno parametri di altri facendo sì che quelli che ne ricevono di meno richiamino quelli più completi
fornendo un valore di default per i parametri mancanti.
Consideriamo la seguente classe che descrive dei calciatori:
classe Calciatori
{
string cognome=””, squadra=””;
double ingaggio=0;
int eta=0;
public Calciatori(string _cognome, string _squadra, int _eta)
{ cognome = _ cognome; squadra = _squadra; eta = _eta; }
}
public Calciatori(string _cognome, string _squadra)
{ cognome = _ cognome; squadra = _squadra; }
Il secondo costruttore è meno completo del primo, da usare quando non si conosce l’età di un
calciatore. A parte questo fa le stesse cose del primo: codice ripetuto inutilmente (e anche da
manutenere due volte). Facciamo in modo che il secondo costruttore chiami il primo che sa già cosa
farsene del cognome e della squadra:
public Calciatori(string _cognome, string _squadra) : this (_cognome, _squadra, 0)
{}
ora il secondo costruttore ‘passa’ cognome e squadra al primo di questa (this) stessa classe che però
pretende tre parametri; ma questo secondo costruttore non ha il terzo parametro ed allora il
programmatore decide di mettere 0. Ora non abbiamo più doppioni di codice ed abbiamo mantenuto
la possibilità di creare un calciatore in due modi.
Metodi setter e getter
Come già accennato, tutti i membri di una classe sono automaticamente definiti privati ( private).
Questo aiuta il programmatore a rispettare la filosofia dell’ information hiding: ci deve essere un buon
motivo per rendere accessibile un membro di una classe. Per rendere accessibile una variabile dello
stato interno o un metodo bisogna far precedere la sua dichiarazione dal modificatore di accessibilità
public, come abbiamo visto per il costruttore.
Poter leggere il valore di una variabile dello stato interno o modificarne il valore NON è un buon
motivo per renderla pubblica. Meglio rendere possibili queste azioni in modo controllato attraverso dei
metodi (questi ovviamente pubblici altrimenti non potremmo invocarli e saremmo punto a capo).
Vediamo ad esempio come leggere/modificare correttamente il valore della variabile Razza di un cane:
per leggere è stato aggiunto il metodo pubblico getRazza; il nome del metodo è composto dal nome
della variabile il cui valore viene restituito e dal prefisso get (prendere); questa è una convenzione
abbastanza diffusa ma non è un obbligo ed avremmo potuto scegliere un qualsiasi alto nome;
simmetricamente per modificare il valore della stessa variabile è stato aggiunto un metodo
setRazza(string Razza), prefisso set (cambiare); ovviamente il parametro è il valore da assegnare alla
variabile dello stato interno:
… parte iniziale classe omessa per brevità …
public string getRazza()
{ return Razza; }
public void setRazza(string Razza)
{ this.Razza = Razza; }
Ed ecco un esempio d’uso: (parte iniziale programma omessa per brevità)
Cani ilMioCane = new Cani("Bichon Frisè", "Tea");
Console.WriteLine(ilMioCane.getRazza());
//scrive 'Bichon Frisè'
Console.WriteLine("Inserire nuova razza: ");
string nuovaRazza = Console.ReadLine();
ilMioCane.setRazza(nuovaRazza);
Console.WriteLine("Razza modificata: " + ilMioCane.getRazza());
Si potrebbe obiettare che sembra un meccanismo che ha complicato un’operazione che, definendo
public la variabile Razza, avrebbe potuto essere codificata semplicemente con:
Console.WriteLine(ilMioCane.Razza); e
ilMioCane.Razza = nuovaRazza
Certamente è più semplice procedere in questo modo ma anche decisamente più pericoloso. I metodi
set e get, infatti, possono far ben di più che semplicemente restituire/modificare il valore della
variabile dello stato interno: ad esempio controllare se l’accesso è consentito a chi lo sta richiedendo
(pensate ai valori di un conto corrente bancario!), presentare all’esterno i dati in modo diverso da
quello di memorizzazione interno (ad esempio internamente una data potrebbe essere mantenuta in
forma anglosassone ma se richiesta venire restituita in formato italiano), compiere altre operazioni
necessarie dopo una modifica (se in un gioco di ruolo viene aumentata la ‘forza’ di un giocatore anche
la sua immagine a video deve essere ‘irrobustita’).
Ereditarieta’, generalizzazione e specializzazione
E’ il meccanismo sintattico che consente di definire nuove classi più specializzate (dette classi
derivate) a partire da una classe preesistente (detta classe base) condividendone lo stato interno ed
i metodi (ma con la possibilità di adattarne o sostituirne alcuni).
L’idea di fondo è quella di non ripetere in tutte le classi derivate le stesse variabili e le stesse funzioni
ma di sfruttare quelle presenti nelle classi base.
Se tutti i sotto tipi di animali hanno un nome scientifico perché ripeterlo come variabile per ogni
specie? Conviene derivare tutte le specie da una classe base ‘Animali’ dove metteremo una volta sola
tutte le variabili comuni a Insetti, Mammiferi ecc. Stessa cosa per i metodi.
Generalizzazione e specializzazione
Nel progettare la gerarchia delle classi a volte viene spontaneo partire dal fondo, dalle classi derivate,
ed individuare le classe antenate. Questo modo di procedere implica un processo mentale di
generalizzazione (identifico le parti che accomunano le classi derivate e le concentro in una classe
antenata). Si procede dal particolare al generale (dall’insetto all’animale).
Altre volte è più spontaneo partire dalla cima, dalle classi antenate e individuare le classi derivate. In
questo caso si procede invece per specializzazione, dal generale al particolare (dall’animale al
cane).
Programmare per differenze. Adottare il meccanismo dell’ereditarietà per la scrittura del codice
significa programmare per differenze, una prospettiva molto efficace!! Ad esempio, per definire
un insetto non si parte da zero, ma si dice in cosa esso si distingue da un animale generico. Oppure
dopo aver realizzato una complessa simulazione di una partita di basket derivo quella per una partita
di pallamano (si tratta sempre di gestire un certo numero di giocatori, un campo di gioco e regole).
Meglio: se in prospettiva dovrò realizzare simulazioni di altri sport, metto a fattore comune tutto ciò
che nei diversi giochi si assomiglia:
NOTA IMPORTANTE: a differenza di quello che accade nella programmazione tradizionale, per
realizzare il codice per il basket e la pallamano, NON copio/incollo il codice che voglio usare della
classe dei giochi! Tutto ciò che si decide di ereditare dai giochi è disponibile nel basket e nella
pallamano grazie al meccanismo dell’ereditarietà.
Derivazione da una classe base
Vediamo come concretamente derivare in C# una classe da un’altra. Immaginiamo di avere definito in
un sorgente la classe degli animali. Tutti gli animali hanno una razza ed un nome scientifico, per cui
questi attributi sono solo in questa classe e non ripetuti inutilmente in ogni classe derivata. La classe
dei leoni viene derivata indicando dopo il suo nome quello della classe antenata separando con due
punti i due nomi:
class Animali //classe base (madre)
{
string Razza = "NON SPECIFICATA";
string NomeScientifico = "NON SPECIFICATO";
public Animali() { }
public Animali(string Razza, string NomeScientifico)
{
this.Razza = Razza;
this.NomeScientifico = NomeScientifico;
}
}
public string getRazza()
{ return Razza; }
NOTA BENE. Il costruttore della classe figlia di solito invoca uno dei costruttori della classe madre
ovviamente fornendo i parametri previsti: public Leoni(Criniera) : base("Leone", "Micius
Magnum")
Significa che prima di tutto sarà invocato questo costruttore e solo poi si procederà con l’esecuzione
delle istruzioni del costruttore della classe figlia. Questo dovrebbe essere sempre fatto e per un motivo
molto logico: chiedo a chi lo sa fare (il costruttore della classe madre) di inizializzare correttamente la
parte ereditata. Diversamente ci ritroveremmo con un oggetto ‘mal formato’ (senza un valore di
partenza per la razza ed il nome scientifico).
NOTA IMPORTANTE: cosa accade se per la classe figlia non viene definito alcun costruttore (cosa
perfettamente lecita) ?
class Leoni : Animali //classe figlia
{
string Criniera = "NON DEFINITA";
}
class Leoni : Animali //classe derivata (figlia)
{
string Criniera = "NON DEFINITA";
public Leoni(string Criniera) : base("Leone", "Micius Magnum")
{
this.Criniera = Criniera;
}
}
RISPOSTA: nulla a patto che nella classe madre sia stato definito anche il costruttore senza parametri.
Infatti nella logica di inizializzare correttamente lo stato interno ereditato il compilatore tenterà di
richiamare in automatico almeno il costruttore vuoto.
Differenziare le classi derivate
La classe derivata non può essere un semplice clone di quella base ma deve potersi differenziare. Le
principali possibilità sono: aggiungere nuove variabili e/o metodi (ovvio), sostituire in toto variabili e/o
metodi non adatti per la classe derivata.
Sostituire una variabile ereditata
Lo si può fare dichiarando una variabile con lo stesso nome (il tipo può essere diverso).
class Figlia
{ double codice=””;
…
}
class Madre
{ public string codice=””;
….
}
Quando in un metodo della classe derivata verrà usato l’identificatore codice senza niente altro si farà
riferimento alla sua variabile; per accedere invece alla variabile della classe base bisognerà premettere
base. come in base.codice=”KJX778A”;
Il livello di accessibilità protected
Nell’esempio d’uso di base.codice appena visto non è un dettaglio trascurabile che la variabile codice
sia stata definita public; se fosse stata private neppure la classe derivata avrebbe potuto accedervi.
Siamo di fronte ad un dilemma: da una parte è normale aspettarsi il poter accedere alle variabili od ai
metodi ereditati ma se per poterlo fare fossimo costretti a rendere public variabili e metodi
esporremmo la classe base a seri rischi vanificando i benefici dell’Information Hiding. E non vorremmo
risolvere mettendo a tutto dei metodi get e set: vorremmo concedere l'accesso non a tutti ma solo alle
classi figlie.
Per questo tutti i linguaggi OOP introducono un livello di accessibilità dedicato alle classe derivate:
protected. Una variabile od un metodo protected concede la visibilità alle classi derivate ma la nega
a tutte le altre (proprio come private):
class Figlia
{ double codice=””;
…
}
class Madre
{ protected string codice=””;
….
}
Sostituire un metodo ereditato
Immaginiamo di aver definito una classe per rappresentare dei rettangoli e di aver derivato poi da
questa un’altra classe per descrivere dei quadrati (un quadrato può essere considerato un caso
speciale di rettangolo). Per migliorare l’efficienza del calcolo del perimetro nelle due figure si può
decidere di usare un metodo diverso per i quadrati invece di usare quello ereditato dai rettangoli:
mentre nei rettangolo la formula di calcolo è ‘somma delle basi per due’ nei quadrati diventa ‘lato per
quattro’; lo vogliamo fare perché il microprocessore è particolarmente efficiente quando deve
moltiplicare un numero per una potenza del due (dovreste ricordare da Sistemi che è sufficiente fare
uno shift verso sinistra dei bit che rappresentano il numero).
Iniziamo con la classe dei rettangoli:
I rettangoli hanno un costruttore senza parametri ed uno che accetta i valori della loro base ed
altezza.
Notate la dichiarazione protected per queste due variabili. Solo il codice delle classi figlie di una
classe può accedere ad un membro protected.
Se fossero state dichiarate private la classe figlia dei quadrati non potrebbe accedere al loro valore
per definire una tecnica più efficiente di calcolo del perimetro.
E’ presente poi il metodo per il calcolo del perimetro.
class Rettangoli
{
string Colore = "nero";
protected float LaBase = 0;
protected float altezza = 0;
public Rettangoli() { }
public Rettangoli(float LaBase, float altezza)
{
this.LaBase = LaBase;
this.altezza = altezza;
}
}
public float perimetro()
{ return (LaBase + altezza) * 2;
}
Oltre al costruttore senza parametri i quadrati ne hanno un secondo che accetta come unico
parametro il valore del lato. Nella classe Quadrati non è prevista una variabile per memorizzarlo ma si
è deciso di sfruttare la classe Rettangoli con :base(lato, lato) che richiama il costruttore della
classe base fornendogli i parametri richiesti (base, altezza) che nel caso di un quadrato sono uguali al
lato (lato, lato).
Il lato viene memorizzato due volte, in effetti.
E vediamo ora la classe dei quadrati:
class Quadrati : Rettangoli
{
public Quadrati() { }
public Quadrati(float lato) : base(lato, lato) { }
}
new public float perimetro()
{
return 4 * LaBase;
}
NB: la keyword new davanti alla dichiarazione del metodo ‘nuovo’ è obbligatoria.
Dubbio … Dubbio … Invece di usare :base(lato, lato) non avremmo pututo semplicemente assegnare il
valore alle variabili LaBase ed altezza direttamente nel costruttore dei quadrati, visto che il livello di
visibilità protected lo consente?
class Quadrati : Rettangoli
{…
public Quadrati(float lato) { LaBase = lato; altezza = lato; } }
Prima di tutto osserviamo che questo codice è lecito e funziona perfettamente. Però stiamo
vanificando ciò che viene offerto dall’ereditarietà: siamo miseramente ricaduti nel copia incolla con
tutti i problemi del caso. Uno su tutti: dovessimo trovare errori o voler migliorare il codice di
inizializzazione dei rettangoli dovremmo poi apportare le stesse modifiche in TUTTI i costruttori delle
classe figlie come i Quadrati. Molto meglio avere una sola versione del codice ereditata da tutte le
classi figlie. Nel costruttore del quadrato dovremmo invece mettere codice specifico necessario SOLO
per i quadrati.
Polimorfismo.
Nei rettangoli e nei quadrati sono presenti due metodi con lo stesso nome e la stessa lista di
parametri (anche se in questo caso è vuota) ma che eseguono diverse istruzioni: si parla di
polimorfismo (più forme per lo stesso metodo).
Rettangoli
public float perimetro()
{
return (LaBase + altezza) * 2;
}
Quadrati
public float perimetro()
{
return 4 * LaBase;
}
Ed attenzione a non confondersi con l’overloading dove almeno un particolare nella lista dei parametri
deve cambiare (il numero o il tipo o l’ordine).
Ancora un esempio: immaginiamo di aver definito la classe dei velivoli ed aver derivato da questa gli
aviogetti e gli elicotteri. E’ naturale pensare di disporre di un comando decolla ed è altrettanto ovvio
aspettarsi un risultato diverso a seconda che il comando (messaggio) sia inviato ad un oggetto di un
tipo piuttosto che all’altro. Torneremo più avanti a parlare di polimorfismo quando affronteremo una
tecnica piuttosto sofisticata.
Oggetti che contengono altri oggetti, composizione ed aggregazione
Oltre alla relazione is-a propria dell’ereditarietà (un quadrato is-a, è un, tipo particolare di rettangolo)
ne esiste un’altra molto importante ed intuitiva da usare: has-a (ha un ...). Nello stato interno di una
classe è cioè possibile inserire oggetti di altre classi per assemblare classi più complesse usandone
altre (un po' come nel famoso gioco del lego)
ESEMPIO
Immaginiamo di aver definito una classe Indirizzi. Una classe Buste può con molta naturalezza
contenere un oggetto indirizzo (pensate alla stampa dell’etichetta per la spedizione di una busta). La
stessa classe Indirizzi può essere sfruttata per realizzare quella delle carte di identità ecc. Una sola
copia del codice riutilizzata molte volte ed una sola copia del codice da mantenere: ‘suona’ bene!
Attenzione. Si potrebbero verificare due situazioni:
composizione: quando è l’oggetto contenitore a creare e distruggere quelli contenuti; nel nostro
caso parleremmo di composizione se fosse il codice della busta a creare l'indirizzo;
aggregazione: quando l’oggetto contenitore riceve dall'esterno dei riferimento agli oggetti già
creati; nel nostro caso prima di creare la busta verrebbe creato un indirizzo passato poi come
parametro al costruttore della busta all'atto della creazione di quest'ultima
La composizione suggerisce quindi un legame più forte dell'aggregazione. Proviamo ad implementare
le buste e gli indirizzi. Per brevità sperimenteremo composizione ed aggregazione tutto nella stessa
classe: implementeremo un costruttore che, rispettando i dettami della composizione, creerà anche
l’indirizzo (composizionie); aggiungeremo poi un secondo costruttore che riceverà dall’esterno un
riferimento ad un indirizzo già creato e pronto all’uso (aggregazione).
class Indirizzi
{
}
string Via=”"; int numeroCivico= 0; int cap=0; string citta=" ";
public Indirizzi() { }
public Indirizzi(string _Via, int _numeroCivico, int _cap, string _citta)
{ Via = _Via; numeroCivico = _numeroCivico; cap = _cap; citta = _citta; }
class Buste
{
Indirizzi indirizzo=null; //riferimento all’oggetto contenuto
string formato=""; //A2, A3 ecc. float peso=0;
public Buste(string Via, int numeroCivico, int cap, string citta, string _formato, float _peso)
{
//composizione: il contenitore si farico di creare e
//distruggere l’oggetto contenuto
indirizzo = new Indirizzi(Via,numeroCivico,cap, citta);
}
//poi copia le variabili proprie di una busta
formato = _ formato; peso = _peso;
public Buste(Indirizzi _indirizzo, string _formato, float _peso)
{
if ( _indirizzo!=null)
indirizzo=indirizzo;
else
indirizzo= new Indirizzi();
}
formato=_formato; peso=_peso;
}
Conformità di tipo, collezioni di oggetti eterogenei, binding anticipato e ritardato (early o
static binding / late o dynamic binding)
L’ultimo argomento che affrontiamo è il più ambizioso ma anche il più complesso; ma se riuscirete a
padroneggiare la prossima tecnica avrete aggiunto al vostro ‘arsenale’ da programmatore il
corrispondente della bomba H. Conviene come al solito partire da una situazione reale.
Un disegno geometrico (pensate a software come Autocad) è composto da un insieme di figure
diverse (segmenti di retta, rettangoli, cerchi, curve ecc.) e per ciascuna useremo una classe.
Prima questione: come memorizzare le diverse figure in modo efficiente? Non potendo sapere in
anticipo né il numero né i tipi di figure che comporranno il disegno dovremmo prevedere un
contenitore (un array) per ogni figura possibile. Infatti i classici array (vettori/matrici) hanno il vincolo
dell’ omogeneità: tutti gli elementi devono essere dello stesso tipo. Un vettore non può memorizzare
stringhe e contemporaneamente numeri reali ed allo stesso modo non può contenere cerchi e
triangoli.
La soluzione: ereditarietà + conformità di tipo
Ma in presenza di una gerachia di classi possiamo sfruttare una caratteristica chiamata conformità di
tipo. Se dichiariamo un vettore di tipo figure potremo memorizzare in esso anche oggetti di classi
figlie di figure.
La conformità di tipo stabilisce infatti una ‘compatibilità’ tra oggetti della classe madre e della classe
figlia: dove si può memorizzare un oggetto di una certa classe posso anche memorizzare un oggetto
di classi derivate.
In questo modo non solo abbiamo un unico contenitore per oggetti diversi ma capace di accettare
figure ancora non previste: sarà sufficiente derivarle dalla comune antenata Figure. E con questo
abbiamo risolto il problema della memorizzazione. Ovviamente, non siamo ancora soddisfatti. Per
capire cosa ancora manca è necessario prima parlare di binding statico e dinamico e relative
conseguenze.
Collegamento anticipato/statico (early/static) e ritardato/dinamico (late/dynamic) binding
Premessa: la parte che segue è abbastanza complicata; non perdetevi d'animo: riuscirete a diventare
bravi programmatori anche rimandando la comprensione di questo argomento ad un momento in cui
avrete consolidato le vostre conoscenze ed acquisito una maggiore esperienza!
La conformità di tipo offre certamente le interessanti possibilità appena esaminate ma fa sorgere però
anche dei dubbi. Immaginiamo di aver definito sia per i rettangoli che per i cerchi il metodo Area.
Nulla di strano: abbiamo visto che lo si può fare senza problemi perché il compilatore sa dal tipo
dell’oggetto quale versione polimorfica del metodo Area chiamare (detto in altre parole sa distinguere
tra l'area di un rettangolo e quella di un cerchio). Infatti date le istruzioni:
Rettangoli unRettangolo = new Rettangoli(12, 6) ; //base ed altezza
Cerchi unCerchio = new Cerchi(8); //il raggio
A) double areaRett = unRettangolo.Area();
B) double areaCerchio = unCerchio.Area();
In A e B il compilatore sa quale versione dei metodi area usare (collegare , bind) e lo capisce
ovviamente dal tipo dell’oggetto. Si parla di collegamento (binding) statico perché la scelta del
metodo avviene a programma fermo, non in esecuzione; viene usato anche il termine anticipato
(early) sempre a sottolineare che la scelta del metodo da usare avviene in anticipo (early) rispetto al
momento dell’esecuzione del programma. Quando il programma viene mandato in esecuzione le scelte
effettuate non possono più essere cambiate.
Ma esaminiamo ora il seguente spezzone di codice:
Figure[] disegno = new Figure[100]; //vettore con 100 riferimenti a figure
disegno[0] = new Rettangoli(12,6);
disegno[1] = new Cerchi(8);
Ci aspetteremmo ora di poter chiedere al rettangolo memorizzato in posizione 0 la sua area
Console.WriteLine( disegno[0].????? );
Scoprireste che l’editor non vi propone in automatico alcun metodo Area. Oppure se forzaste la mano
all’editor e foste voi a scrivere: Console.WriteLine( disegno[0].Area() ); otterreste un messaggio
di errore in compilazione: Figure' non contiene una definizione per 'Area' . Il compilatore si basa suo
fatto che il vettore è di Figure e ritiene che tutti i suoi elementi siano appunto appartenenti a questa
classe: tenta di trovare un metodo Area nella classe delle figure e fallendo produce il suddetto
messaggio. Il collegamento statico che fa scegliere il compilatore in base al tipo degli oggetti così
come dichiarato nel codice non è in grado di operare la scelta giusta.
Si potrebbe obiettare che nell'istruzione 'disegno[0] = new Rettangoli(12,6)' il compilatore dovrebbe
capire senza problemi anche al momento della compilazione che in disegno[0] c'è un rettangolo. Ma
questo non è vero in generale:
Random x = new Random();
if (x.Next() == 777)
disegno[0] = new Rettangoli(12, 6);
else
disegno[0] = new Cerchi(6);
x è un oggetto di classe Random, il generatore di C# per i numeri casuali (come il rand() del c++); il
metodo Next() restituisce un numero intero estratto casualmente a 32 bit. E' ovvio che in questa
situazione è IMPOSSIBILE per il compilatore sapere se in disegno[0] verrà creato un rettangolo o un
cerchio perché il numero generato sarà noto solo a programma in esecuzione e non al momento della
compilazione. Oppure pensate ad una applicazione ‘vera’ con un menù dal quale è l’utente a scegliere
i tipi di figura da aggiungere e l’ordine: anche in questo caso è ovvio che analizzando solo il sorgente
durante la compilazione il compilatore non può avere la sfera magica e prevedere cosa verrà
memorizzato ed i che ordine a causa delle azioni dell’utente! E questa è sicuramente la situazione più
aderente a ciò che avviene nella realtà.
La soluzione: binding dinamico (dynamic) detto anche binding ritardato (late): non deve essere il
compilatore a scegliere i metodi ma si ritarda (late) la decisione al momento in cui durante
l’esecuzione del programma si invoca un metodo Area su un certo oggetto. Ogni volta che un oggetto
viene creato vengono anche memorizzate informazioni sul suo tipo: quando si invoca un metodo è il
programma stesso che risale al vero tipo dell'oggetto e sceglie il giusto metodo.
Si parla anche di binding dinamico (dynamic) proprio per sottolineare il fatto che la decisione
viene presa 'al volo' durante l'esecuzione.
Rimani solo da spiegare come attivare la modalità late binding: di default il compilatore userebbe
infatti la modalità early. Ritorniamo all'esempio. Innanzitutto il metodo Area() DEVE essere dichiarato
anche nella classe madre.
Si tratterà di un metodo come gli altri eccetto per il fatto che sarà etichettato con la parola chiave
virtual:
Il metodo Area aggiunto nella classe antenata è etichettato con la parola virtual (virtuale): sta a
significare che stiamo richiedendo il collegamento ritardato. Poiché una figura generica non ha
un’area, in questo caso il metodo svolge semplicemente il ruolo di ‘segnaposto’ che permetterà di
invocare su un oggetto di classe figure il metodo Area(): ecco perché si limita a restituire 0 anche se
avremmo potuto usare un qualsiasi altro valore.
class Figure
{
…
virtual public float Area()
{
return 0;
}
}
Nelle classi figlie di Figure i metodi Area() saranno invece etichettati con la parola override
(sovrascrivi): perché il *loro* metodo Area() deve sostituirsi a quello delle classi antenate se è attivo il
late binding. Proprio come vogliamo noi!
class Cerchi
{
…
override public float Area()
{
return raggio* raggio * 3.14F;
}
}
class Rettangoli
{
…
override public float Area()
{
return LaBase*Altezza;
}
}
Cosa accade stavolta con il codice:
disegno[0] = new Rettangoli(12,6);
disegno[1] = new Cerchi(8);
Console.WriteLine( disegno[0].Area() );
Durante la compilazione: il compilatore accetta che si richiami Area() su un elemento di disegno (il
metodo esiste in effetti anche nella classe Figure anche se non calcola nulla di utile). Poi sa che deve
attivare il late binding (parola chiave virtual nella classe). Quindi non collega nulla e predispone il tutto
in modo che lo possa fare il programma in esecuzione.
Quando si esegue l’istruzione 'Console.WriteLine( disegno[0].Area() )' il codice predisposto dal
compilatore ‘fa fare’ la scelta al programma; non viene scelto ad occhi chiusi il metodo di Figure
perché si sa che in realtà l’oggetto memorizzato potrebbe essere un rettangolo o un cerchio; il
programma controlla la vera natura dell’oggetto e ‘scopre’ che si tratta di un rettangolo e sa che deve
mandare in esecuzione il metodo Area() dei rettangoli e non altri.
Bingo! Le tre caratteristiche considerate insieme (ereditarietà, conformità di tipo e late binding) sono
letteralmente esplosive per la produttività del programmatore. Analizzate il seguente spezzone di
codice:
float AreaTotale=0;
for (int i=0; i<numeroFigureDisponibili; i++)
AreaTotale += disegno[i].Area();
Con un semplice ciclo for posso richiamare lo stesso metodo per ogni figura memorizzata nel vettore
senza preoccuparmi di sapere di che tipo di figure si tratta. Al momento dell’esecuzione grazie al late
binding verrà scelto automaticamente per ogni tipo di figura il giusto metodo di calcolo dell’area.
Diversamente avrei dovuto codificare una complessa struttura di if (se l'elemento è un rettangolo
allora ... se invece è un cerchio allora ... ecc.).
Ancora più sorprendentemente, se in seguito il programmatore decidesse di aggiungere un tipo di
figura non presente nella versione iniziale del programma, il codice scritto con il late binding non
avrebbe bisogno di modifiche! Agli if avrei dovuto aggiungere un nuovo caso. Significa che con il late
binding il programma è estensibile con una richiesta minima di modifiche al codice già scritto.
Conformità di tipo e late binding realizzano il terzo pilone su cui si fonda la OOP: il polimorfismo cioè
la possibilità di avere più forme di uno stesso metodo (come quelo per l'area) formalmente identiche
nella sintassi (stesso tipo, stesso nome, stessi parametri) ma risultati anche molto diversi a seconda
del tipo di oggetto.