Copiare gli oggetti in C++: parte seconda

Seconda puntata della mini-serie (qui la prima puntata). Oggi vediamo le funzioni virtuali.

Funzioni Virtuali

La scorsa volta ho anticipato uno dei meccanismi di base della programmazione OO: l’ereditarietà tra le classi (ovvero i tipi di dati). Il programmatore può definire nuovi tipi di dati utilizzando il meccanismo delle classi; e può mettere le classi in relazione di ereditarietà fra loro.

Supponiamo di aver fatto un programma con due classi, la classe Base e la classe Derivata:

class Base {
public:
  void print() {
     cout << "Ciao, sono di tipo Base" << endl;
  }
};

class Derivata : public Base {
public:
  void function() {
     cout << "Ciao, è stata chiamata la funzione()" << endl;
  }
};

La classe Derivata “eredita” le caratteristiche della classe base, e in particolare tutte le sue funzioni.
Nella programmazione ad oggetti una funzione all’interno di una classe viene chiamata anche “metodo”, quindi da adesso cercherò di utilizzare questo termine. La classe derivata ha dunque 2 metodi: print() e function(). Ecco che li uso:

...
int main() {
  Derivata x;
  x.print();
  x.function();
}

Il programma produce il seguente output:

Ciao, sono di tipo Base
Ciao, è stata chiamata la funzione()

In pratica, l’oggetto x della classe Derivata ha “ereditato” il metodo print() dalla classe Base, e quando lo invoco, viene eseguito il codice del metodo Base::print() (ovvero della funzione print() che sta dentro la classe Base).

C’è qualcosa che non va, vero? Vorremmo che stampasse la cosa corretta, ovvero “Ciao, sono di tipo Derivata“. Per fare questo, possiamo ridefinire il metodo print() dentro la classe Derivata:

#include <iostream>
using namespace std;
class Base {
public:
  void print() {
     cout << "Ciao, sono di tipo Base" << endl;
  }
};

class Derivata : public Base {
public:
  void print() {
     cout << "Ciao, sono di tipo Derivata" << endl;
  }
  void function() {
     cout << "Ciao, è stata chiamata la funzione()" << endl;
  }
};

int main() {
  Derivata x;
  x.print();
  x.function();
}

Adesso ci sono due print(): la prima la indichiamo con Base::print(), ed è il metodo implementato dentro la classe Base; la seconda la chiamiamo Derivata::print(). Quindi, se usiamo il nome “esteso” (cioè includendo anche il nome della classe) vediamo che si tratta di due metodi differenti.

Potete scaricare il file, compilarlo e lanciarlo con

g++ prova.cpp -o prova
./prova

E dovrebbe darvi in output:

Ciao, sono di tipo Derivata
Ciao, è stata chiamata la funzione()

Ora va meglio, no? Però, c’è un però. Siamo sicuri che venga sempre chiamata la funzione giusta? Vediamo cosa succede con l’upcasting.

L’upcasting è la tecnica di utilizzare un oggetto della classe derivata tramite un puntatore o un riferimento a un oggetto della classe padre. Ed ecco l’esempio in due righe:

int g(Base *p)
{
    p->print();
    p->function();
}

int main() {
    Derivata x;
    Base *q = &x;
    g(q);
}

Questo aggeggio non compila! (provare per credere…). Il perché è presto detto: quando scrivete p->function(), il compilatore non ha idea di quale sia l’oggetto a cui p sta puntando. È vero che noi (i programmatori) che abbiamo appena scritto il programma sappiamo che p sta puntando a un oggetto di tipo Derivata. Ma il compilatore non può fare questa assunzione: deve tener conto che l’oggetto puntato da p potrebbe essere benissimo di tipo Base! Per esempio, magari la funzione g() viene chiamata da qualche altra parte nel programma (non è questo il caso, ma il compilatore non può fare di queste assunzioni). E d’altronde, se volevamo specificare che p dovesse in ogni caso puntare ad un oggetto di classe Derivata, potevamo specificare “Derivata” come tipo del puntatore p! Invece abbiamo scritto “Base *p”, e il compilatore non può fare altro che arrendersi, e utilizzare l’assunzione minima: p punta ad un oggetto di tipo Base o a un tipo derivato (ma non si sa quale). E poiché il metodo function() non è presente nella classe Base, il compilatore da errore.

Eliminiamo dunque la chiamata a function() per il momento, ed ecco il codice completo che potete scaricare ed eseguire.

#include <iostream>
using namespace std;
class Base {
public:
  void print() {
     cout << "Ciao, sono di tipo Base" << endl;
  }
};

class Derivata : public Base {
public:
  void print() {
     cout << "Ciao, sono di tipo Derivata" << endl;
  }
  void function() {
     cout << "Ciao, è stata chiamata la funzione()" << endl;
  }
};
int g(Base *p)
{
    p->print();
}

