Sérialiser

Pour tirer profit de cet article, il faut une certaine aisance avec la programmation générique et avec la programmation d'un peu plus bas niveau (en particulier avec les conversions explicites de types ISO).

Imaginons le problème suivant : dans un système où on trouve des instances de la classe Bonhomme, représenter un Bonhomme sous forme sérialisée et construire un Bonhomme à partir de sa forme sérialisée.

Cette contrainte de stricte primitivité de la forme sérialisée est importante puisqu'elle permet de projeter facilement la forme en question sur un flux quelconque ou de l'émettre sur un lien de communication.

Nous devrons respecter une contrainte stricte : la forme sérialisée devra en bout de ligne reposer strictement sur des représentations primitives, de telle sorte qu'elle puisse être projetée sur une séquence de bytes et récupérée par un outil écrit dans un autre langage de programmation quel qu'il soit.

Évidemment, sérialiser un Bonhomme est une description trop large du mandat qui nous attend; il nous faut tout d'abord décrire ce qu'est un Bonhomme. Dans le cas qui nous intéresse, une description de ce qu'est un Bonhomme en vaut bien une autre.

Pour nos fins, la classe Bonhomme aura une forme telle que celle proposée dans l'ébauche de code à droite. Cette composition nous conviendra assez bien. Un Bonhomme contient un objet (la chaîne de caractères standard nom_), une donnée de type primitif (l'entier vie_) et deux enregistrements (position_ et direction_).

Nous ne nous préoccuperons pas des méthodes puisque la sérialisation ne demande habituellement pas un entreposage des modes opératoires, se limitant à une mise en forme des données de l'objet sérialisé.

Nous pourrions aller plus loin et explorer les membres de classe de même que ceux qui sont partagés, mais nous nous arrêterons là pour le moment.

// ...
#include <string>
// using...
struct Vecteur3D {
   float x, y, z;
   Vecteur3D() : x{}, y{}, z{} {
   }
   Vecteur3D(float x, float y, float z) :x{z}, y{z}, z{z} {
   }
   // autres méthodes
};
class Bonhomme {
public:
   using vie_t = short;
private:
   string nom_;
   vie_t vie_;
   Vecteur3D position_,
             direction_;
   // méthodes
};
// ...

Sérialiser signifie voyager

Sérialiser des données, c'est leur permettre de voyager. Une forme sérialisée devrait être une représentation brute et plus universelle des formes comprises par le processus sérialisant comme par le processus désérialisant. Une projection d'une forme sérialisée sur un lien de communication occasionne un voyage dans l'espace pour cette forme; une projection d'une forme sérialisée sur un flux correspondant à un média physique ou logique (un fichier, une base de données) est un voyage dans le temps pour cette forme.

Sérialisation brute : à éviter

Il y eut une époque où la maxime suivante régnait : la taille d'un enregistrement est la somme de la taille de ses membres. À cette époque, sérialiser un enregistrement équivalait à :

  • Prendre l'adresse de cet enregistrement
  • Le traiter comme un tableau de bytes
  • Examiner la taille de cet enregistrement (avec l'opérateur statique sizeof()) ou faire la somme de la taille de ses membres, et
  • Écrire ces bytes sur un flux
template <class T>
   void serialiser(const T &val, ostream &os) {
      const char *p= reinterpret_cast<const char*>(&val);
      copy(p, p+sizeof(T), ostreambuf_iterator<char>(os));
   }

La désérialisation constituait l'opération inverse :

  • Prendre les données du flux comme un tableau de bytes dont la taille était celle de l'enregistrement
  • Traiter l'enregistrement comme un tableau de bytes, et
  • Copier les bytes un à un du flux vers l'enregistrement
template <class T>
   istream& deserialiser(T &val, istream &is) {
      char *p = reinterpret_cast<char *>(&val);
      for (size_t i = 0; i < sizeof(T) && is.get(*(p+i)); ++i)
         ;
      return is;
   }

Cette époque était celle où les POD constituaient l'essentiel des types de données manipulés par les programmes. Nous devons mettre de l'avant à quel point ces deux exemples sont dangereux dans des programmes contemporains. En effet :

Si vous le désirez vraiment, il est possible d'avoir une représentation binaire brute pour un objet sans être coincé à lui appliquer des reinterpret_cast :

template <class T>
   union EntiteBrute {
      T objet_;
      char alignas(T) bytes_[sizeof(T)]; 
   };

Règle générale, il n'est pas possible de compter sur une forme d'organisation binaire spécifique pour une instance d'un classe donnée.

Nous ne voulons pas savoir avec précision comment un objet est structuré[1], et cela s'applique récursivement aux objets construits par composition ou par héritage, qu'il soit simple ou multiple[2].

En situation d'héritage, il faut que l'objet se sérialisant (a) sérialise d'abord ses parents, puis (b) se sérialise lui-même.

Puisqu'il est en général malvenu, en situation d'héritage multiple, de compter sur l'ordre de construction des parents, il sera particulièrement important de documenter l'ordre de sérialisation des parents par l'enfant pour que l'éventuelle désérialisation soit conforme à la sérialisation.

Au contraire, nous souhaitons que l'opération de sérialisation soit subjective, donc que chaque objet soit tenu pour responsable de sa propre autodescription.

Dans la mesure où le format de l'objet une fois sérialisé est connu et documenté, la désérialisation pourra se faire par divers moyens et l'objet reconstitué pourra être tenu pour structurellement équivalent à l'original, mais pas nécessairement identique[3].

Version simple de la sérialisation

Notez que nous utiliserons ici fréquemment des flux sur des fichiers, std::ifstream et std::ofstream, comme le permet C++ 11, soit en spécifiant le nom du fichier à l'aide d'une std::string. Ceci est illégal en C++ 03, mais vous pouvez contourner le problème si vous utilisez un compilateur C++ 03 en remplaçant un std::string nommée s en tant que nom de fichier par s.c_str(), qui retourne un const char* vers les données dans s.

Évidemment, sérialiser un Bonhomme est une description trop large du mandat qui nous attend; il nous faut tout d'abord décrire ce qu'est un Bonhomme.

Dans le cas qui nous intéresse, une description de ce qu'est un Bonhomme en vaut bien une autre.

Si une instance de Bonhomme est en mesure de se représenter elle-même sous forme sérialisée, la fonction à droite devrait projeter une forme sérialisée de b, un Bonhomme, sur le fichier nommé fich, puis devrait récupérer une copie de b dans la variable b2 dont une copie sera enfin retournée au sous-programme appelant.

Ceci présume bien entendu que la classe Bonhomme expose, en plus des opérateurs d'écriture sur un flux et d'extraction d'un flux, un constructeur par défaut et un constructeur de copie.

#include <string>
#include <fstream>
// using...
Bonhomme test_serialisation(const Bonhomme &b, const string &fich) {
   {
      ofstream os(fich);
      os << b;
   }
   Bonhomme b2;
   ifstream is(fich);
   is >> b2; // tester pour les erreurs
             // (omis pour économie)
   return b2;
}

Dans cette optique, sérialiser une instance de Bonhomme n'implique que rédiger les opérateurs de projection sur un flux et d'extraction d'un flux s'appliquant à un Bonhomme.

Un réflexe serait d'écrire le code à droite pour Vecteur3D, dont les attributs ont été spécifiés publics.

Selon vous, aurait-on dû avoir recours à des variables temporaires x, y et z pour la saisie des valeurs sur le flux plutôt que de lire directement des valeurs dans v.x, v.y et v.z? Réponse ici.

// Vecteur3D.cpp
#include "Vecteur3D.h"
#include <istream>
#include <ostream>
// using ...
ostream& operator<<(ostream &os, const Vecteur3D &v) {
   return os << v.x << v.y << v.z;
}
istream& operator>>(istream &is, Vecteur3D &v) {
   if (!is) return is;
   return is >> v.x >> v.y >> v.z;
}

...puis comme ceci pour Bonhomme. Considérez que chaque accesseur est const et retourne une copie correcte de l'attribut correspondant.

Notez l'emploi de l'opérateur = sur un Bonhomme temporaire, qui nous éviter d'exposer des mutateurs publics (ce qui peut être utile si on ne les juge pertinents que sur le plan privé). Notez aussi qu'on n'accède pas directement aux attributs d'une instance d'une classe, privilégiant le recours au constructeur à partir des éléments sérialisés.

Évidemment, dans chaque cas, les opérations de projection sur un flux et d'extraction d'un flux sont symétriques.

Ce code est simple et semble relativement opérationnel. Cela dit, on y trouve certains irritants.

// Bonhomme.cpp
#include "Bonhomme.h"
#include <iostream>
#include <string>
// using ...
ostream& operator<<(ostream &os, const Bonhomme &b) {
   return os << b.nom() << b.vie()
             << b.position()
             << b.direction();
}
istream& operator>>(istream &is, Bonhomme &b) {
   if (!is) return is;
   string nom;
   Bonhomme::vie_t vie;
   Vecteur3D pos, dir;
   if (is >> nom >> vie >> pos >> dir)
      b = Bonhomme{nom, vie, pos, dir};
   return is;
}

Tout d'abord, écrivons un petit programme de test qui soit semblable en forme à la fonction test_serialisation() proposée plus haut :

#include "Bonhomme.h"
#include <fstream>
#include <iostream>
#include <string>
int main() {
   // using ...
   const string fich = "Test.dat";
   {
      Bonhomme b("Fred", 50, Vecteur3D(5, 5, 5)); // direction par défaut
      cout << b;
      ofstream ofs{fich};
      ofs << b;
   }
   Bonhomme b;
   ifstream ifs{fich};
   if (ifs >> b)
      cout << b << endl;
   else
      cerr << "Incapable de consommer le Bonhomme!" << endl;
}

Ce programme crée un Bonhomme du nom de "Fred" ayant 50 points de vie, une position initiale de {5,5,5} et une direction initiale par défaut. On présume ici que Bonhomme expose un constructeur paramétrique pour lequel le compilateur peut remplacer les paramètres par des valeurs par défaut.

Évidemment, le problème ne se limite pas aux chaînes de caractères.

Si nous avions écrit la vie, la position, la direction puis le nom, alors le fait que les valeurs numériques se seraient retrouvées accolées les unes aux autres sans séparateurs auraient rendu leur distinction impossible lors de l'extraction et l'ensemble des valeurs aurait été consommé comme un seul nombre (50555100).

Si notre travail a été bien réalisé, alors les deux opérations d'écriture à la console (soit celle faite avec le Bonhomme original et celle faite avec le Bonhomme suivant la désérialisation) devraient donner exactement le même résultat, la version désérialisée étant présumée copie conforme de la version originale.

Le problème survient du fait que nous n'avons pas inséré de délimiteurs (des caractères d'espacement, dans ce cas-ci, puisque nous passons par un format intermédiaire sous forme de texte) entre les états lors de la sérialisation.

