Programmation d'applications modulaires

Kacper Kolodziej

Traducteur

Eric GERARD

Aujourd'hui, pratiquement toute application peut être étendue avec de nombreux types différents de greffons ou de plugins. Grâce à ceux-ci, nous pouvons écrire de nouvelles fonctions pour nos applications préférées sans les recompiler chaque fois que nous voulons les étendre ou les modifier. Je vais vous dire comment écrire une application modulaire en C++. ♪

Commentez

- Titre : Programmation d'applications modulaires

- Auteur : Kacper Kolodziej

- Traducteur : Eric GERARD

- Parution : 5 mai 2015

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2015 Kacper Ko?odziej. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- I-A. Bibliothèques partagées et extensions

- I-B. Les outils

- I-C. Deux approches aux applications modulaires sur Linux

- I-C-1. Solution fonction module avec la syntaxe C

- I-C-1-a. Exemple de base

- I-C-1-b. Explication du code

- I-C-1-c. Compilation

- I-C-2. Écrivons un module

- I-C-3. Exemple avec les classes C++

- I-C-3-a. Classe de base des modules

- I-C-3-b. Charger des modules

- I-C-3-c. Écrire un module

- I-C-4. Conception des applications modulaires

- I-D. Exercice

- I-E. Remerciements

- II. Première partie

- II-A. Calculatrice

- II-A-1. L'application

- II-A-2. Les modules de multiplication et division

- II-A-3. La compilation

- II-B. Votre propre interpréteur !

- II-B-1. Explication du code

- II-B-2. Les applications de l'interpréteur

- II-B-3. Applications Echo et Concat

- II-B-4. Compilation

- II-C. Exercices

- II-D. Remerciements

- III. Seconde partie

- III-A. Existe-t-il une bonne solution ?

- III-B. Problèmes avec les plugins C++

- III-C. Des plugins véritablement portables

- III-D. Java au lieu de C++ ?

- III-E. Commencer avec la bibliothèque partagée POCO ?

- III-F. Remerciements

I - Introduction

Aujourd'hui, pratiquement toute application peut être étendue avec de nombreux types différents de greffons ou de plugins. Grâce à ceux-ci, nous pouvons écrire de nouvelles fonctions pour nos applications préférées sans les recompiler chaque fois que nous voulons les étendre ou les modifier. Je vais vous dire comment écrire une application modulaire en C++.

I-A - Bibliothèques partagées et extensions

Souvent, les applications modernes offrent aux développeurs des interfaces de programmation (API) agréables ou encore des langages particuliers avec lesquels ils peuvent écrire des extensions pour des applications existantes. C'est un très bon moyen de donner la possibilité aux utilisateurs de personnaliser les programmes. Je vais vous montrer comment écrire une application qui va charger des extensions depuis des bibliothèques partagées.

Vous devez savoir que l'écriture d'applications modulaires est différente entre les systèmes Unix et Windows. Unix propose la bibliothèque dlfcn qui contient un ensemble de fonctions qui peuvent être utilisées pour charger des fonctions C depuis une bibliothèque compilée. Sous Windows, nous le faisons d'une autre manière. Dans cet article, nous allons apprendre à le faire sur Linux (ces solutions fonctionneront certainement sur BSD et probablement sur Mac OS).

I-B - Les outils

Pour faire tous les exemples de ce tutoriel, vous aurez besoin d'un système d'exploitation Linux (je recommande Debian) avec le compilateur g++ et votre éditeur de code préféré (pour moi, ce sera vim).

I-C - Deux approches aux applications modulaires sur Linux

Pour commencer, je vais vous montrer l'exemple le plus simple d'application qui utilise des modules. En raison du fait que les fonctions de l'en-tête Linux dlfcn.h ne permettent de charger que les fonctions en syntaxe C, nous pouvons réaliser les modules de deux façons :

- écrire tout le code comme une fonction C ;

- écrire une fonction de chargement, avec la syntaxe C, qui crée un objet d'une classe qui représente le module.

Dans cet article, je vais vous montrer les deux solutions.

I-C-1 - Solution fonction module avec la syntaxe C

En préambule, j'aimerais vous dire quelque chose à propos de trois fonctions que nous allons utiliser dans notre application. Elles sont appelées : dlopen, dlsym et dlclose. Elles viennent toutes du fichier d'en-tête dlfcn.h et sont implémentées dans les bibliothèques du système Linux. La première d'entre elles est utilisée pour charger une bibliothèque partagée. Elle nécessite deux arguments : le nom du fichier de la bibliothèque partagée (const char*) et un drapeau (int). Il existe plusieurs drapeaux que nous pouvons utiliser, mais le plus commun est RTLD_LAZY. Il provoque le chargement des symboles lorsque ceux-ci sont utilisés pour la première fois. Nous allons utiliser ce drapeau dans nos premiers exemples. Vous pouvez en apprendre davantage sur les drapeaux dans les pages de man Linux ; (man dlopen). dlopen renvoie un pointeur void (void*) appelé gestionnaire.

