La norme C ++ permet-elle à un booléen non initialisé de planter un programme?

500

Je sais qu'un "comportement non défini" en C ++ peut à peu près permettre au compilateur de faire tout ce qu'il veut. Cependant, j'ai eu un crash qui m'a surpris, car j'ai supposé que le code était suffisamment sûr.

Dans ce cas, le vrai problème ne s'est produit que sur une plate-forme spécifique à l'aide d'un compilateur spécifique, et uniquement si l'optimisation a été activée.

J'ai essayé plusieurs choses afin de reproduire le problème et de le simplifier au maximum. Voici un extrait d'une fonction appelée Serialize, qui prendrait un paramètre booléen et copierait la chaîne trueou falsedans un tampon de destination existant.

Cette fonction serait-elle dans une révision de code, il n'y aurait aucun moyen de dire qu'elle pourrait en fait planter si le paramètre bool était une valeur non initialisée?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

Si ce code est exécuté avec les optimisations de clang 5.0.0 +, il peut / peut se bloquer.

L'opérateur ternaire attendu boolValue ? "true" : "false"paraissait assez sûr pour moi, je supposais, "Quelle que soit la valeur de la poubelle, cela boolValuen'a pas d'importance, car elle sera évaluée comme vraie ou fausse de toute façon."

J'ai configuré un exemple d'Explorateur de compilateur qui montre le problème dans le démontage, voici l'exemple complet. Remarque: afin de reprocher le problème, la combinaison que j'ai trouvée fonctionne en utilisant Clang 5.0.0 avec l'optimisation -O2.

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

Le problème se pose à cause de l'optimiseur: il était assez intelligent pour déduire que les chaînes "true" et "false" ne diffèrent que par la longueur de 1. Donc, au lieu de vraiment calculer la longueur, il utilise la valeur du bool lui-même, qui devrait techniquement soit 0 ou 1, et va comme ceci:

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

Bien que cela soit "intelligent", pour ainsi dire, ma question est la suivante: le standard C ++ permet-il à un compilateur de supposer qu'un booléen ne peut avoir qu'une représentation numérique interne de "0" ou "1" et de l'utiliser de cette manière?

Ou s'agit-il d'un cas défini par l'implémentation, auquel cas l'implémentation a supposé que tous ses bools ne contiendront que 0 ou 1, et toute autre valeur est un territoire de comportement indéfini?

Remz
la source
200
C'est une excellente question. C'est une illustration solide de la façon dont un comportement indéfini n'est pas seulement une préoccupation théorique. Quand les gens disent que quelque chose peut arriver à la suite de l'UB, ce "n'importe quoi" peut vraiment être assez surprenant. On pourrait supposer qu'un comportement indéfini se manifeste toujours de manière prévisible, mais de nos jours avec les optimiseurs modernes, ce n'est pas du tout vrai. OP a pris le temps de créer un MCVE, a étudié le problème de manière approfondie, a inspecté le démontage et a posé une question claire et simple à ce sujet. Je ne pourrais pas demander plus.
John Kugelman
7
Observez que l'exigence selon laquelle «non nul est évalué à true» est une règle concernant les opérations booléennes, y compris «l'affectation à un booléen» (qui pourrait implicitement invoquer un en static_cast<bool>()fonction des spécificités). Il ne s'agit cependant pas d'une exigence sur la représentation interne d'un boolchoisi par le compilateur.
Euro Micelli
2
Les commentaires ne sont pas pour une discussion approfondie; cette conversation a été déplacée vers le chat .
Samuel Liew
3
Sur une note très connexe, c'est une source "amusante" d'incompatibilité binaire. Si vous avez un ABI A qui met des valeurs à zéro avant d'appeler une fonction, mais compile des fonctions de telle sorte qu'il suppose que les paramètres sont remplis à zéro, et un ABI B qui est le contraire (ne met pas à zéro, mais ne suppose pas zéro -padded parameters), cela fonctionnera principalement , mais une fonction utilisant le B ABI causera des problèmes si elle appelle une fonction utilisant le A ABI qui prend un 'petit' paramètre. IIRC vous l'avez sur x86 avec clang et ICC.
TLW
1
@TLW: Bien que la norme n'exige pas que les implémentations fournissent un moyen d'appeler ou d'être appelé par du code extérieur, il aurait été utile d'avoir un moyen de spécifier de telles choses pour les implémentations lorsqu'elles sont pertinentes (implémentations où ces détails ne sont pas pertinentes pourraient ignorer ces attributs).
supercat

