Comment gérer le dépassement d’entier

Article original : Dealing with integer overflows | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Le dépassement d’entier (integer overflow dans la langue de Ritchie) peuvent être considérablement embêtants. Il n’y a pas de manière simple et fiable de les détecter, encore moins de les détecter statiquement, et ils peuvent rendre votre logiciel inconsistant.

Cet article parlera indistinctement du dépassement des entiers signés et non-signés. Même si le dépassement des entiers non-signés n’est pas un comportement indéfini (contrairement au dépassement des entiers signés), 99.9% du temps vous ne voulez ni l’un, ni l’autre.

Comment détecter les dépassements d’entier ?

Même si elle sont peu nombreuses, il existe effectivement des manières de détecter les dépassements d’entier. Le souci est qu’elles sont soit complexes, soit peu fiables.

Dans cette section, je vais vous présenter quelques manières de détecter les dépassements d’entier (si vous en connaissez d’autres, n’hésitez pas à le partager en commentaire pour que tout le monde puisse en profiter)

UBSan

Undefined Behavior Sanitizer, ou UBSan pour les intimes, est un outil dynamique de vérification des comportements indéfinis.

Il est capable de détecter les dépassements d’entier sous la forme d’une option de compilation. Notez que même s’il est conçu pour détecter les comportements indéfinis, il nous fait le plaisir de détecter également les dépassements de entiers non-signés (qui, pour rappel, ne sont pas indéfinis).

Voici, à titre d’exemple, lesdites options de compilations appliquée au compilateur clang :

clang++ -fsanitize=signed-integer-overflow -fsanitize=unsigned-integer-overflow

Cet outil est implémenté pour clang et GCC :

Je peux me tromper, mais je n’ai pas réussi à trouver d’intégration de cet outil sur d’autres compilateurs.

Le principal inconvénient d’un outil dynamique comme celui-ci est qu’il faut re-compiler et lancer des essais exhaustifs du logiciel pour que l’outil puisse détecter un éventuel problème. Si les essais ne couvrent pas assez de cas de figure, il est possible qu’un dépassement d’entier passe entre les mailles du filet.

Écrire des test unitaires adéquats

Si votre projet implémente des tests unitaires (je ne m’étendrais pas sur le fait qu’il le devrait, car les développeurs ne sont pas toujours décisionnaires sur ce sujet), alors vous avez à votre disposition une manière assez directe de prévenir les dépassements d’entier.

Il vous suffit, pour toute fonctionnalité où c’est pertinent, de fournir en entrée de cette fonctionnalité de très grands entiers. Vous n’avez plus qu’à vérifier que le résultat reste cohérent, qu’une exception est levée, qu’un code d’erreur est mis à jour, ou quoique ce soit que la fonctionnalité est sensée faire dans ce cas.

Si vous ne savez pas quel résultat la fonctionnalité est sensée renvoyer parce que l’entrée est trop grosse pour être traitée et qu’elle ne gère pas ces cas d’erreur, alors cette fonctionnalité est dangereuse. Vous devez implémenter les cas d’erreur pour vous assurer qu’aucun dépassement n’aura lieu.

Parfois, détecter un dépassement d’entier potentiel peut nécessiter un refactoring assez lourd. N’ayez pas peur de le faire : il vaut mieux prévenir que guérir.

Ne mettez pas votre code dans une situation où il pourrait faire un dépassement

Le meilleur moyen d’être sûr qu’il n’y aura pas de dépassement et de prévenir toute possibilité qu’il survienne. Si vous être certain que votre code ne peux pas provoquer de dépassement d’entier, vous n’aurez pas besoin de les détecter.

La section suivante va vous fournir quelques pratiques pour vous aider dans cette optique.

Comment prévenir le dépassement d’entier ?

Utilisez des entiers de 64 bits

Une très bonne manière d’éviter le dépassement est d’utiliser int64_t pour implémenter les entiers. Dans la plupart des cas, les entiers de 64 bits ne provoqueront pas de dépassement, contrairement à leurs homologues de 32 bits et moins.

