Scrigroup - Documente si articole

     

HomeDocumenteUploadResurseAlte limbi doc
BulgaraCeha slovacaCroataEnglezaEstonaFinlandezaFranceza
GermanaItalianaLetonaLituanianaMaghiaraOlandezaPoloneza
SarbaSlovenaSpaniolaSuedezaTurcaUcraineana

BiografiaBiologiaBotanicaChimicaComputerComunicazioneCostruzioneDiritto
EducazioneElettronicaFisicaGeografiaGiochiGrammaticaIngegneriaLetteratura
LibriMarketingMatematicaMedicinaMusicaNutrizionePsicologiaRicette
SociologiaStoriaVarie

Reimpiego di codice

computer



+ Font mai mare | - Font mai mic



DOCUMENTE SIMILARE

Reimpiego di codice

La programmazione orientata agli oggetti È nata con lo scopo di risolvere il problema di sempre del modo dell'informatica: rendere economicamente possibile e facile il reimpiego di codice giÀ scritto. Due sono sostanzialmente le tecniche di reimpiego del codice offerte: reimpiego per composizione e reimpiego per ereditarietÀ; il C++ ha poi offerto anche il meccanismo dei Template che puÒ essere utilizzato anche in combinazione con quelli classici della OOP.



Per adesso rimanderemo la trattazione dei template ad un apposito capitolo, concentrando la nostra attenzione prima sulla composizione di oggetti e poi sulla ereditarietÀ il secondo pilastro (dopo l'incapsulazione di dati e codice) della programmazione a oggetti.

Reimpiego per composizione

Benché non sia stato esplicitamente menzionato, non c'È alcun limite alla complessitÀ di un membro dato di un oggetto; un dato attributo puÃ’ avere sia tipo elementare che tipo definito dall'utente, in particolare un attributo puÃ’ a sua volta essere un oggetto.

Vediamo un esempio che mostra come definire una matrice 10x10 di numeri complessi:

class Complex ;

class Matrix ;

L'esempio mostrato suggerisce un modo di reimpiegare codice giÀ pronto quando si È di fronte ad una relazione di tipo Has-a, in cui una entitÀ piÙ piccola È effettivamente parte di una piÙ grossa; tuttavia la composizione puÒ essere utilizzata anche per modellare una relazione di tipo Is-a, in cui invece una istanza di un certo tipo puÒ essere vista anche come istanza di un tipo piÙ 'piccolo':

class Person ;

class Student ;

Student::Student(const char * name, unsigned age, unsigned code) :

Self(name, age), IdCode(code)

void Student::PrintName()

/* */

In sostanza la composizione puÒ essere utilizzata anche quando vogliamo semplicemente estendere le funzionalitÀ di una classe realizzata in precedenza. Esistono due tecniche di composizione:

Contenimento diretto;

Contenimento tramite puntatori.

Nel primo caso un oggetto viene effettivamente inglobato all'interno di un altro (come negli esempi visti), nel secondo invece l'oggetto contenitore in realtÀ contiene un puntatore. Le due tecniche offrono vantaggi e svantaggi differenti. Nel caso del contenimento tramite puntatori:

L'uso di puntatori permette di modellare relazioni 1-n altrimenti non modellabili se non stabilendo un valore massimo per n;

Non È necessario conoscere il modo in cui va costruito una componente nel momento in cui l'oggetto che lo contiene viene istanziato;

È possibile che piÙ oggetti contenitori condividano la stessa componente;

Il contenimento tramite puntatori puÒ essere utilizzato insieme all'ereditarietÀ e al polimorfismo per realizzare classi di oggetti che non sono completamente definiti fino al momento in cui il tutto (compreso le parti accessibili tramite puntatori) non È totalmente costruito.

L'ultimo punto È probabilmente il piÙ difficile da capire e richiede la conoscenza dei principi della OOP.

Sostanzialmente possiamo dire che poiché il contenimento avviene tramite puntatori, in effetti non possiamo conoscere l'esatto tipo del componente, ma solo una sua interfaccia generica (classe base) costituita dai messaggi cui l'oggetto puntato sicuramente risponde. Questo rende il contenimento tramite puntatori piÙ flessibile e potente (espressivo) del contenimento diretto, potendo realizzare oggetti il cui comportamento puÃ’ cambiare dinamicamente nel corso dell'esecuzione del programma.

Pensate al caso di una classe che modelli un'auto utilizzando un puntatore per accedere alla componente motore, se vogliamo testare il comportamento dell'auto con un nuovo motore non dobbiamo fare altro che fare in modo che il puntatore punti ad un nuovo motore. Con il contenimento diretto la struttura del motore (corrispondente ai membri privati della componente) sarebbe stata limitata e non avremmo potuto testare l'auto con un motore di nuova concezione (ad esempio uno a propulsione anziché a scoppio).

Come vedremo invece il polimorfismo consente di superare tale limite. Tutto ciÒ sarÀ comunque piÙ chiaro in seguito.

Consideriamo ora i principali vantaggi e svantaggi del contenimento diretto:

L'accesso ai componenti non deve passare tramite puntatori;

La struttura di una classe È nota giÀ in fase di compilazione, si conosce subito l'esatto tipo del componente e il compilatore puÒ effettuare molte ottimizzazioni altrimenti impossibili (tipo espansione delle funzioni inline dei componenti);

Non È necessario eseguire operazioni di allocazione e deallocazione per costruire le componenti, ma È necessario conoscere il modo in cui costruirle giÀ quando si istanzia (costruisce) l'oggetto contenitore.

Se da una parte queste caratteristiche rendono il contenimento diretto meno flessibile ed espressivo di quello tramite puntatore e anche vero che lo rendono piÙ efficiente, non tanto perché non È necessario passare tramite i puntatori, ma quanto per gli ultimi due punti.

Costruttori per oggetti composti

L'inizializzazione di un oggetto composto richiede che siano inizializzate tutte le sue componenti.

Implicitamente abbiamo visto che un attributo non puÃ’ essere inizializzato mentre lo si dichiara (infatti gli attributi static vanno inizializzati fuori dalla dichiarazione di classe, vedi capitolo VIII, paragrafo 6); la stessa cosa vale per gli attributi di tipo oggetto:

class Composed ;

Il motivo È ovvio, eseguendo l'inizializzazione nel modo appena mostrato il programmatore sarebbe costretto ad inizializzare la componente sempre nello stesso modo; nel caso si desiderasse una inizializzazione alternativa, saremmo costretti a eseguire altre operazioni (e avremmo aggiunto overhead inutile).

La creazione di un oggetto che contiene istanze di altre classi richiede che vengano prima chiamati i costruttori per le componenti e poi quello per l'oggetto stesso; analogamente ma in senso contrario, quando l'oggetto viene distrutto, viene prima chiamato il distruttore per l'oggetto composto, e poi vengono eseguiti i distruttori per le singole componenti.

Il processo puÒ sembrare molto complesso, ma fortunatamente È il compilatore che si occupa di tutta la faccenda, il programmatore deve occuparsi solo dell'oggetto con cui lavora, non delle sue componenti. Al piÙ puÒ capitare che si voglia avere il controllo sui costruttori da utilizzare per le componenti, l'operazione puÒ essere eseguita utilizzando la lista di inizializzazione, come mostra l'esempio seguente:

#include <iostream.h>

class SmallObj

SmallObj(int a, int b) : A1(a), A2(b)

~SmallObj()

private:

int A1, A2;

};