Ainsi, notre Bonhomme, une fois sérialisé, aura la forme "Fred50555100" (présumant que la direction par défaut soit {1,0,0}). Lors de la désérialisation, le tout apparaîtra comme une chaîne de caractères valide, et sera consommé par l'extraction du nom du Bonhomme à partir du flux en entrée.

Un premier correctif à apporter serait donc d'insérer des délimiteurs dans le flux. Puisque nous utilisons des flux texte, nous délimiterons les éléments par des caractères d'espacement[4].

Pour le Vecteur3D, nous obtiendrons ceci.

Remarquez que nous avons corrigé le problème latent de lecture directement dans les attributs de v, problème qui risquait de laisser v dans un état incorrect si l'une des lectures – outre la toute première – devait échouer.

// ...
ostream& operator<<(ostream &os, const Vecteur3D &v) {
   return os << v.x << ' ' << v.y << ' ' << v.z;
}
istream& operator>>(istream &is, Vecteur3D &v) {
   if (!is) return is;
   float x, y, z;
   if (is >> x >> y >> z)
      v = Vecteur3D{x,y,z};
   return is;
}

Remarquez que fondamentalement, l'opération d'extraction d'un flux n'a pas à être modifiée. C'est la projection sur un flux qui doit prendre soin d'appliquer un format permettant la désérialisation.

De même, et pour les mêmes raisons, la sérialisation de Bonhomme pourrait ressembler à celle proposée à droite.

Ceci ne résout pas le problème mais a le mérite de nous rapprocher d'une solution. Avec le même programme de test qu'auparavant, la forme sérialisée de b, notre instance de Bonhomme, devient "Fred 50 5 5 5 1 0 0" et ce, à l'origine comme suite à la désérialisation.

// ...
ostream& operator<<(ostream &os, const Bonhomme &b) {
   return os << b.nom() << ' ' << b.vie() << ' '
             << b.position() << ' ' << b.direction();
}
istream& operator>>(istream &is, Bonhomme &b) {
   if (!is) return is;
   string nom;
   Bonhomme::vie_t vie;
   Vecteur3D pos, dir;
   if (is >> nom >> vie >> pos >> dir)
      b = Bonhomme{nom, vie, pos, dir};
   return is;
}

Tout va donc pour le mieux dans le meilleur des mondes? Malheureusement, non.

Le problème des chaînes de caractères

Modifions très légèrement le programme de test proposé plus haut, et introduisons un nom un peu plus problématique.

#include "Bonhomme.h"
#include <fstream>
#include <iostream>
#include <string>
int main() {
   // using ...
   const string fich = "Test.dat";
   {
      Bonhomme b{"Fred Emile", 50, Vecteur3D{5, 5, 5}}; // direction par défaut
      cout << b;
      ofstream ofs{fich};
      ofs << b;
   }
   Bonhomme b;
   ifstream ifs{fich};
   if (ifs >> b)
      cout << b << endl;
   else
      cerr << "Incapable de consommer le Bonhomme!" << endl;
}

Ici, l'écriture sur un flux ne pose pas de problème. Le premier cout << b; écrira bel et bien "Fred Emile 50 5 5 5 1 0 0" tel qu'attendu. De même, l'écriture dans le fichier projettera exactement la même forme de texte que l'écriture à la console.

Lors de l'extraction du flux, par contre, nous frapperons un os : l'opération d'extraction d'une chaîne de caractères lit du point d'extraction courant jusqu'au premier caractère d'espacement. Ainsi, ifs >> b; ne lira que Fred comme nom, et le reste de la lecture échouera lamentablement (sur mon poste, la projection à la console du Bonhomme désérialisé donne "Fred -13108 0 0 0 0 0 0" ce qui n'est visiblement pas le résultat souhaité).

Notez qu'il y a une option encore plus simple que tout cela : interdire les blancs dans le texte. Cela dit, cette solution manque un peu de souplesse.

Il nous faut donc utiliser une forme de projection du texte sur un flux qui ne soit pas ambiguë et qui permette une désérialisation dont le fruit sera conforme à l'objet avant sérialisation. Quelles sont les options qui s'offrent à nous? Il y en a plusieurs.

Remarque sur l'application des exemples qui suivent

Dans chaque cas en exemple ci-dessous, nous examinerons une paire de fonctions capables de transformer une chaîne standard selon une stratégie symétrique de sérialisation/ désérialisation.

La sérialisation d'un objet contenant du texte et connaissant son contexte d'utilisation procédera à l'application de règles de transformation de ce texte avant et après sérialisation.

Notez que plusieurs signatures sont possibles pour les fonctions appliquant les règles de transformation en question.

Celles qui seront proposées ici suivront en gros la signature des opérateurs sur des flux. Par contre, du fait que l'associativité des opérateurs de flux va de droite à gauche, je vous recommande de traiter les opérations de transformation du texte de manière à éviter toute ambiguïté (prenez pour exemple le code proposé à droite), et d'éviter d'enfiler ces opérations dans la chaîne d'opérateurs en tant que tel[5].

ostream& operator<<(ostream &os, const Bonhomme &b) {
   serialiser(os, b.nom());
   return os << ' ' << b.vie() << ' '
             << b.position() << ' '
             << b.direction();
}
istream& operator>>(istream &is, Bonhomme &b) {
   if (!is) return is;
   string nom;
   Bonhomme::vie_t vie;
   Vecteur3D pos, dir;
   if (deserialiser(is, nom) &&
       is >> vie >> pos >> dir)
      b = Bonhomme{ nom, vie, pos, dir };
   return is;
}

Préfixer le texte de sa taille

Il serait possible de préfixer la séquence de caractères de sa taille, donc d'écrire la séquence "10 Fred Emile" pour que l'entier positif 10 soit consommé comme un entier positif, puis que "Fred Emile" soit consommé comme une séquence de 10 caractères.

Parmi les risques de cette approche, on compte les attaques par un tiers hostile (qui pourrait modifier l'entier indiquant la taille et chercher à générer des débordements à l'extraction) et la difficulté de bien gérer les espacements en début de séquence (si le texte écrit sur le flux débute par des blancs).

Ce dernier problème existait toutefois dans la version précédente, qui n'aurait pas été en mesure de différencier " Fred " de "Fred".

// inclusions... using...
ostream& serialiser(ostream &os, const string &s) {
   return os << s.size() << ' ' << s;
}
istream& deserialiser(istream &is, string &s) {
   if (!is) return is;
   string::size_type n;
   if (!(is >> n)) return is;
   char c;
   is.get(c); // consommer le blanc initial
   s.clear();
   for (string::size_type i = 0; i < n && is.get(c); ++i)
      s+= c;
   return is;
}