Réponses:

285

Oui, ISO C ++ permet (mais ne nécessite pas) des implémentations pour faire ce choix.

Mais notez également que ISO C ++ permet à un compilateur d'émettre du code qui se bloque exprès (par exemple avec une instruction illégale) si le programme rencontre UB, par exemple comme un moyen de vous aider à trouver des erreurs. (Ou parce que c'est une DeathStation 9000. Être strictement conforme n'est pas suffisant pour qu'une implémentation C ++ soit utile dans un but réel). Ainsi, ISO C ++ permettrait à un compilateur de faire un asm qui s'est écrasé (pour des raisons totalement différentes) même sur un code similaire qui lit un fichier non initialisé uint32_t. Même si cela doit être un type à disposition fixe sans représentation d'interruption.

C'est une question intéressante sur le fonctionnement des implémentations réelles, mais rappelez-vous que même si la réponse était différente, votre code serait toujours dangereux car le C ++ moderne n'est pas une version portable du langage d'assemblage.


Vous compilez pour le système V86 x86-64 ABI , qui spécifie qu'un en booltant que fonction arg dans un registre est représenté par les modèles de bits false=0ettrue=1 dans les 8 bits de poids faible du registre 1 . En mémoire, boolest un type à 1 octet qui doit à nouveau avoir une valeur entière de 0 ou 1.

(Un ABI est un ensemble de choix d'implémentation sur lesquels les compilateurs de la même plate-forme s'accordent pour qu'ils puissent créer du code qui appelle les fonctions les uns des autres, y compris les tailles de type, les règles de disposition de structure et les conventions d'appel.)

ISO C ++ ne le spécifie pas, mais cette décision ABI est répandue car elle rend la conversion bool-> int bon marché (juste une extension zéro) . Je ne connais aucun ABI qui ne laisse pas le compilateur assumer 0 ou 1 pour bool, pour n'importe quelle architecture (pas seulement x86). Il permet des optimisations comme !myboolavec xor eax,1pour inverser le bit bas: Tout code possible qui peut inverser un bit / entier / booléen entre 0 et 1 en instruction CPU unique . Ou la compilation a&&bsur un ET au niveau du bit pour les booltypes. Certains compilateurs profitent en fait des valeurs booléennes de 8 bits dans les compilateurs. Les opérations sur eux sont-elles inefficaces? .

En général, la règle as-if permet au compilateur de tirer parti des informations qui sont vraies sur la plate-forme cible à compiler , car le résultat final sera un code exécutable qui implémentera le même comportement visible de l'extérieur que la source C ++. (Avec toutes les restrictions que le comportement indéfini place sur ce qui est réellement "visible de l'extérieur": non pas avec un débogueur, mais à partir d'un autre thread dans un programme C ++ bien formé / légal.)

