Motivazioni
Problemi di questo tipo si presentano spesso nella definizione di framework. Generalmente un framework deve offrire algoritmi comuni e, nello stesso tempo, dare la possibilità all’utilizzatore di estenderli. Deve quindi essere scalabile e, nello stesso tempo, flessibile. Un motore (algoritmo comune del framework) per la costruzione di stream dati che devono rispettare formati dipendenti da uno specifico protocollo (estensioni utente), ne costituisce un valido esempio.
Implementazione
L’implementazione proposta, in linguaggio ANSI/ISO C++, utilizza i puntatori a funzioni membro (metodo).
Ogni programma scritto in C++ dovrebbe avere, al più, puntatori a metodo e non puntatori a funzione per garantire l’incapsulamento, oltre al fatto che il significato di una data operazione è definito dall’oggetto sul quale questa è invocata. La dichiarazione di un puntatore a metodo sottolinea in modo evidente questo aspetto: NOMECLASSE::*.
Di seguito, alcuni aspetti da tenere a mente quando si devono usare i puntatori a metodo; questi confermano quanto appena detto:
- i puntatori a metodo non possono essere usati con metodi statici. Un membro statico non è associato all’oggetto e un puntatore ad esso non è altro che un normale puntatore;
- un puntatore a metodo deve essere invocato, ovviamente, su di un’istanza della classe;
- con un puntatore a metodo l’invocazione di un metodo polimorfico viene risolta a run-time, chiamando direttamente l’operazione sull’oggetto istanziato;
- un puntatore a metodo può essere impostato a 0 (NULL) e può essere confrontato attraverso gli operatori == e !=, ma solo per i metodi della stessa classe, mentre non sono possibili comparazioni mediante gli operatori <, >, <=, >=.
A dimostrazione di quanto detto nel terzo punto, l’esempio seguente evidenzia che la chiamata a metodo, mediante il suo puntatore, corrisponde all’invocazione del metodo della classe derivata, pur essendo dichiarato di tipo method_pointer nella classe base:
class Base {
public:
typedef void (Base::*method_pointer)(int);
virtual void an_operation(int value) {
printf("Base::an_operation(%d)\n", value);
};
};
class Derived : public Base {
public:
void an_operation(int value) {
printf("Derived::an_operation (%d)\n", value);
};
};
int main() {
Base* obj = new Derived;
Base::method_pointer method = &Base::an_operation;
(obj->*method)(1); // method call
}
L’aspetto da tener presente è la necessita di dover puntare sempre ad un metodo della classe base, ossia la classe dove è definito il puntatore al metodo. Il compilatore rifiuterebbe un’assegnazione del tipo Base::method_pointer method = &Derived::an_operation perché incapace di convertire un puntatore a metodo della classe derivata in uno definito nella classe base (controvarianza). L’implementazione presenta quindi un limite strutturale, ma raggirabile attraverso l’uso combinato di template ed ereditarietà. Infatti rendendo template la classe che contiene la definizione del puntatore al metodo, è possibile istanziarla sulla classe derivata che deve esporre metodi come puntatori a metodo del tipo definito nella classe estesa. In tal modo, il puntatore al metodo risulta definito sul tipo che istanzia il template:
template <class T>
class Base {
public:
typedef int (T::* method_pointer)(int value);
void an_operation() {
int result;
result = (this->*method_)(1000); // method call
}
protected:
method_pointer method_;
};
Come è possibile notare, il puntatore a metodo è stato definito rispetto al tipo T che corrisponderà alla classe stessa che estende la classe base. L’attributo method_ sarà impostato rispetto ad un qualsiasi metodo esposto dalla classe derivata, purché rispetti il tipo method_pointer. Per esempio:
class Derived : public Base<Derived>
{
public:
Derived(int choice) {
method_ = (choice == 1) ? &Derived::operation_1 : &Derived::operation_2;
}
int operation_1(int value) {
printf("Derived::operation_1(%d)\n", value);
return 1;
}
int operation_2(int value) {
printf("Derived::operation_2(%d)\n", value);
return 2;
}
};
Figura 1 - Diagramma delle classi
A questo punto non resta che implementare l’algoritmo comune definito nel metodo an_operation() della classe base, essendo questa in grado di invocare il metodo della classe derivata, attraverso l’attributo method_. Purtroppo, ancora una volta, non è così semplice quanto sembra: il codice proposto non può essere compilato.
Come già detto, una funzione membro di una classe è leggermente differente da una classica funzione. Infatti, oltre ai parametri della firma è presente un ulteriore parametro: this. Questo punta alla specifica istanza della classe e verifica, nel caso di funzioni virtuali, quale funzione deve essere eseguita. Il compilatore quindi non associa il metodo alla classe derivata bensì alla classe base istanziata su Derived, cioè Base<Derived>.
Tuttavia eseguendo un cast a T, cioè alla classe Derived, si ottiene il puntatore all’istanza della classe stessa. Ora il compilatore è in grado di eseguire correttamente la chiamata a metodo scelto della Derived, attraverso il suo puntatore a metodo, perché in grado di accedere ai metodi esposti dalla sua interfaccia.
void an_operation() {
T* instance = static_cast<T*>(this);
(instance->*method_)(0);
}
A questo punto non resta che dotare le classi derivate di un numero più o meno alto di operazioni “riferibili” mediate il puntatore a metodo dichiarato nella classe base.
Esempio
Supponiamo di voler creare un algoritmo per la composizione di una proposizione, utilizzando una o più parole in base allo specifico dettaglio. Nelle rispettive classi derivate, si possono dichiarare tante operazioni quante sono le parole che formano la proposizione. Esemplificando, l’algoritmo comune potrebbe aggiungere uno spazio alla fine di ogni parola:
class Composer
{
public:
virtual void compose() = 0;
};
template <class T>
class WordsComposer : public Composer
{
public:
typedef string (T::* method_pointer)(void);
protected:
vector<method_pointer> words_;
typedef typename vector<method_pointer>::const_iterator const_iterator;
public:
T* instance() { return static_cast<T*>(this); }
void compose() {
string word, result;
const_iterator it;
for (it = words_.begin(); it != words_.end(); it++)
result += (instance()->*(*it))() + " "; // adds a space char
cout << result << endl;
}
};
L’introduzione dell’interfaccia Composer consente di creare una struttura polimorfica nascondendo al client la presenza delle classi template. La classe WordsComposer fornisce i meccanismi per la gestione dei passi dell’algoritmo definiti dalle sottoclassi. Oltre al cuore dell’algoritmo stesso, presenta infatti una lista di puntatori a metodo di tipo method_pointer, che punteranno alle operazioni dichiarate nelle sottoclassi. L’aggiunta di elementi a questa lista è compito della sottoclasse stessa, come definito nell’esempio:
class ThreeWords : public WordsComposer<ThreeWords>
{
public:
ThreeWords() {
words_.push_back(&ThreeWords::word_1);
words_.push_back(&ThreeWords::equal);
words_.push_back(&ThreeWords::word_2);
}
string word_1() {
return "A";
}
string word_2() {
return "B";
}
string equal() {
return "is equal to";
}
};
Il client può quindi interagire con i Composer attraverso l’interfaccia comune:
Composer* composer = new ThreeWords();
composer->compose();
Figura 2 - Diagrama delle classi dell'esempio
Sulla scorta di questo esempio, è chiaramente possibile creare una qualsiasi classe Composer dotata di un maggior numero di parole (nell’esempio, ogni parola corrisponde ad un metodo). Le operazioni che restituiscono le parole possono essere registrate, nell’ordine desiderato, nel costruttore e devono rispettare il numero e il tipo dei parametri, come definito nel tipo puntatore a funzione method_pointer. Ovviamente non è presente nessun vincolo sul nome delle operazioni. Questo introduce un ulteriore livello di indirezione, intrinseco nel concetto di puntatore.
Altre implementazioni
Non esistono molti linguaggi di programmazione che forniscono i puntatori a metodo. Tuttavia è possibile utilizzare altri meccanismi specifici del linguaggio per implementare strutture simili. Per esempio, in Java è presente il supporto nativo alla reflection che consente di accedere ad un operazione di una classe, attraverso il suo nome simbolico. Anche in questo caso, quindi, basta registrare il nome delle operazioni sotto forma di stringa, esposte dalla specifica sottoclasse, nella lista della classe Composer, definendone sia l’ordine e che il numero.
Chiaramente in Java non esistono i problemi incontrati con i puntatori a metodo e l’implementazione della classe WordsComposer risulta estremamente più semplice. Questa dovrà contenere la collezione delle stringhe che rappresentano i rispettivi metodi, un meccanismo di controllo della struttura, ossia la firma, delle varie operazioni (non è più presente il tipo puntatore a metodo) e, ovviamente, l’operazione relativa alla parte comune dell’algoritmo.
Riferimenti:
- "Design Patterns: Elements of Reusable Object-Oriented Software", E. Gamma, R. Helm, R. Johnson, and J. Vlissides (Addison-Wesley).
- "The C++ Programming Language", Bjarne Stroustrup (Addison-Wesley).
- "Member Function Pointers and the Fastest Possible C++ Delegates", Don Clugston (http://www.codeproject.com/cpp/FastDelegate.asp).