Veuillez noter que j’utilise des entiers signés comme exemple mais que toute cette section vaut aussi pour les entiers non-signés.

Il y a en réalité très peu de désavantages à utiliser int64_t plutôt que int32_t. La plupart du temps, vous n’aurez pas besoin de vous inquiéter de l’écart de performance ou de l’écart de taille entre les deux types. Seulement si vous travaillez sur des système embarqués ou sur des algorithmes de traitement de données pour pourriez avoir à vous en soucier.

Notez que avoir des entiers plus grands ne veut pas nécessairement dire que les calculs seront plus longs, et avec tout le panel de pointeurs / références / forwarding que le C++ nous fournit, on n’a pas souvent besoin de copier des grosses structure de données.

Dans tous les cas, même si vous avez des contraintes de performance ou de taille, gardez en tête la maxime suivante :

D’abbord la sécurité, ensuite les performances.
– Chloé Lourseyre, 2021

Essayer de suroptimiser et risquer ce faisant un dépassement d’entier est toujours pire que d’écrire du code sécurisé puis d’utiliser des outils pour cibler les morceaux de code qui doivent être optimisés.

Donc, ma recommandation finale est que, sauf contre-indication spécifique, vous utilisiez des int64_t (et des uint64_t) pour coder vos entiers.

À propos des performances de int64_t

J’ai fait tourner quelques opérations arithmétiques (addition, multiplication et division) sur un outil de benchmarking avec à la fois des int32_t et des int64_t pour voir s’il y avait des différences notables.

En utilisant clang et sur deux niveaux d’optimisation différents, voici les résultats :

Cela ne vous surprendra peut-être pas, mais dans tous les cas c’est la division la plus lente. Nonobstant les divisions, vous noterez qu’il n’y a pas de différence sensible entre les entiers 64-bits et les entiers 32-bits. Par contre, si vous effectuez des divisions, les entiers 32-bits seront plus adaptés (mais le simple fait d’utiliser des divisions démoli vos performance, donc c’est un gain en demi-teinte).

Petit rappel sur les types de base

Peut-être vous êtes vous demandé pourquoi, depuis le début de cette section, j’utilise les types int32_t et int64_t et non les bon vieux int et long. La raison à cela, c’est tout simplement parce que la taille de ces deux types dépend de votre environnement.

En effet, les seules contraintes que le standard applique sur les tailles des int et long sont les suivantes :

  • Les int doivent faire au moins 16-bits.
  • Les long doivent faire au moins 32-bits.
  • La tailles des int doit être supérieure ou égale à la taille des short et inférieure ou égale à la taille des long.
  • La taille des long doit être supérieure ou égale à la taille des intbool et wchar_t et inférieure ou égale à la taille des long long.

À cause de cela, évitez autant que possible d’utiliser les int et les long quand vous voulez éviter les dépassements d’entier.

Ne présumez jamais que, parce qu’une valeur est dans les limites, elle ne provoquera pas de dépassement d’entier

Mettons que vous avez une variable int32_t my_val qui représente un donnée qui sera toujours contenue entre 0 et un milliard (1 000 000 000). Comme la valeur max d’un int32_t est 2^31-1 (2 147 483 647), on pourrait penser que la variable ne provoquera jamais de dépassement.

Mais, un jour fatidique, un développeur écrira, sans rien soupçonner :

#include <cstdlib>
 
const int32_t C_FOO_FACTOR = 3;
 
int32_t evaluate_foo(int32_t my_val)
{
    // foo is always C_FOO_FACTOR times my_val
    return my_val * C_FOO_FACTOR;
}

Vous l’avez vu ? Dépassement d’entier. En effet, il existe des valeur de my_val qui, quand elles sont multipliées par 3, causent un dépassement d’entier.

À qui la faute ? Doit-on toujours vérifier qu’une valeur est dans un intervalle adéquat avant toute opération arithmétique ? Comment faire ?