La seconde (dlsym) est utilisée pour obtenir l'adresse de la fonction dont le symbole a été chargé et passé au gestionnaire par la fonction dlopen. dlsym retourne un pointeur (void*) sur la fonction trouvée dans la ressource partagée passée comme premier argument (gestionnaire reçu de dlopen). Le nom de la fonction à trouver est passé en second argument (char*). Si le système n'arrive pas à trouver cette fonction, il retourne un pointeur NULL (adresse zéro). En C++, nous pouvons transtyper le pointeur reçu vers le type de pointeur réel de la fonction.

La dernière fonction, dlclose, sera utilisée pour fermer la bibliothèque partagée ouverte par dlopen .

La connaissance de ces fonctions nous permet d'écrire notre première application modulaire.

I-C-1-a - Exemple de base

Tout d'abord, nous devons écrire une application qui sera capable de charger les modules et de lancer leurs fonctions.

Les noms des modules seront passés comme arguments depuis la ligne de commande :

./application module1 module2

Pour ce faire, nous allons utiliser les arguments argc et argv de la fonction main.

Le code de notre application ressemblera à ceci :

#include <dlfcn.h>
#include <iostream>
int main(int argc, char ** argv)
{
if (argc == 1)
{
std::cerr << "Usage: " << argv[0] << " modules...\n";
return 1;
}
for (int i = 1; i < argc; ++i)
{
void* shared_library = dlopen(argv[i], RTLD_LAZY);
void (*module)() = reinterpret_cast<void (*)()>(dlsym(shared_library, "module"));
if (module)
{
module();
dlclose(shared_library);
} else
{
std::cerr << "Error while loading: " << argv[i] << "\n";
}
}
return 0;
}

I-C-1-b - Explication du code

Dans la boucle qui commence à la ligne 12, nous parcourons tous les arguments de la ligne de commande. Chacun d'entre eux (sauf pour le premier - indexé 0, qui est le nom du fichier binaire de l'application) est un nom de module qui doit être chargé.

Dans le corps de la boucle for, nous utilisons la fonction dlopen pour charger la bibliothèque partagée et la fonction dlsym pour charger la fonction appelée module (dans la syntaxe C !). Ensuite, nous appelons la fonction chargée et, finalement, nous fermons la bibliothèque partagée en utilisant dlclose .

Comme vous pouvez le voir, nous convertissons le pointeur void ( void* ) renvoyé par dlsym au pointeur de la fonction sans arguments renvoyant void ( void (*)() ). C'est nécessaire en C++ afin d'être en mesure d'appeler la fonction indiquée par ce pointeur.

Nous n'avons pas utilisé la fonction dlerror qui informe l'utilisateur sur les erreurs en raison du fait que c'est une application très simple et que son objectif était de montrer comment le chargement dynamique fonctionne. Dans la prochaine partie de cet article, nous allons écrire un outil avancé appelé chargeur de module qui utilisera dlerror .

I-C-1-c - Compilation

Pour compiler l'application ci-dessus, nous lancerons le compilateur g++ (pratiquement n'importe quelle version sera suffisante pour le faire) :

g++ app.cpp -o app -ldl

Nous utilisons l'option -ldl pour indiquer au compilateur qu'il doit lier notre application avec une bibliothèque dynamique.

I-C-2 - Écrivons un module

Il est temps, maintenant, d'écrire un ou deux modules simples. Chaque module sera dans son propre fichier *.cpp. Un tel fichier doit comporter la définition d'au moins une fonction. Le nom de cette fonction doit être module. Comme je le disais avant, cette fonction doit être écrite avec la syntaxe C. Cela signifie que nous devons ajouter extern "C" avant sa déclaration.

Je vais créer deux modules qui vont écrire une ligne de texte sur la sortie standard. Voici le code du premier (module_start.cpp) :

#include <iostream>
extern "C" void module()
{
std::cout << "Start module function!\n";
}

et celui du deuxième (module_other.cpp) :

#include <iostream>
extern "C" void module()
{
std::cout << "Other module function!\n";
}

Maintenant, nous pouvons compiler ces deux modules en utilisant ces commandes :

g++ -fPIC -shared module_start.cpp -o module_start.so
g++ -fPIC -shared module_other.cpp -o module_other.so

Vous devez vous rappeler des options -shared et -fPIC. Le premier ne fait qu'indiquer que le code est celui d'une bibliothèque partagée. Le second est une abréviation pour code à position indépendante, qui signifie que les positions dans le code assembleur seront relatives au lieu d'être absolues. Grâce à cela, le code sera capable d'être chargé dynamiquement n'importe où dans la mémoire de l'application.

Si la compilation n'échoue pas, nous pouvons lancer l'application avec un ou les deux modules :

./app ./module_start.so
./app ./module_other.so
./app ./module_start.so ./module_other.so

Comme vous pouvez le voir, nous passons à l'application les chemins relatifs des bibliothèques partagées. Si le chemin n'est ni un chemin parent ni un chemin absolu, dlopen cherchera l'objet partagé dans ces localisations :

1. Les chemins de LD_LIBRARY_PATH ;

2. Dans la liste placée dans /etc/ld.so.cache ;

3. /lib ;

4. /usr/lib.

Sinon, dlopen utilisera le chemin relatif ou absolu fourni.

I-C-3 - Exemple avec les classes C++