Le compilateur est certainement autorisé à profiter pleinement d'une garantie ABI dans son code-gen, et rendre le code comme vous avez trouvé ce qui permet d' optimiser strlen(whichString)à
5U - boolValue.
(BTW, cette optimisation est assez intelligente, mais peut-être à courte vue par rapport à la ramification et à l'inline en memcpytant que magasins de données immédiates 2. )

Ou le compilateur aurait pu créer une table de pointeurs et l'indexer avec la valeur entière de la bool, en supposant à nouveau qu'il s'agissait d'un 0 ou 1. ( Cette possibilité est ce que la réponse de @ Barmar a suggéré .)


Votre __attribute((noinline))constructeur avec l'optimisation activée a conduit à claquer juste le chargement d'un octet de la pile pour l'utiliser comme uninitializedBool. Il a fait l' espace pour l'objet mainavec push rax( ce qui est plus petit et pour diverses raisons à peu près aussi efficace que sub rsp, 8), de sorte que tout ce qui était dans les ordures AL à l' entrée de mainla valeur qu'elle utilisée pour uninitializedBool. C'est pourquoi vous avez en fait obtenu des valeurs qui n'étaient pas seulement 0.

5U - random garbagepeut facilement encapsuler une grande valeur non signée, ce qui amène memcpy à entrer dans la mémoire non mappée. La destination est en stockage statique, pas la pile, donc vous n'écrasez pas une adresse de retour ou quelque chose.


D'autres implémentations pourraient faire des choix différents, par exemple false=0et true=any non-zero value. Ensuite, clang ne produirait probablement pas de code qui se bloque pour cette instance spécifique d'UB. (Mais il serait toujours autorisé à le faire s'il le voulait.) Je ne connais aucune implémentation qui choisisse autre chose que ce que fait x86-64 bool, mais la norme C ++ autorise beaucoup de choses que personne ne fait ou ne voudrait faire sur matériel qui ressemble à des processeurs actuels.

ISO C ++ ne précise pas ce que vous trouverez lorsque vous examinerez ou modifierez la représentation objet d'unbool . (par exemple par memcpying l' boolen unsigned char, que vous êtes autorisé à le faire parce que char*tout peut alias. Et unsigned charest garanti d'avoir aucun bit de remplissage, de sorte que le standard C ++ ne vous permet formellement HexDump représentations d'objets sans UB. Pointer-casting pour copier l'objet la représentation est différente de l'affectation char foo = my_bool, bien sûr, donc la booléenisation à 0 ou 1 ne se produirait pas et vous obtiendriez la représentation d'objet brut.)

Vous avez partiellement "caché" l'UB sur ce chemin d'exécution du compilateur avecnoinline . Même si elle n'est pas en ligne, cependant, les optimisations interprocédurales pourraient toujours créer une version de la fonction qui dépend de la définition d'une autre fonction. (Premièrement, clang crée un exécutable, pas une bibliothèque partagée Unix où l'interposition de symboles peut se produire. Deuxièmement, la définition se trouve à l'intérieur de la class{}définition de sorte que toutes les unités de traduction doivent avoir la même définition. Comme avec le inlinemot - clé.)

Ainsi, un compilateur pourrait émettre juste un retou ud2(instruction illégale) comme définition pour main, car le chemin d'exécution commençant au sommet de mainrencontre inévitablement un comportement indéfini. (Ce que le compilateur peut voir au moment de la compilation s'il décide de suivre le chemin à travers le constructeur non en ligne.)

Tout programme qui rencontre UB est totalement indéfini pour toute son existence. Mais UB à l'intérieur d'une fonction ou d'une if()branche qui ne s'exécute jamais ne corrompe pas le reste du programme. En pratique, cela signifie que les compilateurs peuvent décider d'émettre une instruction illégale ret, ou de ne pas émettre quoi que ce soit et de tomber dans le bloc / fonction suivant, pour l'ensemble du bloc de base qui peut être prouvé au moment de la compilation pour contenir ou conduire à UB.

GCC et Clang dans la pratique ne fait parfois émettent ud2sur UB, au lieu de même essayer de générer du code pour les chemins d'exécution qui ne font pas de sens. Ou pour des cas comme tomber de la fin d'une non- voidfonction, gcc omettra parfois une retinstruction. Si vous pensiez que "ma fonction ne fera que revenir avec les ordures dans RAX", vous vous trompez profondément. Les compilateurs C ++ modernes ne traitent plus le langage comme un langage d'assemblage portable. Votre programme doit vraiment être C ++ valide, sans faire d'hypothèses sur l'apparence d'une version autonome non intégrée de votre fonction dans asm.

Un autre exemple amusant est: Pourquoi l'accès non aligné à la mémoire mmap est-il parfois un défaut de segmentation sur AMD64? . x86 ne fait pas défaut sur les entiers non alignés, non? Alors pourquoi un mauvais alignement uint16_t*serait-il un problème? Parce que alignof(uint16_t) == 2, et violer cette hypothèse a conduit à une erreur de segmentation lors de la vectorisation automatique avec SSE2.

Voir aussi Ce que tout programmeur C devrait savoir sur le comportement indéfini # 1/3, un article d'un développeur clang.

Point clé: si le compilateur a remarqué l'UB au moment de la compilation, il pourrait "casser" (émettre un asm surprenant) le chemin à travers votre code qui provoque UB même s'il cible un ABI où n'importe quel motif binaire est une représentation d'objet valide pour bool.

Attendez-vous à une hostilité totale envers de nombreuses erreurs de la part du programmeur, en particulier les choses que les compilateurs modernes mettent en garde. C'est pourquoi vous devez utiliser -Wallet corriger les avertissements. C ++ n'est pas un langage convivial, et quelque chose en C ++ peut être dangereux même s'il serait sûr en asm sur la cible pour laquelle vous compilez. (Par exemple, le débordement signé est UB en C ++ et les compilateurs supposeront que cela ne se produit pas, même lors de la compilation pour le complément x86 à 2, sauf si vous l'utilisez clang/gcc -fwrapv.)

