Ereditarietà e Polimorfismo

Un linguaggio di programmazione orientato agli oggetti è caratterizzato da tre proprietà principali:

Oggetti: record che ereditano

Una classe assomiglia ad un record, un involucro che raggruppa, con uno stesso nome, diversi elementi di dati che rappresentano caratteristiche, o proprietà, di una certa entità astratta. In un ambiente grafico, si potrebbero unire in un record le coordinate X e Y di una posizione sullo schermo grafico definendo un tipo record chiamato Locazione:

struct Locazione {

  int X, Y;

} Posizione;

 

  Posizione.X = 20;

  Posizione.Y = 50;

 

  cout << Posizione.X << " " << Posizione.Y;

In questo caso Locazione è un tipo record, una sagoma, che il compilatore usa per creare variabili di un record. La variabile Posizione del tipo Locazione è un'istanza (del tipo Locazione). Il termine istanza è tipico della OOP.

Il tipo Locazione offre due possibilità: le coordinate X e Y possono essere considerate separatamente come i campi X e Y del record. Altrimenti, quando rappresentano le coordinate X e Y che agiscono insieme per fissare uno posto sullo schermo le si possono immaginare congiuntamente come Locazione.

Si immagini di voler visualizzare un punto di luce su una posizione dello schermo individuata dalle coordinate memorizzate in un record Locazione. Si può aggiungere un campo booleano che indichi la presenza di un pixel illuminato in una specifica posizione e costruire un nuovo tipo record:

struct Punto {

  int X, Y;

  boolean Visibile;

}

O, ancora meglio, mantenendo il tipo record Locazione si crea un campo del tipo Locazione all'interno del tipo Punto:

struct Locazione {

int X, Y;

};

 

struct Punto {

  Locazione Posizione;

  bool Visibile;

} Coord;

 

  Coord.Posizione.X = 20;

  Coord.Posizione.Y = 50;

  Coord.Visibile = false;

 

cout << "il punto (" << Coord.Posizione.X << " " << Coord.Posizione.Y <<")";

La dichiarazione di Punto è ovvia se si pensa che "un punto è una locazione che si illumina".

Poichè tutti i punti devono contenere una locazione, si può dire che il tipo Punto è un tipo discendente dal tipo Locazione. Il record Punto eredita tutte le caratteristiche che appartengono a Locazione, aggiungendovi tutto ciò di nuovo che è proprio di Punto.

Il processo per cui un tipo record eredita le caratteristiche di un altro viene detto ereditarietà. Il record erede viene chiamato discendente e il record da cui il tipo discendente eredita, viene chiamata antenato.

I record del C non possono ereditare i campi del predecessore. Il C++ introduce una nuova categoria di strutture dati, che si possono pensare come se fossero un'estensione dei record. Questa nuova categoria viene definita con la parola riservata: class. Un tipo class può essere definito come un tipo completo a se stante allo stesso modo dei record, oppure può essere definito come un discendente di una classe esistente.

Nell'esempio precedente, la derivazione della classe Punto dalla classe Locazione si esprime così:

class Locazione {

public:

  int X, Y;

};

 

class Punto: public Locazione {

public:

  bool Visibile;

};

Notare l'uso delle parentesi per indicare l'ereditariettà

In questo caso Locazione è il tipo antenato e Punto il tipo discendente.

Una classe (come una sotto directory) può avere un numero qualsiasi di discendenti diretti, ma un solo antenato diretto.

I campi X e Y di Locazione non vengono scritti in maniera esplicita all'interno del tipo Punto anche se quest'ultimo li possiede in virtù dell'ereditarietà. Si potrà usare il valore X di Punto, così come il valore X di Locazione.

Istanze di oggetti.

Le istanze degli oggetti vengono dichiarate come qualsiasi altra variabile, sia che si tratti di variabili statiche che di puntatori.

class Locazione {

public:

  int X, Y;

};

 

class Punto: public Locazione {

public:

  bool Visibile;

};

 