Le prochain exemple d'application modulaire sera très similaire, mais maintenant chaque module sera une classe qui dérive de la classe de base des modules. Ils auront également une fonction appelée chargeur qui allouera de la mémoire pour l'objet module de classe et le construira. Elle renverra un pointeur vers la mémoire allouée.

Afin de ne pas réécrire tout depuis le début, nous allons :

1. Écrire la classe de base pour les modules ;

2. Changer un peu le code de l'application ;

3. Écrire de nouveaux modules avec chargeurs.

I-C-3-a - Classe de base des modules

La classe de base pour les modules doit être abstraite. En C++, pour rendre une classe abstraite, vous devez mettre au moins une fonction virtuelle pure. Cette dernière ressemble à ceci :

class SomeClass
{
virtual void myFunction() = 0; // ceci est une fonction virtuelle pure
};

Elle ne peut pas être définie (car sa définition est 0). Nous devons la définir dans chacune des classes qui en héritent.

Dans notre exemple, nous allons créer la classe qui n'aura qu'une seule fonction appelée run. Son code est présenté sur le listing ci-dessous :

#ifndef MODULE_BASE_HPP
#define MODULE_BASE_HPP
class ModuleBase
{
public:
virtual void run() = 0;
};
#endif

I-C-3-b - Charger des modules

En raison du fait que nous ne pouvons pas charger toute la classe en utilisant la fonction dlsym, nous allons créer une fonction spéciale de chargement pour chaque module. Cette fonction créera l'objet à partir du module de classe et retournera un pointeur vers lui. Nous devons ajuster l'application pour l'adapter à notre construction des modules.

#include <dlfcn.h>
#include <iostream>

#include "ModuleBase.hpp"
int main(int argc, char ** argv)
{
if (argc == 1)
{
std::cerr << "Usage: " << argv[0] << " modules...\n";
return 1;
}
for (int i = 1; i < argc; ++i)
{
void* shared_library = dlopen(argv[i], RTLD_LAZY);
ModuleBase* (*loader)() = reinterpret_cast<ModuleBase* (*)()>(dlsym(shared_library, "loader"));
ModuleBase* module;
if (loader)
{
module = loader();
module->run();
} else
{
std::cerr << "Erreur de chargement: " << argv[i] << "\n";
}
}
return 0;
}

Maintenant, nous avons un pointeur vers la fonction de chargement et nous l'appelons. Nous recevons un pointeur vers le module. Ensuite, nous appelons la fonction run de l'objet du module. Le reste du code est le même que dans l'exemple précédent.

I-C-3-c - Écrire un module

Pour écrire un module, nous devons d'abord créer le fichier C++ avec l'en-tête ModuleBase.hpp inclus. Ensuite, nous devons écrire la classe qui dérive de ModuleBase . Nous devons déclarer et définir la méthode run dans la nouvelle classe. À la fin, nous avons une définition de fonction de chargement qui renvoie un pointeur vers ModuleBase (ModuleBase*).

Je vous présente mes deux modules ci-dessous :

#include "ModuleBase.hpp"
#include <iostream>
class ModuleStart :
public ModuleBase
{
public:
void run()
{
std::cout << "Le module de démarrage tourne!\n";
}
};
extern "C" ModuleBase* loader()
{
ModuleBase* m = new ModuleStart;
return m;
}

#include "ModuleBase.hpp"
#include <iostream>
class ModuleSoph :
public ModuleBase
{
public:
void run()
{
std::cout << "Le module sophistiqué tourne!\n";
}
};
extern "C" ModuleBase* loader()
{
ModuleBase* m = new ModuleSoph;
return m;
}

Après la compilation (nous utilisons les mêmes commandes que dans le premier exemple), nous pouvons lancer l'application avec ses modules de la même façon que précédemment. Je vous recommande d'écrire cet exemple vous-même pour vous entraîner à le faire.

I-C-4 - Conception des applications modulaires

Les exemples que je vous ai indiqués ne sont ni très utiles ni flexibles. Ils montrent seulement l'aspect technique de l'écriture d'applications modulaires en C++. Pour écrire une bonne solution modulaire, vous devez la concevoir précisément. Dans la partie suivante de l'article, je vous présenterai des exemples plus complexes d'applications modulaires.

I-D - Exercice

- Essayez d'écrire une application qui peut faire deux opérations mathématiques de base : l'addition et la soustraction.

- Autorisez les utilisateurs à ajouter leurs propres opérations en utilisant des modules.

- Écrivez des modules pour les opérations de division, multiplication et modulo.

La solution de ce problème sera publiée dans la prochaine partie de l'article.

I-E - Remerciements

Cet article est une traduction autorisée de l'article paru sur le site de Kacper Kolodziej.

Merci à milkoseck pour sa relecture orthographique.

II - Première partie

Dans cette partie, je vais vous montrer des applications plus sophistiquées qui utilisent également des modules. Les exemples de cette partie seront orientés objet et présenteront une approche plus professionnelle de la programmation d'applications modulaires.

Dans cette partie, je vais vous montrer deux exemples. Le premier est la solution de l'exercice que je vous ai donné à la fin du dernier article. On vous demandait d'écrire une calculatrice qui pouvait additionner des nombres, les soustraire et charger des modules pour effectuer d'autres opérations. Le deuxième exemple, que je vais écrire pour vous, est un interpréteur système (également appelé ligne de commande).