Et bien, il y a une pratique assez simple à réaliser qui vous aidera à prévenir la plupart des cas de figure concernés. Quand vous aurez à stocker un entier plutôt grand, même si en lui-même il ne peut pas dépasser, mettez-le dans une structure plus grande.

Par exemple, je ne met jamais une valeur qui peut être plus grande que 2^15 dans une donnée dont la valeur maximale est de 2^31. Ainsi, même si la valeur est multipliée avec elle-même, elle ne provoquera pas de dépassement d’entier.

Avec cette méthode, on peut garder les petites valeurs dans de plus petites structures de donnée sans effet de bord. Dans l’exemple mentionné, C_FOO_FACTOR pourra rester un int32_t, et le résultat de toute opération avec lui sera promu s’il est associé avec un type de plus grande taille dans une opération arithmétique.

E.g. :

#include <cstdlib>
 
const int32_t C_FOO_FACTOR = 3;
 
int64_t evaluate_foo(int64_t my_val)
{
    // foo is always C_FOO_FACTOR times my_val
    return my_val * C_FOO_FACTOR; // The result of the multiplication is a int64_t
}

Utilisez auto

Oui, auto peut parfois vous sauver la vie, en particulier si vous n’êtes pas 100% sûr des type que vous manipulez.

Par exemple :

#include <cstdlib>
 
int32_t  C_FOO = 42;
 
int64_t compute_bar();
 
int main()
{
    // Big risk of overflow overflow here
    int32_t foo_bar = compute_bar() + C_FOO;
 
    // Probably no overflow here
    auto better_foo_bar = compute_bar() + C_FOO;
}

Ici, auto est utile car il prévient l’erreur commise ligne 10, où le résultat de l’opération compute_bar() + C_FOO, qui est un int64_t, est rétrogradée en int32_t quand elle est assignée. Ligne 13, auto devient un int64_t, donc aucun dépassement ne peut avoir lieu.

(Note : convertir un entier en un type trop petit pour le contenir est un dépassement d’entier)

Il y a aussi un autre cas spécifique, qui ne survient pas spécialement souvent, où auto peut être utile. Considerez le code suivant :

#include <cstdlib>
 
int32_t  C_FOO = 42;
 
int32_t compute_bar();
 
int main()
{
    // Big risk of overflow overflow here
    auto foo_bar = compute_bar() + C_FOO;
}

Ici, la valeur de retour de compute_bar() est int32_t. Mais si plus tard, l’auteur de cette fonction, voyant que int32_t est trop petit, change le type de retour en un int64_t, comme ceci :

#include <cstdlib>
 
int32_t  C_FOO = 42;
 
int64_t compute_bar();
 
int main()
{
    // Big risk of overflow overflow here
    auto foo_bar = compute_bar() + C_FOO;
}

Alors l’auto sera automatiquement « promu » en int64_t lui aussi, évitant une conversion implicite qui pourrait résulter en un dépassement d’entier.

Si on avait utilisé int32_t à la place de auto dès le départ, alors il y aurait eu un risque que l’auteur de la fonction compute_bar() ne corrige pas le type de la variable foo_bar, sans qu’aucun avertissement ni aucune erreur ne survienne à la compilation. Du coup, avoir auto dans ce cas nous a fait éviter le pire.

En conclusion

Faites toujours attention, quand vous manipulez des grands entiers, d’utiliser des grands type (bien plus grand que nécessaire, par sécurité). Utilisez auto quand vous ne savez pas ce que vous manipulez, et utilisez des analyseurs si vous pensez que votre code peut contenir un dépassement d’entier. Et bien sûr, comme toujours, écrivez de bons tests unitaires.

Si vous connaissez d’autres manières de prévenir ou de détecter des dépassement d’entier, n’héistez pas à le partager en commentaire.

Merci de votre attention et à la semaine prochaine !

Article original : Dealing with integer overflows | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Laisser un commentaire