Cartouche

Champ Valeur
Auteur·e Élise
Édition 2023-11-29
Durée 2 séances
Taille des équipes projet individuel
Rendu via git, dépôt 2023_tp_serde, droits en lecture à delivery_collector

Notation

3 points par étape.

Un rendu sale et du code qui ne respecte pas la norme peut vous faire perdre une partie ou la totalité des points acquis.

Introduction

L’objectif de ce TP c’est d’apprendre à convertir des données dans un programme en un format qui puisse être transporté ou stocké.

Vous allez découvrir les principes de sérialisation et de dé-sérialisation, ainsi que quelques problèmes que l’on peut rencontrer avec et comment s’en prémunir.

À la fin du TP vous devriez avoir une idée de comment sérialiser les données dans vos projets avec un format fait maison.

Étape 1: classe de base

Créez une classe Student avec les fichiers Students.cpp et Students.hpp. Cette classe doit avoir 3 champs :

  • prénom _first_name une chaîne ;
  • nom de famille _last_name une chaîne ;
  • année _year un int.

Écrivez le constructeur de cette classe, il doit prendre une valeur pour chacun des champs, dans cet ordre.

On devrait pouvoir instancier votre classe de la façon suivante :

int main(void)
{
	Student stu0{"Valeria", "Navigo", 1};
}

Théorie: la sérialisation c’est quoi?

Sérialiser une donnée, c’est le principe de l’écrire d’une certaine façon pour son transport ou son stockage avec pour objectif de pouvoir être restitué par le même programme plus tard, ou par quelqu’un d’autre sur une machine accessible par le réseau.

Quand on sérialise une donnée, on a pour intention de faire l’opération inverse plus tard. L’inverse de la sérialisation c’est la dé-sérialisation.

La sérialisation et les protocoles de communication sont deux notions qui vont de paire. Les protocoles de communication décrivant la façon dont les données qui les concernent doivent être présentés.

Pour ce TP on va faire de la sérialisation textuelle. C’est une forme de sérialisation qui peut facilement être lue et écrite par un humain. Elle s’oppose à la sérialisation binaire qui est bien plus compliquée à lire et écrire.

Étape 2: sérialisation de base

Sur votre classe Student ajoutez une fonction membre serialise qui prend en paramètre une référence à un flux en sortie std::ostream & et qui la référence de ce même flux.

Votre fonction doit écrire les différents champs de votre classe à l’aide des opérateurs de flux <<.

Exemple de main et de format attendu en sortie :

int main(void)
{
	Student stu0{"Valeria", "Navigo", 1};

	stu0.serialise(std::cout);
}
Valeria Navigo 1

Étape 3: dé-sérialisation de base

Faites en sorte que l’exemple suivant fonctionne :

int main(void)
{
	Student stu0{"Valeria", "Navigo", 1};
	std::stringstream ioss;

	stu0.serialise(ioss);
	Student stu1{ioss};
	if (stu1.first_name() == "Valeria"
	    && stu1.last_name() == "Navigo"
	    && stu1.year() == 1) {
		std::cout << "OK\n";
	} else {
		std::cout << "failed\n";
	}
}

Pour ce faire, vous devez ajouter un constructeur à votre classe Student qui prend en paramètre une référence sur un flux en entrée std::istream &.

Vous devez extraire les deux chaînes et le nombre entier depuis le flux.

Étape 4: affichage d’erreur sommaire

S’il n’est pas possible d’extraire quelque chose depuis un flux en entrée, il faut que vous arriviez à au moins afficher un message d’erreur.

Il est possible de vérifier si l’extraction a échoué avec un if comme ceci :

int toto;

if (!(std::cin >> toto)) {
	std::cerr << "erreur de lecture de toto" << std::endl;
}

L’extraction peut échouer à n’importe quel moment, il faut donc que vous contrôliez toutes vos opérations de flux.

Vous pouvez tester vos erreurs ainsi :

int main(void)
{
	{
		std::stringstream ioss;
		Student stu1{ioss};
	}
	{
		std::stringstream ioss("valeria");
		Student stu1{ioss};
	}
	{
		std::stringstream ioss("valeria navigo");
		Student stu1{ioss};
	}
	{
		std::stringstream ioss("valeria navigo trop cher");
		Student stu1{ioss};
	}
}

Étape 5: espace

Et si, vous aviez quelqu’un qui porte un nom composé. Du genre “Valéry Giscard d’Estaing”. Que pensez-vous qu’il va se passer avec votre programme ?

Student stu0("Valéry", "Giscard d'Estaing", 2);
stu0.serialise(ioss);
std::stringstream ioss;
Student stu1{ioss};

La dé-sérialisation ne va probablement pas pouvoir ce faire. Ce pour deux raisons :

  • les flux en C++ estiment que les mots sont finis une fois que l’on rencontre un séparateur (de base : des caractères blancs) ; et
  • lorsqu’on regarde la donné sérialisée, nous n’avons aucune façon de délimiter le nom et le prénom.

