web-dev-qa-db-fra.com

À quel moment de la boucle le dépassement d'entier devient-il un comportement indéfini?

Ceci est un exemple pour illustrer ma question qui implique un code beaucoup plus compliqué que je ne peux pas poster ici.

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

Ce programme contient un comportement indéfini sur ma plate-forme car a débordera sur la 3ème boucle.

Est-ce que cela fait que le programme entier a un comportement indéfini, ou seulement après que le débordement se produise réellement ? Le compilateur pourrait-il potentiellement déterminer que a will overflow lui permet de déclarer la boucle entière non définie et de ne pas avoir la peine d’exécuter les printfs même s’ils se produisent tous avant le débordement?

(Les balises C et C++ sont différentes même si je suis intéressé par les réponses des deux langues si elles sont différentes.)

85
jcoder

Si vous êtes intéressé par une réponse purement théorique, la norme C++ permet aux comportements indéfinis de "voyager dans le temps":

[intro.execution]/5: Une mise en œuvre conforme exécutant un programme bien formé produira le même comportement observable comme l'une des exécutions possibles de l'instance correspondante de la machine abstraite avec le même programme et la même entrée. Cependant, si une telle exécution contient une opération non définie, cet International Standard n'impose aucune exigence à l'implémentation qui exécute ce programme avec cette entrée (même pour les opérations précédant la première opération indéfinie)

En tant que tel, si votre programme contient un comportement non défini, le comportement de votre programme complet _ n'est pas défini.

106
TartanLlama

Tout d’abord, laissez-moi corriger le titre de cette question:

Undefined Behavior n'est pas (spécifiquement) du domaine de l'exécution.

Un comportement non défini affecte toutes les étapes: la compilation, la liaison, le chargement et l'exécution.

Quelques exemples pour cimenter ceci, gardez à l’esprit qu’aucune section n’est exhaustive:

  • le compilateur peut supposer que des parties de code contenant un comportement indéfini ne sont jamais exécutées, et donc supposer que les chemins d'exécution qui les mèneraient sont du code mort. Voir Ce que tout programmeur C devrait savoir sur le comportement indéfini par nul autre que Chris Lattner.
  • l'éditeur de liens peut supposer qu'en présence de plusieurs définitions d'un symbole faible (reconnu par son nom), toutes les définitions sont identiques grâce à la One Definition Rule
  • le chargeur (si vous utilisez des bibliothèques dynamiques) peut assumer la même chose, sélectionnant ainsi le premier symbole trouvé; ceci est généralement utilisé pour intercepter des appels en utilisant des astuces LD_PRELOAD sur Unix
  • l'exécution peut échouer (SIGSEV) si vous utilisez des pointeurs en suspens

C'est ce qui fait tellement effrayant à propos du comportement indéfini: il est presque impossible de prédire à l'avance quel comportement exact se produira, et cette prédiction doit être revue à chaque mise à jour de la chaîne d'outils, système d'exploitation sous-jacent, ...


Je recommande de regarder cette vidéo de Michael Spencer (développeur LLVM): CppCon 2016: Mon petit optimiseur: le comportement indéfini est magique .

30
Matthieu M.

Un compilateur C ou C++ optimisant de manière agressive ciblant une int 16 bits va savoir que le comportement lors de l'ajout de 1000000000 à un type int est non défini.

Quelle que soit la norme, il est autorisé à faire tout ce qu’il veut, qui pourrait inclure la suppression de l’ensemble du programme, en laissant int main(){}.

Mais qu’en est-il des ints plus grands? Je ne connais pas de compilateur qui le fasse déjà (et je ne suis en aucun cas un expert en conception de compilateurs C et C++), mais j'imagine que parfois un compilateur ciblant une variable int ou 32 bits supérieur déterminera que la boucle est infinie (i ne change pas) et donc a finira par déborder. Donc, encore une fois, il peut optimiser la sortie à int main(){}. Ce que j'essaie de dire ici est que, à mesure que les optimisations du compilateur deviennent de plus en plus agressives, de plus en plus de constructions de comportement non définies se manifestent de manière inattendue.

Le fait que votre boucle soit infinie n’est pas en soi indéfini puisque vous écrivez sur une sortie standard dans le corps de la boucle.

28
Bathsheba

Techniquement, selon le standard C++, si un programme contient un comportement indéfini, le comportement de l’ensemble du programme, même au moment de la compilation (avant même que le programme soit exécuté), n’est pas défini.

