La sérialisation binaire en C++
Bienvenue sur cet article expliquant la sérialisation binaire, ou comment représenter des informations vers une représentation binaire que tous les ordinateurs peuvent comprendre.
Cet article est un extrait d'un cours que j'ai donné en école de jeu vidéo.
La sérialisation textuelle
Vous avez très probablement déjà rencontré la sérialisation sous sa forme la plus courante aujourd'hui, la sérialisation textuelle.
Non ? Et si je vous dit JSon ou XML ?

Le JSon est une façon très courante aujourd'hui de sérialiser des données, c'est-à-dire transposer des données de travail (que notre code utilise) vers une représentation que nous pouvons sauvegarder ou même partager à un autre ordinateur.
Pourquoi sérialiser ?
Dans l'extrême majorité des langages que nous utilisons, et le C++ ne fait pas exception, les types et structures de données que nous utilisons ne sont pas portables, ça veut concrètement dire que la façon dont un entier est représenté en mémoire, même dans ce simple code :
int x = 42;
ne sera pas la même sur tous les ordinateurs, par exemple la majorité de nos machines représentera ça en utilisant quatre octets, mais sur micro-contrôleur (ex: Arduino) possiblement seulement deux.
De la même façon, les structures ne sont pas garanties d'être représentées de la même façon :
struct Foo
{
int x;
char y;
short z;
};
Ici la seule chose garantie par la norme C++ est que les champs x, y et z sont dans le même ordre en mémoire, mais combien d'octets sont utilisés pour x ? Et est-ce que les champs se suivent juste en mémoire ? Un simple tour dans VS nous montre que c'est plus compliqué :