L'UB visible à la compilation est toujours dangereux, et il est vraiment difficile d'être sûr (avec l'optimisation de la liaison) que vous avez vraiment caché l'UB au compilateur et pouvez donc raisonner sur le type d'asm qu'il générera.

Ne pas être trop dramatique; Souvent, les compilateurs vous permettent de vous en sortir avec certaines choses et d'émettre du code comme vous vous attendez même lorsque quelque chose est UB. Mais ce sera peut-être un problème à l'avenir si les développeurs du compilateur implémentent une optimisation qui obtient plus d'informations sur les plages de valeurs (par exemple, qu'une variable n'est pas négative, lui permettant peut-être d'optimiser l'extension de signe pour libérer l'extension zéro sur x86- 64). Par exemple, dans gcc et clang actuels, faire tmp = a+INT_MINne s'optimise pas a<0comme toujours faux, mais c'est tmptoujours négatif. (Parce que INT_MIN+ a=INT_MAXest négatif sur la cible de complément de 2 et ane peut pas être supérieur à cela.)

Donc, gcc / clang ne fait pas actuellement marche arrière pour dériver les informations de plage pour les entrées d'un calcul, uniquement sur les résultats basés sur l'hypothèse d'aucun débordement signé: exemple sur Godbolt . Je ne sais pas si cette optimisation est intentionnellement «manquée» au nom de la convivialité ou quoi.

Notez également que les implémentations (alias compilateurs) sont autorisées à définir le comportement qu'ISO C ++ laisse non défini . Par exemple, tous les compilateurs qui prennent en charge les intrinsèques d'Intel (comme _mm_add_ps(__m128, __m128)pour la vectorisation SIMD manuelle) doivent permettre de former des pointeurs mal alignés, ce qui est UB en C ++ même si vous ne les déréférencez pas. __m128i _mm_loadu_si128(const __m128i *)effectue des charges non alignées en prenant un __m128i*argument mal aligné , pas un void*ou char*. Est-ce que `reinterpret_cast`ing entre le pointeur vectoriel matériel et le type correspondant est un comportement non défini?

GNU C / C ++ définit également le comportement de décalage à gauche d'un nombre signé négatif (même sans -fwrapv), séparément des règles UB normales de débordement signé. ( Il s'agit d'UB dans ISO C ++ , tandis que les décalages à droite des nombres signés sont définis par l'implémentation (logique ou arithmétique); des implémentations de bonne qualité choisissent l'arithmétique sur HW qui a des décalages à droite arithmétiques, mais ISO C ++ ne spécifie pas). Ceci est documenté dans la section Integer du manuel GCC , ainsi que la définition du comportement défini par l'implémentation que les normes C nécessitent que les implémentations définissent d'une manière ou d'une autre.

Il y a certainement des problèmes de qualité de mise en œuvre qui intéressent les développeurs de compilateurs; ils n'essaient généralement pas de faire des compilateurs intentionnellement hostiles, mais tirer parti de tous les nids-de-poule UB en C ++ (sauf ceux qu'ils choisissent de définir) pour mieux optimiser peut parfois être presque impossible à distinguer.