II-A - Calculatrice

Pour simplifier le problème (et illustrer notre but), nous supposerons que nous ajoutons seulement des fonctions qui œuvrent avec deux nombres réels (addition, soustraction, multiplication, division, modulo). Nous ne pouvons pas ajouter la fonction factorielle (un argument entier et non pas deux arguments réels). Dans le second exemple, vous serez capable d'apprendre à écrire des applications plus flexibles.

Pour commencer : nous allons écrire une classe appelée ModuleManager qui conservera les fonctions des modules. Son en-tête pourrait ressembler à ceci :

#ifndef MODULE_MANAGER_HPP
#define MODULE_MANAGER_HPP
#include <map>
#include <string>
#include <vector>

class ModuleManager
{
public:
typedef double (*CalcFunc)(double, double);

private:
std::map<std::string, CalcFunc> loaded_functions_;
public:
std::vector<std::string> getFunctionsList();
CalcFunc getFunction(std::string);
bool addFunction(std::string, CalcFunc);
bool loadModule(std::string);
};
#endif

Pour stocker les fonctions, nous utiliserons la fonction map de la bibliothèque standard. Celle-ci conserve chaque élément sous forme d'une paire composée d'une clé et de cet élément. Les éléments sont triés par clé. Dans notre cas, la clé est une chaîne (également de la bibliothèque standard). Cette fonction conserve les noms des fonctions. Le second élément de la paire conserve un pointeur vers la fonction. Nous obtenons ce pointeur à l'aide de la fonction dlsym (j'ai parlé de toutes ces fonctions dans l'introduction).

La fonction getFunctionsList renvoie un vecteur de chaînes qui contient les noms de toutes les fonctions chargées. Nous utilisons getFunction pour obtenir un pointeur de la fonction rattachée au nom passé en argument. La fonction addFunction ajoute une fonction. Si la fonction, dont la clé passée en argument, existe, addFunction renvoie FAUX. Sinon, elle renvoie VRAI. La fonction loadModule prend le chemin vers le fichier binaire contenant le module compilé et charge les deux fonctions contenues dans ce fichier : getName ainsi que la fonction dont le nom a été renvoyé par getName.

Voici, l'implémentation de cette classe décrite ci-dessus :

#include "module_manager.hpp"
#include <dlfcn.h>

std::vector<std::string> ModuleManager::getFunctionsList()
{
std::vector<std::string> list;
for (auto &pair : loaded_functions_)
{
list.push_back(pair.first);
}
return list;
}

ModuleManager::CalcFunc ModuleManager::getFunction(std::string key)
{
auto it = loaded_functions_.find(key);
if (it == loaded_functions_.end())
{
return nullptr;
}

return it->second;
}

bool ModuleManager::addFunction(std::string key, ModuleManager::CalcFunc func)
{
auto addition = loaded_functions_.insert(std::make_pair(key, func));
return addition.second;
}

bool ModuleManager::loadModule(std::string fname)
{
void* module = dlopen(fname.data(), RTLD_LAZY);
if (!module)
{
return false;
}

const char* (*name)() = reinterpret_cast<const char* (*)()>(dlsym(module, "getName"));
if (!name)
{
return false;
}
double (*func)(double, double) = reinterpret_cast<double (*)(double, double)>(dlsym(module, name()));
if (func)
{
return addFunction(std::string(name()), func);
}
return false;
}

II-A-1 - L'application

Maintenant que nous savons à quoi ressemble ModuleManager, nous pouvons écrire une application qui l'utilise. J'ai décrit l'en-tête de la bibliothèque dans l'introduction Le reste du code devrait être clair.

Nous pouvons donc écrire l'application. L'application nous permettra de :

- charger un nouveau module ;

- appeler une fonction existante ;

- quitter l'application.

Chaque fonctionnalité sera appelée en utilisant une commande dédiée :

#include <iostream>
#include <cmath>
#include <string>
#include <vector>
#include <sstream>

#include "module_manager.hpp"

double add(double, double);
double subtract(double, double);

int main(int argc, char **argv)
{
ModuleManager manager;
manager.addFunction("addition", add);
manager.addFunction("soustraction", subtract);

std::string selection;
do
{
auto names = manager.getFunctionsList();
for (auto &name : names)
{
std::cout << name << "\n";
}
std::cout << "\\load - charge un nouveau module\n";
std::cout << "\\quit -- quitte l'application\n";
std::cin >> selection;

if (selection == "\\load")
{
std::string path;
std::cout << "module path: ";
std::cin >> path;
if (manager.loadModule(path))
{
std::cout << "++ module chargé!\n";
} else
{
std::cerr << "-- une erreur s'est produite pendant le chargement du module!\n";
}
} else if (selection != "\\quit")
{
auto func = manager.getFunction(selection);
if (func == nullptr)
{
std::cerr << "-- la fonction n'existe pas!\n";
} else
{
double a, b;
std::cout << "arguments (2 doubles): ";
std::cin >> a >> b;
std::cout << "args: " << a << " " << b << "\n";

std::cout << "++ résultat de " << selection << "(" << a << ", " << b << ") = " << func(a,b) << "\n";
}
}
} while (selection != "\\quit");

return 0;
}
double add(double a, double b)
{
return a + b;
}
double subtract(double a, double b)
{
return a - b;
}

