IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Programmation d'applications modulaires


précédentsommairesuivant

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 :

 
Sélectionnez
#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 :

 
Sélectionnez
#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 :

 
Sélectionnez
#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
Sélectionnez
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
Sélectionnez
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
Sélectionnez
#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
Sélectionnez
#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
Sélectionnez
#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
Sélectionnez
#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
Sélectionnez
#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
Sélectionnez
#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 :

 
Sélectionnez
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.


précédentsommairesuivant

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 ni 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.