Note de bas de page 1 : Les 56 bits supérieurs peuvent être des ordures que l'appelé doit ignorer, comme d'habitude pour les types plus étroits qu'un registre.

( D' autres ABIs font faire des choix différents ici . Certains ne nécessitent des types entiers étroits pour être ou signe-zéro étendu pour remplir un registre lorsqu'il est passé ou retour de fonctions, comme MIPS64 et PowerPC64. Voir la dernière section de cette réponse x86-64 qui compare avec les ISA antérieures .)

Par exemple, un appelant peut avoir calculé a & 0x01010101en RDI et l'utiliser pour autre chose, avant d'appeler bool_func(a&1). L'appelant pourrait optimiser le &1car il l'a déjà fait pour l'octet bas dans le cadre de and edi, 0x01010101, et il sait que l'appelé doit ignorer les octets élevés.

Ou si un booléen est passé comme 3e argument, peut-être qu'un appelant optimisant pour la taille du code le charge avec mov dl, [mem]au lieu de movzx edx, [mem], économisant 1 octet au prix d'une fausse dépendance à l'ancienne valeur de RDX (ou tout autre effet de registre partiel, selon sur modèle CPU). Ou pour le premier argument, mov dil, byte [r10]au lieu de movzx edi, byte [r10], car les deux nécessitent de toute façon un préfixe REX.

C'est pourquoi clang émet movzx eax, dilà la Serializeplace de sub eax, edi. (Pour les arguments entiers, clang viole cette règle ABI, en fonction du comportement non documenté de gcc et clang à zéro ou à extension de signe des entiers étroits à 32 bits. Un signe ou une extension zéro est-il requis lors de l'ajout d'un décalage de 32 bits à un pointeur pour le x86-64 ABI? J'ai donc été intéressé de voir qu'il ne fait pas la même chose pour bool.)


Note de bas de page 2: après la movcréation d'un branchement, vous disposez simplement d'un magasin à 4 octets immédiat ou d'un magasin à 4 octets + 1 octet. La longueur est implicite dans les largeurs de magasin + décalages.

OTOH, glibc memcpy fera deux chargements / magasins de 4 octets avec un chevauchement qui dépend de la longueur, donc cela finit vraiment par rendre le tout exempt de branches conditionnelles sur le booléen. Voir le L(between_4_7):bloc dans memcpy / memmove de glibc. Ou du moins, procédez de la même manière pour chaque booléen dans la branche de memcpy pour sélectionner une taille de bloc.

Si vous êtes en ligne, vous pouvez utiliser 2x mov-immediate + cmovet un décalage conditionnel, ou vous pouvez laisser les données de chaîne en mémoire.

Ou si le réglage pour Intel Ice Lake ( avec la fonction Fast Short REP MOV ), un réel rep movsbpeut être optimal. glibc memcpypeut commencer à utiliser rep movsb pour les petites tailles sur les processeurs avec cette fonctionnalité, économisant ainsi beaucoup de branchements.


Outils de détection d'UB et d'utilisation de valeurs non initialisées

Dans gcc et clang, vous pouvez compiler avec -fsanitize=undefinedpour ajouter une instrumentation d'exécution qui avertira ou générera une erreur sur UB qui se produit lors de l'exécution. Cependant, cela n'acceptera pas les variables unitarisées. (Parce qu'il n'augmente pas la taille des caractères pour faire de la place pour un bit "non initialisé").

Voir https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

Pour trouver l'utilisation des données non initialisées, il existe un assainisseur d'adresse et un assainisseur de mémoire dans clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer montre des exemples de clang -fsanitize=memory -fPIE -piedétection de lectures de mémoire non initialisées. Cela peut fonctionner mieux si vous compilez sans optimisation, donc toutes les lectures de variables finissent par se charger réellement à partir de la mémoire dans l'asm. Ils montrent qu'il est utilisé -O2dans un cas où la charge ne serait pas optimisée. Je ne l'ai pas essayé moi-même. (Dans certains cas, par exemple en n'initialisant pas un accumulateur avant de sommer un tableau, clang -O3 émettra du code qui résume dans un registre vectoriel qu'il n'a jamais initialisé. Ainsi, avec l'optimisation, vous pouvez avoir un cas où il n'y a pas de lecture de mémoire associée à l'UB . Mais-fsanitize=memory modifie l'asm généré et peut entraîner une vérification.)

