Cours programmation réseau en C++

Sérialisation avancée

Bousk

Ce chapitre est un approfondissement direct du précédent. À la fin de celui-ci, nous aurons à disposition des types pour manipuler des valeurs sur intervalle et des valeurs à virgule flottante que nous pourrons enfin sérialiser.

Commentez

- Titre : Cours programmation réseau en C++

- Auteur : Bousk

- Parution : 20 décembre 2019

- Mise à jour : 16 décembre 2019

- Public visé :

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

Sommaire

- I. Des types de valeurs à intervalle

- I-A. Déduire le type interne

- I-A-1. Pourquoi déduire un type ?

- I-A-2. Quel type déduire ?

- I-A-3. Comment déduire le type final ?

- I-A-3-a. Déduire le type le plus large

- I-A-3-b. Déduire un type plus large

- I-B. Création de RangedInteger

- I-B-1. Calcul de l’intervalle de valeurs

- I-C. Manipulations avec d’autres types

- II. Sérialisation de valeurs à virgule flottante

- II-A. Une nouvelle classe Float

- II-A-1. Le nombre de décimales

- II-A-2. Le pas

- II-A-3. Domaine de valeurs

- II-A-3-a. Simplifier le cas

- II-A-3-b. Prise en compte des décimales

- II-A-3-c. Prise en compte du pas

- II-A-3-d. Équation finale

- II-A-4. Implémentation

- II-A-4-a. Puissance

- II-A-4-b. Implémentation finale

- III. Sérialiser des types utilisateur

- III-A. Interface sérialisable

- III-B. Sérialisation de RangedInteger et Float

- III-C. Implémentation dans Serializer

- III-D. Implémentation dans Deserializer

- IV. Cas du float32

- IV-A. Fichier de configuration

- V. Sérialisation d’énumérations

- V-A. Prérequis

- V-B. Implémentations

- VI. Tests

I - Des types de valeurs à intervalle

Dans le chapitre précédent, nous avons mis en place une sérialisation pour des valeurs sur intervalles.

Pour aller plus loin, il est intéressant de posséder un type proposant des valeurs sur l’intervalle de notre choix, afin de faciliter leur sérialisation.

Un type qui décrit parfaitement que notre uint8 est valide, par exemple, uniquement sur l’intervalle [42, 128], et qui permet de gérer les erreurs le plus tôt possible si une valeur est en dehors de ces bornes : à l’assignation de la valeur plutôt qu’à la sérialisation.

Il s’agira d’une nouvelle classe qui prendra en paramètres templates les valeurs minimales et maximales valides :

RangedInteger.hpp

1. template<auto MIN, auto MAX>

2. class RangedInteger

3. {

4. static_assert(MIN < MAX, "MIN et MAX doivent être strictement ordonnées");

5. static_assert(possible, "Impossible avec ces valeurs de Min & Max ");

6. private:

7. Type mValue{ Min() };

8. };

Cette classe reposera fortement sur la métaprogrammation et les templates afin de

- définir un type interne adéquat le plus léger en mémoire ;

- compter le nombre de bits nécessaires pour sérialiser la valeur interne ;

- offrir la possibilité de vérifier qu’une valeur est comprise dans l’intervalle choisi ;

- pouvoir être manipulée comme un entier, dans une certaine mesure.

Pour pouvoir créer une telle classe, nous devons utiliser la norme C++17 et en particulier la déclaration de paramètres template auto permettant de définir le type du template à son instanciation.

Dorénavant, le moteur n’aura plus pour prérequis C++11 mais C++17.

I-A - Déduire le type interne

La déduction d’un type interne se déroulera en deux temps :

- déduire le type le plus léger pour chaque paramètre (plus approprié que le simple int généralement choisi par le compilateur) ;

- déduire un type permettant d’accueillir la valeur minimale et maximale, si possible, servant de type interne.

I-A-1 - Pourquoi déduire un type ?

Dans la majorité des cas, une écriture du type auto i = -1; définira i comme int. Et bien que ce soit suffisant en général, ce n’est pourtant pas idéal : si i pouvait être de type int8 plutôt que int, l’empreinte mémoire serait moindre et s’il est trivial d’utiliser un int8 comme int, l’inverse est moins vrai. Économiser de la mémoire quand possible minimisera l’utilisation mémoire de votre moteur réseau et laissera plus de ressources disponibles pour les autres secteurs - et en particulier les éléments graphiques de plus en plus gourmands (modèles, animations, textures… haute définition, 4K…).

I-A-2 - Quel type déduire ?

Les règles de déduction du type sont relativement subjectives. Il s’agit de trouver le plus petit type pouvant accueillir une valeur donnée, en favorisant ou non les types non signés en cas de valeur positive, puis prendre le type englobant directement la valeur : -1 deviendra un int8, 1 deviendra un int8 ou uint8, -200 deviendra un int16, etc.

Pour y parvenir, nous écrirons une série de tests réalisés à la compilation via std::conditionnal_t :

1. template<auto V, bool ForceUnsigned>

2. struct ExtractType