En pratique, étant donné que le compilateur peut supposer (dans le cadre d’une optimisation) que le débordement ne se produira pas, au moins le comportement du programme lors de la troisième itération de la boucle (en supposant une machine 32 bits) sera indéfini, bien que est probable que vous obtiendrez des résultats corrects avant la troisième itération. Cependant, comme le comportement de l’ensemble du programme n’est pas défini techniquement, rien n’empêche le programme de générer une sortie complètement incorrecte (y compris aucune sortie), de se bloquer au moment de l’exécution, voire d’échouer lors de la compilation (le comportement non défini s’étendant à temps de compilation).

Le comportement indéfini offre au compilateur plus de place pour être optimisé car il élimine certaines hypothèses sur ce que le code doit faire. Ce faisant, les programmes qui reposent sur des hypothèses impliquant un comportement indéfini ne fonctionnent pas comme prévu. En tant que tel, vous ne devez pas vous fier à un comportement particulier considéré comme non défini par la norme C++.

11
bwDraco

Pour comprendre le comportement non défini pourquoi, vous pouvez "voyager dans le temps", comme @TartanLlama l'a bien exprimé , examinons la règle "comme si":

1.9 Exécution du programme 

1 Les descriptions sémantiques dans la présente Norme internationale définissent un. machine abstraite non déterministe paramétrée. Cette Internationale La norme n'impose aucune exigence à la structure de conformité mises en œuvre. En particulier, ils n'ont pas besoin de copier ou d'imiter le structure de la machine abstraite. En se conformant plutôt aux implémentations sont tenus d'émuler (uniquement) le comportement observable de l'abstrait machine comme expliqué ci-dessous.

Avec cela, nous pourrions voir le programme comme une "boîte noire" avec une entrée et une sortie. L'entrée peut être une entrée utilisateur, des fichiers et bien d'autres choses. Le résultat est le «comportement observable» mentionné dans la norme.

La norme définit uniquement un mappage entre l'entrée et la sortie, rien d'autre. Pour ce faire, il décrit un "exemple de boîte noire", mais indique explicitement que toute autre boîte noire avec le même mappage est également valide. Cela signifie que le contenu de la boîte noire est sans importance.

En gardant cela à l'esprit, il serait insensé d'affirmer qu'un comportement non défini se produit à un moment donné. Dans la exemple mise en oeuvre de la boîte noire, nous pourrions dire où et quand cela se produit, mais la réelle boîte noire pourrait être quelque chose de complètement différent, nous ne pouvons donc pas dire où et quand. ça n'arrive plus. Théoriquement, un compilateur pourrait par exemple décider d'énumérer toutes les entrées possibles et pré-calculer les sorties résultantes. Ensuite, le comportement indéfini se serait produit lors de la compilation.

Le comportement non défini est l'inexistence d'un mappage entre entrée et sortie. Un programme peut avoir un comportement indéfini pour certaines entrées, mais un comportement défini pour d'autres. Ensuite, la correspondance entre entrée et sortie est simplement incomplète; il existe des entrées pour lesquelles aucun mappage vers la sortie n'existe.
Le programme dans la question a un comportement indéfini pour toute entrée, le mappage est donc vide.

9
alain

La réponse de TartanLlama est correcte. Le comportement indéfini peut survenir à tout moment, même pendant la compilation. Cela peut sembler absurde, mais c’est une fonctionnalité clé pour permettre aux compilateurs de faire ce qu’ils doivent faire. Ce n'est pas toujours facile d'être un compilateur. Vous devez faire exactement ce que la spécification dit, à chaque fois. Cependant, il peut parfois être extrêmement difficile de prouver qu'un comportement particulier se produit. Si vous vous souvenez du problème, il est plutôt facile de développer un logiciel pour lequel vous ne pouvez pas prouver s'il est terminé ou s'il entre dans une boucle infinie lorsqu'il reçoit une entrée particulière.

Nous pourrions rendre les compilateurs pessimistes et les compiler constamment, de peur que la prochaine instruction ne soit l’un de ces problèmes, mais ce n’est pas raisonnable. Au lieu de cela, nous donnons une passe au compilateur: sur ces sujets "comportement indéfini", ils sont dégagés de toute responsabilité. Un comportement indéfini comprend tous les comportements qui sont si subtilement néfastes que nous avons du mal à les séparer des problèmes de blocage vraiment méchants-néfastes et autres.