Il tolérera la copie de la mémoire non initialisée, ainsi que les opérations logiques et arithmétiques simples avec elle. En général, MemorySanitizer suit silencieusement la propagation des données non initialisées en mémoire et signale un avertissement lorsqu'une branche de code est prise (ou non prise) en fonction d'une valeur non initialisée.

MemorySanitizer implémente un sous-ensemble de fonctionnalités trouvées dans Valgrind (outil Memcheck).

Cela devrait fonctionner dans ce cas, car l'appel à glibc memcpyavec une lengthmémoire calculée à partir de la mémoire non initialisée entraînera (à l'intérieur de la bibliothèque) une branche basée sur length. S'il avait intégré une version entièrement sans branche qui vient d'utiliser cmov, l'indexation et deux magasins, cela n'aurait peut-être pas fonctionné.

Valgrindmemcheck recherchera également ce type de problème, ne se plaignant pas non plus si le programme copie simplement des données non initialisées. Mais il dit qu'il détectera quand un "saut ou déplacement conditionnel dépend de valeurs non initialisées", pour essayer d'attraper tout comportement visible de l'extérieur qui dépend de données non initialisées.

Peut-être que l'idée de ne pas signaler uniquement une charge est que les structures peuvent avoir un remplissage, et la copie de la structure entière (y compris le remplissage) avec un large chargement / stockage vectoriel n'est pas une erreur même si les membres individuels n'ont été écrits qu'un par un. Au niveau asm, les informations sur ce qui était du remplissage et ce qui fait réellement partie de la valeur ont été perdues.

Peter Cordes
la source
2
J'ai vu un cas pire où la variable a pris une valeur non comprise dans la plage d'un entier de 8 bits, mais uniquement de l'ensemble du registre CPU. Et Itanium en a encore une pire, l'utilisation d'une variable non initialisée peut planter.
Joshua
2
@Joshua: oh oui, bon point, la spéculation explicite d'Itanium marquera les valeurs de registre avec un équivalent de "pas un nombre", de sorte que l'utilisation des défauts de valeur.
Peter Cordes
11
De plus, cela illustre également pourquoi le bogue de fonctionnalité UB a été introduit dans la conception des langages C et C ++ en premier lieu: car il donne au compilateur exactement ce genre de liberté, qui a maintenant permis aux compilateurs les plus modernes d'effectuer ces performances de haute qualité optimisations qui font de C / C ++ de tels langages intermédiaires de haute performance.
The_Sympathizer
2
Et donc la guerre entre les rédacteurs du compilateur C ++ et les programmeurs C ++ essayant d'écrire des programmes utiles continue. Cette réponse, totalement complète pour répondre à cette question, pourrait également être utilisée telle
quelle
4
@The_Sympathizer: UB a été inclus pour permettre aux implémentations de se comporter de toutes les manières qui seraient les plus utiles à leurs clients . Il n'était pas destiné à suggérer que tous les comportements devraient être considérés comme également utiles.
supercat
56

Le compilateur est autorisé à supposer qu'une valeur booléenne passée en argument est une valeur booléenne valide (c'est-à-dire qui a été initialisée ou convertie en trueou false). La truevaleur ne doit pas nécessairement être la même que l'entier 1 - en effet, il peut y avoir diverses représentations de trueet false- mais le paramètre doit être une représentation valide de l'une de ces deux valeurs, où "représentation valide" est l'implémentation - défini.

Donc, si vous ne parvenez pas à initialiser un bool, ou si vous réussissez à l'écraser via un pointeur d'un type différent, les hypothèses du compilateur seront erronées et un comportement indéfini s'ensuivra. Vous aviez été prévenu:

50) L'utilisation d'une valeur booléenne de la manière décrite par la présente Norme internationale comme «non définie», par exemple en examinant la valeur d'un objet automatique non initialisé, pourrait le faire se comporter comme s'il n'était ni vrai ni faux. (Note en bas de page du paragraphe 6 du §6.9.1, Types fondamentaux)