Un irritant : projeter une chaîne de caractères à la console sous cette forme fait en sorte, du moins par défaut, d'afficher sa taille en tant que préfixe. On a vu plus élégant.

Baliser le texte

Il serait possible de baliser le texte. Ceci pourrait se faire en l'enrobant d'une paire de symboles identiques (disons '\"' avant et après la séquence), ou à tout le moins reconnaissables en tant que marqueurs de début et de fin de chaîne (par exemple <texte> et </texte> pour suivre la mode XML).

Dans les deux cas, la difficulté est la même, soit celle d'éviter la pollution du texte par l'insertion des balises à l'intérieur de la séquence. Par exemple, si nous avons choisi de délimiter le texte par des guillemets et si notre intention est de sérialiser le texte "J'ai dit "Bonjour!"", alors il importe de trouver un mécanisme permettant de distinguer les guillemets balisant la chaîne et ceux qui s'inscrivent dans la séquence de texte en soi.

Pour régler ce problème, il est possible de remplacer le texte irritant par une autre séquence (par exemple, remplacer " par \" lors de la sérialisation et remplacer \" par " lors de la désérialisation, ou remplacer " par &quot; comme on le fait avec HTML). Il faut être prudent car cela demande un schème capable de gérer les remplacements correctement (si le texte \" devait être sérialisé, alors il faudrait trouver un moyen de le représenter en tant que tel, peut-être par \\\"). Notez que ce remplacement symétrique avant projection et après extraction peut coûter cher en temps processeur.

Il est aussi possible, simplement, d'interdire le caractère servant de balise à l'intérieur du texte par une forme de validation dans les interfaces personne/ machine permettant d'injecter dynamiquement du texte dans le système.

Cette solution manque de souplesse mais est pragmatique et rapide, ce qui explique qu'elle serve beaucoup dans les jeux.

Le code à droite montre un exemple d'une telle stratégie reposant sur l'insertion de guillemets en tant que balises et présumant que la chaîne à sérialiser ou à désérialiser ne compte aucune occurrence de cette balise.

Notez l'opération if (!is) return is; qui contrôle l'identification de fin de flux. Cette ligne semble banale mais est très est importante à la mécanique.

ostream& serialiser(ostream &os, const string &s) {
   return os << '\"' << s << '\"';
}
istream& deserialiser(istream &is, string &s) {
   if (!is) return is;
   char c;
   if (is.get(c) && c != '\"') {
      is.putback(c);
      // mettre is en état d'erreur
      return is;
   }
   string temp;
   while(is.get(c) && c != '\"')
      temp += c;
   if (is) // ou if ( == '\"') au choix
      s = temp;
   return is;
}

Il est aussi possible de réaliser une recherche dans le texte à sérialiser et trouver un symbole, quel qu'il soit, qui n'y apparaît pas, puis s'en servir pour baliser le début et la fin de la séquence. Ce schème fonctionne étonnamment bien pour le texte puisqu'il existe plusieurs caractères non affichables qui risquent très peu d'apparaître dans un flux de texte. L'extraction du texte d'un flux est très rapide (le premier caractère est consommé, puis le texte est consommé jusqu'à ce que la fin du flux soit rencontrée ou jusqu'à ce que ce symbole réapparaisse dans le flux). Par contre, chaque projection nécessite une analyse du texte à projeter, ce qui rend ce schème prohibitif pour les systèmes dynamiques (c'est très bien pour les systèmes où le texte est connu à la compilation ou ceux où le texte est préparé d'avance et provient d'un média).

Enfin, il est possible de faire comme dans une séquence ASCIIZ et de baliser le texte à la fin seulement par un caractère non affichable, typiquement la valeur 0 encodée sur la taille d'un caractère, donc '\0' ou L'\0' selon les standards char et wchar_t mais d'autres symboles peu fréquents dans un texte sont possibles. Cette pratique a l'impact pervers de rendre la projection sur un flux plus difficile à lire (le caractère non affichable corrompt la séquence de texte brut).

Éliminer les blancs

Autre solution, enfin: remplacer bêtement chaque blanc par autre chose et faire une lecture et une écriture simple avec les opérateurs >> et << sur les flux. Les mêmes irritant que ceux mentionnés précédemment s'appliquent (gérer les cas où les symboles utilisés comme remplacement pour les blancs apparaissaient dans le flux original, s'assurer de couvrir tous les cas de blancs possibles, un affichage à la console qui n'est pas harmonieux, etc.).

Variantes

Pour nos fins ici, la sérialisation de texte utilisera un système de balises par guillemets englobants, et présumera qu'aucune chaîne de caractères sérialisée ne contiendra de guillemets. C'est un schème parmi plusieurs mais qui rendra valable l'emploi de la console pour exposer des exemples (les guillemets y auront un sens pour les observateurs).

Cela dit, tout schème convenant aux besoins contextuels de l'application conviendra (s'il est correctement implémenté), et d'un point de vue code il nous suffira de présumer que serialiser() et deserialiser() sont codés en fonction des besoins sur une instance de std::string.

On trouve évidemment des variantes à tous ces schèmes, comme par exemple terminer le texte par une nouvelle ligne et le consommer avec std::getline() (mais que faire quand le texte contient un symbole de changement de ligne?) ou baliser le texte par des guillemets et remplacer les guillemets dans le texte par des double guillemets (donc une séquence de texte ne peut se terminer par un nombre pair de guillemets) comme en langage Pascal.

Aucun de ces schèmes n'est parfait; le marteau doré n'existe pas. Aucun ne s'harmonise en totalité avec tous les cas possibles de formats de texte. Il faut donc prendre soin de faire un choix qui convienne à l'utilisation escomptée du texte formaté.

Solution C++ 14 std::quoted()

Depuis C++ 14, on trouve dans <iomanip> la fonction std::quoted() qui permet de consommer ou d'émettre des données balisées (par défaut, ces balises sont des guillemets, mais la fonction est paramétrable). Étant donné la complexité du problème de la consommation de données délimitées, vous apprécierez sûrement l'écriture que permet cette fonction :

#include <iomanip>
// etc.
ostream& serialiser(ostream &os, const string &s) {
   return os << quoted(s);
}
istream& deserialiser(istream &is, string &s) {
   if (!is) return is;
   string temp;
   if(is >> quoted(temp))
      s = temp;
   return is;
}

Version simple de la sérialisation : résumé

En résumé, d'un point de vue simple :

Généralisation à des flux sur plusieurs types de caractères

Bien que nos exemples soient délibérément simples et limités à des chaînes de caractères sur un byte et aux flux qui les accompagnent, il est facile de généraliser les opérations à n'importe quel type de caractère.

En effet, le type ostream est un alias pour basic_ostream<char>, tout comme istream est un alias pour basic_istream<char>. Il existe dans le standard des spécialisations nommées pour les wchar_t, soit wistream et wostream, tout comme il existe wcout, wcin, wcerr, wclog, wstring, etc. et il est possible d'ajouter nos propres spécialisations sur d'autres types de caractères, au besoin[6].

On ne voudra toutefois pas couvrir chacun de ces cas particuliers un à un (ce serait fastidieux); il est nettement préférable de rédiger nos fonctions de manière générique, sur la base du type de caractère visé. Ainsi, plutôt que d'écrire un truc comme :

ostream& operator<<(ostream &os, const Vecteur3D &v) {
   return os << v.x << v.y << v.z;
}
istream& operator>>(istream &is, Vecteur3D &v) {
   if (!is) return is;
   float x, y, z;
   if (is >> x >> y >> z)
      v = Vecteur3D{x, y, z};
   return is;
}

On préférera généralement écrire :

template <class C>
   basic_ostream<C>& operator<<(basic_ostream<C> &os, const Vecteur3D &v) {
      return os << v.x << v.y << v.z;
   }
template <class C>
   basic_istream<C>& operator>>(basic_istream<C> &is, Vecteur3D &v) {
      if (!is) return is;
      float x, y, z;
      if (is >> x >> y >> z)
         v = Vecteur3D{x, y, z};
      return is;
   }

Outre l'écriture, plus générale mais un peu plus longue, cette approche n'entraîne aucun coût, que des gains.

Sérialisation plus complexe

La sérialisation est, lorsque prise sous un angle général, un problème ouvert. Ne laissez pas la simplicité des cas proposés dans la section précédente vous tromper. Cela dit, il est possible d'en arriver à une proposition de solution raisonnable dans la plupart des cas particuliers.

Examinons donc quelques aléas de la sérialisation dont le traitement exige une démarche un peu plus empreinte de subtilité.

Désérialiser un type pour lequel il n'existe pas de constructeur par défaut