class BigObj

BigObj(char c, int a = 0, int b = 1) : Obj(a, b), B(c)

~BigObj()

private:

SmallObj Obj;

char B;

};

void main()

il cui output sarebbe:

Costruttore SmallObj(int, int)

Costruttore BigObj(char, int, int)

Costruttore SmallObj()

Costruttore BigObj()

Distruttore ~BigObj()

Distruttore ~SmallObj()

Distruttore ~BigObj()

Distruttore ~SmallObj()

L'inizializzazione della variabile Test2 viene eseguita tramite il costruttore di default, e poiché questo non chiama esplicitamente un costruttore per la componente SmallObj automaticamente il compilatore aggiunge una chiamata a SmallObj::SmallObj(); nel caso in cui invece desideriamo utilizzare un particolare costruttore per SmallObj bisogna chiamarlo esplicitamente come fatto in BigObj::BigObj(char, int, int) (utilizzato per inizializzare Test).

Il costruttore poteva anche essere scritto nel seguente modo:

BigObj::BigObj(char c, int a = 0, int b = 1)

ma benché funzionalmente equivalente al precedente, non genera lo stesso codice. Infatti poiché un costruttore per SmallObj non È esplicitamente chiamato nella lista di inizializzazione e poiché per costruire un oggetto complesso bisogna prima costruire le sue componente, il compilatore esegue una chiamata a SmallObj::SmallObj() e poi passa il controllo a BigObj::BigObj(char, int, int). Conseguenza di ciÃ’ È un maggiore overhead dovuto a due chiamate di funzione in piÙ: una per SmallObj::SmallObj() (aggiunta dal compilatore) e l'altra per SmallObj::operator=(SmallObj&) (dovuta alla prima istruzione del costruttore).

Il motivo di un tale comportamento potrebbe sembrare piuttosto arbitrario, tuttavia in realtÀ un tale scelta È dovuta alla necessitÀ di garantire sempre che un oggetto sia inizializzato prima di essere utilizzato.