int main(int argc, char *argv[]) {

  Punto Coord;

  Punto *Pt; // variabile dinamica. Creare l'oggetto con new

Pt = new Punto;

 

  Coord.X = 20;

  Coord.Y = 50;

  Coord.Visibile = false;

  cout << "il punto (" << Coord.X << " " << Coord.Y <<")";

 

  Pt->X=30;

  Pt->Y=40;

cout << endl << "il punto (" << Pt->X << " " << Pt->Y <<")" << endl;

I campi di un oggetto

Ai campi di un oggetto si accede mediante la punteggiatura. I campi ereditati sono accessibili quanto quelli dichiarati all'interno della classe base. Ad esempio, anche se X e Y non fanno parte della dichiarazione di Punto (sono stati ereditati dal tipo Locazione), si possono riferire come se lo fossero: Coord.X = 20;

Regole

Anche se si può accedere direttamente ai campi di un oggetto, non è buona norma farlo. I principi su cui si basa la programmazione orientata agli oggetti richiedono che i campi di un oggetto vengano disturbati il meno possibile. All'inizio questa restrizione potrà sembrare rigida e persino arbitraria, ma è parte di un quadro più generale della OOP.

I campi dati di un oggetto sono costituiti da tutto ciò che l'oggetto conosce; i suoi metodi sono costituiti da tutto ciò che l'oggetto è in grado di fare.

Di conseguenza si dovrebbero utilizzare i metodi di un oggetto per accedere ai suoi campi dati, sempre che questo sia possibile. Per metodo si intende una funzione dichiarata all'interno di un oggetto e strettamente correlata all'oggetto.

Metodi

Un primo esempio di utilizzo dei metodi riguarda la necessità di assegnare un valore iniziale valido ai campi. Anche se il procedimento adottato negli esempi precedenti è corretto, rimane strettamente legato a una istanza di record specifica: Coord. Volendo inizializzare più di un record Locazione, occorrono più istruzioni di assegnazione che fanno essenzialmente una stessa cosa. Il naturale passo successivo è quello di costruire una procedura di inizializzazione in grado di generalizzare il riferimento all'oggetto affinchè comprenda qualsiasi istanza della classe Locazione passata come parametro:

void LocazioneIniz (Locazione P, int NewX, int NewY) {

  P.X = NewX;

  P.Y = NewY;

}

La funzione LocazioneIniz è stata progettata specificamente al fine di servire la classe Locazione. I parametri hanno lo scopo di assegnare il valore ai campi X e Y. Tale funzione deve appartenere alla classe, che insieme ai campi formano un'unica struttura.

Un metodo è una funzione saldata strettamente a una classe e ha accesso ai campi della classe. La definizione della classe comprende anche la dichiarazione del metodo:

class Locazione {

public:

  int X, Y;

  Locazione (int NewX, int NewY) {

    X = NewX;

    Y = NewY;

  }

};

 

int main(int argc, char *argv[]) {

Locazione Coord(20, 50);

Locazione *Pt;

Pt = new Locazione(30,40);

 

cout << "il punto (" << Coord.X << " " << Coord.Y << ")";

 

cout << endl << "il punto (" << Pt->X << " " << Pt->Y <<")" << endl;

 

system("PAUSE");

return EXIT_SUCCESS;

}

Per inizializzare un'istanza della classe Locazione si deve chiamare il metodo come se questo fosse un campo di un record, con l'operatore ".", ma questo è un metodo speciale, infatti ha lo stesso nome della classe e non specifica il tipo del valore restituito, è chiamato costruttore e il compilatore lo richiama automaticamente nel momento in cui si dichiara un'istanza della classe: Locazione Coord(20, 50);

Codice e dati insieme

Uno dei principi fondamentali della programmazione orientata agli oggetti è il fatto che il programmatore si abitua a pensare al codice a ai dati insieme durante la progettazione dei programmi. Nè il codice, nè i dati esistono separatamente. I dati dirigono il flusso del codice e questo manipola la forma e i valori assunti dai dati.

Considerando i dati e il codice come due entità separate, si corre sempre il rischio di chiamare la procedura corretta con i dati sbagliati, oppure la procedura sbagliata con i dati corretti. Compito del programmatore è far combaciare i dati e le funzioni.

Come regola, per ottenere il valore di uno dei campi di un oggetto si deve chiamare un metodo appartenente a quell'oggetto affinchè restituisca il valore del campo desiderato. Per impostare il valore di un campo, si deve chiamare un metodo che assegni un nuovo valore a quel campo.

La programmazione orientata agli oggetti è una disciplina che un programmatore deve imporsi. Nonostante sia possibile leggere e scrivere i campi di un oggetto direttamente dall'esterno dell'oggetto, bisogna utilizzare le buone norme della OOP per creare dei metodi per manipolare i campi di un oggetto dall'interno dell'oggetto.

Un metodo, all'interno di un oggetto, è definito dalla dichiarazione della funzione:

class Locazione {

public:

  int X, Y;

  Locazione (int, int);

  int leggiX();

  int leggiY();

};

Occorre dichiarare tutti i campi dati prima della prima dichiarazione del metodo.

Le dichiarazioni dei metodi all'interno di una classe dicono come sono fatti i metodi ma non cosa fanno. Bisogna distinguere la dichiarazione e la definizione di funzione.

La dichiarazione si trova all'interno della classe, mentre la definizione avviene all'esterno. Nella classe Locazione, ad esempio, i tre metodi sono dichiarati all'interno della classe. Al compilatore, basta sapere che il metodo Locazione riceve due parametri interi, ma non ha bisogno di conoscere con quali nomi di variabili vengono identificati questi due parametri. La definizione dei metodi, all'esterno della classe, deve fare riferimento alla classe contenente i metodi, secondo la seguente sintassi:

Locazione::Locazione(int NewX, int NewY) {

  X = NewX;

  Y = NewY;

}

int Locazione::leggiX() {

  return X;

}

 

int Locazione::leggiY() {

  return Y;

}

Oltre ad avere una definizione di metodi che accedono in lettura ai campi della classe, sarebbe corretto definire una funzione per accedere in scrittura ai campi della classe.

Campo di validità del metodo e il parametro this

I campi dati di un oggetto sono liberamente disponibili ai metodi dell'oggetto stesso. Questo spiega perchè i metodi di Locazione contengono assegnazioni del tipo Y = NewY senza specificare a quale struttura appartiene Y.

Y appartiene allo stesso oggetto che possiede il metodo. L'oggetto e il suo metodo hanno uno stesso campo di validità. Quando viene richiamato un metodo, questo riceve un parametro invisibile denominato this ed è un puntatore a 32-bit all'istanza oggetto che sta effettuando la chiamata del metodo. In genere non è necessario avere sempre presente il parametro this, poichè il compilatore provvede a generare il codice. Il riferimento this è un identificatore dichiarato automaticamente.

Sezioni di una classe

Le dichiarazioni dei metodi all'interno di una classe rappresentano l'interfaccia della classe. Significa che il resto del programma vede i metodi come se fossero le azioni ammesse per far funzionare la classe, mentre la sezione contenente le definizioni dei metodi rappresenta la sezione Implementazione, cioè il posto in cui si specifica cosa fanno i metodi.

Nell'esempio che segue viene creato un file contenente la dichiarazione della classe, poi in qualsiasi file che vuole fare uso della classe, basta inserire una riga di inclusione di questo file:

// Locazione.h

class Locazione {

public:

  int X, Y;

public:

  Locazione (int, int);

  int leggiX();

 int leggiY();

};

 

Locazione::Locazione(int NewX, int NewY) {

  X = NewX;

  Y = NewY;

}

 

int Locazione::leggiX() {

  return X;

}

 

int Locazione::leggiY() {

  return Y;

}

 

class Punto : public Locazione {

private:

  bool Visibile;

public:

  Punto(int, int);

  void Mostra();

  void nascondi();

  bool isVisibile();

  void SpostaIn(int, int);

};

 

Punto::Punto(int NewX, int NewY):Locazione (NewX, NewY ) {

  Visibile = false;

  cout << "\nPunto invisibile creato in " << leggiX() << ", " << leggiY();

}

 

void Punto::Mostra() {

  Visibile = true;

  cout << "\npunto visibile in coordinate (" << leggiX() << ", " < leggiY() <<")";

}

 

void Punto::nascondi() {

  Visibile = false;

  cout << "\npunto nascosto in coordinate (" << leggiX() << ", " << leggiY() << ")";

}

 

bool Punto::isVisibile() {

  return Visibile;

}

 

void Punto::SpostaIn(int NewX, int NewY){

  nascondi();

  X = NewX;

  Y = NewY;

  Mostra();

}

Per utilizzare i metodi definiti nel file Locazione.h, si deve dichiarare un'istanza tipo Punto nella sezione dichiarazione delle variabili del programma:

int main(int argc, char *argv[]) {

Punto unPunto(20, 50);

Per creare e mostrare il punto rappresentato da unPunto, chiamare i metodi di unPunto con la sintassi punteggiata:

unPunto.Mostra();

unPunto.SpostaIn(163, 101);

unPunto.nascondi();

Le strutture di dati non sono più considerate come dei recipienti passivi in cui immettere dei valori. Secondo la nuova concezione, un oggetto viene visto come un attore sul palcoscenico che ha memorizzato un dato numero di battute (i metodi). Quando il programmatore (il regista) da il via, l'attore recita seguendo il copione.

Può essere utile considerare I'istruzione unPunto.SpostaIn(242, 118) come se si stesse ordinando all'oggetto unPunto "spostati alla locazione 242, 118". II concetto centrale è l'oggetto; e sia l'elenco dei metodi che l'elenco dei campi sono asserviti all'oggetto.

Il paradigma della programmazione orientata agli oggetti si sforza in tutti i modi di modellare i componenti di un problema in quanto tali e non sotto forma di astrazioni logiche. I diversi oggetti che popolano la vita quotidiana, dai tostapane ai telefoni, fino agli asciugamani possiedono delle caratteristiche (dati) e dei comportamenti (metodi). Le caratteristiche di un tostapane potranno comprendere la tensione che richiede, il numero di fette di pane che può abbrustolire all'istante, l'impostazione dei pulsanti, il colore, il marchio; e così via di seguito. I suoi comportamenti comprenderanno il fatto che può accettare fette di pare, che le può abbrustolire e che fa saltare fuori delle fette di pane tostato.

Volendo scrivere un programma di simulazione di una cucina, uno dei modi migliori consisterebbe nel modellare i diversi elettrodomestici come oggetti, codificando le loro caratteristiche e comportamenti in campi dati e metodi.

Incapsulamento

La saldatura tra codice e dati negli oggetti prende il nome di incapsulamento. Rispettando meticolosamente la regola suggerita, cioè di accedere ai campi di un oggetto solo attraverso metodi, si potranno fornire un numero tale di metodi da far sì che l'utente di un oggetto non debba mai accedere direttamente ai suoi campi.

Le classi Locazione e Punto sono scritte in modo tale che non è assolutamente necessario accedere direttamente a uno qualsiasi dei loro campi dati:
La classe Punto possiede solo tre campi dati: X, Y e isVisibile. II metodo SpostaIn(x, y) carica nuovi valori per X e Y, mentre i metodi leggiX() e leggiY() restituiscono i valori di X e Y. Questo elimina qualsiasi ulteriore necessità di accedere direttamente a X o a Y. Mostra e Nascondi alternano la variabile booleana Visibile tra True e False, mentre la funzione isVisibile riporta lo stato attuale di Visibile.

Metodi

L'aggiunta di questi metodi aumenta alquanto la dimensione del file sorgente in cui è scritta la classe Punto, ma nel file eseguibile manca il codice dei metodi che non vengono mai chiamati in un programma. Non bisogna quindi lesinare nella scrittura di metodi, temendo che il file cresca inutilmente, perchè i metodi inutilizzati non influiscono in alcun modo sulle prestazioni o sulle dimensioni del file .EXE. Se non vengono utilizzati, significa che non ci sono.

La possibilità di isolare completamente Punto da qualsiasi riferimento globale offre dei notevoli vantaggi. Se all'esterno dell'oggetto nessuno "conosce" la rappresentazione dei suoi dati interni, il programmatore che controlla 1'oggetto potrà modificare i particolari della rappresentazione dei dati interni - sempre che la dichiarazione del metodo rimanga inalterata.

All'interno di un oggetto è possibile rappresentare i dati come un vettore (array), ma in seguito, se gli obiettivi dell'applicazione mutano e il volume dei dati cresce, si potrebbe decidere che un albero binario potrà costituire una rappresentazione più efficace. Se l'oggetto è completamente incapsulato, il cambiamento nella rappresentazione dei dati da vettore a albero binario non modificherà in alcun modo l'utilizzo dell'oggetto stesso. L'interfaccia all'oggetto rimane identica, consentendo al programmatore di regolare le prestazioni di un oggetto senza dover intervenire su un qualsiasi codice che lo utilizza.

La programmazione orientata agli oggetti introduce l'ereditarietà: una volta definita una classe discendente, i metodi della classe antenato vengono ereditati ma, volendo, è anche possibile ridefinirli. Per ridefinire il metodo ereditato, è sufficiente definire un nuovo metodo con lo stesso nome di quello ereditato, ma con un corpo diverso e (se necessario) un diverso insieme di parametri.

Un semplice esempio chiarirà il processo di derivazione e le sue implicazioni. Si supponga di definire un tipo discendente da Punto che invece di un punto tracci un cerchio;

class Cerchio : public Punto {

public:

  int Raggio;

public:

  Cerchio(int, int, int);

  void Mostra();

  void Nascondi();

  void Espandi(int AllargaDi);

  void SpostaIn(int NuovoX, int NuovoY);

  void Contrai(int ContraiDi);

};

 

Cerchio::Cerchio(int Xcentro, int Ycentro, int R): Punto(X,Y) {

  Raggio = R;

}

 

void Cerchio::Mostra() {

  Visibile = true;

  cout << "\ndisegna Cerchio (" << X << ", " << Y << ") e raggio: " << Raggio;

}

 

void Cerchio::Nascondi() {

  Visibile = false;

  cout << "\ncerchio nascosto (disegnato con lo stesso colore di sfondo)";

}

 

void Cerchio::Espandi(int AllargaDi) {

  Nascondi();

  Raggio += AllargaDi;

  if (Raggio < 0) Raggio = 0;

  Mostra();

}

 

void Cerchio::Contrai(int ContraiDi){

  Espandi(-ContraiDi);

}

 

void Cerchio::SpostaIn(int NewX, int NewY) {

  Nascondi();

  X = NewX;

  Y = NewY;

  Mostra();

}

La funzione main():

#include "Locazione.h"

#include "Punto.h"

#include "cerchio.h"

 

int main(int argc, char *argv[]) {

  Cerchio unCerchio(80,40,30);

 

  unCerchio.Mostra();

  unCerchio.Espandi(5);

  unCerchio.Contrai(20);

}

In un certo senso si può pensare che un cerchio è un punto allargato: possiede tutte le caratteristiche di un punto (una locazione X, Y, uno stato visibile / invisibile), più un raggio. A prima vista si potrebbe pensare che la classe Cerchio possiede solo il campo Raggio, ma non bisogna dimenticare tutti i campi che Cerchio eredita discendendo da Punto.

La classe Cerchio possiede inoltre X, Y e Visibile anche se non si possono vedere nella definizione della classe Cerchio.

Poichè Cerchio definisce un nuovo campo, Raggio, occorre un nuovo costruttore per inizializzare sia Raggio che i campi ereditati. Invece di assegnare direttamente i valori ai campi ereditati, quali X, Y e Visibile, si riutilizza il costruttore di Punto. La sintassi per chiamare un metodo ereditato è Antenato.Metodo, dove Antenato è l'identificatore di un oggetto di classe antenato e Metodo è l'identificatore del metodo di quella classe.

Dopo aver chiamato il costruttore di Punto, il costruttore di Cerchio potrà eseguire la propria inizializzazione, che in questo caso consiste solo nell'assegnare a Raggio il valore passato in R.

Per tracciare e nascondere il cerchio si possono utilizzare le funzioni della libreria grafica. In tal caso, Cerchio avrà a sua volta bisogno di nuovi metodi Mostra e Nascondi in grado di ignorare quelli di Punto.

I metodi possono essere ridefiniti, ma non si lo può fare anche per i campi dati. Una volta definito un campo dati in una gerarchia di oggetti, nessun tipo discendente può definire un campo dati con lo stesso identificatore.

Metodi virtuali e polimorfismo

Fino ad ora sono stati analizzati solo metodi statici. Essi sono tali per lo stesso motivo per cui le variabili statiche sono statiche: il compilatore le alloca e risolve tutto ciò che ad esse si riferisce durante la fase di compilazione.

I metodi virtuali implementano uno strumento molto potente per la generalizzazione chiamato polimorfismo. Polimorfismo deriva dal greco e significa "con più forme"; Questo è un modo per dare un nome ad un'azione che è condivisa lungo tutta una gerarchia di oggetti, in cui ogni oggetto nella gerarchia implementa l'azione nel modo ad esso appropriato.

La semplice gerarchia di figure grafiche come quelle che sono già state descritte, rappresenta un ottimo esempio del polimorfismo, implementato per mezzo dei metodi virtuali.

Ogni classe nella gerarchia rappresenta sullo schermo un tipo di figura diversa: un punto o un cerchio. In seguito, se si decide di definire degli oggetti che disegnano altre figure, quali linee, quadrati, archi e così via, basta scrivere un metodo per ciascuna di esse che visualizzi quell'oggetto sullo schermo. Secondo il nuovo modo di pensare orientato agli oggetti, si può affermare che tutti questi tipi di figure grafiche sono in grado di apparire sullo schermo. E questa caratteristica è condivisa da tutti.

Quello che invece è diverso per ogni figura, è il modo in cui deve apparire allo schermo. Un punto viene tracciato con una routine che richiede una locazione X,Y e un valore di colore. La visualizzazione di un cerchio richiede una routine grafica completamente separata che non si limiti a considerare X, Y, ma anche un raggio. Andando ancora oltre, un arco avrà bisogno di un angolo di partenza e di un angolo finale e un algoritmo di tracciatura più complesso per poterli considerare.

Si può mostrare qualsiasi figura grafica, ma il meccanismo per mostrare ciascuna di esse dovrà essere specifico per ogni figura. Un'unica parola, "mostrare", viene utilizzata per disegnare più forme. Quest'ultima frase è un ottimo esempio per spiegare cos'è il polimorfismo.

Collegamento anticipato (early hinding) e collegamento ritardato (late hinding)

La differenza che esiste tra una chiamata di metodo statico e una di metodo virtuale, può essere paragonata alla differenza che esiste tra una decisione presa subito e una rimandata. Quando si codifica una chiamata di un metodo statico, è come se si stesse dicendo al compilatore: "Sai cosa voglio. Chiamalo." Quando si effettua una chiamata di un metodo virtuale, è come dire al compilatore: "Non sai ancora quello che voglio. Quando verrà il momento chiedi l'istanza."

Una chiamata a Cerchio.SpostaIn() può andare in un unico posto: l'implementazione più vicina di SpostaIn lungo la gerarchia dell'oggetto. In questo caso, Cerchio.SpostaIn() continuerebbe a chiamare la definizione di SpostaIn, contenuta nella classe Punto, poichè Punto è il più vicino a Cerchio nella gerarchia. Supponendo che nessun tipo discendente abbia definito il proprio metodo SpostaIn perchè escludesse SpostaIn di Punto, qualsiasi tipo discendente di Punto continuerà a chiamare la stessa implementazione di SpostaIn. La decisione potrà essere presa durante la fase di compilazione e non occorre fare altro.

La situazione, tuttavia, cambia quando SpostaIn chiama Mostra. Poichè ogni tipo di figura possiede una propria implementazione di Mostra, quella chiamata da SpostaIn dipenderà esclusivamente dall'istanza che ha chiamato SpostaIn in origine. Questo spiega il motivo per cui la chiamata al metodo Mostra all'interno dell'implementazione di SpostaIn rappresenta una decisione rimandata: quando viene compilato il codice per SpostaIn non è possibile decidere quale metodo Mostra chiamare, in quanto questa informazione non è disponibile nella fase di compilazione. Di conseguenza, la decisione dovrà essere rimandata alla fase di esecuzione, quando sarà possibile interrogare l'istanza che chiama SpostaIn.

Il processo mediante il quale le chiamate ai metodi statici vengono risolte in maniera non ambigua in un metodo singolo dal compilatore durante la fase di compilazione viene chiamato collegamento anticipato (early binding). Durante la fase di compilazione il chiamante e il chiamato vengono collegati. Nel caso del collegamento ritardato (late binding), non è possibile collegare il chiamante e il chiamato durante la fase di compilazione, per cui viene attuato un particolare meccanismo per collegarli in seguito, quando viene realmente effettuata la chiamata. La natura di tale meccanismo è interessante e ingegnosa.

L'ereditarietà modifica alquanto le regole di compatibilità dei tipi. Un tipo discendente eredita, oltre a tutto il resto, la compatibilità con tutti i tipi dei suoi antenati. Questa compatibilità dei tipi estesa assume tre forme:

Per tutte e tre le forme è estremamente importante ricordare che la compatibilità dei tipi si estende unicamente da discendente ad antenato. In altri termini, i tipi discendenti possono essere utilizzati liberamente al posto dei tipi antenati, ma non viceversa. Si esaminino queste dichiarazioni:

Locazione *LocazionePtr;

Punto *PuntoPtr;

Cerchio *CerchioPtr;

 

Locazione unaLocazione(10,20);

Punto unPunto(20,30);

Cerchio unCerchio(50,50,10);

 

LocazionePtr = new Locazione(10,60);

PuntoPtr = new Punto(60,70);

CerchioPtr = new Cerchio(5,5,4);

Con queste dichiarazioni sono permessi gli assegnamenti seguenti:

Ad un oggetto antenato può essere assegnata un'istanza di uno qualsiasi dei suoi tipi discendenti.

    unaLocazione = unPunto;

    unPunto = unCerchio;

    unaLocazione = unCerchio;

Gli assegnamenti inversi non sono ammessi.

All'inizio potrà essere un po' difficile ricordare la direzione della compatibilità dei tipi. La si Consideri nel seguente modo: la sorgente deve essere in grado di riempire completamente la destinazione. In virtù dell'ereditarietà, i discendenti contengono tutto ciò che è contenuto dai loro antenati. Di conseguenza, un discendente può avere dimensioni uguali o (di solito) maggiori ai suoi antenati, ma mai inferiori. L'assegnazione di un oggetto antenato ad un oggetto discendente può lasciare indefiniti alcuni dei campi di quest'ultimo, cosa pericolosa e pertanto viene impedita.

In una assegnazione, solo i campi in comune tra i due tipi verranno copiati dalla sorgente alla destinazione.

Nell'assegnazione:

 

  unaLocazione = unCerchio;

 

In unaLocazione verranno copiati solamente i campi X e Y di unCerchio, poichè X e Y sono tutto ciò che le classi Cerchio e Locazione hanno in comune.

La compatibilità dei tipi funziona anche tra puntatori a oggetti, con le stesse regole generali delle istanze: i puntatori ai discendenti possono essere assegnati ai puntatori agli antenati. Nuovamente, date le precedenti definizioni, si considerano corrette le seguenti assegnazioni:

PuntoPtr = CerchioPtr;

LocazionePtr = PuntoPtr;

LocazionePtr = CerchioPtr;

Le assegnazioni inverse non sono permesse.

Se nella dichiarazione di una funzione si deve specificare un parametro formale di una classe, questo può essere un'istanza di quella classe, oppure di un qualsiasi discendente. Ad esempio, nella dichiarazione seguente:

 

  void muovi(Punto Pt);

 

I parametri effettivi (quelli specificati nella chiamata) possono essere di classe Punto o di classe Cerchio, ma non di classe Locazione.

Analogamente, se un parametro formale è un puntatore a una classe, il parametro effettivo può essere un puntatore a un'istanza di quella classe oppure un puntatore ad un'istanza qualsiasi dei discendenti di quella classe. Usando questa dichiarazione di funzione:

 

  void Figura.Add(PuntoPtr NuovaFigura);

 

I parametri effettivi consentiti possono essere istanze di PuntoPtr oppure di CerchioPtr, ma non di LocazionePtr.

Oggetti polimorfi

Se un tipo discendente può essere passato nel parametro, come fa la funzione a sapere quale tipo di istanza sta ricevendo? Il tipo esatto del parametro effettivo è sconosciuto nella fase di compilazione. Potrebbe essere uno qualsiasi dei tipi discendenti del tipo del parametro; e per questo viene chiamato oggetto polimorfo.

A cosa servono esattamente gli oggetti polimorfi? Principalmente, gli oggetti polimorfi permettono l'elaborazione di oggetti il cui tipo è ignoto nella fase di compilazione.

Si immagini di aver scritto un'applicazione di disegno in grado di trattare diversi tipi di figure: punti, cerchi, quadrati, rettangoli, curve e così via. Come parte dell'applicazione si vuole scrivere una routine in grado di tracciare una figura grafica sullo schermo per mezzo del puntatore del mouse.

Nel modo tradizionale dovrebbe scrivere una diversa procedura di tracciamento per ogni tipo di figura grafica. Ad esempio: si sarebbe dovuto scrivere tracciaCerchio, tracciaQuadrato, tracciaRettangolo e così via.

La differenza tra i tipi di figure grafiche impedisce la scrittura di una routine veramente generale di disegno. Dopo tutto, un cerchio possiede un raggio ma non lati, un quadrato ha una sola lunghezza per il lato, un rettangolo ha due diverse lunghezze per i lati e le curve ...

Una soluzione sarebbe: si passi il record della figura grafica alla funzione Traccia. All'interno di Traccia si esamini un campo del record della figura grafica, per determinare di che tipo di figura si tratta. Quindi, si utilizzi un'istruzione switch per eseguire la diramazione:

switch (Figura) {

  case Punto: tracciaPunto;

    Cerchio: tracciaCerchio;

    Quadrato: tracciaQuadrato;

    Rettangolo: tracciaRettangolo;

    Curva: tracciaCurva;

}

Cosa accade se l'utente dell'applicazione decidesse di definire un nuovo tipo di figura grafica? Se l'utente disegna segnali stradali e vuole utilizzare gli ottagoni come segnale di stop, l'applicazione non possiede un tipo Ottagono, per cui Traccia non avrà un case Ottagono e pertanto si rifiuterà di tracciare la nuova figura Ottagono ed entrerà nella clausola else del switch segnalando "figura sconosciuta".

In poche parole, la realizzazione di un'applicazione da vendere senza codice sorgente soffre di questo problema: l'applicazione può lavorare solo sui tipi di dati che "conosce", cioè quelli definiti dal progettista dell'applicazione. L'utente è impossibilitato a estendere le funzioni dell'applicazione pacchetto nei casi non previsti dai progettisti. L'utente deve accontentarsi di ciò che ha comprato.

La via d'uscita consiste nell'utilizzare le regole stesse di compatibilità dei tipi per gli oggetti, che permettono di progettare le applicazioni in modo da utilizzare gli oggetti polimorfi e i metodi virtuali. Se la procedura Traccia di un'applicazione è predisposta per lavorare con gli oggetti polimorfi, utilizzerà un qualsiasi oggetto definito nell'applicazione - e qualsiasi oggetto discendente che si definirà. Se le classi del pacchetto utilizzano metodi virtuali, gli oggetti e le routine del pacchetto potranno lavorare con le figure grafiche personalizzate. Un metodo virtuale definito oggi è richiamabile da un file di un'applicazione scritta e compilata in precedenza. La programmazione orientata agli oggetti permette tutto questo; e i metodi virtuali ne costituiscono la chiave. La comprensione della modalità per cui i metodi virtuali rendono possibili tali chiamate di metodi polimorfi richiede alcune conoscenze sulle modalità di dichiarazione e utilizzo dei metodi virtuali.

I metodi virtuali

Un metodo diventa virtuale quando la sua dichiarazione nella classe viene preceduta dalla parola riservata virtual. Si ricordi che se la dichiarazione di un metodo in un antenato è virtual, tutti i metodi con lo stesso nome in un qualsiasi discendente dovranno a loro volta essere dichiarati virtual per evitare un errore del compilatore. Queste sono le classi delle forme grafiche analizzate, virtualizzate nel modo corretto:

class Locazione {

public:

  int X, Y;

public:

  Locazione (int, int);

  int leggiX();

  int leggiY();

};

 

class Punto : public Locazione {

public:

  bool Visibile;

public:

  Punto(int, int);

  virtual void Mostra();

  virtual void Nascondi();

  bool isVisibile();

  void SpostaIn(int, int);

};

 

class Cerchio : public Punto {

public:

  int Raggio;

public:

  Cerchio(int, int, int);

  virtual void Mostra();

  virtual void Nascondi();

  virtual void Espandi(int);

  virtual void SpostaIn(int , int);

  virtual void Contrai(int);

};

Ogni classe che possiede metodi virtuali deve avere un costruttore.

Un costruttore è uno speciale tipo di funzione che esegue una parte del lavoro di impostazione per il funzionamento dei metodi virtuali. Inoltre, il costruttore dovrà sempre essere chiamato prima di un qualsiasi metodo virtuale.

Se si chiama un metodo virtuale senza aver prima chiamato il costruttore, si può provocare un errore del sistema, e il compilatore non può controllare l'ordine in cui vengono chiamati i metodi.

Attenzione! Ogni singola istanza di un oggetto deve essere inizializzata da una chiamata di costruttore separata. Non basta inizializzare l'istanza di un oggetto e, quindi, assegnarla ad ulteriori istanze. Queste ultime, anche se contengono dei dati corretti, non possono essere inizializzate dalle proposizioni dell'assegnazione; e bloccheranno il sistema se vengono chiamati i loro metodi virtuali.

Ogni classe possiede una tabella dei metodi virtuali (virtual method table - VMT) nel segmento dei dati. La VMT contiene le dimensioni della classe e, per ciascuno dei suoi metodi virtuali, un puntatore al codice che implementa quel metodo. Il costruttore stabilisce un collegamento tra l'istanza che chiama il costruttore e la VMT della classe.

È molto importante ricordare questa caratteristica, in quanto c'è un'unica VMT per ogni classe. Le singole istanze di una classe (cioè, le variabili di quel tipo), contengono un collegamento alla VMT, ma non la VMT stessa. Il costruttore imposta il valore di questo collegamento con la VMT, questo spiega il motivo per cui l'esecuzione può essere lanciata nel nulla se si chiama un metodo virtuale prima di aver chiamato il costruttore.

Sia Punto che Cerchio possiedono metodi chiamati Mostra e Nascondi. Tutte le dichiarazioni dei metodi per Mostra e Nascondi sono etichettate come metodi virtuali per mezzo della parola riservata virtual. Una volta che una classe antenato etichetta un metodo come virtuale, tutti i suoi discendenti che implementano un metodo con quel nome dovranno etichettare anche quello come virtuale. In altre parole, un metodo statico non può mai escludere un metodo virtuale.

Ricordare che la dichiarazione del metodo virtuale non può essere modificata in alcun modo verso il basso in una gerarchia di oggetti. Le intestazioni di tutte le implementazioni di uno stesso metodo virtuale devono essere identiche, compresi il numero e il tipo dei parametri. Questo non si applica al caso dei metodi statici, in quanto un metodo statico che ne esclude un altro può avere tutti i numeri e tipi di parametri diversi quanti ne saranno necessari.