Arrive un nouvel enjeu de la sérialisation : comment traiter correctement des données utilisateurs et comment les différencier de notre grammaire.

Pour les chaînes de caractères, la solution est en réalité assez simple : stocker la taille des chaînes avant la chaîne en elle même.

Exemple pour stocker la chaîne toto tata, on pourrait la sérialiser 9:toto tata.

Au moment de la dé-serialisation, pour lire une chaîne il faudra commencer par lire sa taille, lire le séparateur puis lire la chaîne en elle-même. Il est possible de lire une quantité exacte d’octets depuis un flux et de l’enregistrer dans char * avec la fonction membre get. https://en.cppreference.com/w/cpp/io/basic_istream/get. Une fois que vous avez un char * il est facile de créer un std::string.

Étape 6: vraie gestion d’erreur

Pour que nos erreurs soient vraiment utiles, il faut que notre programme ait une façon de savoir lorsqu’elles ont eu lieu. Un message sur la sortie d’erreur ne suffisant pas. Pour ce faire, on va utiliser les mots clés try/catch/throw, dans le constructeur de la classe Student.

Au lieu d’afficher des erreurs, utilisez throw avec le type std::runtime_error, qui hérite de std::exeption. C’est un type qui représente une erreur qui ne peut que être reconnue à l’exécution et qui est probablement provoquée par un comportement de l’utilisateur ou d’un autre programme qui interagit avec le notre.

thorw std::runtime_error("Student::ctor: failed getting name.");
thorw std::runtime_error("Student::ctor: failed getting year.");

Ensuite, la fonction qui essaie de construire un Student pourra :

int gen_and_add_student_to_list(std::list<Student> stulist, std::istream &is)
{
	try {
		Student stu0{is};
		std::cout << "sucessfully built student\n";
		// do something with the object
		stulist.push_back(is);
		return (1);
	} catch (const std::runtime_error &err) {
		std::cerr << "failed to construct student";
		// choose what to do on error
		// if your program doesn't care it can continue it's work
		return (0);
	}
}

Étape 7: énumérations

Lorsque l’on veut communiquer un ordre, un état, un status, on utilise souvent des énumérations. Elles rendent le code plus simple à lire.

Dans vos jeux, l’état peut être un code qui permet de savoir à qui le tour, quelle est la phase de jeu, et si le jeu est terminé qui a gagné. On peut facilement imaginer un code qui correspond soit à :

  • tour joueur 1 ;
  • tour joueur 2 ;
  • mauvais move joueur 1, il faut rejouer ;
  • mauvais move joueur 2, il faut rejouer ;
  • victoire joueur 1 ;
  • victoire joueur 2 ;
  • égalité.

Reprenons la classe Student et ajoutons-y une énumération “Curriculum” simple :

  • MKT : pour marketing numérique
  • PRG : pour programmation informatique
  • SEC : pour sécurité informatique
enum class Curriculum : int {
	MKT = 1,
	PRG,
	SEC
};

Le problème avec les enums class, c’est qu’on ne peut pas les envoyer vers un flux pour les afficher.

On peut cependant implémenter operator<< entre un std::ostream & et un Curriculum.

En partant du principe que Curriculum a été déclarée en public dans la classe Student on peut permettre son affichage ainsi.

std::ostream &operator<<(std::ostream &os, Student::Curriculum &prog)
{
    os << static_cast<int>(prog);
    return os;
}

Cherchez maintenant comment est-ce que l’on peut faire le processus inverse en prenant en compte quelles erreurs peuvent se produire et en prenant soin de throw lorsqu’elles se produisent.

Une fois que c’est fait :

  • ajoutez un champ du type de cette énumération sur votre classe Student ;
  • ajoutez au constructeur un paramètre pour Curriculum ;
  • ajoutez Curriculum au processus de sérialisation/dé-sérialisation de votre classe.

Conclusion

Vous devriez maintenant avoir une meilleure idée du format que doivent prendre vos données pour être sauvegardées ou pour transiter à travers une socket vers un autre programme.

Votre objectif c’est de garder ces outils dans un coin, ils vous seront utiles pour vos projets en C++.

De façon générale, les concepts vus ici ne s’appliquent pas que au C++. Ils vous aident à mieux comprendre comment les bibliothèques de sérialisation fonctionnent et de quelles sont les considérations dans l’établissement d’un format.

Notre format à pour avantage d’être très léger et de ne pas demander des 100aines de lignes de code pour charger ou restituer quelques données. C’est même probablement plus rapide de le programmer nous-même que d’apprendre à ce servir de bibliothèques qui font un travail similaire.

Sur le court terme c’est parfait. Sur le long terme ou sur un projet qui se focalise moins sur la technique et plus sur la logique métier, bien-sur que nous utiliserions une bibliothèque capable de générer du JSON pour nous (ou un autre format commun et standard dans l’industrie.)