rici
la source
11
La " truevaleur ne doit pas nécessairement être la même que l'entier 1" est en quelque sorte trompeuse. Bien sûr, le modèle binaire réel pourrait être autre chose, mais lorsqu'il est implicitement converti / promu (la seule façon dont vous verriez une valeur autre que true/ false), trueest toujours 1et falseest toujours0 . Bien sûr, un tel compilateur serait également incapable d'utiliser l'astuce que ce compilateur essayait d'utiliser (en utilisant le fait que boolle modèle de bits réel ne pouvait être que 0ou 1), donc c'est un peu sans rapport avec le problème de l'OP.
ShadowRanger
4
@ShadowRanger Vous pouvez toujours inspecter directement la représentation de l'objet.
TC
7
@shadowranger: mon point est que l'implémentation est en charge. S'il limite les représentations valides de trueau motif binaire 1, c'est sa prérogative. S'il choisit un autre ensemble de représentations, il ne pourrait en effet pas utiliser l'optimisation notée ici. S'il choisit cette représentation particulière, il le peut. Il doit seulement être cohérent en interne. Vous pouvez examiner la représentation d'un boolen le copiant dans un tableau d'octets; ce n'est pas UB (mais il est défini par l'implémentation)
rici
3
Oui, l'optimisation des compilateurs (c'est-à-dire l'implémentation C ++ dans le monde réel) émet souvent du code qui dépend de la boolprésence d'un modèle binaire de 0ou 1. Ils ne re-booléensent pas boolchaque fois qu'ils le lisent depuis la mémoire (ou un registre contenant une fonction arg). Voilà ce que dit cette réponse. exemples : gcc4.7 + peut optimiser return a||bvers or eax, edidans une fonction de retour bool, ou MSVC peut optimiser a&bvers test cl, dl. x86 testest un bit and , donc si cl=1et dl=2test définit les drapeaux selon cl&dl = 0.
Peter Cordes
5
Le point sur le comportement indéfini est que le compilateur est autorisé à tirer beaucoup plus de conclusions à ce sujet, par exemple à supposer qu'un chemin de code qui conduirait à accéder à une valeur non initialisée n'est jamais pris du tout, car s'assurer que c'est précisément la responsabilité du programmeur . Il ne s'agit donc pas seulement de la possibilité que les valeurs de bas niveau soient différentes de zéro ou un.
Holger
52

La fonction elle-même est correcte, mais dans votre programme de test, l'instruction qui appelle la fonction provoque un comportement non défini en utilisant la valeur d'une variable non initialisée.

Le bogue se trouve dans la fonction appelante et il pourrait être détecté par un examen du code ou une analyse statique de la fonction appelante. En utilisant votre lien d'explorateur de compilateur, le compilateur gcc 8.2 détecte le bogue. (Vous pourriez peut-être déposer un rapport de bogue contre clang qu'il ne trouve pas le problème).

Un comportement indéfini signifie que tout peut arriver, ce qui inclut le programme qui plante quelques lignes après l'événement qui a déclenché le comportement indéfini.

NB. La réponse à "Un comportement indéfini peut-il provoquer _____?" est toujours "Oui". C'est littéralement la définition d'un comportement indéfini.

MM
la source
2
La première clause est-elle vraie? Est-ce que la simple copie d' un booldéclencheur UB non initialisé ?
Joshua Green
10
@JoshuaGreen voir [dcl.init] / 12 "Si une valeur indéterminée est produite par une évaluation, le comportement n'est pas défini sauf dans les cas suivants:" (et aucun de ces cas n'a d'exception pour bool). La copie nécessite l'évaluation de la source
MM
8
@JoshuaGreen Et la raison en est que vous pourriez avoir une plate-forme qui déclenche une panne matérielle si vous accédez à des valeurs non valides pour certains types. Celles-ci sont parfois appelées «représentations pièges».
David Schwartz
7
Itanium, bien qu'obscur, est un processeur qui est toujours en production, a des valeurs d'interruption et dispose de deux compilateurs C ++ au moins semi-modernes (Intel / HP). Il a littéralement true, falseet des not-a-thingvaleurs pour booléens.
MSalters
3
D'un autre côté, la réponse à «La norme exige-t-elle que tous les compilateurs traitent quelque chose d'une certaine manière» est généralement «non», même / surtout dans les cas où il est évident que tout compilateur de qualité devrait le faire; plus quelque chose est évident, moins les auteurs de la norme devraient avoir besoin de le dire.
supercat
23