Il arrive qu'il n'existe pas de constructeur par défaut pour un type donné. Par exemple, on présume de la classe Bonhomme proposée plus haut qu'elle expose un constructeur par défaut (voir le code de lire_bonhomme() ci-dessous) or, en temps normal, il est possible que cette option ne soit pas jugée raisonnable pour certaines applications.

À tout hasard, un observateur externe serait en droit de se demander quel nom nous devrions donner à un Bonhomme par défaut, par exemple.

Si aucun constructeur par défaut n'existe pour une classe comme Bonhomme, alors du code comme celui proposé à droite devient illégal.

Le même problème se pose dans le cas d'une classe où au moins un membre d'instance est const. Ces instances sont immuables une fois construites ce qui rend leur éventuelle modification (pas des mutateurs ou par une affectation) illégale.

Il existe donc des situations où la désérialisation normale de C++, reposant sur l'opérateur d'extraction d'un flux prenant en paramètre de droite une référence à un objet déjà créé, n'est pas appropriée.

// ...
istream& operator>>(istream &is, Bonhomme &b) {
   if (!is) return is;
   string nom;
   Bonhomme::vie_t vie;
   Vecteur3D pos, dir;
   if (!deserialiser(is, nom)) return is;
   if (is >> vie >> pos >> dir)
      b = Bonhomme{nom, vie, pos, dir};
   return is;
}
Bonhomme lire_bonhomme(istream &is) {
   Bonhomme b; // constructeur par défaut
   if (is)
      is >> b;
   return b;
}
// ...

Heureusement, tout n'est pas perdu. On peut très bien désérialiser un objet soumis à de telles contraintes (devant être correctement initialisé dès sa construction) en utilisant une variante du schéma de conception fabrique.

La technique est simple. Il suffit d'ajouter une méthode de classe[7] à la classe visée (pour nos fins, la classe Bonhomme), prenant en paramètre le flux d'où extraire la forme sérialisée et retournant une copie de l'objet construit par la désérialisation.

// inclusions... using...
class PasUnBonhomme {};
class Bonhomme {
   // ... voir plus haut ...
public:
   // ... voir plus haut ...
   static Bonhomme deserialiser(istream&);
};

Cette méthode ne doit pas être une méthode d'instance. En effet, comme pour toutes les fabriques, son rôle et de prendre en charge la construction d'une instance; si elle se présentait comme une méthode d'instance, alors nous aurions (à l'occasion) une version du problème de l'oeuf et de la poule.

La définition de la méthode de fabrication (de la fabrique) est relativement simple pour qui aura suivi le propos jusqu'ici, et s'apparente fort à l'opérateur d'extraction d'un flux pour le type fabriqué (je vous invite à comparer visuellement les deux).

Notez bien :

  • La lecture individuelle des paramètres au constructeur
  • L'utilisation explicite du préfixe :: à la fonction deserialiser() pour string, car la méthode deserialiser() de Bonhomme cache celle plus globale que nous avions définie plus haut, et
  • Le fait que le seul Bonhomme utilisé par la méthode est celui construit et retourné en fin de compte
// inclusions... using...
Bonhomme Bonhomme::deserialiser(istream &is) {
   if (!is) return is;
   std::string nom;
   Bonhomme::vie_t vie;
   Vecteur3D pos, dir;
   ::deserialiser(is, nom);
   if (!(is >> vie >> pos >> dir))
      throw PasUnBonhomme{};
   return Bonhomme{nom,vie,pos,dir};
}

Certains se diront qu'il reste un problème à régler. En effet, lorsqu'un appelant voudra invoquer la méthode Bonhomme::deserialiser(), il lui faudra bien déposer le Bonhomme retourné quelque part, alors ne vient-on pas de créer un nouveau problème de même nature que celle du problème original?

Heureusement, non. À titre d'exemple, voici un programme désérialisant un Bonhomme d'un flux en entrée. Remarquez que l'appel à la fabrique est combiné à une construction de Bonhomme, ce qui sollicite non pas le constructeur par défaut ni l'opérateur d'affectation mais bien le constructeur de copie.

Ce problème a donc, heureusement pour nous, une solution.

#include "Bonhomme.h"
#include <fstream>
#include <iostream>
int main() {
   // using...
   ifstream ifs{"Bonhomme.txt"};
   auto b = Bonhomme::deserialiser(ifs);
   cout << b << endl;
}

Cas où la structure doit être découverte dynamiquement

Un autre cas problème est celui où l'ordre et le type des objets sérialisés ne sont pas connus a priori. En effet, tous les cas couverts jusqu'ici étaient des cas où le code client savait quels étaient les types des objets sérialisés et dans quel ordre ces types apparaissaient dans le flux.

Ces cas sont très fréquents : sérialiser un bonhomme implique sérialiser son nom, sa vie, sa position et sa direction; chacune de ces sérialisations est un problème fermé en soi. Le problème de découverte dynamique des types sérialisés apparaît quand les objets à sérialiser sont manipulés de manière polymorphique, donc lorsque le programme opère sur des abstractions plutôt que sur des types concrets ou sur des instances de classes terminales.

L'approche OO préconise l'abstraction dans bien des cas; il nous faut donc réfléchir à un schème convenable pour les cas reposant sur des abstractions comme pour les cas reposant sur des types concrets.

Si l'objectif n'est pas de reconstituer tous les objets entreposés sur un flux lors de la désérialisation, alors cette contrainte peut être remplacée par une autre, moins stricte, selon laquelle l'homologue impliqué dans la désérialisation doit connaître le format des types qu'il doit désérialiser et doit être capable de les retrouver dans le flux sans ambiguïté.

Quelques présupposés s'imposent d'eux-mêmes :

Un autre rappel des constats fondamentaux de la sérialisation doit être fait avant de poursuivre :

Étant donné qu'un type peut être utilisé pour fins de sérialisation simple ou à travers un mécanisme qui permettra sa désérialisation à l'aide de détection dynamique de types, nous ferons en sorte d'éviter de modifier le schème de sérialisation normal, privilégiant plutôt une stratégie qui enrobera la forme primitive de la sérialisation d'un objet à l'aide d'information susceptible d'assister le mécanisme de désérialisation.

Imposer une structure non locale

Permettre la désérialisation sans connaissance a priori de l'ordre des types sérialisés demande un effort de programmation nettement plus important que ce que nous avons fait dans le cas de la sérialisation simple.

Tout d'abord, il faut que l'entité responsable de désérialiser les objets soit en mesure reconnaître le type de chacun d'eux dans le flux. Les moteurs comme Java et .NET, qui offrent un support intrinsèque à la mécanique de sérialisation, le font implicitement dans un format qui leur est propre lorsque des objets sont écrits sur un flux.

Plusieurs schèmes sont possibles pour identifier les types, revenant la plupart à l'emploi de numéros uniques ou de noms uniques pour chaque type. En situation d'agrégation, identifier les instances de manière unique devient aussi précieux (et la numérotation doit être unique au monde dans certains schèmes globaux comme ceux destinés à des applications dans Internet).

Ce qui complique un peu les choses est que les schèmes de numérotation dynamique, où chaque type se donne une identification unique au démarrage d'un programme, compliquent la sauce lors de la désérialisation.

Un schème reposant sur les noms n'est pas portable, soit, mais il est relativement aisé pour une équipe de programmeurs de construire une correspondance entre les noms d'un langage à l'autre pour faciliter la désérialisation une fois la forme de la sérialisation connue.