3. {

4. static_assert(std::numeric_limits<decltype(V)>::is_integer, "ExtractType doit être utilisé avec des entiers");

5. static_assert(!std::is_same_v<decltype(V), bool>, "ExtractType ne doit pas être utilisé avec bool");

6. using Type = std::conditional_t< V < 0

7. , std::conditional_t< V < std::numeric_limits<int32>::min(), int64

8. , std::conditional_t< V < std::numeric_limits<int16>::min(), int32

9. , std::conditional_t< V < std::numeric_limits<int8>::min(), int16

10. , int8

11. >>>

12. // > 0 : doit-on forcer un type non signé ?

13. , std::conditional_t< (V > std::numeric_limits<int64>::max()), uint64

14. , std::conditional_t< (V > std::numeric_limits<uint32>::max()), std::conditional_t<ForceUnsigned, uint64, int64>

15. , std::conditional_t< (V > std::numeric_limits<int32>::max()), std::conditional_t<ForceUnsigned, uint32, int64>

16. , std::conditional_t< (V > std::numeric_limits<uint16>::max()), std::conditional_t<ForceUnsigned, uint32, int32>

17. , std::conditional_t< (V > std::numeric_limits<int16>::max()), std::conditional_t<ForceUnsigned, uint16, int32>

18. , std::conditional_t< (V > std::numeric_limits<uint8>::max()), std::conditional_t<ForceUnsigned, uint16, int16>

19. , std::conditional_t< (V > std::numeric_limits<int8>::max()), std::conditional_t<ForceUnsigned, uint8, int16>

20. , std::conditional_t<ForceUnsigned, uint8, int8>

21. >>>>>>>

22. >;

23. };

I-A-3 - Comment déduire le type final ?

Maintenant que nous savons déduire un type pour nos valeurs minimales et maximales, il faut déduire un type capable d’accueillir toutes les valeurs comprises dans l’intervalle [MIN, MAX], qui sera le type interne final de notre classe.

Il existe un cas où cette opération ne sera pas possible : si MAX est uint64 alors que MIN est signé. Dans un tel cas, il n’existe aucun type capable de supporter un tel écart de valeurs – les entiers 128 bits n’étant pas supportés.

Cette limitation ne devrait pas déranger puisqu’un tel ensemble de valeurs est rarement utilisé – et je ne l’ai personnellement jamais constaté.

La déduction du type final sera faite selon quelques règles :

- est-ce qu’un tel type existe ?

- est-ce que l’un des types de MIN ou MAX est suffisant pour accueillir l’ensemble des valeurs ?

- sinon, prendre le type directement plus grand qui l’est.

Voilà comment se traduisent les règles définies ci-dessus en code :

1. // Déduire un type capable d’accueillir les valeurs MIN et MAX, si possible

2. template<auto MIN, auto MAX>

3. struct FittingType

4. {

5. using MinType = typename ExtractType<MIN, (MIN >= 0)>::Type;

6. using MaxType = typename ExtractType<MAX, (MIN >= 0)>::Type;

7. // Si nous avons une valeur < 0 et l’autre > int64 max, impossible de trouver un type pouvant les accueillir

8. static constexpr bool IsPossible = !(MIN < 0 && MAX > std::numeric_limits<int64>::max()) || HoldingType<MinType, MaxType>::IsPossible;

9. using Type =

10. std::conditional_t<!IsPossible, void,

11. // Est-ce que MIN est compatible avec MaxType ?

12. std::conditional_t<(MIN >= std::numeric_limits<MaxType>::min()), MaxType,

13. // Est-ce que MAX est compatible avec MinType ?

14. std::conditional_t<(MAX <= std::numeric_limits<MinType>::max()), MinType,

15. // Sinon, trouver un type suffisamment grand pour accommoder MIN et MAX

16. typename HoldingType<MinType, MaxType>::Type

17. >>>;

18. };

Et enfin, la structure HoldingType qui permet de déduire le type permettant d’accommoder les types de MIN et MAX : si les types sont identiques, utiliser ce type, sinon prendre un type plus grand en faisant attention s’il faut un type signé ou non.

1. // Trouver un type qui permette d’accueillir les valeurs du type A et du type B

2. template<class A, class B>

3. struct HoldingType

4. {

5. // Est-ce qu’un tel type existe ?

6. // Un cas est impossible : si l’un est uint64 alors que l’autre est signé

7. static constexpr bool IsPossible = !((std::is_same_v<A, uint64> && std::is_signed_v<B>) || (std::is_same_v<B, uint64> && std::is_signed_v<A>));

9. using Type = typename std::conditional_t<!IsPossible, void

10. // Ce sont les mêmes types : utilisons-le

11. , std::conditional_t<std::is_same_v<A, B>, A

12. // Tous deux signés ou non : utiliser le plus large type

13. , std::conditional_t<std::is_signed_v<A> == std::is_signed_v<B>, typename Biggest<A, B>::Type

14. // Le plus large est signé, utilisons-le

15. , std::conditional_t<std::is_signed_v<typename Biggest<A, B>::Type>, typename Biggest<A, B>::Type

16. // Sinon, utiliser le type signé plus large que le plus large des deux

17. , std::make_signed_t<typename Promote<typename Biggest<A, B>::Type>::Type>

18. >>>>;

19. };