Il y a un exemple que j'adore publier, bien que j'avoue avoir perdu la source, je dois donc paraphraser. C'était à partir d'une version particulière de MySQL. Dans MySQL, ils disposaient d'un tampon circulaire rempli de données fournies par l'utilisateur. Bien sûr, ils voulaient s'assurer que les données ne débordaient pas de la mémoire tampon, ils avaient donc un contrôle:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

Cela semble assez sain. Cependant, que se passe-t-il si numberOfNewChars est vraiment gros et déborde? Ensuite, il tourne autour et devient un pointeur plus petit que endOfBufferPtr, de sorte que la logique de débordement ne soit jamais appelée. Alors ils ont ajouté un deuxième chèque, avant celui-là:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

On dirait que vous vous êtes occupé de l'erreur de débordement de mémoire tampon, n'est-ce pas? Cependant, un bogue a été soumis indiquant que ce tampon avait débordé sur une version particulière de Debian! Une enquête minutieuse a montré que cette version de Debian était la première à utiliser une version de gcc particulièrement saignante. Sur cette version de gcc, le compilateur a reconnu que currentPtr + numberOfNewChars peut jamais être un pointeur plus petit que currentPtr car le débordement des pointeurs est un comportement indéfini! C’était suffisant pour que gcc optimise l’intégralité du contrôle, et du coup vous n’êtes plus protégé contre les débordements de tampon bien que vous ayez écrit le code pour le vérifier!

C'était un comportement spécifique. Tout était légal (bien que d'après ce que j'ai entendu, Gcc a annulé ce changement dans la prochaine version). Ce n’est pas ce que je considérerais comme un comportement intuitif, mais si vous étirez un peu votre imagination, il est facile de voir comment une légère variante de cette situation pourrait devenir un problème épineux pour le compilateur. Pour cette raison, les rédacteurs de spécifications ont défini le "comportement indéfini" et ont déclaré que le compilateur pouvait faire absolument tout ce qui lui plaisait.

6
Cort Ammon

En supposant que int est 32 bits, le comportement non défini se produit à la troisième itération. Ainsi, si, par exemple, la boucle n'était accessible que de manière conditionnelle, ou pouvait l'être de manière conditionnelle avant la troisième itération, il n'y aurait aucun comportement indéfini à moins que la troisième itération ne soit réellement atteinte. Cependant, en cas de comportement indéfini, toute la sortie du programme est indéfinie, y compris la sortie "dans le passé" relative à l'appel du comportement indéfini. Par exemple, dans votre cas, cela signifie qu’il n’ya aucune garantie de voir 3 messages "Bonjour" dans la sortie.

6
R..

Au-delà des réponses théoriques, une observation pratique serait que depuis longtemps les compilateurs ont appliqué diverses transformations en boucles afin de réduire la quantité de travail effectué en leur sein. Par exemple, étant donné:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

un compilateur pourrait transformer cela en:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

Sauvegarde ainsi une multiplication à chaque itération de boucle. Une forme d’optimisation supplémentaire, adaptée par les compilateurs avec plus ou moins d’agressivité, transformerait cela en:

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