Ovviamente poiché ogni classe possiede un solo distruttore, non esistono problemi di scelta!

In pratica possiamo riassumere quanto detto dicendo che:

la costruzione di un oggetto composto richiede prima la costruzione delle sue componenti, utilizzando le eventuali specifiche presenti nella lista di inizializzazione del suo costruttore; in caso non venga specificato il costruttore da utilizzare per una componente, il compilatore utilizza quello di default. Alla fine viene eseguito il corpo del costruttore per l'oggetto composto;

la distruzione di un oggetto composto avviene eseguendo prima il suo distruttore e poi il distruttore di ciascuna delle sue componenti;

[p1] 

In quanto detto È sottinteso che se una componete di un oggetto È a sua volta un oggetto composto, il procedimento viene iterato fino a che non si giunge a componenti di tipo primitivo.

Reimpiego di codice con l'ereditarietÀ

Il meccanismo dell'ereditarietÀ È per molti aspetti simile a quello della composizione quando si vuole modellare una relazione di tipo Is-a.

L'idea È quella di dire al compilatore che una nuova classe (detta classe derivata) È ottenuta da una preesistente (detta classe base) 'copiando' il codice di quest'ultima nella classe derivata e modificandolo (eventualmente) con nuove definizioni:

class Person ;

class Student : Person ;

In pratica quanto fatto finora È esattamente la stessa cosa che abbiamo fatto con la composizione (vedi esempio), la differenza È che non abbiamo inserito nella classe Student alcuna istanza della classe Person ma abbiamo detto al compilatore di inserire tutte le dichiarazioni e le definizioni fatte nella classe Person nello scope della classe Student, a tal proposito si dice che la classe derivata eredita i membri della classe base.

Ci sono due sostanziali differenze tra l'ereditarietÀ e la composizione:

Con la composizione ciascuna istanza della classe contenitore possiede al proprio interno una istanza della classe componente; con l'ereditarietÀ le istanze della classe derivata non contengono nessuna istanza della classe base, le definizioni fatte nella classe base vengono 'quasi' immerse tra quelle della classe derivata senza alcuno strato intermedio (il 'quasi' È giustificato dal punto 2);

Un oggetto composto puÒ accedere solo ai membri pubblici della componente, l'ereditarietÀ permette invece di accedere direttamente anche ai membri protetti della classe base (quelli privati rimangono inaccessibili alla classe derivata).

Accesso ai campi ereditati

La classe derivata puÃ’ accedere ai membri protetti e pubblici della classe base come se fossero suoi (e in effetti lo sono):

class Person ;

class Student : Person ;

void Student::DoNothing()

Il codice ereditato continua a comportarsi nella classe derivata esattamente come si comportava nella classe base: se Person::PrintData() visualizzava i membri Name e Age della classe Person, il metodo PrintData() ereditato da Student continuerÀ a fare esattamente la stessa cosa.

Come alterare dunque tale codice? Tutto quello che bisogna fare È ridefinire il metodo ereditato; c'È perÒ un problema, non possiamo accedere direttamente ai dati privati della classe base. Come fare?

Semplice riutilizzando il metodo che vogliamo ridefinire:

class Student : Person ;

void Student::PrintData()

Si osservi la notazione usata per richiamare il metodo PrintData() della classe Person, se avessimo utilizzato la notazione usuale scrivendo

void Student::PrintData()

avremmo commesso un errore, poiché il risultato sarebbe stato una chiamata ricorsiva. Utilizzando il risolutore di scope (::) e il nome della classe base abbiamo invece forzato la chiamata del metodo PrintData() della classe Person.

Un'ultima osservazione

Se fosse stato possibile avremmo potuto evitare la chiamata di Person::PrintData() utilizzando eventualmente altri membri della classe base, tuttavia È una buona norma della OOP evitare di ridefinire un metodo attribuendogli una semantica radicalmente diversa da quella del metodo originale: se Person::PrintData() aveva il compito di visualizzare lo stato dell'oggetto, anche Student::PrintData() deve avere lo stesso compito. Stando cosÌ le cose, richiamare il metodo della classe base significa ridurre la possibilitÀ di commettere un errore.

È per questo motivo infatti che non tutti i membri vengono effettivamente ereditati: costruttori, distruttore, operatore di assegnamento e operatori di conversione di tipo non vengono ereditati perché la loro semantica È troppo legata alla effettiva struttura di una classe (il compilatore comunque continua a fornire per la classe derivata un costruttore di default, uno di copia e un operatore di assegnamento, esattamente come per una qualsiasi altra classe).

Naturalmente la classe derivata puÒ anche definire nuovi membri, compresa la possibilitÀ di eseguire l'overloading di una funzione ereditata.