Je vais vous expliquer, maintenant, comment tout ça fonctionne. À la ligne 14, nous créons un objet ModuleManager et nous ajoutons deux fonctions : l'addition et la soustraction. Nous le faisons sans charger de code externe, car elles sont incluses dans l'application. Ensuite, nous montrons toutes les fonctions chargées ainsi que les commandes additionnelles : \load et \quit qui pourront être utilisées pour charger de nouveaux modules et quitter l'application. L'utilisateur effectue son choix à la ligne 28. Puis, nous effectuons ce qui correspond au choix de l'utilisateur. S'il demande de charger un nouveau module, nous lui demandons le chemin vers le fichier binaire contenant le module. S'il choisit quelque chose de différent de la commande \quit, nous le traitons comme un appel à une fonction chargée. Nous la recherchons dans le contrôleur et nous l'appelons à l'aide des arguments reçus après les avoir demandés à l'utilisateur en ligne 52.

II-A-2 - Les modules de multiplication et division

Maintenant, place aux modules. Ils sont très simples. La multiplication se trouve dans multiply_module.cpp :

multiply_module.cpp

extern "C" double multiply(double a, double b)
{
return a * b;
}
extern "C" const char * getName()
{
return "multiply";
}

et la division dans divide_module.cpp:

divide_module.cpp

extern "C" double divide(double a, double b)
{
return a / b;
}
extern "C" const char * getName()
{
return "divide";
}

Comme vous pouvez le voir, nous avons deux fonctions dans nos modules. La première est la fonction appropriée qui effectue l'opération mathématique. La seconde (getName) renvoie le nom de la première. Il est utilisé pour charger la fonction appropriée utilisée par la calculatrice et pour obtenir les noms des fonctions pour le ModuleManager.

II-A-3 - La compilation

Vous pouvez compiler la totalité du programme en utilisant ces commandes :

g++ -std=c++11 module_manager.cpp -c -DDEBUG -o module_manager.o
g++ -std=c++11 calc.cpp module_manager.o -DDEBUG -ldl -o calc
g++ -fPIC -shared multiply_module.cpp -DDEBUG -o multiply_module.so
g++ -fPIC -shared divide_module.cpp -DDEBUG -o divide_module.so

C'est une façon simple (mais peut-être pas la plus simple) d'effectuer l'exercice que je vous avais donné dans la partie précédente de l'article. Nous allons, maintenant, programmer quelque chose de plus raffiné !

II-B - Votre propre interpréteur !

Je vais vous montrer comment écrire votre propre ligne de commande. Cela sera un peu plus difficile à coder, mais sa flexibilité et ses capacités seront plus grandes.

À l'instar des exemples précédents, nous devons écrire l'application et la classe du module de base. Nous implémenterons l'application dans une classe Shell. La fonction main appellera seulement la fonction membre de l'objet Shell.

shell_main.cpp

#include <iostream>
#include <string>
#include "shell.hpp"
#include "shell_application.hpp"
int main(int argc, char **argv)
{
if (argc != 2)
{
std::cerr << "Utilisation: " << argv[0] << " prompt\n";
return 1;
}
Shell shell(argv[1]);
return shell.loop();
}

shell.hpp

#ifndef SHELL_HPP
#define SHELL_HPP
#include <string>
#include <vector>
#include <map>

class ShellApplication;

class Shell
{
typedef const char* (*GetNamePtr)();
typedef ShellApplication* (*LoadPtr)();
private:
const std::string prompt_;
const std::string load_name_;
std::map<std::string, LoadPtr> available_apps_;
std::vector<void*> apps_handlers_;

public:
Shell(std::string = std::string("shell> "), std::string = std::string(":load"));
~Shell();

bool loadApplication(std::string);
int runCommand(std::string);
int loop();
private:
std::vector<std::string> parseCommand_(std::string);
};
#endif

shell.cpp

#include "shell.hpp"

#include <dlfcn.h>
#include <iostream>
#include <sstream>
#include <utility>

#include "shell_application.hpp"

Shell::Shell(std::string prompt, std::string load_name) :
prompt_(prompt),
load_name_(load_name)
{
available_apps_.insert(std::make_pair(load_name_, nullptr));
}

Shell::~Shell()
{
for (auto handler : apps_handlers_)
{
dlclose(handler);
}
apps_handlers_.clear();
}

bool Shell::loadApplication(std::string path)
{
void* handler = dlopen(path.data(), RTLD_LAZY);
if (handler == 0)
{
std::cerr << "erreur bibliothèque dynamique: " << dlerror();
return false;
}

apps_handlers_.push_back(handler);

GetNamePtr getName = reinterpret_cast<GetNamePtr>(dlsym(handler, "getName"));
LoadPtr load = reinterpret_cast<LoadPtr>(dlsym(handler, "load"));
if (!(getName && load))
{
std::cerr << "erreur bibliothèque dynamique: " << dlerror();
return false;
}

auto insertion = available_apps_.insert(std::make_pair(getName(), load));
if (insertion.second)
{
std::clog << getName() << " application chargée!\n";
}
return insertion.second;
}