int main() {
    Derivata x;
    Base *q = &x;
    g(q);
    x.function();
}

E questa vi stampa:

Ciao, sono di tipo Base
Ciao, è stata chiamata la funzione()

Di nuovo? (che palle questo C++!).
Beh, si di nuovo, e d’altronde, che vi aspettavate? Se p è di tipo “puntatore a Base”, chiamarà il metodo Base::print(), anche se l’oggetto è di tipo Derivata. La regola è:

Se si chiamano metodi di oggetti tramite puntatori, il metodo effettivamente chiamato dipende dal tipo del puntatore, e non dal tipo dell’oggetto.

La stessa regola vale anche per i riferimenti. Ecco l’esempio di prima usando un riferimento invece del puntatore:

int g(Base &r)
{
    r.print();
}

int main() {
    Derivata x;
    g(x);
    x.function();
}

E questa vi stampa ancora:

Ciao, sono di tipo Base
Ciao, è stata chiamata la funzione()

E se invece io volessi chiamare il metodo dipendentemente dal tipo dell’oggetto? Posso farlo?
Non è difficile, basta specificare “virtual” davanti la dichiarazione del metodo. Ecco il programma modificato.

#include <iostream>
using namespace std;
class Base {
public:
  virtual void print() {
     cout << "Ciao, sono di tipo Base" << endl;
  }
};

class Derivata : public Base {
public:
  virtual void print() {
     cout << "Ciao, sono di tipo Derivata" << endl;
  }
  void function() {
     cout << "Ciao, è stata chiamata la funzione()" << endl;
  }
};
int g(Base &r)
{
    r.print();
}

int main() {
    Derivata x;
    g(x);
    x.function();
}

Se compilate ed eseguite quest’ultimo, vi stamperà a video:

Ciao, sono di tipo Derivata
Ciao, è stata chiamata la funzione()

Che è successo? La regola completa è:

Se si chiamano metodi di oggetti tramite puntatori:

  1. se il metodo è dichiarato “virtual” nella classe base, il metodo effettivamente chiamato dipende dal tipo dell’oggetto puntato e non dal tipo del puntatore;
  2. se il metodo è dichiarato normalmente in tutte le classi della gerarchia, il metodo chiamato dipende dal tipo del puntatore, e non dal tipo dell’oggetto puntato.

Il primo meccanismo di chiamata dei metodi delle classi si chiama dynamic binding, mentre il secondo si chiama static binding. Come fa il compilatore a sapere quale metodo chiamare nel primo caso? abbiamo detto che non sa quale sia il vero tipo dell’oggetto puntato! Il compilatore usa delle tabelle chiamate “virtual tables” (e completamente nascoste all’utente) di puntatori a funzione: ogni oggetto possiede al suo interno, oltre ai suoi dati privati, anche questa tabella, riempita con gli indirizzi giusti delle funzioni da chiamare. La chiamata di un metodo tramite puntatore implica quindi un “salto” utilizzando l’indirizzo contenuto dentro la corrispondente riga della tabella.

Nel nostro esempio, la tabella avrà una sola riga (perché c’è una sola funzione virtuale). UN oggetto di tipo Base metterà nella riga della virtual table l’indirizzo della funzione Base::print(); un oggetto di tipo Derivata metterà nella tabella l’indirizzo della funzione Derivata::print(). La chiamata p.print() viene tradotta in due passi: 1) prendo l’indirizzo dalla tabella 2) ci salto dentro.

Confronti

Dato che ci sono questi due passi (invece di uno solo come nelle chiamate normali), alcuni si ostinano a dire che il C++ sia “inefficiente” rispetto al C. A me in realtà sembra che l’impatto di tale meccanismo sia trascurabile sulle nuove architetture, e assolutamente trascurabile comparato ai vantaggi che si ottengono dal punto di vista della pulizia del codice!

Altri linguaggi OO non hanno questa distinzione tra metodi virtuali e non. Per esempio in Java, tutti i metodi sono automaticamente virtuali, e non c’è modo di implementare lo static binding: non esiste neanche la parole chiave virtual! Stessa cosa con il Python. Meglio o peggio? Sicuramente più semplici da studiare e imparare: meno flessibili e controllabili, però. Il C++ si è fatto da sempre un punto d’onore nell’essere estremamente controllabile dal programmatore, che può fare il fine tuning dei meccanismi di base del linguaggio.

Disegnare le forme

Torniamo al nostro esempio delle forme, che abbiamo introdotto la scorsa puntata. Come abbiamo detto, possiamo creare forme di tipo diverso, tutte derivate da Shape. Possiamo anche raggrupparle in un vettore di puntatori.
Inoltre, dobbiamo essere in grado di disegnarle tutte in una volta. Per cui, converrebbe avere un bel ciclo for sul contenuto del vettore per chiamare la funzione draw() su ognuna di esse.