Ici on voit qu'il y a un octet que nous n'avons pas demandé entre y et z, il sert à garantir que z soit aligné en mémoire par rapport à son type. Nos ordinateurs préfèrent[1] que les types qu'il doit manipuler aient une adresse mémoire multiple de leur alignement. Pour garantir ceci notre compilateur va rajouter un membre de padding dans notre structure.
struct Foo
{
int x;
char y;
char _padding; // on ne l'a pas demandé et on ne peut pas l'utiliser, mais il est là
short z;
};
Ces octets de padding peuvent être introduits entre les membres de nos structures selon des règles dépendant du compilateur et de la plateforme.
Mais cela signifie qu'à moins d'y faire particulièrement attention[2], nous ne savons pas comment nos structures sont représentées en mémoire et cela change d'un ordinateur à l'autre.
Ce qui est particulièrement embêtant si on souhaite sauvegarder nos informations sur le disque pour les relire dans un autre programme, ou les envoyer à un autre ordinateur dans le cadre du réseau.
La solution est donc de sérialiser, convertir notre structure d'une façon qui soit la même peu importe la machine, au moment de la lecture/l'écriture ou de l'envoi à un autre ordinateur.
Dans le cadre du réseau dans le jeu vidéo où on fait particulièrement attention à la bande passante et les performances, on sérialisera en binaire.
La sérialisation binaire
La base 2
Lorsque nous représentons et calculons des nombres, nous utilisons dix chiffres (0, 1, 2, 3, 4, 5, 6, 7, 8, 9), on parle alors de base 10.
Votre ordinateur est conçu autour de ce qu'on appelle couramment le "binaire", ou la base 2. Cela signifie que pour lui, tout n'est qu'une suite de 0 et de 1 (bits).
Par exemple, le chiffre 7 s'écrit 111 en binaire.
Pour bien comprendre comment cela peut représenter un nombre, prenons 137 en base 10.
1 3 7
| | |--- sept fois 10^0 (= 1) => 7
| |----- trois fois 10^1 (= 10) => 30
|------- une fois 10^2 (= 100) => 100
La valeur du nombre est donc 1 * 100 + 3 * 10 + 7 * 1 = 137.
Maintenant, voyons ce même nombre mais écrit en base 2 :
1 0 0 0 1 0 0 1
| | | | |-- une fois 2^0 (= 1)
| | | |---- zéro fois 2^1 (= 2)
| | |------ zéro fois 2^2 (= 4)
| |-------- une fois 2^3 (= 8)
|
|---------------- une fois 2^7 (= 128)
La valeur du nombre est donc 1 * 128 + 1 * 8 + 1 * 1 = 137.
D'autres "bases" sont couramment utilisées comme par exemple l'hexadécimal qui utilise seize chiffres (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f) et qui est souvent utilisé pour représenter certaines valeurs (comme les pointeurs), grâce à une propriété intéressante : il suffit de deux chiffres hexadécimaux pour représenter un octet.
Les bits s'écrivent donc en partant de celui ayant le plus d'impact vers celui ayant le moins d'impact, on parle alors de bits de poids fort et de bits de poids faibles, il s'agit des bits qui affectent le plus/moins un nombre.
Pour l'octet plus haut, changer le bit le plus à gauche (= poids fort) de 1 à 0 affectera le nombre bien plus que changer son bit le plus à droite (= poids faible).
Le bit de signe
Contrairement à nous qui utilisons le signe - pour indiquer qu'un nombre est négatif, votre ordinateur gère les nombres négatifs à l'aide d'un "bit de signe", cela signifie que le bit de poids le plus fort change de sens et indique si le nombre est positif (0) ou négatif (1). Il affecte aussi le calcul de la valeur avec le complément à deux.
Comment un ordinateur représente les nombres
Votre ordinateur utilise donc des bits pour représenter les nombres.
Ces bits sont regroupés par ensemble de 8 (bytes, ou octets en français), il s'agit de l'unité élémentaire que votre processeur est conçu pour manipuler.
Les nombres que vous utilisez au quotidien sont conçus de sorte à utiliser un certain nombre d'octets, par exemple un int fait quatre octets, c'est à dire 32 bits (quatre fois huit).
Lorsque vous écrivez un nombre dans votre code source celui-ci est converti automatiquement vers sa représentation en binaire, la seule que votre ordinateur sait manipuler.
Une conversion vers la base 10 est faite au moment où vous souhaitez afficher ce nombre car c'est la base à laquelle nous sommes habitués.
Les types de nombres en C++
En C++ les différents types existants pour représenter les nombres sont les suivants :
char: au moins un octet, range classique de -128 à 127.short: au moins deux octets, range classique de -32 768 à 32 767 (+- ~32 milliers)int: au moins deux octets (quatre en pratique), range classique de -2 147 483 648 à 2 147 483 647 (+- ~2 milliards)long: au moins quatre octets, range classique de -2 147 483 648 à 2 147 483 647 (+- ~2 milliards)long long: au moins huit octets, range classique de -9 223 372 036 854 775 808 à 9 223 372 036 854 775 807 (+- ~9 trillions)
Il existe aussi leur variante "non-signée", qui n'ont pas de bit de signe (qui leur permet donc pour stocker un nombre plus grand) :
unsigned char: au moins un octet, range classique de 0 à 255.unsigned short: au moins deux octets, range classique de 0 à 65 535 (~65 milliers)unsigned int: au moins deux octets (quatre en pratique), range classique de 0 à 4 294 967 295 (~4 milliards)unsigned long: au moins quatre octets, range classique de 0 à 4 294 967 295 (~4 milliards)unsigned long long: au moins huit octets, range classique de 0 à 18 446 744 073 709 551 615 (~18 trillions)
Notez que je précise à chaque fois "au moins" car en C++ la taille des types n'est pas figée, il n'y a que des contraintes sur la taille minimale.
Les types vus jusqu'ici sont pour gérer les entiers, mais il existe aussi trois types pour gérer les nombres flottants, ou nombres à virgule.
float: quatre octets, possède une précision d'environ sept chiffres.double: huit octets, possède une précision d'environ quinze chiffres.long double: type à taille variable selon les implémentations, aujourd'hui soit dix octets soit équivalent à double (il est hautement déconseillé d'utiliser ce type aujourd'hui).
Théoriquement le standard C++ ne garanti pas non plus leur taille ni leur représentation mais en pratique tous les ordinateurs suivent la norme IEEE 754.
Les flottants ont une représentation bien spécifique qu'il n'est pas utile de comprendre ici (mais si vous êtes curieux/curieuse), en revanche leur taille est importante.
L'opérateur sizeof permet de récupérer la taille d'un type à la compilation (sizeof(float) == 4).
Les types à taille fixe
Il existe des alias standards permettant d'obtenir des types avec une taille précise :
std::int32_t: Entier 32bits signé (range de -2147483648 à 2147483647)std::uint16_t: Entier 16bits non-signé (range de 0 à 65535)
etc.
Ces alias existent en version signée et non-signée pour les tailles 8, 16, 32 et 64 et sont trouvables dans le header <cstdint>.
Les alias pour les flottants ne sont arrivés qu'en C++23, en cas (probable) d'utilisation d'une norme inférieure nous partirons du principe que la taille d'un float et celle d'un double sont fixes (respectivement quatre et huit octets).
Les chaînes de caractères
En C++, les caractères sont stockés sur des char, qui malgré leur nom sont bien des entiers (de la taille d'un seul octet).
Votre ordinateur utilise une table d'association (par exemple la fameuse table ASCII, et de façon plus moderne l'unicode) qui permet d'associer une valeur à un caractère.
Par exemple la chaîne "SubT3Pls" s'écrit numériquement 83, 117, 98, 84, 51, 80, 108, 115 avec en général un zéro terminal (un octet à zéro permettant d'identifier la fin de la chaîne de caractères).
Cela signifie donc que les codes
char str[] = "SubT3Pls";
et
char str[] = {83, 117, 98, 84, 51, 80, 108, 115, 0};
sont totalement équivalents (exemple).
Comment sérialiser en binaire
Lorsqu'on désire sérialiser une structure en binaire, ce qu'on va faire c'est traiter chaque champ de cette structure individuellement : on décompose la structure vers des nombres entiers ou flottants (tout peut s'y résumer) et l'enregistrer vers un tableau d'octets.
Pour représenter un tableau d'octets en C++, on peut utiliser std::vector<std::uint8_t> byteArray; (tableau d'octets).
La structure suivante :
struct PlayerData
{
int health; // valeur entre 0 et 150
int shield; // valeur entre 0 et 4500
float posX;
float posY;
};
sera donc sérialisée en copiant successivement chaque champ vers un tableau d'octet.
Cela peut se faire avec la fonction std::memcpy, celle-ci copie la quantité d'octets voulue d'un emplacement mémoire à un autre. C'est une fonction très puissante mais aussi assez dangereuse.
std::memcpy(void* dest, const void* src, std::size_t)
Le premier paramètre est un pointeur (= adresse mémoire) vers la destination, "où est-ce que l'écriture doit commencer".
Le second paramètre est également un pointeur vers la source, "où est-ce que la lecture doit commencer".
Le troisième paramètre (size_t, alias vers un type entier) est le nombre d'octets à copier.
Nous allons considérer que chaque type que nous allons manipuler est comme un petit tableau d'octets. Par exemple un int est assimilable à sizeof(int) (== 4) octets se suivant.
Si nous souhaitons donc copier le premier entier vers notre tableau d'octets, nous ferons ceci :
#include <cstring>
#include <vector>
struct PlayerData
{
int health; // valeur entre 0 et 150
int shield; // valeur entre 0 et 4500
float posX;
float posY;
};
int main()
{
PlayerData playerData = { 100, 0, 3.1415f, 50.f };
std::vector<std::uint8_t> byteArray;
byteArray.resize(sizeof(int)); // on s'assure que le vector ait suffisamment de place pour y copier les données (important!)
// On copie les quatre octets composant health vers le début de notre tableau d'octets
// Rappel : l'opérateur & permet de prendre l'adresse (= pointeur) vers une variable ou une case de tableau
std::memcpy(&byteArray[0], &playerData.health, sizeof(int));
}
Pourquoi ne pas copier toute la structure ?
On peut se poser la question de pourquoi ne pas copier la structure directement, en effet il pourrait être tentant de juste écrire
std::vector<std::uint8_t> byteArray;
byteArray.resize(sizeof(PlayerData));
std::memcpy(&byteArray[0], &playerData, sizeof(PlayerData));
pour ne pas devoir traiter chaque champ successivement. Cependant les structures sont adaptées à un fonctionnement sur notre ordinateur, les champs ne se suivent pas forcément en mémoire.
Comme vu en introduction, le compilateur va faire en sorte que chaque champ soit aligné selon les besoins de son type (que l'adresse d'un int soit toujours multiple de sizeof(int)), et pour cela il va ajouter du padding entre les membres, des octets "inutiles", et la façon dont il va faire cela peut varier en fonction des compilateurs.
De plus, cela ne fonctionnerait pas avec certains types plus complexes comme les objets ou simplement std::string.
Gérer le champ suivant
Pour copier le champ suivant il suffit de s'assurer que le vector soit assez grand, soit en prévoyant à l'avance, soit en le redimensionnant au besoin.
Par exemple nous pouvons faire ceci :
std::vector<std::uint8_t> byteArray;
byteArray.resize(sizeof(int) + sizeof(int));
std::memcpy(&byteArray[0], &playerData.health, sizeof(int));
// On copie le second champ juste après la taille prise par le premier
// (sizeof(int) renvoyant 4, et le premier champ prenant les cases de 0 à 3
// , la case [4] sera la première pouvant accueillir notre second champ).
std::memcpy(&byteArray[sizeof(int)], &playerData.shield, sizeof(int));
mais nous pouvons aussi l'écrire de cette façon :
std::vector<std::uint8_t> byteArray;
// Variable représentant notre prochaine case d'écriture
std::size_t offset = 0;
// on redimensionne le tableau pour accueillir un int à la fin
byteArray.resize(offset + sizeof(int));
// On effectue la copie sur la case d'écriture
std::memcpy(&byteArray[offset], &playerData.health, sizeof(int));
// on déplace l'offset d'écriture de la taille que nous venons d'écrire.
offset += sizeof(int);
Ce code parait plus complexe mais il possède un avantage non-négligeable : il n'y a pas besoin de préparer le tableau à l'avance, nous rajoutons juste un entier à la fin de celui-ci.
Nous pouvons en faire une fonction facilement :
std::size_t Serialize_int(std::vector<std::uint8_t>& byteArray, int value)
{
// Comme nous souhaitons ajouter la valeur à la fin du tableau, notre curseur est le nombre de cases
// avant de changer sa taille
std::size_t offset = byteArray.size();
byteArray.resize(offset + sizeof(int));
// On effectue la copie sur la case d'écriture
std::memcpy(&byteArray[offset], &value, sizeof(int));
// On retourne l'offset où se trouve la valeur (peut servir dans certains cas)
return offset;
}
Cela nous permet de simplifier notre code drastiquement, qui devient alors :
std::vector<std::uint8_t> byteArray;
Serialize_int(byteArray, playerData.health);
Serialize_int(byteArray, playerData.shield);
Pour gérer les deux champs suivant, des flottants, nous pouvons adapter la fonction très facilement :
std::size_t Serialize_float(std::vector<std::uint8_t>& byteArray, float value)
{
// Comme nous souhaitons ajouter la valeur à la fin du tableau, notre curseur est le nombre de cases
// avant de changer sa taille
std::size_t offset = byteArray.size();
byteArray.resize(offset + sizeof(float));
// On effectue la copie sur la case d'écriture
std::memcpy(&byteArray[offset], &value, sizeof(float));
// On retourne l'offset où se trouve la valeur (peut servir dans certains cas)
return offset;
}
La seule différence étant au niveau du paramètre et des sizeof.
Et nous pouvons alors sérialiser notre structure entièrement de cette façon :
std::vector<std::uint8_t> byteArray;
Serialize_int(byteArray, playerData.health);
Serialize_int(byteArray, playerData.shield);
Serialize_float(byteArray, playerData.posX);
Serialize_float(byteArray, playerData.posY);
Choisir un type à taille fixe
Afin de maximiser la portabilité de notre application, c'est-à-dire faire en sorte que d'autres ordinateurs (ou consoles/téléphones) que le nôtre puissent déserialiser notre byteArray, il nous faut choisir des types à taille fixe.
En effet, comme expliqué plus haut, en C++ les types natifs int, long, etc. ont une taille qui peut varier selon le système d'exploitation ou même le compilateur utilisé. Par exemple sur Arduino, un int fait deux octets, et la taille d'un long n'est pas la même entre Windows (4 octets) et Linux (8 octets).
Pour palier ces problèmes, nous choisirons un type à taille fixe (= dont la taille ne varie pas en fonction de la plateforme), comme std::int32_t, std::uint8_t, etc.
std::size_t Serialize_u16(std::vector<std::uint8_t>& byteArray, std::uint16_t value)
{
std::size_t offset = byteArray.size();
byteArray.resize(offset + sizeof(value));
std::memcpy(&byteArray[offset], &value, sizeof(value));
return offset;
}
En plus de ça, nous pouvons optimiser la bande passante en choisissant un type plus petit que celui de l'application mais capable de représenter toutes les valeurs nécessaires.
Dans l'exemple ci-dessus, le champ health est stocké sur un int mais il ne stocke que des valeurs entre 0 et 150 (cela dépend évidemment de l'application et peut être amené à évoluer). Le type le plus petit pour représenter toutes ses valeurs est std::uint8_t, qui n'occupe qu'un seul octet et permet donc une réduction de 75% de l'espace utilisé pour le sérialiser.
La fonction pour sérialiser un uint8 est beaucoup plus simple car nous manipulons déjà un tableau de ce même type.
std::size_t Serialize_u8(std::vector<std::uint8_t>& byteArray, std::uint8_t value)
{
std::size_t offset = byteArray.size();
byteArray.push_back(value);
return offset;
}
Et les entiers signés ?
Un entier signé et un entier-non signé de même taille ont la même représentation binaire, et ne sont en réalité différents qu'au moment de l'utilisation / affichage.
Ainsi nous pouvons sérialiser un entier signé (et même avec le même code) que pour un entier non-signé.
std::size_t Serialize_i32(std::vector<std::uint8_t>& byteArray, std::int32_t value)
{
std::size_t offset = byteArray.size();
byteArray.resize(offset + sizeof(value));
std::memcpy(&byteArray[offset], &value, sizeof(value));
return offset;
}
Le problème de l'endianness
Si tous les processeurs sont d'accord sur la façon dont on représente un octet/byte (et estimez-vous heureux il a existé une époque où le nombre de bits par byte pouvait varier), ils ne sont en revanche pas d'accord sur l'ordre dans lequel ceux-ci se suivent en mémoire.
Si on prend un entier 16bits valant 256, celui-ci se représente pour nous de la façon suivante :
0000 0001 0000 0000.
L'octet de poids fort (celui qui a le plus d'impact sur la valeur du nombre) est en premier, suivi de l'octet de poids faible.
Il en va de même pour notre propre représentation des nombres en base 10, le chiffre le plus fort est celui le plus à gauche (dans 1000002, le 1 du million a plus d'impact que le 2 des unités).
Si on se met à considérer un entier comme une suite d'octets dans l'ordinateur (ce que nous faisons avec memcpy), nous nous rendons compte qu'il existe des CPUs qui vont :
- Placer l'octet de poids le plus fort en premier dans la mémoire (on parle d'architecture big endian).
256est représenté0000 0001 0000 0000 - Placer l'octet de poids le plus faible en premier dans la mémoire (on parle d'architecture little endian).
256est représenté0000 0000 0000 0001
Attention que ça n'a aucun impact sur les calculs que nous effectuons, il s'agit là d'un pur détail technique, même les opérateurs de bit shifting (<</>>) dont je parle après dans ce document ne sont pas affectés et se comportement comme si les nombres étaient écrits "naturellement".
Pour régler ces problèmes, nous allons choisir d'écrire nos nombres vers un endianness arbitraire, little ou big, et convertir à la volée à la sérialisation et déserialisation s'il ne correspond pas à celui de notre machine.
La convention du réseau est d'écrire les nombres en big endian (même si en pratique vous faites ce que vous voulez).
L'API socket met à notre disposition la famille de fonctions de conversion hton (host to network), convertissant des nombres de l'endianness de votre machine (host) vers la convention du réseau (network), le big endian.
Il existe donc :
htons(s pour short = entier 16bits pour l'API socket)htonl(l pour long = entier 32bits pour l'API socket)htonll(ll pour long long = entier 64bits pour l'API socket)htonf(f pour float), extension de WinSock, renvoie un uint32_t (!)htond(d pour double), extension de WinSock, renvoie un uint64_t (!)
Sur un ordinateur little endian (processeur AMD/Intel), un appel à htons(256) inverserait les octets et renverrait 1 (0000 0000 0000 0001 -> 0000 0001 0000 0000), cette valeur inversée n'a aucune importance, il faut juste comprendre que c'est le nombre équivalents dont on a inversé l'ordre des octets.
Sur un ordinateur big endian, ces fonctions ne font rien du tout et renvoient la valeur inchangée, l'intérêt étant de pouvoir compiler le même code source sans se soucier de l'endianness sous-jacent.
Pour repasser de la convention du réseau à notre machine, nous pouvons utiliser la famille de fonctions ntoh (network to host). Celles-ci sont équivalentes à hton (excepté pour les variantes flottantes qui prennent alors des entiers).
On retrouve :
ntohs(s pour short = entier 16bits pour l'API socket)ntohl(l pour long = entier 32bits pour l'API socket)ntohll(ll pour long long = entier 64bits pour l'API socket)ntohf(f pour float), extension de WinSock, prend un uint32_t (!)ntohd(d pour double), extension de WinSock, prend un uint64_t (!)
Adapter nos fonctions de sérialisation
Nous devons donc adapter les fonctions de sérialisation des entiers de plus d'un octet (toutes sauf i8/u8).
Par exemple :
std::size_t Serialize_i32(std::vector<std::uint8_t>& byteArray, std::int32_t value)
{
value = htonl(value); // Conversion vers le big endian
// Sérialisation classique
std::size_t offset = byteArray.size();
byteArray.resize(offset + sizeof(value));
std::memcpy(&byteArray[offset], &value, sizeof(value));
return offset;
}
Comment déserialiser en binaire ?
Une fois les données sérialisées et transmises sur le réseau, nous recevons un tableau d'octets contenant les informations que nous souhaitons décoder.
Cela ne se fait pas à l'aveugle, à cette étape nous savons comment sont encodées les données, nous devons juste les extraires.
Pour ce faire, on procédera à une copie des octets qui nous intéressent depuis le byte array vers le type cible et réinversons si l'endianness ne correspond pas.
Par exemple :
std::uint16_t Deserialize_u16(const std::vector<std::uint8_t>& byteArray, std::size_t& offset)
{
// On déclare la variable cible
std::uint16_t value;
// On copie les octets depuis le byte array vers notre variable
std::memcpy(&value, &byteArray[offset], sizeof(value));
// On inverse à nouveau l'ordre des octets en cas de besoin (dans le byte array le nombre est en big endian, il faut donc à nouveau inverser si notre machine est en little endian)
value = ntohs(value);
// On incrémente l'offset de la taille que nous venons de lire
offset += sizeof(value);
return value;
}
en passant l'offset par référence, nous avons un "index de lecture" qui avance au fur et à mesure de ce que nous décodons.
En faisant toutes les fonctions de déserialisation, nous pouvons alors écrire :
std::vector<std::uint8_t> byteArray;
// Sérialisation (faite par un ordinateur avant d'envoyer sur le réseau)
Serialize_u8(byteArray, playerData.health);
Serialize_u16(byteArray, playerData.shield);
Serialize_float(byteArray, playerData.posX);
Serialize_float(byteArray, playerData.posY);
// Déserialisation
PlayerData playerData;
std::size_t offset = 0;
// Il est important d'appeler les fonctions de déserialisation dans le même ordre (et avec les mêmes types) que la sérialisation
playerData.health = Deserialize_u8(byteArray, offset);
playerData.shield = Deserialize_u16(byteArray, offset);
playerData.posX = Deserialize_float(byteArray, offset);
playerData.posY = Deserialize_float(byteArray, offset);
Et voilà !
Gérer les tableaux
Lorsque nous souhaitons sérialiser un tableau, c'est-à-dire un nombre d'éléments qui peut varier, il suffit de sérialiser chaque élément à la suite.
Par exemple, si notre joueur possède un inventaire où chaque objet est identifié par un nombre :
struct PlayerData
{
int health; // valeur entre 0 et 150
int shield; // valeur entre 0 et 4500
float posX;
float posY;
std::vector<int> inventory; // object id entre -1 et 127
};
Nous pouvons le sérialiser en écrivant chaque entier à la suite :
std::size_t Serialize_PlayerData(std::vector<std::uint8_t>& byteArray, const PlayerData& playerData)
{
std::size_t offset = byteArray.size();
// Sérialisation des champs déjà vus
Serialize_u8(byteArray, playerData.health);
Serialize_u16(byteArray, playerData.shield);
Serialize_float(byteArray, playerData.posX);
Serialize_float(byteArray, playerData.posY);
// Écriture de l'inventaire
for (int objectId : playerData.inventory)
Serialize_i8(byteArray, objectId); // i8 car type le plus petit pouvant représenter notre range de valeur
return offset;
}
Le problème qui va alors se poser lors de la déserialisation est le suivant :
PlayerData Deserialize_PlayerData(std::vector<std::uint8_t>& byteArray, std::size_t& offset)
{
PlayerData playerData;
// Désérialisation des champs déjà vus
playerData.health = Deserialize_u8(byteArray, offset);
playerData.shield = Deserialize_u16(byteArray, offset);
playerData.posX = Deserialize_float(byteArray, offset);
playerData.posY = Deserialize_float(byteArray, offset);
// Lecture de l'inventaire
// Combien d'entiers devons-nous déserialiser ?
return playerData;
}
Il nous faut donc rajouter la taille de l'inventaire nous-même avant celui-ci, lors de la sérialisation :
// Écriture de l'inventaire
Serialize_u16(byteArray, playerData.inventory.size()); // u16 car notre inventaire a une capacité de 300 objets (faites comme si)
for (int objectId : playerData.inventory)
Serialize_i8(byteArray, objectId);
Ce qui nous permet alors à la déserialisation de savoir combien d'objets nous attendons :
// Lecture de l'inventaire
std::uint16_t inventorySize = Deserialize_u16(byteArray, offset);
playerData.inventory.reserve(inventorySize); // On prépare le vector a accueillir le nombre d'objets (petite optimisation)
for (std::uint16_t i = 0; i < inventorySize; ++i)
playerData.inventory.push_back(Deserialize_i8(byteArray, offset));
Gérer les chaînes de caractères
Une chaîne de caractère comme std::string n'est qu'un tableau de char, vous pouvez le traiter exactement comme un std::vector<char>, en sérialisant/désérialisant d'abord la taille et puis chaque caractère.
Nous n'avons pas besoin de nous soucier de l'encodage (ex: UTF-8) dès lors que nous le déserialisons de la même façon que nous le sérialisons.
Gérer les dictionnaires (hashmaps)
De la même façon, un dictionnaire peut être perçu comme un tableau où chaque case possède une clé et une valeur. On sérialisera donc le nombre d'éléments du dictionnaire, et pour chaque élément sa clé et sa valeur.
Les opérateurs bits à bits
En plus des opérateurs arithmétiques et logiques, il existe six opérateurs permettant d'agir directement sur les bits d'un nombre.
Ces opérateurs sont :
~a(Binary NOT) : inverse tous les bits d'un nombre.a & b(Binary AND) : effectue un ET sur les bits de chaque nombre.a | b(Binary OR) : effectue un OU sur les bits de chaque nombre.a ^ b(Binary XOR) : effectue un OU exclusif sur les bits de chaque nombre.a << b(Binary left shift) : décale les bits d'un nombre par un décalage entier.a >> b(Binary right shift) : décale les bits d'un nombre par un décalage entiers.
Par exemple, si nous écrivons
std::uint8_t a = 13; // 0000 1101
std::uint8_t b = 7; // 0000 0111
nous pouvons voir le résultat de ces opérations :
// 0000 1101
// & 0000 0111
// = 0000 0101
std::uint8_t c = a & b; // 5
// 0000 1101
// | 0000 0111
// = 0000 1111
std::uint8_t c = a | b; // 15
// 0000 1101
// ^ 0000 0111
// = 0000 1010
std::uint8_t c = a ^ b; // 10
// 0000 1101 << 3
// = 0110 1000
std::uint8_t c = a << 3;
// 0000 1101 >> 3
// = 0000 0001
std::uint8_t c = a >> 3;
Ces opérateurs sont utiles pour optimiser encore davantage la compression binaire.
Par exemple, si nous savons que notre valeur à sérialiser est entre 0 et 15, ce qui ne nécessite que quatre bits à représenter, on se retrouve avec un octet 0000 XXXX dont les quatre bits de poids fort ne seront pas utiles.
Nous pouvons utiliser ces bits libres pour stocker d'autres informations, par exemple si nous avons un entier dont la valeur est entre 0 et 3, il ne nécessite alors que deux bits pour être représenté. 0000 00YY.
Nous pouvons combiner ces deux valeurs en un seul octet grâce à deux opérations, le décalage et le OU bit à bit.
std::uint8_t byte = 7; // valeur entre 0 et 15, donc entre 0000 et 1111 en binaire
std::uint8_t b = 2; // valeur entre 0 et 3, donc entre 00 et 11 en binaire
byte = byte | (b << 4); // on décale d'abord b de quatre bits vers la gauche (0000 00XX => 00XX 0000) et on le joint avec notre octet
// Notre byte contient maintenant 00YY XXXX, où X représente les bits de la première valeur, et Y celle de la seconde valeur
De la même façon, si nous avons deux booléens à stocker en plus nous pouvons les stocker sur notre octet (un booléen n'ayant que deux états possibles, ils ne prennent qu'un bit).
bool stateA = false;
bool stateB = true;
if (stateA)
byte |= 1 << 6; // On décale 1 de six vers la gauche, donnant 0100 0000 et on le joint (a |= b est équivalent à a = a | b)
if (stateB)
byte |= 0x80; // 0x80 signifie "80 en écriture hexadécimale", ce qui en binaire représente 1000 0000, cette écriture est aussi très commune (l'hexadécimal est souvent utilisé pour écrire des constantes binaires)
Et certains même l'imposent, comme les processeurs ARM ↩︎
On peut garantir l'absence d'octets de padding via du code spécifique au compilateur, ou via un static_assert. ↩︎
Conclusion
Voilà pour cette petite introduction à la sérialisation à la main en C++. Bien sûr, ce genre de processus s'automatise aujourd'hui assez régulièrement (par exemple avec Protocol Buffers [https://protobuf.dev] qui fonctionne en générant le code de sérialisation dans plusieurs langages), néanmoins on reviendra quand même à la sérialisation manuelle pour pousser le gain en terme de place, surtout dans le domaine du réseau. Je vous renvoie vers cet article de Glenn Fielder où il explore certaines possibilités d'optimisation d'espaces.