int Shell::runCommand(std::string cmd)
{
std::vector<std::string> cmd_parts = parseCommand_(cmd);
if (cmd_parts.size() == 0)
{
std::cerr << "commande invalide: " << cmd << "\n";
return -1;
}
std::string& app_name = cmd_parts[0];

if (app_name == load_name_)
{
std::cout << "Chargement des applications...\n";
int result = 0;
for (auto it = cmd_parts.begin() + 1; it != cmd_parts.end(); ++it)
{
std::cout << " + " << *it << " ";
if (loadApplication(std::string("./") + *it + std::string("_application.so")))
{
std::cout << "[ OK ]\n";
} else
{
std::cout << "\n";
result = 1;
}
}
return result;
}

auto app_load = available_apps_.find(app_name);
if (app_load == available_apps_.end())
{
std::cerr << "Application introuvable: `" << app_name << "`\n";
return -1;
}

ShellApplication* app = reinterpret_cast<ShellApplication*>(app_load->second());
int result = app->runApplication(cmd_parts);
delete app;
return result;
}

int Shell::loop()
{
std::string cmd;
std::cout << prompt_;
while (std::getline(std::cin, cmd))
{
int status = runCommand(cmd);
std::cout << "Sortie du code: " << status << "\n" << prompt_;
}

return 0;
}
std::vector<std::string> Shell::parseCommand_(std::string cmd)
{
std::vector<std::string> tokens;
std::stringstream token;
const char delimiter = ' ', quote = '\"', escape = '\\';
bool escapeNext = false;
bool quoteMode = false;
for (std::string::iterator it = cmd.begin(); it != cmd.end(); ++it)
{
char c = *it;
if (c == escape && escapeNext == false)
{
escapeNext = true;
} else if (c == quote && escapeNext == false)
{
quoteMode = !quoteMode;
} else if (c == delimiter && escapeNext == false && quoteMode == false)
{
if (token.str().empty() == false)
{
tokens.push_back(token.str());
token.str(std::string());
}
} else
{
token << c;
escapeNext = false;
}
}
if (token.str().empty() == false)
{
tokens.push_back(token.str());
}
return tokens;
}

II-B-1 - Explication du code

La classe Shell possède deux membres std::string. Le premier est prompt_ - il contient le message de prompt -, le deuxième est load_name_ qui spécifie le nom de la fonction load qui est responsable du chargement des applications. Ils sont définis dans le constructeur de Shell. Nous avons également deux conteneurs : un map pour les noms des applications et les pointeurs vers les fonctions de chargement et un vector pour toutes les ressources (pointeurs void retournés par la fonction dlopen). En marge du constructeur et destructeur de Shell, on trouve trois fonctions membres publiques : loadApplication, runCommand, loop. La première charge les bibliothèques partagées à partir de leur chemin, runCommand exécute la commande entrée par l'utilisateur, et loop comporte une boucle while qui demande à l'utilisateur d'écrire une commande puis l'exécute. Shell comporte également une fonction membre privée appelée parseCommand_. Elle contient un algorithme très simple qui sépare la commande en jetons (tokens). J'écrirai un article séparé sur ce sujet pour les débutants.

Shell suppose que nous chargeons les applications à partir des fichiers situés dans le répertoire de travail courant. La commande load myapp recherchera un fichier nommé : myapp_application.so dans le répertoire courant.

La fonction loadApplication suppose que chaque module partagé (application de Shell) possède deux fonctions. La première d'entre elles est getName et retourne un const char* contenant le nom de l'application. La seconde fonction initialise un objet de classe qui hérite de ShellApplication (la classe de base pour toutes les applications) et retourne un pointeur vers celui-ci.

La fonction runCommand utilise parseCommand_ pour découper la commande, appelle la fonction load pour cette application et lance la fonction runApplication de l'objet application. Je ne vais pas éclaircir la totalité du code. Si vous ne le comprenez pas, vous pouvez trouver l'explication de chaque instruction de dlfcn.h dans l'introduction.

II-B-2 - Les applications de l'interpréteur

Une application est représentée par une classe héritée de la classe de base ShellApplication. Pour rendre cet exemple plus clair, ShellApplication ne possède qu'un nom clair et une fonction virtuelle pure runApplication. Nous déclarerons également les fonctions load et getName dans le fichier d'en-tête de ShellApplication. Le compilateur ne criera pas si vous ne les définissez (implémentez) pas dans votre application, car les applications sont des bibliothèques partagées, mais des déclarations rappelleront au programmeur de les écrire. L'absence des fonctions load et getName dans une application en cours de chargement provoquera l'arrêt de Shell.

ShellApplication.hpp

#ifndef SHELL_APPLICATION_HPP
#define SHELL_APPLICATION_HPP
#include <string>
#include <vector>

class ShellApplication
{
private:
std::string name_;

public:
ShellApplication(std::string name) :
name_(name)
{}
virtual ~ShellApplication() {}
std::string getName()
{
return name_;
}
virtual int runApplication(std::vector<std::string>&) = 0;
};
extern "C" const char* getName();
extern "C" ShellApplication* load();
#endif