Même sur des machines avec un bouclage silencieux en cas de dépassement de capacité, cela pourrait ne pas fonctionner correctement si Un nombre inférieur à n permettait, une fois multiplié par échelle, d'obtenir 0. Il pourrait également se transformer en boucle sans fin si scale était lu plus d'une fois dans la mémoire et que quelque chose changeait sa valeur de façon inattendue (dans tous les cas où "Scale" pouvait changer à mi-boucle sans invoquer UB, un compilateur ne le ferait pas. être autorisé à effectuer l'optimisation).

Bien que la plupart de ces optimisations ne posent aucun problème dans les cas où deux types Courts non signés sont multipliés pour donner une valeur comprise entre INT_MAX + 1 Et UINT_MAX, gcc a quelques cas où une telle multiplication dans une boucle peut provoquer la sortie anticipée de la boucle. Je n'ai pas remarqué de tels comportements provenant des instructions de comparaison dans le code généré, mais il est observable dans les cas Où le compilateur utilise le dépassement de capacité pour déduire qu'une boucle peut s'exécuter au maximum 4 fois ou moins; par défaut, il ne génère pas d'avertissements dans les cas où certaines entrées .__ causeraient UB et d'autres ne le feraient pas, même si ses inférences font en sorte que la limite supérieure de la boucle soit ignorée.

4
supercat

Un comportement indéfini est, par définition, une zone grise. Vous ne pouvez tout simplement pas prédire ce qu’il fera ou ne fera pas - c’est ce que «comportement indéfini» signifie.

Depuis des temps immémoriaux, les programmeurs ont toujours essayé de sauver des restes de définition d'une situation indéfinie. Ils ont un code qu’ils veulent vraiment utiliser, mais qui s’avère être indéfini, alors ils essaient de faire valoir: "Je sais que c’est indéfini, mais ce sera sûrement, au pire, faire ceci ou cela; cela ne fera jamais cette." Et parfois, ces arguments sont plus ou moins corrects - mais souvent, ils sont erronés. Et à mesure que les compilateurs deviennent de plus en plus intelligents (ou, diront-ils, de plus en plus furtifs), les limites de la question changent constamment.

Donc, vraiment, si vous voulez écrire du code dont le fonctionnement est garanti et le restera longtemps, il n’ya qu’un choix: éviter à tout prix le comportement indéfini. En vérité, si vous y barbotez, il reviendra vous hanter.

4
Steve Summit

Une chose que votre exemple ne considère pas est l'optimisation. a est défini dans la boucle mais jamais utilisé, et un optimiseur pourrait résoudre ce problème. En tant que tel, il est légitime que l'optimiseur supprime a complètement, et dans ce cas, tout comportement non défini disparaît comme une victime de boojum.

Cependant, bien sûr, cela n’est pas défini car l’optimisation n’est pas définie. :)

1
Graham

Étant donné que cette question est à double balise C et C++, je vais essayer de répondre à la fois. C et C++ ont des approches différentes ici. 

En C, l'implémentation doit pouvoir prouver que le comportement non défini sera invoqué afin de traiter l'ensemble du programme comme s'il avait un comportement non défini. Dans l'exemple des PO, il semblerait trivial pour le compilateur de le prouver et c'est donc comme si le programme entier n'était pas défini.

Nous pouvons le voir dans Defect Report 109 qui pose les questions suivantes:

Si toutefois la norme C reconnaît l’existence séparée de "valeurs indéfinies" (dont la simple création n’entraîne pas un "comportement indéfini"), une personne effectuant des tests du compilateur peut écrire un scénario de test tel que celui-ci, mais elle peut également s’attendre à ce que (ou éventuellement exiger) qu’une implémentation conforme compile, à tout le moins, ce code (et éventuellement le laisse également exécuter) sans "échec".

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

La question fondamentale est donc la suivante: le code ci-dessus doit-il être "traduit avec succès" (peu importe ce que cela signifie)? (Voir la note de bas de page jointe au sous-paragraphe 5.1.1.3.) 

et la réponse fut:

La norme C utilise le terme "valeur indéterminée" et non "valeur indéfinie". L'utilisation d'un objet à valeur indéterminée entraîne un comportement indéfini . La note de bas de page relative au sous-paragraphe 5.1.1.3 indique qu'une implémentation est libre de générer un nombre illimité de diagnostics tant qu'un programme valide est toujours traduit correctement . Si une expression dont le résultat serait un comportement indéfini apparaît dans un contexte où une expression constante est requise, le programme qui le contient n'est pas strictement conforme. De plus, si chaque exécution possible d'un programme donné devait entraîner un comportement indéfini, ce programme n'était pas strictement conforme . Une implémentation conforme ne doit pas manquer de traduire un programme strictement conforme simplement parce qu'une exécution possible de ce programme entraînerait un comportement indéfini. Foo pouvant ne jamais être appelé, l’exemple donné doit être traduit avec succès par une implémentation conforme.

En C++, l'approche semble plus détendue et suggère qu'un programme a un comportement non défini, que l'implémentation puisse le prouver de manière statique ou non.

Nous avons [intro.abstrac] p5 qui dit:

Une mise en œuvre conforme exécutant un programme bien formé produira le même comportement observable que l'une des exécutions possibles de l'instance correspondante de la machine abstraite avec le même programme et la même entrée . Cependant, si une telle exécution contient une opération non définie, ce document n'impose aucune obligation à la mise en œuvre exécutant ce programme avec cette entrée (même pour les opérations antérieures à la première opération non définie).

0
Shafik Yaghmour