Si insérer le nom du type à chaque sauvegarde pose problème (entre autre pour raisons d'espace), alors il est possible d'associer un entier unique au fichier pour chaque nom et de débuter chaque fichier par une table de correspondances nom/ valeur, générant ainsi une économie d'échelle.

Nos exemples dans cette section feront fi de ces considérations, mais sachez qu'elles existent et ont leur raison d'être.

Pour que le processus responsable de la désérialisation s'y retrouve, il importe que le schème d'identification soit statique, donc connu a priori par les deux homologues de la relation.

Information dynamique de type

Une stratégie serait de tenir à jour une liste d'entiers, soit un pour chaque classe pertinente à sérialiser, mais ce schème rend ardu le contrôle des versions et de l'évolution du logiciel.

Il existe toutefois un moyen, même avec C++, d'identifier de manière unique chaque classe, soit d'utiliser le même schème de nomenclature que celui utilisé par le compilateur dans sa propre génération de code.

En effet, si le code d'un module est compilé de manière à inclure l'information dynamique sur les types (Run-Time Type Information, RTTI), alors il est possible de découvrir à l'exécution un peu d'information sur chaque type, incluant les classes polymorphiques, les classes concrètes et les types primitifs.

Utiliser RTTI implique (forcément) accepter un léger accroissement de la taille du code généré. Abuser des mécanismes d'inférence explicite de types a un impact négatif sur la qualité de l'implémentation et est habituellement signe de fautes de design.

L'information dynamique de type pour un type donné sous C++ est codée dans une instance de la classe std::type_info. Il n'est pas possible d'instancier cette classe directement dans un programme, la grande majorité de ses attributs (et tous ses constructeurs) étant privés; pour connaître l'information de type pour un type T, il faut avoir recours à l'opérateur typeid() prenant en paramètre le nom du type et retournant une référence-vers-const sur l'instance de std::type_info correspondante.

La classe std::type_info expose en particulier une méthode d'instance nommée name(). Cette méthode retourne ce que nous nommerons le nom lisible, correspondant au nom tel qu'on le voit dans le programme.

Un petit programme proposant un exemple d'utilisation de std::type_info et de l'opérateur typeid() suit.

#include <iostream>
#include <string>
#include <typeinfo>
class Bonhomme;
int main() {
   using namespace std;
   cout << typeid(string).name() << endl << endl
        << typeid(Bonhomme).name() << endl endl << endl
        << typeid(int).name() << endl << endl
        << typeid(float).name() << endl << endl;
}

Ce programme affiche à la console (avec Visual Studio 2012) le texte suivant, dans lequel je vous invite à remarquer la différence entre nom lisible et nom décoré pour chaque type.

class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >

class Bonhomme

int

float

Noms statiques de types

Étant donné le caractère non portable des noms retournés par la méthode name() d'une instance de std::type_info, il devient raisonnable de s'interroger sur la possibilité d'avoir un schème de nommage des types qui soit à la fois portable et inclusif pour tous les types, qu'il s'agisse de classes ou de types primitifs.

Il se trouve que la technique des traits permet d'établir un schème statique et portable de nommage pour les types dans un programme donné. Puisque nommer les types pertinents un à la fois peut être fastidieux, voici un exemple utilisant le résultat de la méthode name() de std::type_info(T) en général mais utilisant des noms définis par programmation dans certains cas particuliers.

#include <iostream>
#include <typeinfo>
#include <string>
// using...
class Bonhomme;
template <class T>
   struct traits_type {
      static string nom()
         { return typeid(T).name(); }
   };
//
// Exemple de spécialisation
//
template <>
   struct traits_type<string> {
      static string nom()
         { return "class std::string"; }
   };
int main() {
   // using ...
   cout << traits_type<string>::nom() << endl
        << traits_type<Bonhomme>::nom() << endl
        << traits_type<int>::nom() << endl
        << traits_type<float>::nom()<< endl;
}

Cet exemple montre comment, en général, il serait possible d'utiliser le mécanisme intégré mais non portable, puis de spécialiser le comportement de traits_type pour certains types, mais notez que le cas extrême serait de spécialiser pour tous les types... et ce cas est plausible.

Si réduire l'espace occupé par la forme sérialisée des objets est important, il peut être possible de définir une table de correspondances entre nom lisible de type et valeur entière compacte dans un en-tête de fichier sérialisé puis d'utiliser, dans le reste du fichier, les valeurs entières.

Cette correspondance sera locale à chaque fichier et il faudra, pour être efficace, construire la table de correspondance lors de la lecture de tout fichier sérialisé.

Nous privilégierons le nom lisible au nom décoré dans notre processus de formalisation de la sérialisation des types, sachant que le nom décoré peut différer d'une plateforme à l'autre et que la stratégie pour générer un nom décoré dans un compilateur donné peut ne pas être documentée de manière publique.

Au passage, remarquez que le nom lisible contient souvent des caractères d'espacement, ce qui implique que nous devrons lui appliquer la même stratégie que celle appliquée aux autres chaînes de caractères.

Une stratégie générale et simple de sérialisation en situation de découverte dynamique des types est de préfixer chaque entité par son nom descriptif. Ceci se prête particulièrement bien à de la programmation générique.

Pour en arriver à une forme vraiment générique de génération d'une forme sérialisable pour une donnée d'un type T, il faut que la fonction capable de sérialiser un T puisse oeuvrer sur n'importe quel type T, or nous venons de voir qu'il y a toujours au moins un cas particulier à prendre en charge.

Dans l'optique où notre forme sérialisée est une chaîne de caractères, le cas particulier sera celui d'une std::string.

Ici, le polymorphisme ne représenterait probablement pas la meilleure option, puisque cela nous forcerait à modifier tous les types (ou à tous les enrober) pour prendre la sérialisation en charge.

En retour, par programmation générique, il est possible de déterminer un algorithme applicable à un type T de manière générale, puis de déterminer en quoi l'algorithme applicable au type string (par exemple) diverge de celui applicable aux autres types.

#ifndef SERIALISATION_OUTILS_H
#define SERIALISATION_OUTILS_H
#include <iosfwd>
#include <string>
#include <sstream>
//
// Exemple un peu abusif; on pourrait faire plus
// simple avec de la surcharge de fonctions
//
template <class T>
   struct Serialisation {
      static string formater(const T &val) {
         stringstream sstr;
         sstr << '\"' << typeid(T).name() << "\" "
              << val;
         return sstr.str();
      }
   };
template <>
   struct Serialisation<string> {
      static string formater(const string &val) {
         stringstream sstr;
         sstr << '\"' << typeid(string).name() << "\" "
              << '\"' << val << '\"';
         return sstr.str();
      }
   };
#endif

Évidemment, si le besoin s'en fait sentir, il y a lieu d'offrir des spécialisations supplémentaires de cette stratégie pour d'autres types. De même, si la forme sérialisée n'est pas une chaîne de caractères mais bien un format plus compact, la même stratégie peut s'appliquer (peut-être de manière à retourner un vecteur de bytes bruts).

La spécialisation des templates s'applique mieux à partir de méthodes de classe qu'à partir de fonctions globales, ce qui explique le recours à des struct dans notre exemple ci-dessus.

Un exemple simple d'application de ces outils de sérialisation sur un Bonhomme serait...

int main() {
   // using...
   const string fich = "Test.dat";
   Bonhomme b{"Fred Emile", 50, Vecteur3D{5, 5, 5}};
   cout << Serialisation<Bonhomme>::formater(b);
   ofstream ofs{fich};
   ofs << Serialisation<Bonhomme>::formater(b);
}

...et la chaîne affichée serait celle ci-dessous :

"class Bonhomme" "Fred Emile" 50 5 5 5 1 0 0

Le préfixe "class Bonhomme" est ce qui nous manquait pour être en mesure de mettre en place une stratégie de désérialisation et de reconstruction à partir d'un flux.

Jusqu'où aller?

Le contrôle des versions

Un problème incontournable dans un cas où la forme peut servir de voyage dans le temps est celui du contrôle des versions.

En effet, comment gérer le cas où un moteur de désérialisation serait confronté à une forme sérialisée plus ancienne ou plus nouvelle? Il serait possible d'échouer et de générer un message d'erreur, mais cela peut entraîner de la frustration chez les utilisateurs.

Une stratégie commune est d'avoir recours à des balises. Notre en-tête pour chaque type sérialisé peut servir à cette fin: si l'en-tête est présumée ne pas apparaître dans le flux sérialisé autrement que comme séquence de contrôle, alors cette en-tête pourrait apparaître avant la forme sérialisée et être répétée à la fin de cette même forme. Ainsi, le moteur de désérialisation pourrait passer par-dessus les entités qu'il ne comprend pas et serait peut-être en mesure de fonctionner malgré tout.

C'est là, en grande partie, une raison du succès des formes balisées à la XML.

Il n'est pas nécessaire de pousser la mécanique de sérialisation assistée à l'extrême et de préfixer toute donnée primitive d'une information descriptive de type, même s'il est techniquement possible de le faire.

L'objectif derrière la stratégie d'insertion de préfixes dans la forme sérialisée est de permettre à la mécanique de désérialisation de se guider sans connaître a priori la liste ordonnée des types sérialisés. Si le processus de désérialisation connaît au préalable la structure exacte de la forme sérialisée, alors il n'a pas besoin de cette information. De même, si le processus de désérialisation rencontre le préfixe de la classe Bonhomme, alors il devra désérialiser un Bonhomme, ce qui lui suffit probablement; si la structure interne d'un Bonhomme est connue du programme, alors pas besoin de préfixer chaque attribut de chaque Bonhomme pour le désérialiser.

Souvenons-nous que la forme selon laquelle sont sérialisées les données doit être connue des deux homologues. Il est raisonnable d'être pragmatique (pour faire une sorte de pléonasme volontaire).

Le problème de la reconstruction : une approche pragmatique

La reconstruction lors d'une désérialisation est plus complexe à mettre en place que la sérialisation, et ce pour plusieurs raisons documentées dans tout bon manuel de POO.

Entre autres choses, il est relativement facile de mettre en place une mécanique de sérialisation homogène et universelle : il suffit de ramener le problème à celui de projection subjective de tout type à une forme commune et d'en retirer les ambiguïtés potentielles.

La destination vers laquelle sont projetées les formes sérialisées est un flux; dans la mesure où la forme sérialisée se projette avec aisance sur un flux, la sérialisation est un dossier à peu près clos. La désérialisation et la reconstruction des objets à partie d'une forme sérialisée n'accepte pas de solution universelle

Une approche polymorphique simple pour la sérialisation est même possible :

Pensez-y bien : lors d'une sérialisation, le programme sait ce qu'il sérialise et peut simplement demander à chaque objet de se sérialiser lui-même. La destination et la forme sont toujours homogènes, ce qui fait de la sérialisation une simple application d'une opération sur une séquence.

Le problème d'une désérialisation dynamique des objets, donc sans connaissance a priori de la structure et de l'ordre des objets sérialisés, peut s'exprimer comme ceci :

Constat fondamental : toute solution sera nécessairement dépendante du contexte. Un jeu vidéo reconstruisant un monde peuplé d'avatars voudra peut-être déposer les avatars reconstruits dans un vecteur d'avatars mais déposer les bâtiments à la fois dans une liste ordonnée d'objets fixes sur une grille et de structures dessinables.

Autre constat fondamental : les objets ne doivent pas se reconstruire eux-mêmes, bien qu'ils puissent contribuer à leur reconstruction. Tout comme la problématique de la sérialisation formatée est une problématique transversale au modèle OO[8], la problématique de la désérialisation et de la reconstruction est un problème de fabrication d'objets à partir d'une forme protocolaire, qui entraînera éventuellement une construction à partir d'une forme sérialisée.

Désérialiser et fabriquer

Pour obtenir une stratégie homogène de désérialisation et reporter les détails propres au contexte (quoi fabriquer? Où placer les objets reconstruits?), nous aurons recours à deux outils.

Le premier sera un singleton chargé de prendre en charge une série de fabriques. Ce singleton procédera par abonnement, et inclura une fabrique par type d'objet à reconstituer.

Le singleton, lorsqu'on lui demandera de désérialiser un flux, consommera le flux en extrayant chaque en-tête d'objet sérialisé, puis en repérant dans sa liste la fabrique capable de reconstruire ce type et en déléguant à cette fabrique la tâche de désérialiser cet objet et de le placer à l'endroit approprié dans le système en cours de reconstitution.

Les fabriques, quant à elles, seront polymorphiques. Elles dériveront toutes d'une même racine et exposeront toutes une méthode de reconstruction à partir d'un flux (ou de la forme sérialisée). La correspondance entre fabrique et type d'objet à fabriquer se fera à partir du texte servant d'en-tête au type à désérialiser.

Dans le code proposé à droite, le singleton se nomme Deserialiseur et l'abstraction mère de toutes les fabriques se nomme Reconstructeur. La méthode de fabrication à spécialiser se nomme reconstruire(). Le contrôle de la correspondance entre identité du type à désérialiser et fabrique est codé directement dans la classe Reconstructeur puisqu'elle dépend du protocole.

Vous remarquez que l'exemple à droite a recours à l'idiome de classe incopiable.

#ifndef SERIALISATION_OUTILS_H
#define SERIALISATION_OUTILS_H
#include <iosfwd>
#include <string>
#include <vector>
#include <memory>
//
// using, outils de sérialisation et de
// désérialiation, omis pour d'économie
//
class Reconstructeur {
   string nom_lisible_;
public:
   Reconstructeur(const string &nom_lisible)
      : nom_lisible_{nom_lisible}
   {
   }
   virtual ~Reconstructeur() = default;
   string nom_lisible() const
      { return nom_lisible_; }
   virtual void reconstruire(istream &) = 0;
};
#include "Incopiable.h"
class Deserialiseur : Incopiable {
   vector<unique_ptr<Reconstructeur>> reconstr_;
   Deserialiseur() = default;
public:
   bool deserialiser(istream &);
   static Deserialiseur &get() {
      static Deserialiseur singleton;
      return singleton;
   }
   void ajouter(unique_ptr<Reconstructeur> p)
      { if (p) reconstr_.push_back(p); }
};
#endif

Le schéma de conception singleton devrait vous être familier à ce stade-ci. Comme pour tous les singletons, prenez soin d'y ajouter des mécanismes de synchronisation s'il est sujet à être placé en situation de concurrence.

La fabrique peut par contre vous sembler un peu hors normes, mais notez qu'il ne servirait à rien de faire retourner un type autre que void à la méthode de reconstruction du fait que tout type peut y être reconstruit.

Le code derrière le singleton nommé ici Deserialiseur est relativement simple.

Il importe de choisir une stratégie qui soit symétrique et qui place en un même lieu logique les opérations d'abonnement et de désabonnement. Ne vous éparpillez pas trop.

Désérialiser un objet d'un flux impliquera de désérialiser son en-tête (à l'aide de la fonction globale deserialiser() étudiée précédemment: remarquez le préfixe :: explicite!), de retrouver la fabrique correspondante et de lui demander de reconstruire l'objet à partir du flux.

