Caricamento delle classi C++ a runtime sotto Linux. Introduzione: Ai più sarà certamente noto il caricamento delle funzioni a runtime che linux fornisce attraverso il linker e dlopen, purtroppo non è ugualmente presente un sistema standard per fare l'equivalente in C++. Durante lo sviluppo di un bot irc mi venne l'idea di inserire il supporto per i moduli, il bot era però scritto interamente in C++ e l'idea di usare solo funzioni C non mi piaceva in quanto avrebbe "sporcato" il codice e non mi avrebbe certo permesso la stessa flessibilità di una classe C. Anche caricando proprietà e metodi a runtime l'interfaccia della classe doveva sempre rimanere predefinita nel programma e la cosa non mi soddisfava, fu proprio qui che entrò in gioco il polimorfismo. Polimorfismo: Ogni programmatore C++ dovrebbe essere abbastanza familiare con quello che io ritengo essere l'aspetto più potente di questo linguaggio, per chi non lo conoscesse farò un breve riassunto senza pretendere di sostituire questo documento ad un buon libro sul C++, anche perché non è questo il suo argomento. Volendo riassumere il poliformismo si potrebbe dire che è la classe a decidere quale metodo chiamare. Ammettendo che noi abbiamo una classe shape ed una derivata circle e che la loro interfaccia sia la seguente: class shape { public: virtual void disegna(void)=0; }; class circle { public: void disegna(void); }; Nel momento in cui dichiariamo un puntatore di tipo shape e lo allochiamo con un'istanza di circle, quando chiameremo il metodo disegna quello chiamato sarà quello di circle e non di shape. shape *pointer = new circle; pointer->disegna(); Chiamerà quindi il metodo disegna di circle e non di shape. dlopen e libdl: Linux mette a disposizione una potente libreria per il caricamento di funzioni a runtime, questa è la libdl che si basa sul dynamic linker ld. Questa libreria è composta da quattro funzioni il cui prototipo è il seguente: void *dlopen(const char *filename, int flag); const char *dlerror(void); void *dlsym(void *handle, char *symbol); int dlclose(void *handle); dlopen e dlclose si occupano rispettivamente di aprire e chiudere l'oggetto condiviso creando e cancellando il riferimento a questo, dlerror ci informa sugli errori avvenuti durante l'uso di queste funzioni ritornandoci un testo con l'errore. La parte più complessa è certamente dlsym la quale dato un riferimento all'oggetto condiviso ed il nome della funzione che ci interessa ritorna un puntatore alla stessa. Caricamento delle classi: Come avrete capito dagli argomenti trattati prima per caricare una classe a runtime è necessario l'uso delle funzioni di libdl e del polimorfismo. Vi sono due modi per caricare una classe a runtime. Il primo consiste nel fornire una funzione che allochi un'istanza della classe ed una che la deallochi mentre il secondo suggerisce un piccolo trucco che usufruisce dei costruttori. Nel primo caso supponendo di avere una classe animale dichiarata come segue: class animale { public: virtual void muovi(char *)=0; }; Per semplificarci il lavoro facciamo un typedef per quelli che andranno ad essere i puntatori alle due funzioni che ci forniranno la nostra classe typedef animale* create_f(); typedef void destroy_f(animale*); Ora che abbiamo scritto quello che andrà ad essere il nostro module.h e che ci fornisce le istruzioni di base per il caricamento delle classi possiamo definire quella che sarà la classe che andremo a caricare a runtime. #include "module.h" #include class giraffa : public animale { public: void muovi(char *); }; void giraffa::muovi(char *dove) { cout << "Una giraffa tende il collo verso " << dove << endl; } extern "C" giraffa *build_object(void) { return new giraffa; } extern "C" void delete_object(giraffa *oggetto) { delete oggetto; } Questo che sarà il nostro giraffa.cpp contiene tutto ciò che ci serve per gestire la classe da caricare, cioé un allocatore, un deallocatore e la classe stessa. Le due funzioni per allocare e deallocare la classe sono precedute da extern "C" per indicare al compilatore che vogliamo che vengano compilate come si usa per il C, cioé usando come nome del simbolo il nome stesso della funzione, cosa che il C++ solitamente non fa e che ci impedirebbe di ritrovare le due funzioni Abbiamo le nostre funzioni e classi di supporto per i moduli, abbiamo il nostro modulo, ci manca solo il codice che ne fa uso. Definiamo quindi la nostra main nell'omonimo file main.cpp e procediamo a provare la classe. #include "module.h" #include #include int main() { animale *oggetto; void *handler = dlopen("./giraffa.so", RTDL_NOW); if (!handler) { cerr << dlerror() << endl; exit(1); } create_f *builder = (creat_f*)dlsym(handler, "build_object"); oggetto = builder(); oggetto->muovi("nord"); delete oggetto; dlclose(handler); } Ora non ci resta che compilare il tutto e provarlo. Per prima cosa compiliamo il modulo con la classe giraffa come segue: g++ -shared -o giraffa.so giraffa.cpp Poi anche il main.cpp g++ main.cpp -rdynamic -ldl Ne otterremo il file a.out che se lanciato ci mostrerà il seguente output: javanx@cosmo:~/prj/runtimeclass/> ./a.out Una giraffa tende il collo verso nord La classe viene caricata senza problemi e all'esecuzione l'output è quello che ci aspettavamo, ma il tutto non finisce qui. Come vi avevo accennato vi è un secondo metodo per il caricamento delle classi a runtime, un metodo decisamente più elegante e che non necessita l'uso di funzioni singole. Un sistema che prende il nome di "self-registering objects". Questo sistema fa uso di una classe proxy e di un container che tenga conto degli allocatori delle classi. Al nostro module.h dovremo aggiungere la dichiarazione del container che andrà a tenere conto degli allocatori in questo caso usereremo una map con il nome della classe ed un puntatore all'allocatore. #include #include /* dichiarazione classe animale * typedef allocatore e deallocatore */ extern map > class_map; Mentre nel giraffa.cpp dovremo estendere l'extern "C" racchiudendo oltre alle due funzioni anche la classe proxy ed un'istanza della stessa, in quanto sarà proprio questa istanza a far funzionare il trucco. Al momento in cui verrà caricato il file .so verrà anche creato l'oggetto e quindi chiamato il suo costruttore che si occuperà di aggiungere alla class_map i dati riguardo alla classe giraffa. / * definizione classe giraffa */ extern "C" { animale *build_object(void) { return new giraffa; } void delete_object(animale *oggetto) { delete oggetto; } class proxy { public: proxy() { class_map["giraffa"] = build_object; } }; proxy p; } Ora resta soltanto da riscrivere la main che faccia uso della class_map invece che chiamare direttamente le funzioni costruttrici. #include "module.h" #include #include map > class_map; int main() { animale *oggetto; /* dlopen su giraffa.so e controllo della riuscita di questo */ oggetto = class_map["giraffa"](); oggetto->muovi("nord"); delete oggetto; dlclose(handler); } Questo è tutto anche per la seconda parte. Come avrete notato io per abbreviare ho fatto un delete direttamente sull'oggetto ritornato dagli allocatori pur avendo creato le funzioni dei moduli che facciano altrettanto. Queste non sono state chiamate solo per accorciare il codice degli esempi, in realtà nel caso in cui si usino le funzioni bisogna chiamarle e nel caso in cui si usino i proxy bisognerebbe creare una map anche dei deallocatori oppure una struct che contenga entrambe le map ed andare poi a chiamarli. Per sicurezza la classe base del modulo dovrebbe anche implementare un distruttore virtuale garantendo così che questo venga chiamato evitando così possibili memory leak. Con l'uso degli oggetti autoregistranti è possibile implementare il caricamento dei moduli senza bisogno di sapere nulla di questi. Il nome dello stesso ce lo può fornire la map ed anche il suo allocatore e deallocatore sono ottenibili sempre tramite questa o altri container. Questo sistema permetterebbe infatti di caricare l'elenco dei moduli guardando i files di moduli presenti in una directory e far decidere all'utente quali vuole usare e quali no. Alessandro Molina