EreditarietÀ pubblica privata e protetta

Per default l'ereditarietÀ È privata, tutti i membri ereditati diventano cioÈ membri privati della classe derivata e non sono quindi parte della sua interfaccia. È possibile alterare questo comportamento richiedendo un'ereditarietÀ protetta o pubblica (È anche possibile richiedere esplicitamente l'ereditarietÀ privata), ma quello che bisogna sempre ricordare È che non si puÒ comunque allentare il grado di protezione di un membro ereditato (i membri privati rimangono dunque privati e comunque non accessibili alla classe derivata):

Con l'ereditarietÀ pubblica i membri ereditati mantengono lo stesso grado di protezione che avevano nella classe da cui si eredita (classe base immediata): i membri public rimangono public e quelli protected continuano ad essere protected;

Con l'ereditarietÀ protetta i membri public della classe base divengono membri protected della classe derivata; quelli protected rimangono tali.

La sintassi completa per l'ereditarietÀ diviene dunque:

class <DerivedClassName> : [<Qualifier>] <BaseClassName> ;

dove Qualifier È opzionale e puÒ essere uno tra public, protected e private; se omesso si assume private.

Lo standard ANSI in via di definizione consente anche la possibilitÀ di esportare singolarmente un membro in presenza di ereditarietÀ privata o protetta, con l'ovvio limite di non rilasciare il grado di protezione che esso possedeva nella classe base:

class MyClass ;

class Derived1 : private MyClass ;

class Derived2 : private MyClass ;

class Derived3 : private MyClass ;

L'esempio mostra sostanzialmente tutte le possibili situazioni, compresa il caso di un errore dovuto al tentativo di far diventare public un membro che era protected.

Si noti la notazione utilizzata, non È necessario specificare niente piÙ del semplice nome del membro preceduto dal nome della classe base e dal risolutore di scope (per evitare confusione con una possibile ridefinizione).

La possibilitÀ di esportare singolarmente un membro È stata introdotta per fornire un modo semplice per nascondere all'utente della classe derivata l'interfaccia della classe base, salvo alcune cose; si sarebbe potuto procedere utilizzando l'ereditarietÀ pubblica e ridefinendo le funzioni che non si desidera esportare in modo che non compiano azioni dannose, il metodo perÒ presenta alcuni inconvenienti:

Il tentativo di utilizzare una funzione non esportata viene segnalato solo a run-time;

È una operazione che costringe il programmatore a lavorare di piÙ aumentando la possibilitÀ di errore e diminuendone la produttivitÀ.

I vari 'tipi' di derivazione (ereditarietÀ) hanno conseguenze che vanno al di lÀ della semplice variazione del livello di protezione di un membro.

Con l'ereditarietÀ pubblica si modella effettivamente una relazione di tipo Is-a poiché la classe derivata continua ad esportare l'interfaccia della classe base (È cioÈ possibile utilizzare un oggetto derived come un oggetto base); con l'ereditarietÀ privata questa relazione cessa, a meno che il programmatore non ridichiari l'intera interfaccia della classe base (in un certo senso possiamo vedere l'ereditarietÀ privata come una sorta di contenimento). L'ereditarietÀ protetta È invece una sorta di ibrido ed È scarsamente utilizzata.

EreditarietÀ multipla

Implicitamente È stato supposto che una classe potesse essere derivata solo da una classe base, in effetti questo È vero per molti linguaggi, tuttavia il C++ consente l'ereditarietÀ multipla. In questo modo È possibile far ereditare ad una classe le caratteristiche di piÙ classi basi, un esempio È dato dall'implementazione della libreria per l'input/output di cui si riporta il grafo della gerarchia (in alto le classi basi, in basso quelle derivate; fanno eccezione le classi collegate da linee tratteggiate):

La sintassi per l'ereditarietÀ multipla non si discosta da quella per l'ereditarietÀ singola, l'unica differenza È che bisogna elencare tutte le classi basi separandole con virgole; al solito se non specificato diversamente per default l'ereditarietÀ È privata. Ecco un esempio tratto dal grafo precedente:

class iostream : public istream, public ostream ;

L'ereditarietÀ multipla comporta alcune problematiche che non si presentano in caso di ereditarietÀ singola, quella a cui si puÒ pensare per prima È il caso in cui le stesse definizioni siano presenti in piÙ classi base (name class):

class BaseClass1 ;

class BaseClass2 ;

class Derived : BaseClass1, BaseClass2 ;

La classe Derived eredita piÙ volte gli stessi membri, e quindi una situazione del tipo

void Derived::DoSomething()

non puÃ’ che generare un errore perché il compilatore non sa a quale membro si riferisce l'assegnamento. La soluzione consiste nell'utilizzare il risolutore di scope:

void Derived::DoSomething()

in questo modo non esiste piÙ alcuna ambiguitÀ.

Si faccia attenzione al fatto che non È necessario che la stessa definizione si trovi in piÙ classi basi, È sufficiente che essa giunga alla classe derivata attraverso due classi basi distinte, ad esempio (con riferimento alla precedenti dichiarazioni):

class Derived2 : public BaseClass2 ;

class Derived3 : public BaseClass1, public Derived2 ;

Nuovamente Derived3 presenta lo stesso problema, È cioÈ sufficiente che la stessa definizione giunga attraverso classi basi indirette (nel precedente esempio BaseClass2 È una classe base indiretta di Derived3).

Il problema diviene piÙ grave quando una o piÙ copie della stessa definizione sono nascoste dalla keyword private nelle classi basi (dirette o indirette), in tal caso la classe derivata non ha alcun controllo su quella o quelle copie (in quanto vi accede indirettamente tramite le funzioni membro ereditate) e il pericolo di inconsistenza dei dati diviene piÙ grave (vedi paragrafo successivo).

Classi base virtuali

Il problema dell'ambiguitÀ che si verifica con l'ereditarietÀ multipla, puÒ essere portato al caso estremo in cui una classe ottenuta per ereditarietÀ multipla erediti piÙ volte una stessa classe base:

class BaseClass ;

class Derived1 : public BaseClass ;

class Derived2 : private BaseClass ;

class Derived3 : public Derived1, private Derived2 ;

Di nuovo quello che succede È che alcuni membri (in particolare tutta una classe) sono duplicati nella classe Derived3 (anche se una copia di questi non È immediatamente accessibile dalla classe derivata).

Consideriamo l'immagine in memoria di una istanza della classe Derived3, la situazione che avremmo sarebbe la seguente:

La classe Derived3 contiene una istanza di ciascuna delle sue classi base dirette: Derived1 e Derived2.

Ognuna di esse contiene a sua volta una istanza  della classe base BaseClass e opera esclusivamente su tale istanza.

In alcuni casi situazioni di questo tipo non creano problemi, ma in generale si tratta di una possibile fonte di inconsistenza.

Supponiamo ad esempio di avere una classe Person e di derivare da essa prima una classe Student e poi una classe Employee al fine di modellare un mondo di persone che eventualmente possono essere studenti o impiegati; dopo un po' ci accorgiamo che una persona puÒ essere contemporaneamente uno studente ed un lavoratore, cosÌ tramite l'ereditarietÀ multipla deriviamo da Student e Employee la classe Student-Employee. Il problema È che la nuova classe contiene due istanze della classe Person e queste due istanze vengono accedute (in lettura e scrittura) indipendentemente l'una dall'altra Cosa accadrebbe se nelle due istanze venissero memorizzati dati diversi?

La soluzione viene chiamata ereditarietÀ virtuale, e la si utilizza nel seguente modo:

class Person ;

class Student : virtual private Person ;

class Employee : virtual private Person ;

class Student-Employee : private Student, private Employee ;

Quando una classe eredita tramite la keyword virtual il compilatore non si limita a copiare il contenuto della classe base nella classe derivata, ma inserisce nella classe derivata un puntatore ad una istanza della classe base. Quando una classe eredita (per ereditarietÀ multipla) piÙ volte una classe base virtuale (È questo il caso di Student-Employee che eredita piÙ volte Person), il compilatore inserisce solo una istanza della classe virtuale e fa si che tutti i puntatori a tale classe puntino a quell'unica istanza.

La situazione in questo caso È illustrata dalla seguente figura:

La classe Student-Employee contiene ancora una istanza di ciascuna delle sue classi base dirette: Student e Employee, ma ora esiste una sola istanza della classe base indiretta Person poiché essa È stata dichiarata virtual nelle definizioni di Student e Employee.

Il puntatore alla classe base virtuale non È visibile al programmatore, non bisogna tenere conto di esso poiché viene aggiunto dal compilatore a compile-time, semplicemente si accede ai membri della classe base virtuale come si farebbe con una normale classe base.

Il vantaggio di questa tecnica È che non È piÙ necessario definire la classe Student-Employee derivandola da Person (al fine di eliminare la fonte di inconsistenza), in tal modo si risparmiano tempo e fatica riducendo la quantitÀ di codice da produrre e limitando la possibilitÀ di errori e la quantitÀ di memoria necessaria al nostro programma per girare. C'È perÃ’ un costo da pagare: un livello di indirezione in piÙ perché l'accesso alle classi base virtuali (nell'esempio Person) avviene tramite un puntatore.

L'ereditarietÀ virtuale genera anche un nuovo problema: il costruttore di una classe derivata chiama i costruttori delle classi base e nel caso di ereditarietÀ virtuale una determinata classe base virtuale potrebbe essere inizializzata piÙ volte.

Nel nostro esempio la classe base virtuale Person È inizializzata sia da Student che da Employee, entrambe le classi hanno il dovere di eseguire la chiamata al costruttore della classe base, ma quando queste due classi vengono fuse per derivare la classe Student-Employee il costruttore della nuova classe, chiamando i costruttori di Student e Employee, implicitamente chiamerebbe due volte il costruttore di Person.

Per evitare tale comportamento È stato deciso che i costruttori di una classe che possieda una classe base (diretta o indiretta) virtuale debbano eseguire esplicitamente una chiamata ad un costruttore della classe virtuale, il compilatore farÀ poi in modo che l'inizializzazione sia effettivamente eseguita solo dalla classe massimamente derivata (ovvero quella cui appartiene l'istanza che si sta creando). In questo modo ogni classe base virtuale È inizializzata una sola volta e in modo deterministico.

Il seguente codice

Person::Person()

Student::Student() : Person()

Employee::Employee() : Person()

Student-Employee::Student-Employee() : Person(), Student(), Employee()

/* */

cout << 'Definizione di Tizio:' << endl;

Person Tizio;

cout << endl << 'Definizione di Caio:' << endl;

Student Caio;

cout << endl << 'Definizione di Sempronio:' << endl;

Employee Sempronio;

cout << endl << 'Definizione di Bruto:' << endl;

Student-Employee Bruto;

opportunamente completato, produrrebbe il seguente output:

Definizione di Tizio:

Costruttore Person invocato

Definizione di Caio:

Costruttore Person invocato

Costruttore Student invocato

Definizione di Sempronio:

Costruttore Person invocato

Costruttore Employee invocato

Definizione di Sempronio:

Costruttore Person invocato

Costruttore Student invocato

Costruttore Employee invocato

Costruttore Student-Employee invocato

Come potete osservare il costruttore della classe Person viene invocato una sola volta, per verificare poi da chi viene invocato basta tracciare l'esecuzione con un debugger simbolico.

Analogamente anche i distruttori seguono una regola simile, solo che in questo caso viene eseguito tutto dal compilatore (i distruttori non devono mai essere invocati esplicitamente).

Funzioni virtuali

Il meccanismo dell'ereditarietÀ È stato giÀ di per se una grande innovazione nel mondo della programmazione, tuttavia le sorprese non si esauriscono qui. Esiste un'altra caratteristica tipica dei linguaggi a oggetti (C++ incluso) che ha valso loro il soprannome di 'Linguaggi degli attori', tale caratteristica consiste nella possibilitÀ di rimandare a tempo di esecuzione il linking di una o piÙ funzioni membro (late-binding).

L'ereditarietÀ pone nuove regole circa la compatibilitÀ dei tipi, in particolare se Ptr È un puntatore di tipo T, allora Ptr puÒ puntare non solo a istanze di tipo T ma anche a istanze di classi derivate da T (sia tramite ereditarietÀ semplice che multipla). Se Td È una classe derivata (anche indirettamente) da T, istruzioni del tipo

T * Ptr = 0; // Puntatore nullo

/* */

Ptr = new Td;

sono assolutamente lecite e il compilatore non segnala ne errori ne warning.

CiÒ consente ad esempio la realizzazione di una lista per contenere tutta una serie di istanze di una gerarchia di classi, magari per poter eseguire un loop su di essa e inviare a tutti gli oggetti della lista uno stesso messaggio. Pensate ad esempio ad un programma di disegno che memorizza gli oggetti disegnati mantenendoli in una lista, ogni oggetto sa come disegnarsi e se È necessario ridisegnare tutto il disegno basta scorrere la lista inviando ad ogni oggetto il messaggio di Paint.

Purtroppo la cosa cosÃŒ com'È non puÃ’ funzionare poiché le funzioni sono linkate staticamente a compile-time. Anche se tutte le classi della gerarchia possiedono un metodo Paint(), noi sappiamo solo che Ptr punta ad un oggetto di tipo T o T-derivato, non conoscendo l'esatto tipo una chiamata a Ptr->Paint() non puÃ’ che essere risolta chiamando Ptr->T::Paint() (che non farÀ ciÃ’ che vorremmo).

Il compilatore non puÃ’ infatti rischiare di chiamare il metodo di una classe derivata, poiché questo potrebbe tentare di accedere a membri che non fanno parte dell'effettivo tipo dell'oggetto (causando inconsistenze o un crash del sistema), chiamando il metodo della classe T al piÙ il programma non farÀ la cosa giusta, ma non metterÀ in pericolo la sicurezza e l'affidabilitÀ del sistema (perché un oggetto derivato possiede tutti i membri della classe base).

Si potrebbe risolvere il problema inserendo in ogni classe della gerarchia un campo che stia ad indicare l'effettivo tipo dell'istanza:

enum TypeId ;

class T ;

class Td : public T ;

e risolvere il problema con una istruzione switch:

switch Ptr->Type ;

Una soluzione di questo tipo funziona ma È macchinosa, allunga il lavoro, una dimenticanza puÒ costare cara e soprattutto ogni volta che si modifica la gerarchia di classi bisogna modificare anche il codice che la usa.

La soluzione migliore È invece quella di far in modo che il corretto tipo dell'oggetto puntato sia automaticamente determinato al momento della chiamata della funzione e rinviando il linking di tale funzione a run-time.

Per fare ciÃ’ bisogna dichiarare la funzione membro virtual:

class T ;

La definizione del metodo procede poi nel solito modo:

void T::Paint()

I metodi virtuali vengono ereditati allo stesso modo di quelli 'normali' (o meglio statici), possono anch'essi essere sottoposti a overloading ed essere ridefiniti, non c'È alcuna differenza eccetto che una loro invocazione non viene risolta se non a run-time. Quando una classe possiede un metodo virtuale, il compilatore associa alla classe (non all'istanza) una tabella che contiene per ogni metodo virtuale l'indirizzo alla corrispondente funzione, ogni istanza di quella classe conterrÀ poi al suo interno un puntatore alla tabella; una chiamata ad una funzione membro virtuale (e solo alle funzioni virtuali) viene risolta con del codice che accede alla tabella corrispondente al tipo dell'istanza tramite il puntatore contenuto nell'istanza stessa, ottenuta la tabella invocare il metodo corretto È semplice.

Le funzioni virtuali hanno il grande vantaggio di consentire l'aggiunta di nuove classi alla gerarchia e di renderle immediatamente e correttamente utilizzabili dal vostro programma senza doverne modificare il codice, basta solo ricompilare il tutto, il late-binding farÀ in modo che siano chiamate sempre le funzioni corrette senza che il vostro programma debba curarsi dell'effettivo tipo dell'istanza che sta manipolando.

L'invocazione di un metodo virtuale È piÙ costosa di quella per una funzione membro ordinaria, tuttavia il compilatore puÒ evitare tale overhead risolvendo a compile-time tutte quelle situazioni in cui il tipo È effettivamente noto; ad esempio:

Td Obj1;

T * Ptr = 0;

/* */

Obj1.Paint(); // Chiamata risolvibile staticamente

Ptr->Paint(); // Questa invece no

La prima chiamata al metodo Paint() puÃ’ essere risolta in fase di compilazione perché il tipo di Obj1 È sicuramente Td, nel secondo caso invece non possiamo saperlo (anche se un compilatore intelligente potrebbe cercare di restringere le possibilitÀ e, in caso di certezza assoluta, risolvere staticamente la chiamata). Se poi volete avere il massimo controllo, potete costringere il compilatore ad una 'soluzione statica' utilizzando il risolutore di scope:

Td Obj1;

T * Ptr = 0;

/* */

Obj1.Td::Paint(); // Chiamata risolta staticamente

Ptr->Td::Paint(); // ora anche questa.

Adesso sia nel primo che nel secondo caso, il metodo invocato È Td::Paint(). Fate attenzione perÒ ad utilizzare questa possibilitÀ con i puntatori (come nell'ultimo caso), se per caso il tipo corretto dell'istanza puntata non corrisponde, potreste avere delle brutte sorprese.

Il meccanismo delle funzioni virtuali È alla base del polimorfismo: poiché l'oggetto puntato da un puntatore puÃ’ appartenere a tutta una gerarchia di tipi, possiamo considerare l'istanza puntata come un qualcosa che puÃ’ assumere piÙ forme (tipi) e comportarsi sempre nel modo migliore 'recitando' di volta in volta il ruolo corretto (da qui il soprannome di 'Linguaggi degli attori'), in realtÀ perÃ’ un'istanza non puÃ’ cambiare tipo, e solo il puntatore che puÃ’ cambiare tipo e istanza.

Se decidete di utilizzare le funzioni virtuali dovete ricordare che quando derivate una nuova classe, se decidete di ridefinire un metodo virtuale di una classe base, esso dovrÀ essere dichiarato ancora virtual, altrimenti il meccanismo viene interrotto. Fate attenzione anche in casi di questo tipo:

class T ;

/* implementazione di T::Foo() e T::Foo2() */

void T::DoSomething()

class Td : public T ;

/* implementazione di Td::Foo2() */

void Td::DoSomething()

Si tratta di una situazione pericolosa: la classe Td ridefinisce un metodo statico (ma poteva anche essere virtuale), ma non uno virtuale da questo richiamato. Di per se non si tratta di un errore, la classe derivata potrebbe non aver alcun motivo per ridefinire il metodo ereditato, tuttavia puÃ’ essere difficile capire cosa esattamente faccia il metodo Td::DoSomething(), soprattutto in un caso simile:

class Td2 : public Td ;

Questa nuova classe ridefinisce un metodo virtuale, ma non quello che lo chiama, per cui in una situazione del tipo:

Td2 * Ptr = new Td2;

/* */

Ptr->DoSomething();

viene chiamato il metodo Td::DoSomething() ereditato, ma in effetti questo poi chiama Td2::Foo() per via del linking dinamico. Consiglio vivamente di riflettere sull'evoluzione di una esecuzione di funzione che chiami funzioni virtuali, solo in questo modo si apprendono vantaggi e pericoli derivanti dall'uso di funzioni virtuali.

Per concludere l'argomento resta solo da dire che qualsiasi funzione membro di una classe puÒ essere dichiarata virtual (anche un operatore, come vedremo), con l'eccezione dei costruttori. I costruttori infatti, in presenza di classi con funzioni virtuali, hanno il compito di inizializzare il puntatore alla tabella dei metodi virtuali contenuto nell'istanza e quindi non essendo ancora stabilito il link tra istanza e tabella non È materialmente possibile avere costruttori virtuali. Si noti inoltre che non conoscendo il momento in cui tale link sarÀ stato stabilito, non È lecito chiamare metodi virtuali all'interno dei costruttori (a meno che non si forzi il linking statico con il risolutore di scope).

Ovviamente non esiste alcun motivo per cui un distruttore non possa essere virtuale, anzi in presenza di funzioni virtuali È sempre bene che anche il distruttore lo sia (al 99,9% È necessario, negli altri casi È una garanzia per il funzionamento del programma, soprattutto in previsione di future revisioni).

Classi astratte

I meccanismi dell'ereditarietÀ e delle funzioni virtuali possono essere combinati per realizzare delle classi il cui unico scopo È quello di stabilire una interfaccia comune a tutta una gerarchia di classi:

class TShape ;

Notate l'assegnamento effettuato alle funzioni virtuali, funzioni di questo tipo vengono dette funzioni virtuali pure e l'assegnamento ha il compito di informare il compilatore che non intendiamo definire i metodi virtuali. Una classe che possiede funzioni virtuali pure È detta classe astratta e non È possibile istanziarla; essa puÒ essere utilizzata unicamente per derivare nuove classi forzandole a fornire determinati metodi (quelli corrispondenti alle funzioni virtuali pure). Il compito di una classe astratta È quella di fornire una interfaccia senza esporre dettagli implementativi. Se una classe derivata da una classe astratta non implementa una qualche funzione virtuale pura, diviene essa stessa una classe astratta.

Le classi astratte possono comunque possedere anche attributi e metodi completamente definiti (costruttori e distruttore compresi) ma non possono comunque essere istanziate, servono solo per consentire la costruzione di una gerarchia di classi secondo un ordine incrementale:

class TShape ;

class TPoint : public TShape {

public:

TPoint(int x, int y) : X(x), Y(y)

private:

int X, Y; // coordinate del punto

};

void TPoint::Paint()

void TPoint::Erase()

Non È possibile creare istanze della classe TShape, ma la classe TPoint ridefinisce tutte le funzioni virtuali pure e puÃ’ essere istanziata e utilizzata dal programma; la classe TShape È comunque ancora utile al programma, perché possiamo dichiarare puntatori di tale tipo per gestire una lista di figure.


 [p1]

Vecchia frase :

la costruzione di un oggetto BigObj richiede prima la costruzione delle sue componenti, utilizzando le eventuali specifiche presenti nella lista di inizializzazione del costruttore per BigObj; in caso non venga specificato il costruttore da utilizzare per una componente, il compilatore utilizza quello di default. Alla fine viene eseguito il corpo del costruttore per BigObj;

la distruzione di un oggetto BigObj richiede prima la distruzione delle sue componenti; quando i distruttori di tutte le sue componenti sono stati eseguiti, viene eseguito quello per BigObj;



Politica de confidentialitate | Termeni si conditii de utilizare



DISTRIBUIE DOCUMENTUL

Comentarii


Vizualizari: 692
Importanta: rank

Comenteaza documentul:

Te rugam sa te autentifici sau sa iti faci cont pentru a putea comenta

Creaza cont nou

Termeni si conditii de utilizare | Contact
© SCRIGROUP 2024 . All rights reserved