On aurait pu utiliser une map plutôt qu'un vector dans ce cas-ci; je vous invite à écrire le code correspondant pour voir ce que ça donne.

Tout cela nous donne un cadre de travail général. Reste à voir comment reconstruire un type spécifique, comme par exemple le type Bonhomme.

#include "SerialisationOutils.h"
#include <iostream>
#include <algorithm>
#include <locale>
#include <string>
#include <vector>
// using...
bool Deserialiseur::deserialiser(istream &is) {
   if (!is) return false;
   string s;
   if (!::deserialiser(is, s)) return false;
   auto it = find_if(
      begin(reconstr_), end(reconstr_),
      [&](unique_ptr<Reconstructeur> &p) {
         return s == p->nom_lisible();
      });
   if (it == end(reconstr_)) return false;
   (*it)->reconstruire(is);
   return true;
}
// fonctions globales serialiser() et
// deserialiser() omises pour fins d'économie

Une fabrique de reconstruction ne peut exister dans le vide. Elle reconstruit pour un système, pas juste pour reconstruire. Elle doit savoir où déposer des objets une fois ceux-ci reconstruits, et ce savoir est nécessairement lié au contexte applicatif.

Nous présumerons, pour les fins de notre présentation, que la destination des instances reconstruites de Bonhomme est un vecteur de Bonhomme.

La reconstruction d'un Bonhomme à partir d'un flux en entrée devient alors une opération désérialisation du Bonhomme, suivie d'une insertion de cet objet dans le vecteur en question.

class ReconstruireBonshommes : public Reconstructeur {
   vector<Bonhomme> &bonshommes_;
public:
   ReconstruireBonshommes(vector<Bonhomme> &v)
      : Reconstructeur{ traits_type<Bonhomme>::nom() },
        bonshommes_{v}
   {
   }
   void reconstruire(istream &is) {
      Bonhomme b;
      if (is >> b)
         bonshommes_.push_back(b);
   }
};

Un programme de démonstration simple capable de sérialiser un Bonhomme puis de le désérialiser en découvrant dynamiquement son type et en appliquant la stratégie de fabrication appropriée suit :

// inclusions ...
int main() {
   // using ...
   const string fich = "Test.dat";
   {
      Bonhomme b("Fred Emile", 50, Vecteur3D(5, 5, 5));
      cout << Serialisation<Bonhomme>::formater(b);
      ofstream ofs{fich};
      ofs << Serialisation<Bonhomme>::formater(b);
   }
   vector<Bonhomme> v;
   Deserialiseur::get().ajouter(new ReconstruireBonshommes(v));
   for (ifstream ifs{fich}; Deserialiseur::get().deserialiser(ifs); )
      ; 
   copy(begin(v), end(v), ostream_iterator<Bonhomme>(cout, "\n"));
}

Remarquez que ce code est aussi approprié dans le cas où on ne manipule aucune instance de Bonhomme que dans le cas où il y en a une ou plusieurs.

Application de la sérialisation : infrastructure générique de paramètres