Vous voyez l’utilisation d’encore plus de templates afin de trouver quel type est le plus large ou un type plus large que le paramètre. Ces structures utilitaires sont plutôt simples et détaillées ci-dessous.

I-A-3-a - Déduire le type le plus large

Pour définir quel type est le plus large, nous avons tout simplement recours à l’opérateur sizeof.

1. // Retourne le type le plus large

2. template<class A, class B>

3. struct Biggest

4. {

5. using Type = std::conditional_t<(sizeof(A) > sizeof(B)), A, B>;

6. };

I-A-3-b - Déduire un type plus large

Nous devons maintenant promouvoir un type vers un type plus large. C’est-à-dire : int32 vers int64, uint8 vers uint16…

1. // Promeut un type vers un type plus large (int32 => int64, uint8 => uint16...)

2. template<class T>

3. struct Promote { using Type = std::conditional_t<std::is_signed_v<T>, int64, uint64>; }; // Par défaut, utiliser le type le plus large possible. Ne devrait pas être utilisé mais nécessaire pour compiler.

4. template<> struct Promote<int32> { using Type = int64; };

5. template<> struct Promote<uint32> { using Type = uint64; };

6. template<> struct Promote<int16> { using Type = int32; };

7. template<> struct Promote<uint16> { using Type = uint32; };

8. template<> struct Promote<int8> { using Type = int16; };

9. template<> struct Promote<uint8> { using Type = uint16; };

Cette structure pourrait également s’écrire avec une série de std::conditional_t mais je trouve que la spécialisation est ici plus claire.

I-B - Création de RangedInteger

Maintenant que nous pouvons déduire un type à partir de valeurs, nous pouvons commencer à créer notre classe RangedInteger.

Cette classe prend en paramètres templates les valeurs minimales et maximales de son intervalle de validité et, à partir de ces valeurs, déduit un type interne adéquat et de taille optimale.

Si un tel type interne est impossible à définir, parce que l’intervalle [min, max] est trop large, une erreur de compilation sera générée.

Étant une classe permettant de manipuler une valeur entière, elle doit également pouvoir être initialisée à partir d’un entier et retourner sa valeur interne.

Enfin cette classe devra valider sa valeur interne lors de l’assignation afin d’alerter le programmeur au plus tôt si une valeur invalide est assignée. De la même manière, elle fournira une interface permettant de vérifier qu’une valeur donnée est valide ou non pour son intervalle.

RangedInteger.hpp

1. template<auto MIN, auto MAX>

2. class RangedInteger

3. {

4. static_assert(MIN < MAX, "MIN et MAX doivent être strictement ordonnées");

5. static_assert(FittingType<MIN, MAX>::IsPossible, "Aucun type ne peut accueillir Min & Max");

6. public:

7. using Type = typename FittingType<MIN, MAX>::Type;

8. static constexpr Type Min() { return MIN; }

9. static constexpr Type Max() { return MAX; }

10. static constexpr uint64 Range = Range<MIN, MAX>();

11. static constexpr uint8 NbBits = NbBits<Range>::Value;

13. RangedInteger() = default;

14. explicit RangedInteger(Type v) : mValue(v) { checkValue(); }

15. RangedInteger& operator=(Type v) { CheckValue(v); mValue = v; return *this; }

17. static constexpr bool IsWithinRange(Type v) { return (v >= Min() && v <= Max()); }

19. inline Type get() const { return mValue; }

20. inline operator Type() const { return mValue; }

22. private:

23. void checkValue() { assert(IsWithinRange(mValue)); }

24. static void CheckValue(Type v) { assert(IsWithinRange(v)); }

26. private:

27. Type mValue{ Min() };

28. };

I-B-1 - Calcul de l’intervalle de valeurs

Les plus attentifs auront remarqué que le calcul de l’intervalle de valeurs n’est pas un simple MAX – MIN comme on pourrait s’y attendre.

Le problème est que ce simple calcul, avec des valeurs extrêmes telles que les limites de int64, entraîne des avertissements comme warning C4307: '-': signed integral constant overflow, au moins sur Visual Studio. Il faut donc aider le compilateur afin de trouver une valeur valide sur uint64 via des opérations et conversions que nous savons valides, et en désactivant l’avertissement que nous savons invalide. Créons pour cela une fonction réalisant ces opérations, qui prendra les valeurs en paramètres templates.

1. template<auto MIN, auto MAX>

2. constexpr uint64 Range()

3. {

4. static_assert(MIN < MAX);

5. if constexpr (MAX < 0)

6. {

7. // MIN & MAX < 0

8. return static_cast<uint64>(static_cast<int64>(-1) * MIN) - static_cast<uint64>(static_cast<int64>(-1) * MAX);

9. }

10. else if constexpr (MIN < 0)

11. {

12. #pragma warning(push)

13. #pragma warning(disable: 4307) // '*': signed integral constant overflow

14. return static_cast<uint64>(MAX) + static_cast<uint64>(static_cast<int64>(-1) * MIN);

15. #pragma warning(pop)

16. }

17. else

18. {

19. return static_cast<uint64>(MAX) - static_cast<uint64>(MIN);

20. }

21. }

Nous pouvons maintenant définir l’intervalle de valeurs et le nombre de bits nécessaires à sérialiser la valeur ainsi :

template<auto MIN, auto MAX>
class RangedInteger
{
…
static constexpr uint64 Range = Range<MIN, MAX>();
static constexpr uint8 NbBits = NbBits<Range>::Value;
…
};

I-C - Manipulations avec d’autres types

Une dernière chose intéressante pour cette classe est de fournir des interfaces permettant de l’utiliser comme on utiliserait les types de base. En particulier je souhaite pouvoir assigner une valeur d’un autre type que le type réel interne (si cette valeur est comprise entre les bornes).

Ajoutons donc un opérateur d’assignation et un constructeur de copie, pouvant prendre un autre type que le type réel interne :

RangedInteger.hpp

1. template<auto MIN, auto MAX>

2. class RangedInteger

3. {

4. …

5. template<typename OtherType>

6. explicit RangedInteger(OtherType v) : { CheckValue(v); mValue = static_cast<Type>(v); }

7. template<typename OtherType>

8. RangedInteger& operator=(OtherType v) { CheckValue(v); mValue = static_cast<Type>(v); return *this; }

9. …

10. template<typename OtherType>

11. static constexpr bool IsWithinRange(OtherType v)

12. {

13. if constexpr (!HoldingType<Type, OtherType>::IsPossible)

14. {

15. return false;

16. }

17. else

18. {

19. using CastType = typename HoldingType<Type, OtherType>::Type; // Déduire un type permettant de convertir le type interne et en paramètre

20. return (static_cast<CastType>(v) >= static_cast<CastType>(Min()) && static_cast<CastType>(v) <= static_cast<CastType>(Max()));

21. }

22. }

23. …

24. private:

25. template<typename OtherType>

26. void CheckValue(OtherType v) { assert(IsWithinRange(v)); }

27. …

28. };

II - Sérialisation de valeurs à virgule flottante

Vous aurez remarqué que la sérialisation de float avait été laissée de côté jusqu’ici.

La première raison de cette absence est que dans son cas rien ne change : on utilise toujours l’union du chapitre précédent et utilisons les nouvelles implémentations de uint32 pour son transfert. Avec la nouvelle interface, il n’y a besoin d’aucune modification.

La seconde raison est que transférer un float32 (ou float64) est un gâchis incroyable de données : à l’utilisation, de combien de chiffres significatifs avez-vous réellement besoin ?

Pour ces raisons, la sérialisation de valeurs à virgule flottante de base (comme float et double) va laisser place à une sérialisation de valeurs à virgule fixe et dont le domaine de valeurs et la précision sont maîtrisés.

II-A - Une nouvelle classe Float

Nous allons donc créer une nouvelle classe de valeurs à virgule flottante qui correspond à notre besoin : un nombre flottant à virgule fixe. Nous ajouterons également une notion de pas puisque toutes les valeurs intermédiaires ne sont pas forcément intéressantes.

Cette classe prendra un domaine de valeurs, une valeur minimale et une valeur maximale, un nombre de décimales et un pas de précision. Pour une utilisation plus simple, elle pourra supporter des valeurs float ou double au choix de l’utilisateur.

Float.hpp

template<class FLOATTYPE, int32 MIN, int32 MAX, uint8 NBDECIMALS, uint8 STEP = 1 >
class Float
{
static_assert(std::is_same_v<FLOATTYPE, float32> || std::is_same_v<FLOATTYPE, float64>, "Float peut seulement être utilisé avec float32 ou float64");
} ;

Puisque c’est une classe template, j’ai pour habitude de créer des variables static constexpr afin de pouvoir accéder à leur valeur aisément.

Ajoutons également des assertions afin qu’elle respecte quelques règles et prérequis d’utilisation.

Float.hpp

1. template<class FLOATTYPE, int32 MIN, int32 MAX, uint8 NBDECIMALS, uint8 STEP = 1 >

2. class Float

3. {

4. static_assert(NBDECIMALS > 0, "Au moins 1 décimale est nécessaire");

5. static_assert(NBDECIMALS < 10, "Maximum de 10 décimales");

6. static_assert(STEP != 0, "Le pas ne peut être nul");

7. static_assert(STEP % 10 != 0, "Le pas ne peut être un multiple de 10. Retirez une décimale");

8. using FloatType = FLOATTYPE;

9. static constexpr int32 Min = MIN;

10. static constexpr int32 Max = MAX;

11. static constexpr uint32 Diff = Max - Min;

12. static constexpr uint8 NbDecimals = NBDECIMALS;

13. static constexpr uint8 Step = STEP;

14. } ;

II-A-1 - Le nombre de décimales

Le nombre de décimales définira la précision de notre flottant. Elle est obligatoirement inférieure à 10 pour limiter la taille des données.

Il s’agit d’une limite arbitraire que je n’ai jamais vu être atteinte. Généralement une précision de deux ou trois chiffres suffira.

La précision la plus élevée que j’ai constatée jusqu’à présent était de 5.

II-A-2 - Le pas

Le pas désigne l’incrémentation de la dernière décimale entre chaque valeur.

Ainsi, si nous avons un flottant sur [0, 1] avec 1 décimale et un pas de 2, alors les valeurs possibles seront 0, 0.2, 0.4, 0.6, 0.8 et 1. Cet ensemble des valeurs possibles sera appelé domaine de valeurs.

Notez que le pas sert d’incrément depuis la borne inférieure.

Ainsi, la borne supérieure ne sera pas toujours atteignable.

II-A-3 - Domaine de valeurs

Le domaine de valeurs sera essentiel pour la compression des données.

Il s’agit de ramener les valeurs décimales sur un intervalle entier que l’on sait sérialiser sans erreur.

Une simple opération doit permettre de passer d’une valeur flottante à sa valeur entière sur le domaine de valeurs et inversement.

II-A-3-a - Simplifier le cas

Commençons par remarquer que le domaine pour un flottant sans décimale n’est autre que ce que nous appelions l’intervalle de valeurs pour un entier signé.

II-A-3-b - Prise en compte des décimales

Puis ajoutons les décimales dans l’équation.

En ajoutant 1 décimale pour les flottants sur [0, 1] les valeurs possibles sont alors 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9 et 1.

L’ajout de la première décimale va ajouter 10 valeurs. Sérialiser [0, 1] avec 1 décimale revient donc à sérialiser un entier sur [0, 10].

Vous pouvez faire ce même raisonnement pour 2 décimales ou plus et constaterez que de manière générale notre domaine est multiplié par 10NBDECIMALES.

II-A-3-c - Prise en compte du pas

Enfin, le pas, à l’inverse du nombre de décimales, va diminuer le domaine de valeurs possibles.

Si nous reprenons les flottants sur [0, 1] mais cette fois avec un pas de 2, alors les valeurs possibles sont 0, 0.2, 0.4, 0.6, 0.8, 1.

Nous sommes passés de 11 valeurs à 6 et sérialiser un flottant sur [0, 1] avec 1 décimale et un pas de 2 revient donc à sérialiser un entier sur [0, 5].

Ainsi, le pas va entraîner une division du domaine de valeurs.

II-A-3-d - Équation finale

Maintenant que nous avons vu l’effet de chaque paramètre, voyons comment passer d’une valeur flottante à une valeur entière aisément sérialisable et inversement.

Cette opération s’appelle quantification, de l’anglais quantize – qui sera le terme à préférer pour vos recherches.

La valeur quantifiée sera définie par Q = (valeur – Min) * 10^NBDECIMALES / Pas.

Et pour retrouver la valeur de départ à partir d’une valeur quantifiée, il suffit de faire les calculs inverses : valeur = Q * Pas / 10^NBDECIMALES + Min.

II-A-4 - Implémentation

Puisque nous sommes en présence d’une classe template, et afin d’utiliser au maximum le compilateur, un peu de métaprogrammation sera nécessaire pour parvenir au résultat.

II-A-4-a - Puissance

Commençons par l’élévation à la puissance NbDecimals de 10.

Puisque nous sommes en présence d’un faible nombre (nous avons posé comme prérequis que les décimales maximales sont de 10) alors nous pouvons faire un peu de récursivité sans risque.

Types.hpp

1. template<uint8 BASE, uint8 EXPONENT>

2. struct Pow

3. {

4. template<uint8 EXP>

5. struct InternalPow

6. {

7. static constexpr uint32 Value = Return<uint32, BASE * InternalPow<EXP-1>::Value>::Value;

8. };

9. template<>

10. struct InternalPow<1>

11. {

12. static constexpr uint32 Value = BASE;

13. };

14. template<>

15. struct InternalPow<0>

16. {

17. static constexpr uint32 Value = 1;

18. };

19. static constexpr uint32 Value = InternalPow<EXPONENT>::Value;

20. };

Avec l’aide d’une simple structure Return qui sert uniquement à exposer un membre Value du type et à la valeur souhaitée :

Types.hpp

template<typename TYPE, TYPE V>
struct Return
{
static constexpr TYPE Value = V;
};

II-A-4-b - Implémentation finale

Maintenant que nous savons calculer le domaine, nous pouvons finaliser l’écriture de notre classe ainsi :

Types.hpp

1. template<class FLOATTYPE, int32 MIN, int32 MAX, uint8 NBDECIMALS, uint8 STEP = 1 >

2. class Float

3. {

4. static_assert(NBDECIMALS > 0, "Au moins 1 décimale");

5. static_assert(NBDECIMALS < 10, "Maximum de 10 décimales");

6. static_assert(STEP != 0, "Le pas ne peut être nul");

7. static_assert(STEP % 10 != 0, "Le pas ne peut être un multiple de 10. Retirez une décimale");

8. using FloatType = FLOATTYPE;

9. static constexpr int32 Min = MIN;

10. static constexpr int32 Max = MAX;

11. static constexpr uint32 Diff = Max - Min;

12. static constexpr uint8 NbDecimals = NBDECIMALS;

13. static constexpr uint32 Multiple = Pow<10, NbDecimals>::Value;

14. static constexpr uint8 Step = STEP;

15. static constexpr uint32 Domain = (MAX - MIN) * Multiple / STEP;

17. public:

18. Float() = default;

19. Float(FloatType value)

20. {

21. mQuantizedValue = Quantize(value);

22. }

24. static uint32 Quantize(FloatType value)

25. {

26. assert(value >= Min && value <= Max);

27. return static_cast<uint32>(((value - Min) * Multiple) / Step);

28. }

30. inline FloatType get() const { return static_cast<FloatType>((mQuantizedValue.get() * Step * 1.) / Multiple + Min); }

31. inline operator FloatType() const { return get(); }

33. private:

34. RangedInteger<0, Domain> mQuantizedValue;

35. };

III - Sérialiser des types utilisateur

Il est intéressant de rendre sérialisable les types créés par les utilisateurs de ce code.

Il arrive très vite qu’un utilisateur veuille sérialiser son propre type, sa propre structure, dans un message. On pourrait envisager de ne fournir que les types de base gérés jusqu’ici et laisser l’utilisateur faire ce qu’il souhaite pour ses propres types, mais il est intéressant de lui faciliter les choses. D’autant plus que vous verrez que nous en profiterons également.

III-A - Interface sérialisable

Pour y parvenir, le plus simple est de proposer une interface permettant de rendre n’importe quelle classe sérialisable via héritage et surcharge de fonction.

Serialization.hpp

1. class Serializable

2. {

3. public:

4. virtual ~Serializable() = default;

6. virtual bool write(Serializer&) const = 0;

7. virtual bool read(Deserializer&) = 0;

8. };

Ainsi n’importe quel objet devant être sérialisé n’aura qu’à hériter de cette interface puis implémenter les deux méthodes correspondantes.

III-B - Sérialisation de RangedInteger et Float

Dans les paragraphes précédents nous avons créé des classes pour contenir des valeurs sur un intervalle et des nombres à virgule flottante. Mais vous aurez sans doute remarqué qu’aucun moyen de les sérialiser n’avait alors été proposé.

Afin de rendre ces types sérialisables, nous allons simplement les faire hériter de notre nouvelle interface :

RangedInteger.hpp

template<auto MIN, auto MAX>
class RangedInteger : public Serialization::Serializable
{
…
bool write(Serialization::Serializer& serializer) const override { return serializer.write(get(), Min(), Max()); }
bool read(Serialization::Deserializer& deserializer) override { return deserializer.read(mValue, Min(), Max()); }

private:
Type mValue{ Min() };
};

Et nous réutilisons les implémentations du sérialiseur et désérialiseur avec intervalles.

Dans le cas de Float, l’implémentation est similaire :

Float.hpp

template<class FLOATTYPE, int32 MIN, int32 MAX, uint8 NBDECIMALS, uint8 STEP = 1 >
class Float : public Serialization::Serializable
{
…
bool write(Serialization::Serializer& serializer) const override { return mQuantizedValue.write(serializer); }
bool read(Serialization::Deserializer& deserializer) override { return mQuantizedValue.read(deserializer); }

private:
RangedInteger<0, Domain> mQuantizedValue;
};

Remarquez que nous n’utilisons pas la sérialisation de flottant du chapitre précédent, mais la sérialisation d’entiers sur intervalle de la valeur quantifiée.

L’interface des classes cpp Serializer et cpp Deserializer restent inchangés et ne fournissent qu’une interface pour les types de base.

III-C - Implémentation dans Serializer

L’implémentation pour supporter ces types dans la classe Serializer est des plus simples :

Serializer.hpp

class Serializer
{
…
bool write(const Serializable& serializable);
…
};

Serializer.cpp

bool Serializer::write(const Serializable& serializable)
{
return serializable.write(*this);
}

III-D - Implémentation dans Deserializer

Pour la désérialisation, l’implémentation est tout aussi simple :

Deserializer.hpp

class Deserializer
{
…
bool read(Serializable& data);
…
};

Deserializer.cpp

bool Deserializer::read(Serializable& data)
{
return data.read(*this);
}

IV - Cas du float32

Maintenant que nous avons un type optimisé pour les valeurs à virgule flottante, l’interface de sérialisation de float32 devient obsolète.

Je vous conseillerais de retirer purement et simplement cette interface.

Retirer la sérialisation de float32 forcera votre équipe à utiliser la classe Float et à réfléchir sur l’utilisation qu’ils font des valeurs à virgule flottante et le domaine de valeurs dont ils ont besoin. Ceci entraînera une diminution de la bande-passante utilisée et participera à l’amélioration globale de vos transferts de données.

Si vous travaillez sur du code existant et avez besoin de cette sérialisation, il est alors intéressant de l’entourer de ifdef afin de pouvoir la désactiver plus tard, une fois que toutes les variables ont été mises à jour vers un Float au domaine adéquat.

#ifdef BOUSKNET_ALLOW_FLOAT32_SERIALIZATION
bool write(float32 data);
#endif // BOUSKNET_ALLOW_FLOAT32_SERIALIZATION

IV-A - Fichier de configuration

Pour gérer ceci il est intéressant d’utiliser un fichier de configuration qui regroupera cette option et celles à venir.

Settings.hpp

1. #pragma once

3. // Active la sérialisation de float32

4. //#define BOUSKNET_ALLOW_FLOAT32_SERIALIZATION

Ainsi, si l’utilisateur souhaite pouvoir sérialiser des float32 il n’aura qu’à l’activer en décommentant la ligne correspondante dans Settings.hpp. L’inclusion de ce fichier doit bien sûr être ajoutée quand nécessaire.

V - Sérialisation d’énumérations

Il reste un dernier cas à aborder : la sérialisation des énumérations.

On pourrait laisser ceci à la discrétion de l’utilisateur : il possède désormais tous les éléments pour y parvenir. Mais il est intéressant de lui proposer des interfaces pour y parvenir afin de lui simplifier la vie.

V-A - Prérequis

Nous allons commencer par délimiter le champ d’action. Les énumérations qui pourront être sérialisées via notre interface devront suivre quelques règles :

- avoir uniquement des valeurs continues ;

- être de type interne supporté par la sérialisation : int8, uint8, int16, uint16, int32 uint32, int64 ou uint64 ;

- définir une entrée Min : la première valeur possible de l’énumération ;

- définir une entrée Max : la dernière valeur possible de l’énumération.

Ces hypothèses vont nous permettre d’utiliser les interfaces précédemment créées afin d’optimiser la sérialisation d’une variable de type enum ou enum class, et permettent de rendre une énumération existante compatible avec le système de sérialisation sans modification importante ou trop intrusive nécessaire.

Par exemple, si je possède des énumérations :

enum Forme {
Cercle,
Carre,
Losange
};
enum class Couleur {
Rouge,
Jaune,
Bleu
};

Alors pour qu’elles soient sérialisables j’ai juste à les modifier ainsi :

enum Forme {
Min,
Cercle = Min,
Carre,
Losange,
Max = Losange
};
enum class Couleur : int8 {
Min,
Rouge = Min,
Jaune,
Bleu,
Max = Bleu
};

V-B - Implémentations

Ici l’implémentation est un peu plus laborieuse. Souvenez-vous que lors de la création des interfaces du Serializer et Deserializer, nous avons refusé d’avoir une fonction template pour l’écriture et lecture des variables.

Puisque nous devons maintenant traiter des types créés par l’utilisateur, et des types qui ne peuvent pas participer à un héritage qui plus est, nous n’avons donc pas le choix que d’avoir recours à une fonction template.

Et cette fonction template ne doit être utilisable que pour des énumérations.

Pour y parvenir, nous utiliserons une syntaxe un peu lourde, à l’aide de std::enable_if :

class Serializer
{
template<class E>
typename std::enable_if<std::is_enum<E>::value, bool>::type write(E value)
{
using T = std::underlying_type<E>::type;
return write(static_cast<T>(value), static_cast<T>(E::Min), static_cast<T>(E::Max));
}
};
class Deserializer
{
template<class E>
typename std::enable_if<std::is_enum<E>::value, bool>::type read(E& data)
{
using T = std::underlying_type<E>::type;
T temp{};
if (read(temp, static_cast<T>(E::Min), static_cast<T>(E::Max)))
{
data = static_cast<E>(temp);
return true;
}
return false;
}
};

Si vos énumérations sont générées, contiennent des valeurs continues et que vous avez un moyen de compter le nombre d’entrées qu’elles contiennent, alors vous pourriez vous contenter de créer un RangedInteger avec l’intervalle correspondant et utiliser un simple static_cast pour passer de la valeur énumérée vers la valeur entière et inversement.

VI - Tests

Les tests seront ici très succincts puisque l’essentiel du travail a été de proposer une surcouche à ce qui existait déjà : une sérialisation de valeurs sur intervalles. Le plus gros est de vérifier que les types déduits sont ceux attendus.

Avec nos nouvelles interfaces et nouveaux objets nous pouvons sérialiser des variables du type std::vector<Bousk::RangedInteger<0, 42>> et bien d’autres combinaisons et permettons à l’utilisateur de créer ses propres types sérialisables également.

Serialization_Test.cpp

1. void Serialization_Test::TestAdvanced()