Nous sommes, maintenant, prêts à créer nos applications !

II-B-3 - Applications Echo et Concat

Pour montrer comment fonctionne notre Shell, nous devons avoir au moins une application. J'ai écrit deux exemples similaires : echo et concat. Le premier écrit tous les arguments séparés par un espace à l'écran. concat assemble tous les arguments et les affiche.

echo_application.cpp

#include <iostream>
#include "shell_application.hpp"

class EchoApplication :
public ShellApplication
{
public:
EchoApplication() :
ShellApplication("Echo")
{}

int runApplication(std::vector<std::string>& argv)
{
for (auto it = argv.begin() + 1; it != argv.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << "\n";
return 0;
}
};
extern "C" const char* getName()
{
return "echo";
}
extern "C" ShellApplication* load()
{
return new EchoApplication();
}

concat_application.cpp

#include <iostream>
#include "shell_application.hpp"

class ConcatApplication :
public ShellApplication
{
public:
ConcatApplication() :
ShellApplication("Concatenate strings")
{}

int runApplication(std::vector<std::string>& argv)
{
for (auto it = argv.begin() + 1; it != argv.end(); ++it)
{
std::cout << *it;
}
std::cout << "\n";
return 0;
}
};
extern "C" const char* getName()
{
return "concat";
}
extern "C" ShellApplication* load()
{
return new ConcatApplication();
}

II-B-4 - Compilation

Pour compiler l'application entière (Shell) et les applications Shell, utilisez ce Makefile :

CXX=g++
CXXFLAGS=-std=c++11 -g -DDEBUG -Wall
APPSFLAGS=-fPIC -shared
OBJS=shell_main.o shell.o
APPS=concat_application.so echo_application.so
LIBS=-ldl
TARGET=shell

all: $(OBJS) $(APPS)
$(CXX) $(CXXFLAGS) $(OBJS) $(LIBS) -o $(TARGET)

$(APPS): %so: %cpp
$(CXX) $(CXXFLAGS) $(APPSFLAGS) $< -o $@
$(OBJS): %.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
cleanup:
rm -f $(OBJS) $(TARGET) $(APPS)

Pour ajouter de nouvelles applications au fichier make, ajoutez leur nom à la 5e ligne. Ensuite, vous pourrez charger les applications existantes en utilisant : load command. Pour le tester, écrivez ces lignes dans votre fenêtre de commandes :

make && ./shell « prompt> »
prompt> :load echo
prompt> :load concat
prompt> echo text one two three
prompt> concat text ont two three « four five »

Vous trouverez tous les fichiers de l'exemple Shell sur mon Gist.

II-C - Exercices

Écrivez quelques applications pour Shell. Elles peuvent être de simples opérations mathématiques ou des fonctions de création de fichiers.

- Ajoutez une commande exit à Shell. Vous devez le faire de la même manière que celle utilisée avec : load command.

- Essayez d'ajouter le support des touches de navigation (flèches). Les flèches Haut et Bas permettent de naviguer dans l'historique des commandes. Les flèches Gauche et Droite permettent de naviguer dans la commande écrite.

- Astuce : vous aurez besoin de la bibliothèque termios.h.

II-D - Remerciements

Cet article est une traduction autorisée de l'article paru sur le site de Kacper Kolodziej.

Merci aussi à Claude Leloup pour sa relecture orthographique.

III - Seconde partie

Ces applications fonctionnent probablement correctement avec vos plugins dédiés. Les problèmes surviennent lorsque vous essayez de mélanger des plugins écrits par vos utilisateurs compilés à l'aide de compilateurs différents (cela peut fonctionner) ou bien pour des systèmes d'exploitation différents (cela ne peut pas fonctionner !).

Lorsque nous concevons de véritables applications, nous ne pouvons utiliser cette approche que très rarement. Nous n'avons la garantie que les plugins, issus de bibliothèques partagées liées dynamiquement, fonctionneront comme ils le doivent que si nous les compilons avec la même version de compilateur et de système d'exploitation que ceux utilisés pour produire les fichiers binaires des applications. La deuxième restriction avec l'approche que j'ai présentée dans les deux derniers articles (introduction et première partie) est que ces programmes fonctionneront seulement avec certains systèmes compatibles UNIX. Si nous avons besoin d'applications modulaires portables, nous devons utiliser des bibliothèques dédiées qui proposent des interfaces portables. Un exemple de ce type de bibliothèques est Poco::SharedLibrary. J'écrirai quelque chose là-dessus plus tard.

III-A - Existe-t-il une bonne solution ?

Les problèmes d'écriture de bibliothèques partagées portables en C++ ont sans doute conduit au développement de bibliothèques qui transposent les applications Python au C++ ou des interpréteurs pour des langages comme JavaScript. Grâce à eux, nous pouvons écrire des extensions dans un langage de plus haut niveau qui est, par nature, portable et qui pourront être utilisées par C++.

Les meilleurs exemples d'utilisation de telles solutions sont les navigateurs web provenant de Mozilla et Google. Tous les deux possèdent une grande et respectable collection d'extensions écrites par des développeurs du monde entier. Les plugins de Firefox et de Chrome sont écrits en JavaScript. Les navigateurs d'aujourd'hui ont toujours un moteur JavaScript, et donc l'utiliser pour un besoin supplémentaire n'est pas un problème.

Mozilla a créé un moteur d'analyse syntaxique JavaScript open source appelé SpiderMonkey. Google propose également leur solution appelée V8. JavaScript semble être un bon moyen d'implémentation d'une interface de plugins.

La bibliothèque Boost::Python est un autre grand outil qui fournit des outils permettant d'utiliser du code C++ dans un programme Python et inversement. Bien sûr, parfois, nous devons écrire un code très rapide et très efficace. Dans ce cas JavaScript ou même Python ne sont pas assez bons. Nous devons toujours chercher quelle est la solution optimale pour nous.

III-B - Problèmes avec les plugins C++

Je vous ai dit que les plugins C++ pouvaient poser problème lorsque nous utilisons des compilateurs différents pour transformer le code en fichier binaire. Les principales causes de ce problème viennent des différences dans l'interface binaire de l'application (ABI) qui peuvent être différentes pour le même code source compilé à l'aide de compilateurs différents voire entre différentes versions d'un même compilateur. On peut remarquer ce problème avec des systèmes d'exploitation à la sauce UNIX. Un format souvent utilisé, appelé « Executable and Linkable Format » (ELF), devient de plus en plus populaire ce qui pourrait résulter en une meilleure compatibilité entre des applications compilées à l'aide de compilateurs différents et qui utilisent ce format par défaut. Cependant, il y a toujours un risque que quelqu'un compile le plugin avec un compilateur différent et que l'interface binaire de l'application soit logée ailleurs que dans l'application. Et que se passe-t-il si vous décidez un jour de livrer votre application sur un autre système d'exploitation ? La nouvelle version ne sera pas capable d'utiliser les plugins compilés pour d'autres systèmes. Vous devrez demander aux développeurs de compiler leurs plugins pour la nouvelle plateforme. Cela pourrait être difficile, voire impossible.

III-C - Des plugins véritablement portables

La meilleure solution est d'utiliser un des langages interprétés. Pourquoi est-ce la meilleure solution ? Parce que vous pouvez toujours écrire votre propre interpréteur de ce langage et l'attacher à votre logiciel. Vous serez probablement capable d'utiliser un des interpréteurs existants. Comme je l'ai mentionné plus haut, il existe au moins deux bons langages que vous pouvez utiliser pour implémenter l'interface du plugin dans votre logiciel. Je pense que JavaScript est moins bon que Python. Python est plus méthodique et comprend beaucoup de possibilités intégrées que JS ne contient pas.

III-D - Java au lieu de C++ ?

Que se passe-t-il si les plugins ne sont pas quelque chose d'exotique dans votre programme, mais sont implémentés comme une part importante de l'application avec tous les droits ? C'est sans doute une bonne raison d'abandonner C++ et d'utiliser un langage qui possède sa propre couche entre le code exécutable et le système d'exploitation. Cette méthode est utilisée par Eclipse. Eclipse est une plateforme écrite en Java. Eclipse tout nu est inutilisable. Ses fonctionnalités principales sont fournies par les plugins. Lorsque vous voulez écrire en C++, vous utilisez Eclipse CDT, en PHP vous utilisez Eclipse avec un jeu différent de plugins. Il y a peu, j'ai vu un environnement de développement intégré Eclipse pour Arduino. Eclipse peut être exécuté sur tous les systèmes qui possèdent une machine virtuelle Java.

À mon avis, le meilleur langage est Python. Il est également disponible sur différentes plateformes, mais il est plus rapide. De plus, vous pouvez compiler son code en code Java exécutable (à l'aide de Jython) qui pourra fonctionner sur des systèmes qui implémentent Java et pas Python (par exemple de vieux appareils mobiles).

III-E - Commencer avec la bibliothèque partagée POCO ?

Revenons au C++. Lorsque nous fournissons des plugins, compilés par nous-mêmes, pour des systèmes d'exploitation différents, nous pouvons omettre le problème de différences d'interface binaire de l'application. Nous pouvons maintenant nous occuper de l'interface de programmation. En d'autres termes, nous devons maintenant penser à la programmation des parties de l'application d'une façon portable du point de vue du code. Un code bien écrit peut être compilé sans changements sur tous les systèmes d'exploitation de pointe. Nous pouvons utiliser notre propre bibliothèque ou bien dépendre de bibliothèques externes qui fournissent une interface pour bibliothèques partagées. J'ai testé le composant Poco::SharedLibrary et je pense qu'il est satisfaisant à beaucoup d'égards. Il fournit des méthodes de chargement pour chacune des interfaces de programmation des systèmes d'exploitation les plus populaires.

POCO est un outil très complexe et bien documenté, je ne vais donc pas le décrire dans cet article. Je recommande de lire cette présentation pour obtenir quelques informations sur cette bibliothèque et la documentation officielle pour parfaire vos connaissances.

Dans une future partie de l'article traitant de la programmation modulaire, je vous montrerai comment écrire une application utilisant Boost::Python.

III-F - Remerciements

Cet article est une traduction autorisée de l'article paru sur le site de Kacper Kolodziej.

Merci aussi à Claude Leloup pour sa relecture orthographique.