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 :
2.
3.
4.
5.
6.
7.
8.
template
<
auto
MIN, auto
MAX>
class
RangedInteger
{
static_assert
(MIN <
MAX, "MIN et MAX doivent être strictement ordonnées"
);
static_assert
(possible, "Impossible avec ces valeurs de Min & Max "
);
private
:
Type mValue{
Min() }
;
}
;
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
template
<
auto
V, bool
ForceUnsigned>
struct
ExtractType
{
static_assert
(std::
numeric_limits<
decltype
(V)>
::
is_integer, "ExtractType doit être utilisé avec des entiers"
);
static_assert
(!
std::
is_same_v<
decltype
(V), bool
>
, "ExtractType ne doit pas être utilisé avec bool"
);
using
Type =
std::
conditional_t<
V <
0
, std::
conditional_t<
V <
std::
numeric_limits<
int32>
::
min(), int64
, std::
conditional_t<
V <
std::
numeric_limits<
int16>
::
min(), int32
, std::
conditional_t<
V <
std::
numeric_limits<
int8>
::
min(), int16
, int8
>>>
// > 0 : doit-on forcer un type non signé ?
, std::
conditional_t<
(V >
std::
numeric_limits<
int64>
::
max()), uint64
, std::
conditional_t<
(V >
std::
numeric_limits<
uint32>
::
max()), std::
conditional_t<
ForceUnsigned, uint64, int64>
, std::
conditional_t<
(V >
std::
numeric_limits<
int32>
::
max()), std::
conditional_t<
ForceUnsigned, uint32, int64>
, std::
conditional_t<
(V >
std::
numeric_limits<
uint16>
::
max()), std::
conditional_t<
ForceUnsigned, uint32, int32>
, std::
conditional_t<
(V >
std::
numeric_limits<
int16>
::
max()), std::
conditional_t<
ForceUnsigned, uint16, int32>
, std::
conditional_t<
(V >
std::
numeric_limits<
uint8>
::
max()), std::
conditional_t<
ForceUnsigned, uint16, int16>
, std::
conditional_t<
(V >
std::
numeric_limits<
int8>
::
max()), std::
conditional_t<
ForceUnsigned, uint8, int16>
, std::
conditional_t<
ForceUnsigned, uint8, int8>
>>>>>>>
>
;
}
;
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
// Déduire un type capable d’accueillir les valeurs MIN et MAX, si possible
template
<
auto
MIN, auto
MAX>
struct
FittingType
{
using
MinType =
typename
ExtractType<
MIN, (MIN >=
0
)>
::
Type;
using
MaxType =
typename
ExtractType<
MAX, (MIN >=
0
)>
::
Type;
// Si nous avons une valeur < 0 et l’autre > int64 max, impossible de trouver un type pouvant les accueillir
static
constexpr
bool
IsPossible =
!
(MIN <
0
&&
MAX >
std::
numeric_limits<
int64>
::
max()) ||
HoldingType<
MinType, MaxType>
::
IsPossible;
using
Type =
std::
conditional_t<!
IsPossible, void
,
// Est-ce que MIN est compatible avec MaxType ?
std::
conditional_t<
(MIN >=
std::
numeric_limits<
MaxType>
::
min()), MaxType,
// Est-ce que MAX est compatible avec MinType ?
std::
conditional_t<
(MAX <=
std::
numeric_limits<
MinType>
::
max()), MinType,
// Sinon, trouver un type suffisamment grand pour accommoder MIN et MAX
typename
HoldingType<
MinType, MaxType>
::
Type
>>>
;
}
;
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
// Trouver un type qui permette d’accueillir les valeurs du type A et du type B
template
<
class
A, class
B>
struct
HoldingType
{
// Est-ce qu’un tel type existe ?
// Un cas est impossible : si l’un est uint64 alors que l’autre est signé
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>
));
using
Type =
typename
std::
conditional_t<!
IsPossible, void
// Ce sont les mêmes types : utilisons-le
, std::
conditional_t<
std::
is_same_v<
A, B>
, A
// Tous deux signés ou non : utiliser le plus large type
, std::
conditional_t<
std::
is_signed_v<
A>
==
std::
is_signed_v<
B>
, typename
Biggest<
A, B>
::
Type
// Le plus large est signé, utilisons-le
, std::
conditional_t<
std::
is_signed_v<
typename
Biggest<
A, B>
::
Type>
, typename
Biggest<
A, B>
::
Type
// Sinon, utiliser le type signé plus large que le plus large des deux
, std::
make_signed_t<
typename
Promote<
typename
Biggest<
A, B>
::
Type>
::
Type>
>>>>
;
}
;
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
.
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…
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
template
<
auto
MIN, auto
MAX>
class
RangedInteger
{
static_assert
(MIN <
MAX, "MIN et MAX doivent être strictement ordonnées"
);
static_assert
(FittingType<
MIN, MAX>
::
IsPossible, "Aucun type ne peut accueillir Min & Max"
);
public
:
using
Type =
typename
FittingType<
MIN, MAX>
::
Type;
static
constexpr
Type Min() {
return
MIN; }
static
constexpr
Type Max() {
return
MAX; }
static
constexpr
uint64 Range =
Range<
MIN, MAX>
();
static
constexpr
uint8 NbBits =
NbBits<
Range>
::
Value;
RangedInteger() =
default
;
explicit
RangedInteger(Type v) : mValue(v) {
checkValue(); }
RangedInteger&
operator
=
(Type v) {
CheckValue(v); mValue =
v; return
*
this
; }
static
constexpr
bool
IsWithinRange(Type v) {
return
(v >=
Min() &&
v <=
Max()); }
inline
Type get() const
{
return
mValue; }
inline
operator
Type() const
{
return
mValue; }
private
:
void
checkValue() {
assert(IsWithinRange(mValue)); }
static
void
CheckValue(Type v) {
assert(IsWithinRange(v)); }
private
:
Type mValue{
Min() }
;
}
;
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
template
<
auto
MIN, auto
MAX>
constexpr
uint64 Range()
{
static_assert
(MIN <
MAX);
if
constexpr
(MAX <
0
)
{
// MIN & MAX < 0
return
static_cast
<
uint64>
(static_cast
<
int64>
(-
1
) *
MIN) -
static_cast
<
uint64>
(static_cast
<
int64>
(-
1
) *
MAX);
}
else
if
constexpr
(MIN <
0
)
{
#pragma warning(push)
#pragma warning(disable: 4307)
// '*': signed integral constant overflow
return
static_cast
<
uint64>
(MAX) +
static_cast
<
uint64>
(static_cast
<
int64>
(-
1
) *
MIN);
#pragma warning(pop)
}
else
{
return
static_cast
<
uint64>
(MAX) -
static_cast
<
uint64>
(MIN);
}
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
template
<
auto
MIN, auto
MAX>
class
RangedInteger
{
…
template
<
typename
OtherType>
explicit
RangedInteger(OtherType v) : {
CheckValue(v); mValue =
static_cast
<
Type>
(v); }
template
<
typename
OtherType>
RangedInteger&
operator
=
(OtherType v) {
CheckValue(v); mValue =
static_cast
<
Type>
(v); return
*
this
; }
…
template
<
typename
OtherType>
static
constexpr
bool
IsWithinRange(OtherType v)
{
if
constexpr
(!
HoldingType<
Type, OtherType>
::
IsPossible)
{
return
false
;
}
else
{
using
CastType =
typename
HoldingType<
Type, OtherType>
::
Type; // Déduire un type permettant de convertir le type interne et en paramètre
return
(static_cast
<
CastType>
(v) >=
static_cast
<
CastType>
(Min()) &&
static_cast
<
CastType>
(v) <=
static_cast
<
CastType>
(Max()));
}
}
…
private
:
template
<
typename
OtherType>
void
CheckValue(OtherType v) {
assert(IsWithinRange(v)); }
…
}
;
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.
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
template
<
class
FLOATTYPE, int32 MIN, int32 MAX, uint8 NBDECIMALS, uint8 STEP =
1
>
class
Float
{
static_assert
(NBDECIMALS >
0
, "Au moins 1 décimale est nécessaire"
);
static_assert
(NBDECIMALS <
10
, "Maximum de 10 décimales"
);
static_assert
(STEP !=
0
, "Le pas ne peut être nul"
);
static_assert
(STEP %
10
!=
0
, "Le pas ne peut être un multiple de 10. Retirez une décimale"
);
using
FloatType =
FLOATTYPE;
static
constexpr
int32 Min =
MIN;
static
constexpr
int32 Max =
MAX;
static
constexpr
uint32 Diff =
Max -
Min;
static
constexpr
uint8 NbDecimals =
NBDECIMALS;
static
constexpr
uint8 Step =
STEP;
}
;
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
template
<
uint8 BASE, uint8 EXPONENT>
struct
Pow
{
template
<
uint8 EXP>
struct
InternalPow
{
static
constexpr
uint32 Value =
Return<
uint32, BASE *
InternalPow<
EXP-
1
>
::
Value>
::
Value;
}
;
template
<>
struct
InternalPow<
1
>
{
static
constexpr
uint32 Value =
BASE;
}
;
template
<>
struct
InternalPow<
0
>
{
static
constexpr
uint32 Value =
1
;
}
;
static
constexpr
uint32 Value =
InternalPow<
EXPONENT>
::
Value;
}
;
Avec l’aide d’une simple structure Return qui sert uniquement à exposer un membre Value du type et à la valeur souhaitée :
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
template
<
class
FLOATTYPE, int32 MIN, int32 MAX, uint8 NBDECIMALS, uint8 STEP =
1
>
class
Float
{
static_assert
(NBDECIMALS >
0
, "Au moins 1 décimale"
);
static_assert
(NBDECIMALS <
10
, "Maximum de 10 décimales"
);
static_assert
(STEP !=
0
, "Le pas ne peut être nul"
);
static_assert
(STEP %
10
!=
0
, "Le pas ne peut être un multiple de 10. Retirez une décimale"
);
using
FloatType =
FLOATTYPE;
static
constexpr
int32 Min =
MIN;
static
constexpr
int32 Max =
MAX;
static
constexpr
uint32 Diff =
Max -
Min;
static
constexpr
uint8 NbDecimals =
NBDECIMALS;
static
constexpr
uint32 Multiple =
Pow<
10
, NbDecimals>
::
Value;
static
constexpr
uint8 Step =
STEP;
static
constexpr
uint32 Domain =
(MAX -
MIN) *
Multiple /
STEP;
public
:
Float() =
default
;
Float(FloatType value)
{
mQuantizedValue =
Quantize(value);
}
static
uint32 Quantize(FloatType value)
{
assert(value >=
Min &&
value <=
Max);
return
static_cast
<
uint32>
(((value -
Min) *
Multiple) /
Step);
}
inline
FloatType get() const
{
return
static_cast
<
FloatType>
((mQuantizedValue.get() *
Step *
1.
) /
Multiple +
Min); }
inline
operator
FloatType() const
{
return
get(); }
private
:
RangedInteger<
0
, Domain>
mQuantizedValue;
}
;
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.
2.
3.
4.
5.
6.
7.
8.
class
Serializable
{
public
:
virtual
~
Serializable() =
default
;
virtual
bool
write(Serializer&
) const
=
0
;
virtual
bool
read(Deserializer&
) =
0
;
}
;
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 :
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 :
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 :
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 :
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.
2.
3.
4.
#pragma once
// Active la sérialisation de float32
//#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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
void
Serialization_Test::
TestAdvanced()
{
STATIC_CHECK(std::
is_same_v<
Bousk::
Biggest<
Bousk::
uint8, Bousk::
uint8>
::
Type, Bousk::
uint8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
Biggest<
Bousk::
uint8, Bousk::
uint32>
::
Type, Bousk::
uint32>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
Biggest<
Bousk::
int16, Bousk::
uint64>
::
Type, Bousk::
uint64>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
HoldingType<
Bousk::
uint8, Bousk::
uint8>
::
Type, Bousk::
uint8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
HoldingType<
Bousk::
uint8, Bousk::
uint16>
::
Type, Bousk::
uint16>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
HoldingType<
Bousk::
uint8, Bousk::
int16>
::
Type, Bousk::
int16>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
Biggest<
Bousk::
int8, Bousk::
uint16>
::
Type, Bousk::
uint16>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
HoldingType<
Bousk::
int8, Bousk::
uint16>
::
Type, Bousk::
int32>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
ExtractType<
1
, false
>
::
Type, Bousk::
int8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
ExtractType<
1
, true
>
::
Type, Bousk::
uint8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
ExtractType<
200
, false
>
::
Type, Bousk::
int16>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
ExtractType<
200
, true
>
::
Type, Bousk::
uint8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<-
1
, 1
>
::
MinType, Bousk::
int8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<-
1
, 1
>
::
MaxType, Bousk::
int8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<-
1
, 1
>
::
Type, Bousk::
int8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<-
1
, 200
>
::
MinType, Bousk::
int8>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<-
1
, 200
>
::
MaxType, Bousk::
int16>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<-
1
, 200
>
::
Type, Bousk::
int16>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
ExtractType<
std::
numeric_limits<
Bousk::
int32>
::
min(), false
>
::
Type, Bousk::
int32>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
ExtractType<
std::
numeric_limits<
Bousk::
int64>
::
max(), false
>
::
Type, Bousk::
int64>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<
std::
numeric_limits<
Bousk::
int32>
::
min(), std::
numeric_limits<
Bousk::
int32>
::
max()>
::
Type, Bousk::
int32>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<
std::
numeric_limits<
Bousk::
int32>
::
min(), std::
numeric_limits<
Bousk::
int64>
::
max()>
::
Type, Bousk::
int64>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
RangedInteger<
std::
numeric_limits<
Bousk::
int32>
::
min(), std::
numeric_limits<
Bousk::
int64>
::
max()>
::
Type, Bousk::
int64>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
RangedInteger<
std::
numeric_limits<
Bousk::
int64>
::
min(), std::
numeric_limits<
Bousk::
int64>
::
max()>
::
Type, Bousk::
int64>
);
STATIC_CHECK(Bousk::
RangedInteger<
std::
numeric_limits<
Bousk::
int32>
::
min(), std::
numeric_limits<
Bousk::
int64>
::
max()>
::
IsWithinRange(42
));
STATIC_CHECK(Bousk::
RangedInteger<
std::
numeric_limits<
Bousk::
int64>
::
min(), std::
numeric_limits<
Bousk::
int64>
::
max()>
::
IsWithinRange(42
));
STATIC_CHECK(!
Bousk::
RangedInteger<
std::
numeric_limits<
Bousk::
int64>
::
min(), std::
numeric_limits<
Bousk::
int64>
::
max()>
::
IsWithinRange(std::
numeric_limits<
Bousk::
uint64>
::
max()));
STATIC_CHECK(std::
is_same_v<
Bousk::
HoldingType<
Bousk::
int64, Bousk::
uint64>
::
Type, void
>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<
std::
numeric_limits<
Bousk::
int64>
::
min(), std::
numeric_limits<
Bousk::
uint64>
::
max()>
::
MinType, Bousk::
int64>
);
STATIC_CHECK(std::
is_same_v<
Bousk::
FittingType<
std::
numeric_limits<
Bousk::
int64>
::
min(), std::
numeric_limits<
Bousk::
uint64>
::
max()>
::
MaxType, Bousk::
uint64>
);
STATIC_CHECK(!
Bousk::
FittingType<
std::
numeric_limits<
Bousk::
int64>
::
min(), std::
numeric_limits<
Bousk::
uint64>
::
max()>
::
IsPossible);
//Bousk::RangedInteger<std::numeric_limits<Bousk::int64>::min(), std::numeric_limits<Bousk::uint64>::max()> impossibleVar;
{
enum
Enum1 {
Min, Entry1 =
Min, Entry2, Entry3, Entry4, Max =
Entry4 }
;
enum
class
Enum2 : Bousk::
uint8 {
Min, Entry1 =
Min, Entry2, Entry3, Entry4, Max =
Entry4 }
;
Bousk::Serialization::
Serializer serializer;
std::
vector<
Bousk::
RangedInteger<
0
, 42
>>
vec{
0
, 2
, 4
, 8
, 16
, 32
}
;
Bousk::
Float<
Bousk::
float32, -
5
, 5
, 3
>
fv =
-
2.048
f;
CHECK(serializer.write(vec));
CHECK(serializer.write(fv));
CHECK(serializer.write(Enum1::
Entry1));
CHECK(serializer.write(Enum2::
Entry3));
Bousk::Serialization::
Deserializer deserializer(serializer.buffer(), serializer.bufferSize());
std::
vector<
Bousk::
RangedInteger<
0
, 42
>>
vec2;
CHECK(deserializer.read(vec2));
CHECK(vec ==
vec2);
Bousk::
Float<
Bousk::
float32, -
5
, 5
, 3
>
fv2;
CHECK(deserializer.read(fv2));
CHECK(fv ==
fv2);
Enum1 e1;
CHECK(deserializer.read(e1));
CHECK(e1 ==
Enum1::
Entry1);
Enum2 e2;
CHECK(deserializer.read(e2));
CHECK(e2 ==
Enum2::
Entry3);
}
}
Le code source est désormais disponible sur Github.
Article précédent | |
---|---|
<< Sérialisation de bits |