int main() {
  Shape * shapes[3];

  shapes[0] = new Circle(1,1,4);
  shapes[1] = new Rect(2,2,5,6);
  shapes[2] = new Triangle(3,3,7,10);

  for (int i=0; i<3; ++i) shapes[i]->draw();
  ...
}

Dove draw() è un metodo virtuale della classe Shape. In questo modo, ognuna delle forme potrà ridefinire la funzione draw() nella maniera più opportuna, e il metodo corretto verrà chiamato secondo il meccanismo del dynamic binding descritto precedentemente.
Ecco degli snapshot di codice. Innanzitutto la classe Shape:

class Shape {
 protected:
  int xx,yy;
 public:
  Shape(int x, int y);
  void move(int x, int y);

  virtual void draw() = 0;
  virtual void resize(int scale) = 0;
  virtual void rotate(int degree) = 0;
};

Abbiamo una funzione move() specifica della classe Shape; e tre metodi virtuali che saranno ridefiniti nelle classi derivate. La scrittura ” = 0″ dopo la dichiarazione del metodo significa che non intendiamo fornire una implementazione per questi metodi nella classe Shape. In altre parole, questi metodi sono virtuali puri. Non si possono istanziare oggetti di una classe che ha metodi virtuali puri, quindi non possiamo creare oggetti di tipo Shape, perché altrimenti il compilatore non saprebbe che funzione chiamare quando uno scrive obj.draw(). Va bene, nel nostro caso non esistono “forme astratte” ma solo “forme concrete” come triangoli, cerchi, ecc., e quindi impediamo che si possano creare oggetti di tipo Shape.

Adesso vediamo una classe derivata, per esempio la Circle:

class Circle : public Shape {
  int rr;
 public:
  Circle(int x, int y, int r);
  virtual void draw();
  virtual void resize(int scale);
  virtual void rotate(int degree);
};

Diciamo che Circle deriva da Shape, e definisce i tre metodi virtuali. L’implementazione la forniamo un un file a parte:

Circle::Circle(int x, int y, int r) : Shape(x,y), rr(r) {}

void Circle::draw()
{
  cout << "Circle::draw() called" << endl;
  PR(xx); PR(yy); PR(rr); 
}

void Circle::resize(int scale)
{
  cout << "Circle::resize() called" << endl;
  rr *= scale;
  PR(xx); PR(yy); PR(rr); 
}

void Circle::rotate(int degree)
{
  cout << "Circle::rotate() called" << endl;
  PR(xx); PR(yy); PR(rr); 
}

Facciamo lo stesso per Triangle, Rectangle, ecc.

Infine, utilizziamo le forme nel file principale:

#include "circle.hpp"
#include "rect.hpp"
#include "triangle.hpp"

int main()
{
  Shape * shapes[3];

  shapes[0] = new Circle(1,1,4);
  shapes[1] = new Rect(2,2,5,6);
  shapes[2] = new Triangle(3,3,7,10);

  int i;

  for (i=0; i<3; ++i) TRACE(shapes[i]->draw());
  
  for (i=0; i<3; ++i) TRACE(shapes[i]->move(1,1));
  
  for (i=0; i<3; ++i) TRACE(shapes[i]->resize(2));
  
  for (i=0; i<3; ++i) TRACE(shapes[i]->rotate(1));
}

TRACE è una macro che mi sono definito per evitare di ripetere sempre lo stesso codice nell’esempio. Tutto il codice dell’esempio lo trovate qui. Ed ecco le istruzioni per compilare:

  • scaricate, e scompattate con:

    tar xzvf cpp_shapes.tgz

  • entrate nella directory e compilate con:

    make

  • infine eseguite il programma con

    ./shapes

Conclusioni

Ok, più o meno ci siamo. Spero abbiate compreso il meccanismo di base delle funzioni/metodi virtuali. La prossima puntata sarà un intermezzo per spiegare un design pattern piuttosto classico: il Composite. Alla prossima!

Posta un commento o usa questo indirizzo per il trackback.

Rispondi

Inserisci i tuoi dati qui sotto o clicca su un'icona per effettuare l'accesso:

Logo di WordPress.com

Stai commentando usando il tuo account WordPress.com. Chiudi sessione /  Modifica )

Google photo

Stai commentando usando il tuo account Google. Chiudi sessione /  Modifica )

Foto Twitter

Stai commentando usando il tuo account Twitter. Chiudi sessione /  Modifica )

Foto di Facebook

Stai commentando usando il tuo account Facebook. Chiudi sessione /  Modifica )

Connessione a %s...

Questo sito utilizza Akismet per ridurre lo spam. Scopri come vengono elaborati i dati derivati dai commenti.

%d blogger hanno fatto clic su Mi Piace per questo: