web-dev-qa-db-fra.com

L'utilisation d'un int non signé plutôt que signé est-elle plus susceptible de causer des bugs? Pourquoi?

Dans le Guide de style de Google C++ , au sujet de "Entiers non signés", il est suggéré de:

En raison d'un accident historique, la norme C++ utilise également des entiers non signés pour représenter la taille des conteneurs - de nombreux membres de l'organisme de normalisation pensent qu'il s'agit d'une erreur, mais il est effectivement impossible de corriger à ce stade. Le fait que l'arithmétique non signée ne modélise pas le comportement d'un entier simple, mais soit défini par la norme pour modéliser l'arithmétique modulaire (encapsulation sur dépassement de capacité/dépassement de capacité), signifie qu'une classe importante de bogues ne peut pas être diagnostiquée par le compilateur.

Quel est le problème avec l'arithmétique modulaire? N'est-ce pas le comportement attendu d'un int non signé?

À quel type de bogues (une classe significative) le guide fait-il référence? Débordant d'insectes?

N'utilisez pas un type non signé simplement pour affirmer qu'une variable est non négative.

Une des raisons pour lesquelles je peux penser à utiliser un int signé par un autre, c'est que s'il déborde (en négatif), il est plus facile à détecter.

76
user7586189

Certaines des réponses ici mentionnent les règles de promotion surprenantes entre les valeurs signées et non signées, mais cela ressemble davantage à un problème relatif à mélange valeurs signées et non signées, et n'explique pas nécessairement pourquoi signé est préférable à nsigned, en dehors des scénarios de mélange.

D'après mon expérience, en dehors des comparaisons mixtes et des règles de promotion, il existe deux raisons principales pour lesquelles les valeurs non signées sont de grands aimants pour les bogues.

Les valeurs non signées ont une discontinuité à zéro, valeur la plus courante en programmation

Les entiers non signés et signés ont un discontinuités à leurs valeurs minimum et maximum, où ils se terminent (unsigned) ou provoquent un comportement non défini (signé). Pour unsigned ces points sont à zéro et UINT_MAX. Pour int ils sont à INT_MIN et INT_MAX. Les valeurs typiques de INT_MIN et INT_MAX sur un système à 4 octets int sont -2^31 et 2^31-1, et sur un tel système UINT_MAX est typiquement 2^32-1.

Le principal problème provoquant un bogue avec unsigned qui ne s'applique pas à int est qu'il a une discontinuité à zéro. Le zéro, bien sûr, est une valeur très commune dans les programmes, avec d’autres petites valeurs telles que 1,2,3. Il est courant d’additionner et de soustraire de petites valeurs, en particulier 1, dans diverses constructions, et si vous soustrayez quelque chose d’une valeur unsigned et qu’il s’agit bien d’être nul, vous obtenez juste une énorme valeur positive et un bug presque certain.

Considérer que le code parcourt toutes les valeurs d’un vecteur par index, sauf le dernier.0.5:

for (size_t i = 0; i < v.size() - 1; i++) { // do something }

Cela fonctionne bien jusqu'au jour où vous passez dans un vecteur vide. Au lieu de faire zéro itération, vous obtenez v.size() - 1 == a giant number1 et vous ferez 4 milliards d'itérations et presque une vulnérabilité de débordement de mémoire tampon.

Vous devez l'écrire comme ceci:

for (size_t i = 0; i + 1 < v.size(); i++) { // do something }

Donc, il peut être "corrigé" dans ce cas, mais seulement en réfléchissant soigneusement à la nature non signée de size_t. Parfois, vous ne pouvez pas appliquer le correctif ci-dessus car, au lieu d'une constante, vous souhaitez appliquer un décalage variable, qui peut être positif ou négatif: le "côté" de la comparaison sur lequel vous devez placer dépend de la signature. - maintenant le code devient vraiment ​​désordonné.

Il existe un problème similaire avec le code qui tente de parcourir jusqu'à zéro, y compris. Quelque chose comme while (index-- > 0) fonctionne bien, mais apparemment while (--index >= 0) ne se terminera jamais pour une valeur non signée. Votre compilateur peut vous avertir lorsque le côté droit est littéral zéro, mais certainement pas s'il s'agit d'une valeur déterminée au moment de l'exécution.

Contrepoint

Certains pourraient soutenir que les valeurs signées ont également deux discontinuités, alors pourquoi choisir les valeurs non signées? La différence est que les deux discontinuités sont très (au maximum) très éloignées de zéro. Je considère vraiment cela comme un problème distinct de "débordement", les valeurs signées et non signées pouvant déborder à des valeurs très grandes. Dans de nombreux cas, le dépassement est impossible en raison de contraintes sur la plage de valeurs possible, et le dépassement de nombreuses valeurs 64 bits peut être physiquement impossible). Même si cela est possible, le risque d'un débordement lié à un débordement est souvent minuscule par rapport à un bogue "à zéro", et n débordement se produit également pour les valeurs non signées. Donc, non signé combine le pire des deux mondes: débordement potentiel avec des valeurs de très grande amplitude et une discontinuité à zéro. Signé seulement a l'ancien.

Beaucoup diront "tu perds un peu" avec unsigned. Ceci est souvent vrai - mais pas toujours (si vous devez représenter des différences entre des valeurs non signées, vous perdrez ce bit de toute façon: de nombreux objets 32 bits sont limités à 2 GiB de toute façon, ou vous aurez une zone grise étrange où, disons, un fichier peut contenir 4 Go, mais vous ne pouvez pas utiliser certaines API sur la deuxième 2 GiB half).

Même dans les cas où unsigned vous achète un peu: il ne vous achète pas beaucoup: si vous deviez supporter plus de 2 milliards de "choses", vous devrez probablement en supporter plus de 4 milliards.

Logiquement, les valeurs non signées sont un sous-ensemble de valeurs signées

Mathématiquement, les valeurs non signées (entiers non négatifs) sont un sous-ensemble d'entiers signés (juste appelé _integers).2. Cependant, les valeurs signées sont naturellement supprimées des opérations uniquement sur les valeurs non signées, telles que la soustraction. Nous pourrions dire que les valeurs non signées ne sont pas fermées en soustraction. Il n'en va pas de même pour les valeurs signées.

Vous voulez trouver le "delta" entre deux index non signés dans un fichier? Vous feriez mieux de faire la soustraction dans le bon ordre, sinon vous obtiendrez la mauvaise réponse. Bien sûr, vous avez souvent besoin d'une vérification à l'exécution pour déterminer le bon ordre! Lorsque vous traitez avec des valeurs non signées sous forme de nombres, vous constaterez souvent que les valeurs signées (logiquement) continuent à apparaître de toute façon.

Contrepoint

Comme indiqué dans la note de bas de page 2 ci-dessus, les valeurs signées en C++ ne sont pas en réalité un sous-ensemble de valeurs non signées de la même taille. Les valeurs non signées peuvent donc représenter le même nombre de résultats que les valeurs signées.

C'est vrai, mais la gamme est moins utile. Envisagez la soustraction et les nombres non signés avec une plage de 0 à 2N et les nombres signés avec une plage de -N à N. Les soustractions arbitraires entraînent des résultats dans la plage de -2N à 2N dans les deux cas, et les deux types d'entiers peuvent uniquement représenter La moitié de ça. Eh bien, il s'avère que la région centrée autour de zéro de -N à N est généralement beaucoup plus utile (contient plus de résultats réels dans le code du monde réel) que la plage de 0 à 2N. Considérez toute distribution typique autre que uniforme (log, zipfian, normale, quelle que soit) et envisagez de soustraire les valeurs sélectionnées aléatoirement de cette distribution: beaucoup plus de valeurs aboutissent dans [-N, N] que dans [0, 2N] (en fait, la distribution résultante) est toujours centré à zéro).

64 bits ferme la porte à de nombreuses raisons d'utiliser des valeurs signées en tant que nombres

Je pense que les arguments ci-dessus étaient déjà convaincants pour les valeurs 32 bits, mais les cas de dépassement de capacité, qui concernent à la fois les signataires et les non signés à différents seuils, do se produisent pour les valeurs 32 bits, puisque "2 milliards" est un nombre qui peut être dépassé par de nombreuses quantités abstraites et physiques (milliards de dollars, milliards de nanosecondes, tableaux contenant des milliards d'éléments). Donc, si quelqu'un est suffisamment convaincu par le doublement de la plage positive pour les valeurs non signées, il peut affirmer que le dépassement de capacité est important et qu'il favorise légèrement les non-signés.

En dehors des domaines spécialisés, les valeurs 64 bits éliminent en grande partie ce problème. Les valeurs 64 bits signées ont une plage supérieure de 9 223 372 036 857 775 807 - plus de neuf quintillions. Cela fait beaucoup de nanosecondes (environ 292 ans) et beaucoup d’argent. Il s'agit également d'un ensemble plus vaste que tout ordinateur susceptible d'avoir RAM dans un espace d'adressage cohérent pendant une longue période. Alors peut-être que 9 quintillions sont suffisants pour tout le monde (pour l'instant)?

Quand utiliser des valeurs non signées

Notez que le guide de style n'interdit pas, ni même décourage nécessairement, l'utilisation de nombres non signés. Il se termine par:

N'utilisez pas un type non signé simplement pour affirmer qu'une variable est non négative.

En effet, il existe de bonnes utilisations pour les variables non signées:

  • Lorsque vous souhaitez traiter une quantité de N bits non pas comme un entier, mais simplement comme un "sac de bits". Par exemple, en tant que bitmask ou bitmap, ou N valeurs booléennes ou autre. Cette utilisation va souvent de pair avec les types à largeur fixe tels que uint32_t et uint64_t, car vous voulez souvent connaître la taille exacte de la variable. Un indice qu'une variable particulière mérite ce traitement est que vous ne l'utilisez qu'avec les opérateurs bitwise tels que ~, |, &, ^, >> et ainsi de suite, et non avec les opérations arithmétiques telles que +, -, *, / etc.

    Unsigned est idéal ici car le comportement des opérateurs au niveau des bits est bien défini et normalisé. Les valeurs signées présentent plusieurs problèmes, tels qu'un comportement indéfini et non spécifié lors du décalage et une représentation non spécifiée.

  • Quand vous voulez réellement de l'arithmétique modulaire. Parfois, vous voulez réellement 2 ^ N arithmétique modulaire. Dans ces cas, le "débordement" est une fonctionnalité, pas un bogue. Les valeurs non signées vous donnent ce que vous voulez ici car elles sont définies pour utiliser l'arithmétique modulaire. Les valeurs signées ne peuvent pas être utilisées (facilement, efficacement) car elles ont une représentation non spécifiée et le débordement est indéfini.

0.5 Après avoir écrit ceci, j'ai réalisé que c'était presque identique à exemple de Jarod , ce que je n'avais pas vu - et pour cause, c'est un bon exemple!

1 Nous parlons de size_t ici donc généralement 2 ^ 32-1 sur un système 32 bits ou 2 ^ 64-1 sur un système 64 bits.

2 En C++, ce n'est pas tout à fait le cas, car les valeurs non signées contiennent plus de valeurs à l'extrémité supérieure que le type signé, mais il existe un problème fondamental: la manipulation de valeurs non signées peut générer des valeurs signées (logiquement), mais aucun problème ne correspond à les valeurs signées (puisque les valeurs signées incluent déjà des valeurs non signées).

66
BeeOnRope

Comme indiqué précédemment, mélanger unsigned et signed peut entraîner un comportement inattendu (même s'il est bien défini).

Supposons que vous souhaitiez parcourir tous les éléments du vecteur, à l'exception des cinq derniers, vous pourriez écrire à tort:

for (int i = 0; i < v.size() - 5; ++i) { foo(v[i]); } // Incorrect
// for (int i = 0; i + 5 < v.size(); ++i) { foo(v[i]); } // Correct

Supposons que v.size() < 5, alors, comme v.size() soit unsigned, s.size() - 5 serait un très grand nombre et que i < v.size() - 5 serait true pour un plus plage attendue de la valeur de i. Et UB se produit alors rapidement (accès illimité une fois i >= v.size())

Si v.size() aurait renvoyé une valeur signée, alors s.size() - 5 aurait été négatif et, dans le cas précédent, la condition serait immédiatement fausse.

De l'autre côté, l'index doit être compris entre [0; v.size()[ pour que unsigned ait un sens. Signed a également son propre problème, UB, avec débordement ou comportement défini par l'implémentation pour le déplacement à droite d'un nombre signé négatif, mais source de bogues moins fréquente pour l'itération.

33
Jarod42

L'un des exemples d'erreur les plus déconcertants concerne le mélange de valeurs signées et non signées:

#include <iostream>
int main()  {
    auto qualifier = -1 < 1u ? "makes" : "does not make";
    std::cout << "The world " << qualifier << " sense" << std::endl;
}

Le résultat:

Le monde n'a pas de sens

À moins que vous n'ayez une application triviale, il est inévitable que vous vous retrouviez avec des mélanges dangereux entre les valeurs signées et non signées (entraînant des erreurs d'exécution) ou, si vous générez des avertissements et les fassions lors des erreurs de compilation, vous vous retrouvez avec beaucoup static_casts dans votre code. C'est pourquoi il est préférable d'utiliser strictement des entiers signés pour les types de comparaison mathématique ou logique. Utilisez uniquement des signes non signés pour les masques de bits et les types représentant des bits.

Modéliser un type non signé en fonction du domaine attendu des valeurs de vos nombres est une mauvaise idée. La plupart des nombres sont plus proches de 0 qu'ils ne le sont à 2 milliards. Ainsi, avec les types non signés, bon nombre de vos valeurs sont plus proches du bord de la plage valide. Pour aggraver les choses, la valeur finale peut se situer dans une plage positive connue, mais lors de l'évaluation d'expressions, les valeurs intermédiaires peuvent dépasser et, si elles sont utilisées sous une forme intermédiaire, peuvent être des valeurs TRES incorrectes. Enfin, même si vos valeurs doivent toujours être positives, cela ne signifie pas qu'elles n'interagiront pas avec autres variables qui peut seront négatives, et vous terminez donc. avec une situation forcée de mélange de types signés et non signés, ce qui est le pire endroit pour être.

19
Chris Uzdavinis

Pourquoi l'utilisation d'un entier non signé est-elle plus susceptible de causer des bogues que l'utilisation d'un entier signé?

L'utilisation d'un type non signé n'est pas plus susceptible de causer des bogues que l'utilisation d'un type signé avec certaines classes de tâches.

Utilisez le bon outil pour le travail.

Quel est le problème avec l'arithmétique modulaire? N'est-ce pas le comportement attendu d'un int non signé?
Pourquoi l'utilisation d'un entier non signé est-elle plus susceptible de causer des bogues que l'utilisation d'un entier signé?

Si la tâche est bien concordante: rien de mal. Non, pas plus probable.

Les algorithmes de sécurité, de chiffrement et d'authentification reposent sur des calculs modulaires non signés.

Les algorithmes de compression/décompression aussi bien que divers formats graphiques sont bénéfiques et moins bogués avec maths non signés .

Si des opérateurs et des décalages temporels sont utilisés, les opérations non signées ne sont pas gâchées par les problèmes d'extension de signature de maths signés .


Les mathématiques entières signées ont une apparence intuitive que tous comprennent, y compris les apprenants, à la programmation. C/C++ n'était pas ciblé à l'origine et ne devrait maintenant plus être un langage intro. Pour le codage rapide utilisant des filets de sécurité contre les débordements, d'autres langues sont mieux adaptées. Pour le code rapide maigre, C suppose que les codeurs savent ce qu’ils font (ils ont de l’expérience).

Un piège de mathématiques signées aujourd'hui est l'omniprésent int 32 bits qui, avec autant de problèmes, est suffisamment vaste pour les tâches courantes sans vérification de plage. Cela conduit à la complaisance que le débordement n'est pas codé. Au lieu de cela, for (int i=0; i < n; i++)int len = strlen(s); est considéré comme correct car n est supposé <INT_MAX et les chaînes ne seront jamais trop longues, au lieu d'être protégées à distance dans le premier cas ou en utilisant size_t, unsigned ou même long long 2ème.

C/C++ développé à une époque qui comprenait int 16 bits ainsi que 32 bits et le bit supplémentaire fourni par size_t 16 bits non signé était significatif. Il fallait faire attention aux problèmes de débordement, que ce soit int ou unsigned.

Avec les applications 32 bits (ou plus larges) de Google sur les plateformes int/unsigned non 16 bits, le manque d'attention accordée au dépassement de capacité de int étant donné sa plage étendue. Cela a du sens pour de telles applications d’encourager int sur unsigned. Cependant, les mathématiques int ne sont pas bien protégées.

Les préoccupations int/unsigned 16 bits étroites s’appliquent aujourd’hui à certaines applications intégrées.

Les directives de Google s'appliquent bien au code qu'elles écrivent aujourd'hui. Ce n'est pas un guide définitif pour la plus large gamme de code C/C++.


Une des raisons pour lesquelles je peux penser à utiliser un int signé par un autre, c'est que s'il déborde (en négatif), il est plus facile à détecter.

En C/C++, le dépassement mathématique signé est un comportement indéfini et n'est donc certainement pas plus facile à détecter que le comportement défini d'un calcul mathématique non signé .


Comme @ Chris Uzdavinis bien commenté, il est préférable d'éviter le mélange signé et non signé (+++) débutants) et sinon codés avec soin au besoin.

11
chux

J'ai une certaine expérience avec le guide de style de Google, AKA, le Guide de l'auto-stoppeur, contenant des directives insensées de la part de mauvais programmeurs qui ont intégré la société il y a très longtemps. Cette directive particulière n'est qu'un exemple des douzaines de règles relatives aux noisettes contenues dans ce livre.

Les erreurs se produisent uniquement avec les types non signés si vous essayez de les calculer (voir l'exemple de Chris Uzdavinis ci-dessus), autrement dit, si vous les utilisez comme nombres. Les types non signés ne sont pas destinés à être utilisés pour stocker des quantités numériques, ils sont destinés à stocker comptes tels que la taille des conteneurs, qui ne peuvent jamais être négatifs, et ils peuvent et doivent être utilisés à cette fin.

L'idée d'utiliser des types arithmétiques (comme des entiers signés) pour stocker les tailles de conteneur est idiote. Voulez-vous utiliser un double pour stocker la taille d'une liste, aussi? Le fait que des personnes chez Google stockent des tailles de conteneurs en utilisant des types arithmétiques et obligent les autres à faire la même chose en dit long sur la société. Une chose que je remarque à propos de tels dictats est que plus ils sont douteux, plus ils doivent être rigoureux, car des règles simples permettraient aux personnes qui ont du bon sens de les ignorer.

5
Tyler Durden

Utilisation de types non signés pour représenter des valeurs non négatives ...

  • est plus susceptible de causer des bogues liés à la promotion du type, lorsqu’il utilise des valeurs signées et non signées, comme le montre une autre réponse, en détail, mais
  • est moins susceptible de provoquer des bogues impliquant le choix de types avec des domaines capables de représenter des valeurs indésirables/non autorisées. Dans certains endroits, vous supposerez que la valeur appartient au domaine et que vous risquez un comportement inattendu et potentiellement dangereux lorsque d'autres valeurs se faufileront d'une manière ou d'une autre.

Les directives de codage de Google mettent l'accent sur le premier type de considération. D'autres ensembles de directives, tels que C++ Core Guidelines , mettent davantage l'accent sur le deuxième point. Par exemple, considérons la directive de base I.12 :

I.12: Déclarer un pointeur qui ne doit pas être nul comme not_null

Raison

Pour éviter les erreurs de déréférencement nullptr. Pour améliorer les performances en évitant les contrôles redondants pour nullptr.

Exemple

int length(const char* p);            // it is not clear whether length(nullptr) is valid
length(nullptr);                      // OK?
int length(not_null<const char*> p);  // better: we can assume that p cannot be nullptr
int length(const char* p);            // we must assume that p can be nullptr

En indiquant l'intention dans le source, les implémenteurs et les outils peuvent fournir de meilleurs diagnostics, tels que la recherche de classes d'erreurs via une analyse statique, et effectuer des optimisations, telles que la suppression de branches et de tests null.

Bien sûr, vous pourriez argumenter en faveur d'un wrapper non_negative pour les entiers, ce qui évite les deux catégories d'erreurs, mais aurait ses propres problèmes ...

1
einpoklum