Imaginons que nous ayons besoin d'une hiérarchie d'objets susceptibles d'être sollicités à l'aide de paramètres. Pensons par exemple, un système où plusieurs événements doivent se produire en fonction de conditions précises, parmi lesquels on compte (pour les fins de l'illustration) :

Notez qu'un modèle de ce genre est très souple : si chaque événement peut modifier l'état du monde, alors l'occurrence d'un événement peut mener à l'ajout au monde d'autres événements susceptibles d'être éventuellement provoqués.

On pourrait[9] par exemple utiliser ce mécanisme à la fois pour certains effets spéciaux, pour des situations de combat, pour surveiller la fin du jeu, pour appliquer les lois de la physique sur des objets en situation de chute et pour provoquer le chargement de segments de carte.

La signature du modèle vers lequel nous orienterons ici notre réflexion ira comme suit :

Si nous présumons que la méthode d'instance par laquelle un événement peut se provoquer se nomme reagir(), et que la méthode reagir() d'un singleton servant de gestionnaire d'événement ait pour rôle d'itérer de manière synchronisée à travers tous les événements connus pour leur permettre de réagir au besoin, alors le thread générique chargé de la gestion des événements pourrait avoir l'air de ceci (à droite, notation Microsoft Windows pour le thread).

void reagir(Evenement *p)
   { if (p) p->reagir(); }
unsigned long __stdcall ThEvenements(void *) {
   auto &monde = Monde::get();
   auto &ges = GestionnaireEvenements::get();
   // itérer jusqu'à la fin de la partie
   while (!monde.fin()) {
      ges.reagir();
      // dodo...
   }
   return {};
}

Ce site Web offre une version OO de la démarche proposée ici, avec objets autonomes, outils de synchronisation OO, singletons etc. Le choix de ne pas entrer dans le détail ici est volontaire puisque notre propos porte sur la construction d'un modèle de paramètres génériques.

D'ailleurs, à quoi un paramètre générique ressemblerait-il?

Une représentation possible tirerait profit de la sérialisation, et nous permettrait d'affirmer que tout type sérialisable peut servir en tant que type pour un paramètre.

Imaginons donc qu'un Param contienne un attribut de type stringstream, qui est le type standard pour représenter un flux en mémoire.

Un Param par défaut est, sans avoir de valeur. Un Param pour une donnée de type T donné contient une version sérialisé de cette donnée. Un cas particulier de T est celui où T est Param, ce qui nous permet de définir un constructeur de copie pour Param.

Vous remarquerez sûrement que la version de Param proposée à droite expose aussi des opérateurs d'insertion dans un flux et d'extraction d'un flux. Les opérateurs d'insertion sont présents pour fins de souplesse, mais les opérateurs d'extraction sont essentiels à la mécanique que nous développons présentement.

Posez un regard attentif aux opérations de copie que sont le constructeur de copie et l'opérateur d'affectation : le flux du Param de destination est (au besoin) vidé de son contenu puis reçoit une version sérialisée du contenu du flux du Param source. Aucune connaissance des types effectivement impliqués n'est requise.

#ifndef PARAMETRE_H
#define PARAMETRE_H
#include <sstream>
class Param {
   stringstream flux_;
public:
   Param() = default;
   template <class T>
      Param(T &&val)
         { *this << val; }
   Param(const Param &p)
      { flux_ << p.flux_.str(); }
   template <class T>
      Param& operator<<(T &&val) {
         flux_ << val << ' ';
         return *this;
      }
   template <class T>
      Param& operator>>(T &val) {
         flux_ >> val;
         return *this;
      }
   Param& operator=(const Param &p) {
      if(this == &p) return *this;
      flux_.clear();
      flux_ << p.flux_.str();
      return *this;
   }
};
// ...

Remarquez aussi que la version de Param proposée ici ne tient pas à jour un nom pour chaque paramètre. Le concept de Param est autosuffisant sans injection d'un nom s'il doit y avoir correspondance entre ordre d'insertion (par le créateur du Param) et ordre d'extraction (par son utilisateur). Pour des structures plus complexes, le type Params (avec un « s ») suit.

La tâche de garder une correspondance avec un nom n'est donc pas essentielle au concept de paramètre. Il est ainsi possible d'avoir les listes de paramètres nommés si le besoin s'en fait sentir, en associant un nom à chaque paramètre de manière non intrusive, tout comme il est possible d'escamoter l'emploi d'un nom pour les applications où cela serait redondant et coûteux.

La classe Params (au pluriel) représentera une liste de paramètres. Dans notre exemple, elle conservera une correspondance entre nom de paramètre et Param correspondant.

Cette correspondance est gérée ici par un tableau associatif (map), mais d'autres options sont possibles.

Il sera possible d'extraire un Param d'une instance de Params par son nom. Un schème de liste de paramètres n'utilisant pas de nom pourrait avoir un prédicat empty() pour savoir s'il reste un Param à extraire de même qu'une méthode prochain() pour extraire le prochain Param de la liste.

// ...
#include <string>
#include <map>
#include <algorithm>
// using...
class Params {
   map<string,Param> params_;
public:
   class Redondant {};
   Params() = default;
   Params(const string &nom, const Param &p)
      { ajouter(nom, p); }
   void ajouter(const string &nom, const Param &p) {
      if (params_.find(nom) != end(params))
         throw Redondant{};
      params_[nom] = p;
   }
   Param get(const string &nom) {
      return const_cast<map<string,Param>&>(params_)[nom];
   }
};
#endif

Comment utiliser ce modèle de paramètres, maintenant? Imaginons que la classe abstraite représentant un événement en tant qu'événement se nomme Occurrence, ceci pour la distinguer des événements au sens de la synchronisation en milieu multiprogrammé.

Nous dirons qu'une Occurrence peut expirer, ce qui permet à un événement de se faire un hara-kiri moral et de se placer sur la voie d'évitement (pour une éventuelle collecte) une fois son heure venue. Ceci simplifie la synchronisation entre le retrait des événements et le parcours de la liste des événements pour fin de réaction.

Toute instance d'Occurrence implémentera la méthode reagir(), ce qui permettra un comportement polymorphique et subjectif chez les événements.

Ajouter des catégories d'événements dans le système signifiera simplement y injecter des instances de nouveaux dérivés d'Occurrence.

#ifndef OCCURRENCE_H
#define OCCURRENCE_H
class Occurrence {
   bool expire_;
public:
   OccurrenceEvenement() : expire_{} {
   }
   bool est_expire() const
      { return expire_; }
   void expiration()
      { expire_ = true; }
   virtual void reagir() = 0;
   virtual ~Occurrence() = default;
};
// ...

Pour que le système tienne la route, il nous faudra une mécanique de fabrication d'événements. Imaginons donc maintenant une fabrique d'événements, qui sera pour nos fins une classe singleton nommée FabEvenements. Le singleton FabEvenements offrira des services de fabrication d'événements.

La méthode pour ce faire se nommera creer() et prendra en paramètre un type d'événement (ici : son nom) et une liste de paramètres génériques.

Une méthode types() permettra d'obtenir la liste des types d'événements supportés par la fabrique. Il est attendu qu'en situation normale de développement, les types d'événements disponibles seront connus a priori des développeurs. Toutefois, pouvoir obtenir cette information de manière dynamique peut faciliter le développement d'éditeurs visuels et d'autres outils sympathiques.

Une stratégie semblable pourrait être appliquée à Occurrence pour être en mesure de découvrir les types des données attendues en tant que paramètres (de même que le nom ou l'ordre des paramètres).

Vous remarquerez que FabEvenements tient à jour, dans un tableau associatif, une liste de paires, chacune des paires en question étant faite d'un nom et d'un pointeur sur un objet de fabrication. Le constructeur du singleton insérera dans cette liste un objet de fabrication pour chaque type d'événement supporté. Ceci permettra une fabrication d'événements très efficace à l'exécution.

Remarquez la paire de méthodes nommées creer() et creer_impl() dans Fabrique. La méthode publique est concrète alors que la protégée est polymorphique. Cet idiome est souvent nommé idiome NVI.

La version concrète contrôle les caractéristiques globales de tout appel pour tout enfant (ici, le recours à un paramètre par défaut si aucun paramètre n'est fourni àl'appel) alors que la seconde permet aux enfants de raffiner le comportement (ici abstrait) du parent.

// ...
#include "Incopiable.h"
#include "Parametre.h"
#include <mutex>
#include <string>
#include <map>
#include <vector>
#include <memory>
class FabEvenements {
public:
   using type_t = string;
   struct Fabrique {
      unique_ptr<Occurrence>
         creer(const Params &p = {}) {
         return creer_impl(p);
      }
      virtual ~Fabrique() = default;
   protected:
      virtual unique_ptr<Occurrence>
         creer_impl(const Params &) = 0;
   };
private:
   map<type_t, unique_ptr<Fabrique>> fabriques_;
   FabriqueEvenements();
   std::mutex mutex_;
public:
   class TypeInconnu {};
   vector<type_t> types() const;
   static FabEvenements &get() {
      static FabEvenements singleton;
      return singleton;
   }
   unique_ptr<Occurrence> creer(const type_t&, const Params& = {});
};
#endif

Un exemple concret d'événement irait comme suit. Imaginons la classe EvenementMoment, dont chaque instance représente un événement susceptible de se produire une fois à partir d'un instant précis dans l'exécution d'un programme. Dans notre exemple, le moment de provocation d'un tel événement sera lié au passage du temps réel tel que donné par time(), mais dans la plupart des systèmes on privilégiera un examen du temps système, qui a un sens local[11].

Avec cette implémentation un peu naïve, attention aux débordements dans un time_t.

Si vous examinez la méthode reagir(), vous constaterez que celle-ci fait du bruit puis marque l'objet comme étant expiré (ce qui devrait faire en sorte de l'éliminer du système).

Une question pertinente de design logiciel à ce stade serait : est-il possible de découpler le concept d'événement devant se produire à un moment précis et celui d'action à poser lorsque l'événement se sera produit? La réponse, évidemment, est oui, et repose sur l'inclusion d'un pointeur d'acteur dans un EvenementMoment.

#include "Occurrence.h"
#include <mutex>
#include <ctime>
#include <iostream>
// using...
class EvenementMoment : public Occurrence {
   time_t moment_;
public:
   EvenementMoment(time_t moment) : moment_{moment} {
   }
   time_t moment() const
      { return moment_; }
   void reagir() {
      if(time(nullptr) >= moment()) {
         cout << "\a\a\a";
         expiration();
      }
   }
};
// ...

Pour chaque catégorie d'événement, il faudra une paire faite d'une fabrique et d'un nom.

Une fabrique est une classe simple exposant une spécialisation de la méthode générique de fabrication et exploitant le mécanisme générique de paramètres sur lequel nous travaillons dans cette section.

L'exemple à droite extrait le paramètre nommé "Quand" dans une variable de type time_t et instancie un EvenementMoment à partir de cette valeur. Il est entendu ici que le créateur de l'événement doit connaître les noms et les types des paramètres requis pour instancier l'événement souhaité.

// ...
class FabTemps : public FabEvenements::Fabrique {
   unique_ptr<Occurrence> creer_impl(const Params &p) {
      Param paramMoment = p.get("Quand");
      time_t quand;
      paramMoment >> quand;
      return make_unique<EvenementMoment>(quand);
   }
};
// ...

La généricité, en ce sens, a ses limites : comme dans toute tâche de programmation, il est nécessaire (du moins pour être efficace) que le code client sache parler au code serveur et que le client connaisse les types et les noms des paramètres lors d'une invocation de méthode ou lors de la création d'un objet.

L'instanciation du singleton servant de fabrique d'événements peut, en général, se limiter à l'instanciation successive des diverses fabriques individuelles, une par type d'événement.  Notre exemple utilise pour ce faire un tableau associatif, mais d'autres stratagèmes tout aussi valides seraient envisageables.

// ...
FabEvenements::FabEvenements() {
   fabriques_["EvenementTemps"] = make_unique<FabTemps>();
}
// ...

Si le nom des types d'événements doit servir de clé, alors il peut être utile d'offrir un service listant ces noms, quand bien même ce ne serait que pour concevoir des outils de développement. Plusieurs stratégies sont possibles pour mettre en place un tel service.

Si nous présumons que le service ne sera que peu utilisé en période de pointe, alors il est possible de construire sur demande un vecteur de noms à partir des noms servant effectivement en tant que clés dans le tableau associatif et d'en retourner une copie au sous-programme appelant.

L'exemple de code proposé à droite réalise cette tâche. Il accumule dans un vecteur le « nom » de chaque paramètre. Il repose sur la syntaxe unifiée de fonctions de C++ 11 pour une écriture allégée.

// ...
#include <algorithm>
#include <vector>
auto FabEvenements::types() const -> vector<type_t> {
   lock_guard<mutex> av {mutex_};
   vector<type_t> v;
   FabEvenements &temp(
      const_cast<FabEvenements&>(*this)
   );
   for(const auto &fab : fabriques_)
      v.push_back(fab->first);
   return v;
}
// ...

Enfin, il nous reste à explorer la méthode de fabrication d'événements sur demande.

Cette tâche est très simple quand les structures ci-dessus sont en place : une fois un test réalisé pour s'assurer que le type demandé soit bien un type répertorié, la création se fait par une simple délégation vers la fabrique la plus appropriée.

Une fois le logiciel testé à fond, si la performance importe, le test sur le type pourrait même disparaître.

// ...
unique_ptr<Occurrence> FabriqueEvenements::creer(const type_t type, const Params &p) {
   lock_guard<mutex> av(mutex_);
   if (fabriques_.find(type) == end(fabriques_))
      throw TypeInconnu{};
   return fabriques_[type]->creer(p);
}

Un exemple de programme de test simpliste suit.

int main() {
   auto v = FabEvenements::get().types();
   // obtenir les noms des types d'événements inscrits
   for(const auto &type : v)
      cout << "Type: " << type << endl;
   // creer un événement
   auto p = FabEvenements::get().creer(
      "EvenementTemps", Params("Quand", Param(time(nullptr)+2))
   );
   // le provoquer jusqu'à épuisement
   while(!p->est_expire()) p->reagir();
}

Lectures complémentaires

Il existe des tas d'approches à la sérialisation. Vous pouvez entre autres jeter un coup d'oeil à http://www.codeproject.com/Articles/225988/A-practical-guide-to-Cplusplus-serialization tiré du Code Project et qui utilise la bilbiothèque Boost.

Ce texte de 2014 par Mark Nelson propose une approche générique aux entrées/ sorties de bits, ce qui peut être utile entre autres pour résoudre des problèmes de compression de données : http://www.drdobbs.com/cpp/bit-oriented-io-with-templates/240168766

Texte de Martin C. Martic en 2015 sur les difficultés inhérentes à la sérialisation binaire avec C++ : http://martincmartin.com/2015/02/02/writing-to-a-binary-stream-in-cc-harder-than-it-should-be/

Survol très bref de quelques enjeux la sérialisation avec C++, par Tony Da Silva en 2015 : http://bulldozer00.com/2015/07/27/is-it-safe-2/

Sérialisation en C++, technique s'appuyant sur une forme de réfléxivité dynamique, texte du Code Project par Phillip Voyle en 2015 : http://www.codeproject.com/Tips/1000274/Can-You-Serialize-Objects-to-and-from-Cplusplus

En 2016, Sergey Ignatchenko et Dmytro Ivanchykhin proposent une technique de sérialisation qu'ils qualifient d'ultra-rapide : https://accu.org/index.php/journals/2317#[NoBugs15


[1] Donc nous ne voulons pas savoir quels attributs, dans quel ordre, avec ou sans espace vide entre les attributs pour accélérer l'accès aux données alignées sur la taille d'un mot mémoire, etc.

[2] En effet, un enfant ne veut pas connaître (ou dépendre de) l'organisation interne des états de ses parents, non plus que l'ordre selon lequel chacun apparaît en lui. Ce serait à la fois un bris d'encapsulation et un solide cauchemar d'entretien de code.

[3] L'encapsulation, par nature, fait en sorte que d'exiger que l'objet reconstitué soit identique à l'objet originalement sérialisé soit superflu.

[4] Nous aurions pu utiliser n'importe quel caractère d'espacement ici, incluant des tabulations et des sauts de ligne. Cela dit, l'usage est de laisser le sous-programme réalisant la sérialisation décider de la forme générale des entités une fois sérialisées (qui va sur quelle ligne? Y aura-t-il de l'indentation?). Chaque entité se sérialisera habituellement elle-même de la manière la plus simple possible.

