- Si la valeur d’un
floataprès suppression de sa partie fractionnaire sort de la plage du type entier cible, un comportement indéfini (UB) se produit ; les conversions implicites, les casts fonctionnels etstatic_castsont tous concernés -Wallet-Wextrane signalent pas ce cas, et-Wconversionne détecte que les conversions implicites, ce qui le rend facile à manquer- La fonction de conversion rétrécissante sûre
gsl::narrowde Microsoft GSL peut elle aussi provoquer un UB pour certaines entrées flottant→entier, et ne respecte donc pas le comportement documenté consistant à lancer une exception pour les valeurs non représentables - Sur x86,
CVTTSS2SItraite les valeurs non représentables commeINT_MIN, tandis que sur AArch64,FCVTZSeffectue une conversion saturante et transforme NaN en 0 ; les résultats peuvent donc varier selon le matériel - Pour convertir en toute sécurité, il faut vérifier la plage avant le cast ; l’option UBSan
-fsanitize=float-cast-overflowde Clang et GCC permet de détecter le problème
Règles de conversion et limites de la détection
- Selon les règles de conversion flottant-entier en C++, si, après suppression de la partie fractionnaire, la valeur n’entre pas dans le type entier cible, cela devient un comportement indéfini
- Même si la cible est unsigned, l’arithmétique modulaire ne s’applique pas
int i0 = f,int(f)etstatic_cast<int>(f)peuvent tous provoquer un UB pour certaines entrées
- Il est difficile de trouver tous les problèmes uniquement avec les avertissements courants des compilateurs
-Wallet-Wextran’émettent aucun avertissement pour ces trois conversions-Wconversionn’avertit que pour les conversions implicites
- Même si le programme continue de s’exécuter sur le processeur et le compilateur actuels, le résultat peut varier selon la plateforme
- Sur x86,
CVTTSS2SImappe les entrées non représentables versINT_MIN - Sur AArch64,
FCVTZSapplique une saturation et mappe NaN vers 0 - Un UB exécuté peut faire soudainement dysfonctionner le code lorsque le compilateur applique une autre conversion
- Sur x86,
Cas de GSL et réponse sûre
gsl::narrowde la Guidelines Support Library de Microsoft se présente comme une conversion rétrécissante sûre qui lance une exception pour les valeurs non représentables dans le type cible- En réalité, la conversion flottant→entier exécute d’abord un UB pour certaines entrées, ce qui ne correspond pas à la documentation
- L’équipe GSL a estimé que, sur la plateforme cible, l’UB interne était inoffensif car il ne touchait pas de représentation matérielle de trap ; cette logique est restée reflétée dans le code, et le problème n’a pas été corrigé
- La bonne solution consiste à vérifier la plage avant le cast
- Il existe une bibliothèque de preuve de concept cpp-clamp-cast fondée sur l’approche de conversion saturante de Rust
- Dans l’Undefined Behavior Sanitizer de Clang et GCC,
-fsanitize=float-cast-overflowpermet de détecter cet UB- Il est recommandé de tester tout le code C++ avec UBSan
1 commentaires
Avis sur Lobste.rs
Je connaissais déjà pas mal de comportements indéfinis subtils en C et C++, mais ce cas m’a surpris.
Rust a lui aussi hérité pendant un temps du même comportement indéfini, à cause des mêmes règles de conversion flottant→entier dans l’IR LLVM, et n’a été corrigé qu’en 2020 pour générer un IR plus complexe. Comme l’écart de performances est important, Rust fournit aussi une conversion flottant→entier sans vérification pour les boucles hautes performances où l’on sait que la valeur est finie et dans l’intervalle du type cible.
Que la C++ Core Guidelines Library prenne cela à la légère est absurde. LLVM a utilisé les informations tirées de la conversion pour éliminer des vérifications de bornes, ce qui a conduit à un accès hors limites à un tableau malgré la présence d’une vérification. Si l’on prend au sérieux la sécurité mémoire et l’évitement des comportements indéfinis, on ne peut pas ignorer ce point, et il est décevant qu’Herb Sutter parle de « comportement indéfini bénin ».
Je savais qu’il y avait beaucoup de comportements indéfinis en C++, mais celui-ci est particulièrement surprenant. Je me demande si la conversion a été conçue pour être aussi rapide que possible, et laissée en comportement indéfini parce que les architectures ont des instructions qui traitent ces valeurs extrêmes différemment. Cela semble être la principale raison de ce genre de règles, un peu comme le dépassement d’entier signé.
Le comportement indéfini devrait être limité aux cas où l’on ne peut pas garantir un résultat cohérent même sur une même plateforme, à cause d’effets hors de la machine abstraite du C, comme l’utilisation après libération, ou aux cas qui peuvent piéger sur certaines cibles.
Une implémentation conforme au standard peut définir elle-même un comportement indéfini, et GCC le fait pour certains points. Si une sémantique stable peut être garantie sans coût en performances, on pourrait simplement définir le comportement comme celui de l’instruction flottant→entier propre à la cible, même si d’autres implémentations le garderaient indéfini.
fctiwde PowerPC effectue une conversion saturante, transforme NaN enINT_MINet définit aussi des drapeaux FPSCR. Lefctidde Power ISA 64 bits applique le même traitement à des entiers plus grands, mais aucun des deux ne correspond au comportement d’AArch64 ou de x86.Dans ce genre de cas, il serait plus approprié d’en faire un comportement défini par l’implémentation ou une valeur non spécifiée. Il n’est pas raisonnable que tout le programme échappe au contrôle du standard C++ simplement parce qu’on a converti l’infini en
int.C++26 a supprimé plusieurs comportements indéfinis absurdes, et celui-ci est clairement un candidat à la suppression. D’après des essais, GCC et Clang ne semblent pas exploiter ce comportement indéfini pour l’optimisation, donc l’impact pratique paraît limité.
C’est encore un exemple désagréable où la spécification n’a aucune raison de qualifier cette opération de comportement indéfini.
Une raison de plus de détester IEEE 754. Sauf nécessité liée à d’autres bibliothèques ou aux performances, j’essaie autant que possible d’utiliser des entiers purs, des rationnels avec grands entiers au numérateur et au dénominateur, ou des décimaux à virgule fixe, plutôt que des flottants.
Si l’on a utilisé C ou C++, la différence entre
float32etint32, voireint64, ne devrait pas surprendre. Unfloat32avec un grand exposant peut représenter des valeurs entières bien plus grandes qu’unint64.Entre des formats qui ne sont pas des sur-ensembles l’un de l’autre et qui ont des capacités de représentation différentes, il n’y a aucune raison de supposer qu’une conversion flottant→entier soit sûre, indépendamment de la syntaxe du langage.
uint32_tne peut pas non plus représenter toutes les valeurs deuint64_t, mais la sémantique de conversion est définie par troncature. Ici, le problème fondamentalement différent est que, pour certaines entrées, le compilateur peut faire n’importe quoi.Pour un langage bas niveau, on pourrait aussi l’associer à des instructions d’assemblage comme
CVTTSS2SIouFCVTZS. Comme les langages bas niveau sont proches de l’assembleur et que d’autres comportements indéfinis dits « bénins » sont déjà très controversés, le fait que cette conversion permette un comportement indéfini est d’autant plus surprenant.Ce qui est surprenant, c’est que cela autorise aussi une mauvaise compilation étrange de code sans rapport, le formatage du disque dur, ou à « faire jaillir des démons de votre nez ».
Le concept de comportement indéfini où tout peut arriver est pertinent pour une double libération ou une écriture hors limites dans un tableau, mais C et C++ en abusent même là où ils pourraient imposer des règles plus strictes, comme une valeur poubelle définie par l’implémentation ou l’arrêt du programme. Rust fait piéger certains dépassements d’entiers en mode debug et renvoie une valeur non spécifiée en mode release, mais ne permet pas pour autant de casser du code sans rapport.
C++ n’a pas besoin de devenir Java ou Rust, mais il deviendrait clairement meilleur en réduisant les comportements indéfinis inutiles.