2. {

3. STATIC_CHECK(std::is_same_v<Bousk::Biggest<Bousk::uint8, Bousk::uint8>::Type, Bousk::uint8>);

4. STATIC_CHECK(std::is_same_v<Bousk::Biggest<Bousk::uint8, Bousk::uint32>::Type, Bousk::uint32>);

5. STATIC_CHECK(std::is_same_v<Bousk::Biggest<Bousk::int16, Bousk::uint64>::Type, Bousk::uint64>);

6. STATIC_CHECK(std::is_same_v<Bousk::HoldingType<Bousk::uint8, Bousk::uint8>::Type, Bousk::uint8>);

7. STATIC_CHECK(std::is_same_v<Bousk::HoldingType<Bousk::uint8, Bousk::uint16>::Type, Bousk::uint16>);

8. STATIC_CHECK(std::is_same_v<Bousk::HoldingType<Bousk::uint8, Bousk::int16>::Type, Bousk::int16>);

9. STATIC_CHECK(std::is_same_v<Bousk::Biggest<Bousk::int8, Bousk::uint16>::Type, Bousk::uint16>);

10. STATIC_CHECK(std::is_same_v<Bousk::HoldingType<Bousk::int8, Bousk::uint16>::Type, Bousk::int32>);

11. STATIC_CHECK(std::is_same_v<Bousk::ExtractType<1, false>::Type, Bousk::int8>);

12. STATIC_CHECK(std::is_same_v<Bousk::ExtractType<1, true>::Type, Bousk::uint8>);

13. STATIC_CHECK(std::is_same_v<Bousk::ExtractType<200, false>::Type, Bousk::int16>);

14. STATIC_CHECK(std::is_same_v<Bousk::ExtractType<200, true>::Type, Bousk::uint8>);

15. STATIC_CHECK(std::is_same_v<Bousk::FittingType<-1, 1>::MinType, Bousk::int8>);

16. STATIC_CHECK(std::is_same_v<Bousk::FittingType<-1, 1>::MaxType, Bousk::int8>);

17. STATIC_CHECK(std::is_same_v<Bousk::FittingType<-1, 1>::Type, Bousk::int8>);

18. STATIC_CHECK(std::is_same_v<Bousk::FittingType<-1, 200>::MinType, Bousk::int8>);

19. STATIC_CHECK(std::is_same_v<Bousk::FittingType<-1, 200>::MaxType, Bousk::int16>);

20. STATIC_CHECK(std::is_same_v<Bousk::FittingType<-1, 200>::Type, Bousk::int16>);

21. STATIC_CHECK(std::is_same_v<Bousk::ExtractType<std::numeric_limits<Bousk::int32>::min(), false>::Type, Bousk::int32>);

22. STATIC_CHECK(std::is_same_v<Bousk::ExtractType<std::numeric_limits<Bousk::int64>::max(), false>::Type, Bousk::int64>);

23. STATIC_CHECK(std::is_same_v<Bousk::FittingType<std::numeric_limits<Bousk::int32>::min(), std::numeric_limits<Bousk::int32>::max()>::Type, Bousk::int32>);

24. STATIC_CHECK(std::is_same_v<Bousk::FittingType<std::numeric_limits<Bousk::int32>::min(), std::numeric_limits<Bousk::int64>::max()>::Type, Bousk::int64>);

25. STATIC_CHECK(std::is_same_v<Bousk::RangedInteger<std::numeric_limits<Bousk::int32>::min(), std::numeric_limits<Bousk::int64>::max()>::Type, Bousk::int64>);

26. STATIC_CHECK(std::is_same_v<Bousk::RangedInteger<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::int64>::max()>::Type, Bousk::int64>);

27. STATIC_CHECK(Bousk::RangedInteger<std::numeric_limits<Bousk::int32>::min(), std::numeric_limits<Bousk::int64>::max()>::IsWithinRange(42));

28. STATIC_CHECK(Bousk::RangedInteger<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::int64>::max()>::IsWithinRange(42));

29. STATIC_CHECK(!Bousk::RangedInteger<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::int64>::max()>::IsWithinRange(std::numeric_limits<Bousk::uint64>::max()));

30. STATIC_CHECK(std::is_same_v<Bousk::HoldingType<Bousk::int64, Bousk::uint64>::Type, void>);

31. STATIC_CHECK(std::is_same_v<Bousk::FittingType<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::uint64>::max()>::MinType, Bousk::int64>);

32. STATIC_CHECK(std::is_same_v<Bousk::FittingType<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::uint64>::max()>::MaxType, Bousk::uint64>);

33. STATIC_CHECK(!Bousk::FittingType<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::uint64>::max()>::IsPossible);

34. //Bousk::RangedInteger<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::uint64>::max()> impossibleVar;

35. {

36. enum Enum1 { Min, Entry1 = Min, Entry2, Entry3, Entry4, Max = Entry4 };

37. enum class Enum2 : Bousk::uint8 { Min, Entry1 = Min, Entry2, Entry3, Entry4, Max = Entry4 };

38. Bousk::Serialization::Serializer serializer;

39. std::vector<Bousk::RangedInteger<0, 42>> vec{ 0, 2, 4, 8, 16, 32 };

40. Bousk::Float<Bousk::float32, -5, 5, 3> fv = -2.048f;

41. CHECK(serializer.write(vec));

42. CHECK(serializer.write(fv));

43. CHECK(serializer.write(Enum1::Entry1));

44. CHECK(serializer.write(Enum2::Entry3));

46. Bousk::Serialization::Deserializer deserializer(serializer.buffer(), serializer.bufferSize());

47. std::vector<Bousk::RangedInteger<0, 42>> vec2;

48. CHECK(deserializer.read(vec2));

49. CHECK(vec == vec2);

50. Bousk::Float<Bousk::float32, -5, 5, 3> fv2;

51. CHECK(deserializer.read(fv2));

52. CHECK(fv == fv2);

53. Enum1 e1;

54. CHECK(deserializer.read(e1));

55. CHECK(e1 == Enum1::Entry1);

56. Enum2 e2;

57. CHECK(deserializer.read(e2));

58. CHECK(e2 == Enum2::Entry3);

59. }

60. }

Le code source est désormais disponible sur Github.

Article précédent

<< Sérialisation de bits

UDP – Gérer des connexions entre machines >>