return os << serialiser(os, b.nom())
          << b.vie();

[5] Évitez donc quelque chose comme le code proposé à droite car même si cela compilerait sans peine, l'ordre d'invocation des opérateurs pourrait vous causer des surprises.

[6] Par exemple pour couvrir le cas des unsigned char, utile pour fins d'interopérabilité avec COM ou CORBA, ou pour utiliser nos propres classes se comportant comme des caractères.

[7] Une fonction globale ferait aussi l'affaire.

[8] Un objet doit savoir se sérialiser, mais l'insertion d'information de contrôle autour de la forme sérialisée est un problème de protocole, qui peut changer d'une application à l'autre sans que les objets n'aient à le savoir. On parle donc d'une application de programmation générique ou de programmation orientée aspect (POA).

[9] Notez le verbe : on pourrait, pas on devrait. Il existe plusieurs manières de gérer la dynamique d'un système, et il n'est pas clair qu'une gestion réactive soit souhaitable. Les événements nous serviront ici d'illustration pour une stratégie permettant de construire des objets à partir de paramètres génériques; d'autres illustrations auraient pu être envisagées.

[10] Nous prenons une approche OO ici, ce qui implique que plutôt que de provoquer les événements, ceux-ci se provoquent en fonction de critères qui leur sont propres.

[11] Au plus simple, un objet autonome encapsulé dans un singleton, qui garde à jour dans un attribut une valeur (probablement entière) représentant le moment présent, et permettant une consultation synchronisée de cette valeur.


Valid XHTML 1.0 Transitional

CSS Valide !