Un booléen est uniquement autorisé à contenir les valeurs dépendant de l'implémentation utilisées en interne pour trueet false, et le code généré peut supposer qu'il ne contiendra qu'une seule de ces deux valeurs.

En règle générale, l'implémentation utilisera l'entier 0pour falseet 1pour true, pour simplifier les conversions entre boolet int, et pour if (boolvar)générer le même code que if (intvar). Dans ce cas, on peut imaginer que le code généré pour le ternaire dans l'affectation utiliserait la valeur comme index dans un tableau de pointeurs vers les deux chaînes, c'est-à-dire qu'il pourrait être converti en quelque chose comme:

// the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];

S'il boolValuen'est pas initialisé, il pourrait en fait contenir n'importe quelle valeur entière, ce qui entraînerait alors un accès en dehors des limites du stringstableau.

Barmar
la source
1
@SidS Merci. Théoriquement, les représentations internes pourraient être l'opposé de la façon dont elles sont converties en / à partir d'entiers, mais ce serait pervers.
Barmar
1
Vous avez raison, et votre exemple se bloquera également. Cependant, il est "visible" pour une révision de code que vous utilisez une variable non initialisée comme index d'un tableau. En outre, il se bloquerait même lors du débogage (par exemple, un débogueur / compilateur s'initialiserait avec des modèles spécifiques pour le rendre plus facile à voir lorsqu'il se bloque). Dans mon exemple, la partie surprenante est que l'utilisation du bool est invisible: l'optimiseur a décidé de l'utiliser dans un calcul non présent dans le code source.
Remz
3
@Remz J'utilise simplement le tableau pour montrer à quoi le code généré pourrait être équivalent, sans suggérer que quiconque l'écrirait réellement.
Barmar
1
@Remz Refondez le boolà intavec *(int *)&boolValueet imprimez-le à des fins de débogage, voyez s'il s'agit de quelque chose d'autre 0ou 1quand il se bloque. Si tel est le cas, cela confirme à peu près la théorie selon laquelle le compilateur optimise l'inline-if sous forme de tableau, ce qui explique pourquoi il se bloque.
Havenard
2
@MSalters: std::bitset<8>ne me donne pas de bons noms pour tous mes différents drapeaux. Selon ce qu'ils sont, cela peut être important.
Martin Bonner soutient Monica le
15

En résumant beaucoup votre question, vous vous demandez si la norme C ++ permet à un compilateur de supposer qu'un boolne peut avoir qu'une représentation numérique interne de «0» ou «1» et de l'utiliser de cette manière?

La norme ne dit rien sur la représentation interne d'un bool. Il définit uniquement ce qui se passe lors de la conversion d'un boolvers un int(ou vice versa). Généralement, en raison de ces conversions intégrales (et du fait que les gens y dépendent plutôt), le compilateur utilisera 0 et 1, mais il n'est pas obligé (bien qu'il doive respecter les contraintes de tout ABI de niveau inférieur qu'il utilise ).

Ainsi, le compilateur, lorsqu'il voit un, boolest en droit de considérer que ledit boolcontient l'un des modèles de bits ' true' ou ' false' et de faire tout ce qu'il ressent. Donc , si les valeurs trueet falsesont 1 et 0, respectivement, le compilateur est en effet permis d'optimiser strlenà 5 - <boolean value>. D'autres comportements amusants sont possibles!

Comme indiqué à plusieurs reprises ici, un comportement indéfini a des résultats indéfinis. Y compris, mais sans s'y limiter

  • Votre code fonctionne comme prévu
  • Votre code échoue à des moments aléatoires
  • Votre code n'est pas exécuté du tout.

Voir ce que chaque programmeur doit savoir sur le comportement non défini

Tom Tanner
la source