A. Veneziani - Alcune note sull'ereditarietà in C#
←
→
Trascrizione del contenuto della pagina
Se il tuo browser non visualizza correttamente la pagina, ti preghiamo di leggere il contenuto della pagina quaggiù
A. Veneziani – Alcune note sull’ereditarietà in C# Cos’è l’ereditarietà Per ereditarietà si intende un meccanismo sintattico (presente in molti linguaggi quali Object Pascal C++, Java ed altri), tramite il quale le classi vengono messe poste in gerarchia fra loro. Il meccanismo gerarchico, oltre a definire un collegamento logico tra esse, definisce una maggior o minor specializzazione delle classi appartenenti ad una stessa famiglia (in quanto orientate a rappresentare oggetti simili) o un minor o oppure più ampio ventaglio di funzionalità (estensione) offerte da classi che hanno però caratteristiche comuni. In pratica si può dire che l’ereditarietà viene utilizzata quindi nei casi si desideri effettuare una estensione di una classe, oppure alternativamente creare una versione più specializzata di una classe già esistente. Ereditarietà semplice Con ereditarietà semplice si indica una situazione in cui una sottoclasse può ereditare le sue funzionalità da una sola classe, ossia quella che chiamiamo la sua superclasse. In pratica in questo schema di ereditarietà, ogni superclasse può avere una o più sottoclassi da essa dipendenti, ma ogni sottoclasse ha una ed un'unica superclasse. C# segue questo tipo di regola per organizzare l’ereditarietà delle classi, analogamente a numerosi altri linguaggi tra cui Object Pascal e Java. Alcuni linguaggi permettono invece una architettura più generale di ereditarietà, per la quale una sottoclasse può eventualmente dipendere da più superclassi (oltre alla situazione, usuale, per cui da una superclasse possono derivare più sottoclassi). Tipicamente C++ è uno dei linguaggi ad oggetti che adotta questo tipo di organizzazione. In questo caso si parla di ereditarietà multipla. Un esempio Consideriamo, come già in passato la classe Contatore. Si immagini di considerare tale classe che rappresenterà oggetti capaci di tenere memoria di un conteggio in base a quante volte un apposito metodo ( incrementa() ), sia richiamato. Prevediamo anche la possibilità di un azzeramento del contatore tramite il metodo azzera(). Si immagini poi di creare una estensione di Contatore (chiamata ContatoreExt) che rappresenti un contatore con le stesse caratteristiche, ed aggiunte ad esse la capacità di settare un valore iniziale, effettuare decrementi ed un controllo sul raggiungimento del valore 0 nel contatore. In sintesi la classe Contatore possiede i metodi: incrementa azzera leggi_conteggio E supponiamo che la classe ContatoreExt sua estensione implementi anche i metodi: setta valore iniziale decrementa e_zero In questa situazione, ossia se ContatoreExt è programmata come sottoclasse di Contatore, si avrà che la classe ContatoreExt sarà dotata anche dei metodi di Contatore, in quanto sua estensione. Si dice per converso che ContatoreExt è una sottoclasse (o classe derivata) di Contatore. Si noti anche che in questo caso i metodi di ContatoreExt si aggiungono semplicemente a quelli di Contatore, senza per questo dover riutilizzarne il codice. In questo specifico caso ContatoreExt si può considerare effettivamente solo una semplice estensione di Contatore, dato che ContatoreExt aggiunge semplicemente funzionalità. In altri casi una sottoclasse è effettivamente una specializzazione della relativa superclasse. Pagina 1
Sottoclasse come specializzazione Per evidenziare le situazioni in cui una sottoclasse non è altro che la gestione di un caso particolare di una superclasse, possiamo considerare un altro esempio spesso utilizzato. Consideriamo la classe Rettangolo come classe implementante le caratteristiche dei rettangoli ed alcune operazioni possibili su di essi. Consideriamo ora una classe che raggruppi una categoria molto particolare di rettangoli, ossia i quadrati. Possiamo pensare ad una classe Quadrato come ad una sottoclasse di Rettangolo, e questo in quanto caso specifico della classe Rettangolo stessa. Ad esempio si potrebbe pensare Rettangolo come avente i metodi: area perimetro diagonale (lunghezza della diagonale) rapporto_bh (rapporto tra base ed altezza) Ma se pensiamo ad una classe Quadrato, anche per essa potremo pensare a metodi del tutto simili: area perimetro diagonale solo che essi dovranno essere calcolati sul caso specifico di un certo quadrato. In realtà Quadrato è evidentemente solo un caso specifico di Rettangolo ed essa può ri-utilizzare in modo ampio il codice della sua superclasse, almeno per re-implementare metodi del tutto simili nello scopo. Infatti un quadrato non è altro che un rettangolo con entrambi i lati uguali. Quindi: Il costruttore di Quadrato potrà appoggiarsi su quello di Rettangolo Quadrato potrà utilizzare le variabili globali contenute in Rettangolo I metodi area() e perimetro() potranno essere calcolati utilizzando il codice già presente nella classe Rettangolo Diamo ora alcune regole sintattiche che permettano di realizzare effettivamente in C#, i modelli prima proposti di classi in gerarchia. Sottoclasse In C# per definire una classe C2 come sottoclasse di un'altra (C1) si usa la semplice sintassi: class C1 { . . . } class C2 : C1 { ... } Dato questo la classe C2 eredita tutti i metodi (di tipo public o eventualmente protected) di C1, ossia ad un oggetto di C2 saranno applicabili tutti i metodi della classe C1 di tipo public o protected. Inoltre essa potrà implementare nuovi metodi specifici propri di C2, o ridefinire metodi (modificandoli) già presenti in C1, tramite overloading ed overriding. I metodi definiti in C2, viceversa non saranno visibili nella superclasse C1. C’è da evidenziare la regola generale che i costruttori non vengono ereditati. La specifica protected Per quanto riguarda le variabili di stato della classe C1, esse saranno visibili ed utilizzabili in C2, ma solo se esse siano di tipo protected (oppure, ovviamente, se fossero public). Variabili con specifica private non saranno visibili nelle classi derivate. In modo analogo non saranno utilizzabili in classi derivate metodi di tipo private. Pagina 2
Il tipo di visibilità protected è una particolare specifica di visibilità che rende possibile utilizzare metodi o variabili globali ad una classe, anche nelle sottoclassi della classe in cui lo specificatore di visibilità protected compare, permettendone l’utilizzo in gerarchie di classi, senza allargare il campo di visibilità a livello generale equivalente alla indicazione public. Costruttori Ripetuta la regola principale che i costruttori non sono soggetti alla ereditarietà, ossia non vengono ereditati dalle sottoclassi, consideriamo alcune regole relative ai costruttori di classi in relazione gerarchica tra loro: a) Qualunque costruttore di una sottoclasse richiama sempre un costruttore relativo alla superclasse. b) Una sottoclasse senza altra specifica riguardo ai costruttori possiede un costruttore sottinteso di default che opera senza parametri e compie l’unica operazione di allocare oggetti della sottoclasse. Tale costruttore richiama in modo implicito, in cascata, anche il costruttore di default o un costruttore senza parametri della relativa superclasse. c) Nel caso si desideri definire costruttori diversi da quello di default nella sottoclasse ciò è ovviamente possibile (spesso questi costruttori operano allo scopo di inizializzare le variabili interne dell’oggetto creato). Un costruttore definito ex-novo in una sottoclasse possiede genericamente una chiamata in modo implicito al costruttore di default della superclasse. d) Nell’ulteriore caso che nella superclasse sia stato definito un solo costruttore con parametri, allora per creare un oggetto della superclasse è necessaria una chiamata esplicita al costruttore stesso della superclasse. In questo ultimo caso, se richiamato, un costruttore della sottoclasse deve indicare esplicitamente la chiamata al costruttore con parametri della superclasse, in questo modo: class C2 : C1 { public C2(int n) : base(n) { . . . } } e) Ricordiamo inoltre un'altra regola che vale per tutte le classi; la definizione di un costruttore in una classe, elimina in modo automatico la definizione (sottintesa) del costruttore di default della classe. Quindi, in particolare, in una classe se viene definito un, primo, nuovo costruttore con uno o più parametri, in tale classe non esisterà più il costruttore (di default) senza parametri fino a che esso non sia eventualmente ridefinito opportunamente. Da ciò ne consegue che un costruttore in una classe derivata del tipo: class C1 { (costruttore sottinteso di default) . . . } class C2 : C1 { public C2(int n) { . . . } } Se in C1 si definirà un costruttore con parametri: class C1 { int n; public C1(int v) Pagina 3
{ n = v; } } class C2 : C1 { public C2(int n) { . . . } } La chiamata al costruttore di C2 darà l’errore: “ Error does not contain a constructor that takes 0 arguments” Ossia segnalando che il costruttore senza parametri di default non è più definito. Accesso a variabili della superclasse L’accesso a variabili di tipo protected di una superclasse è sempre possibile indicando semplicemente il nome delle stesse nel codice della sottoclasse. Come già detto le variabili private (o senza specifica, quindi considerate di default private in C#) non saranno accessibili. Metodi ereditati Come si è già detto i metodi public o protected di una superclasse vengono ereditati dalle sue sottoclassi e quindi sono visibili ed utilizzabili dalle sottoclassi stesse. Metodi private non sono ereditati dalle sottoclassi. Omonimia e keyword base In caso di omonimia tra una variabile globale ed il parametro di un metodo, si avrà che il parametro “coprirà” (hide) la omonima variabile globale della classe corrente o della superclasse. Tuttavia anche in questo caso la variabile resta accessibile utilizzando: this. per le variabili (omonime) della classe corrente base. per le variabili (omonime) della superclasse. La parola chiave base viene anche utilizzata: per chiamare metodi sottoposti ad override presenti nella classe base, tramite la sintassi: base. specificare quale costruttore della classe base debba essere richiamato, relativamente ad un certo costruttore della sottoclasse Upcasting Si parla di upcasting, quando ottenuto un oggetto di una sottoclasse, ed un riferimento ad esso, si tenti di assegnare il riferimento a tale oggetto ad una variabile riferimento del tipo della superclasse. Questa operazione è di norma possibile operando una semplice assegnazione e senza l’uso di cast. Ad esempio: class Animale { . . . } class Leone : Animale { . . . } class Program { static void Main(string[] args) { Animale a = new Animale(); Leone l = new Leone(); a = l; // la variabile a è caricabile con l’oggetto di tipo Leone // (che rimane tale) Pagina 4
E’ da evidenziare che la natura intrinseca dell’oggetto non cambia (ossia il linguaggio ha memoria che l’oggetto è originariamente di tipo Leone), ma semplicemente per esso cambia formalmente solo il tipo della variabile riferimento. Downcasting Con questo termine si intende il problema opposto a quello precedente. Presa una variabile riferimento ad un oggetto di una superclasse, è possibile convertire lo stesso oggetto al tipo di una sottoclasse ? Usualmente il downcasting non è possibile in modo diretto (ossia utilizzando le usuali indicazioni di cast). In tal caso si ottiene un errore a run-time di InvalidCastException. Come scritto nella documentazione del linguaggio C#, questo tipo di errore abbisogna di solito di una modifica alle operazioni svolte dal codice (ossia specificamente rimuovere o condizionare il tentativo di conversione in questione) piuttosto che una usuale gestione della eccezione tramite blocco try / catch. Un caso in cui il downcasting è possibile è quello in cui l’oggetto riferito dalla variabile della superclasse sia in realtà un oggetto della sottoclasse. static void Main(string[] args) { Animale a = new Animale(); Leone l = new Leone(), l2; // l = (Leone) a; // questo cast non è eseguibile a è Animale e non è // convertibile // ossia un puntatore alla sottoclasse non può // puntare all'oggetto // della superclasse a = l; // a può fare da riferimento per un oggetto più specializzato (leone) // quindi ora a "punta" a un leone, ma essendo i metodi ridefiniti... // (upcast) if (a is Leone) // qui ho il controllo se il tipo di a sia convertibile l2 = (Leone) a; // qui il downcast è possibile in quanto a punta già // ad una variabile in realtà di tipo Leone ... } Si deduce anche dall’esempio che tramite la parola chiave is è possibile controllare il tipo effettivo di un oggetto (al di là del tipo della varaibile riferimento). Overloading di metodi Si dice che un metodo viene sottoposto ad overloading se viene scritta una sua versione mantenendo lo stesso nome, ma utilizzando tipo o numero di parametri diverso (ossia si scrive un metodo simile, ed appunto perché il suo scopo è analogo non ha senso cambiarne il nome). L’overloading di un metodo può avvenire in una stessa classe o in una superclasse ed in sue sottoclassi. Sovrapposizione di un metodo (Method hiding) Un metodo può essere ridefinito in sottoclassi in relazione di ereditarietà con una superclasse. In tal caso un metodo con stesso nome e stesso tipo e numero di parametri, definito nella sottoclasse, “nasconderà” e sostituirà il suo omologo presente nella superclasse. In presenza di più sottoclassi, nelle sottoclassi dove il metodo non sia stato sostituito, resterà visibile il metodo originario della superclasse. In questa situazione la sintassi C# prevede che si inserisca una denominazione new anteposta al nome del metodo; ad esempio: Pagina 5
class A { protected int n; public void cambia() { n++; } } class B : A { public new void cambia() { n = n + 2; } } class Program { static void Main(string[] args) { B b = new B(); A a; A a2 = new A(); a = b; a.cambia(); b.cambia(); ... Nella chiamata ad a.cambia() il metodo eseguito sarà quello della superclasse A. Viceversa nella chiamata a b.cambia() eseguirà il metodo nella sottoclasse B. Nella chiamata ad a2.cambia() il metodo eseguito sarà quello della superclasse A. Con questa sintassi ad ogni oggetto viene applicato il metodo relativo solo alla classe a cui appartiene il riferimento ad esso. La presenza di un hiding di metodi comporta quindi che se un riferimento ad oggetto della classe base, punta ad un oggetto di una sottoclasse, esso eseguirà il metodo presente nella classe base. Infine c’è da osservare che nell’hiding di metodi non è tassativo che il tipo ritornato dal metodo coincida. Overriding di un metodo In altri casi si desidera ridefinire un metodo di una superclasse in una sottoclasse. La ridefinizione di un metodo prevede l’utilizzo dello stesso nome e di numero e tipo coincidenti di parametri. La sintassi C# che permette di definire che un metodo e ridefinito è: a) Inserire la parola chiave virtual prima del nome del metodo della superclasse ridefinito b) Inserire la parola chiave override prima del nome del metodo che nella sottoclasse ridefinisce il metodo della superclasse. La presenza di un override di metodi comporta che se un riferimento ad oggetto della classe base, punta ad un oggetto di una sottoclasse, esso eseguirà il metodo presente nella classe derivata (sottoclasse). Inoltre vi è da sottolineare che in C# non è possibile effettuare overriding di metodi se il tipo ritornato non coicide. Vediamo un analogo codice di esempio contenente un caso di overriding: using System; Pagina 6
namespace ConsoleApplication1 { class A { protected int n; public virtual void cambia2() { n++; } } class B : A { public override void cambia2() { n = n + 2; } } class Program { static void Main(string[] args) { B b = new B(); A a; A a2 = new A(); a = b; a.cambia2(); b.cambia2(); a2.cambia2(); Ciò che viene eseguito date le relative chiamate al metodo cambia2() è: a.cambia2(); esegue il metodo cambia2() della classe B b.cambia2(); esegue il metodo cambia2() della classe B a2.cambia2(); esegue il metodo cambia2() della classe A in pratica il metodo effettivamente eseguito, in caso di override, dipende solo dalla effettiva natura dell’oggetto. In realtà il riferimento a in realtà si riferisce ad un oggetto della classe B. Nella seconda situazione il riferimento b “punta” ad un oggetto di tipo B. In entrambi casi il metodo cambia2() eseguito è quindi quello della classe B. Nell’ultimo caso il riferimento a2 è di tipo A, ma così anche l’oggetto puntato da esso, quindi il metodo cambia2() che viene eseguito è quello della classe A. Polimorfismo Il comportamento rilevato nel paragrafo precedente, ossia la selezione del metodo da eseguire esclusivamente in base alla reale natura (classe) dell’oggetto a cui si applica tale metodo è detto polimorfismo. Overloading di operatori Un argomento collaterale, che ha però ampie aree di uso ed ha a che fare con la OOP è l’overloding di operatori (in inglese operator overloading). Pagina 7
In pratica si tratta di una tecnica di programmazione che permette di utilizzare i vari operatori già presenti nel linguaggio per vari scopi, e rimpostarli di modo che possano essere utilizzati su nuovi tipi di dato definiti dal programmatore. Gli operatori si suddividono in due grandi categorie: unari, ossia che ha senso applicare ad un solo operando (ad esempio l’operatore ! (not – negato) binari ossia che si applicano a due operandi (ad esempio l’operatore + (somma) Una delle regole dell’operator overloading è che operatori unari dovranno essere ridefiniti in modo che operino nuovamente come unari e operatori binari dovranno essere ridefiniti in modo che operino nuovamente come binari. Inoltre non è possibile definire operatori diversi da quelli già definiti precedentemente per qualche scopo. La sintassi per ri-definire un nuovo operatore è: public static operator () per esempio il metodo sottostante ridefinisce il ben noto operatore - per abbinarlo alla operazione di differenza tra insiemi: public static Insieme operator-(InsiemeExt i1, InsiemeExt i2) { .... Si parla per questa ridefinizione di overloading perché gli operatori vengono paragonati a funzioni il cui nome è il medesimo, i cui parametri sono sì in numero uguale (1 o 2), ma il tipo di essi è ogni volta diverso per ogni definizione effettuata. Pagina 8
Puoi anche leggere