web-dev-qa-db-fra.com

Pourquoi le comportement non défini de f (i = -1, i = -1)?

Je lisais à propos de ordre d'évaluation des violations , et ils donnent un exemple qui me laisse perplexe.

1) Si un effet secondaire sur un objet scalaire n’est pas séquencé par rapport à un autre effet secondaire sur le même objet scalaire, le comportement est indéfini.

// snip
f(i = -1, i = -1); // undefined behavior

Dans ce contexte, i est un objet scalaire, ce qui signifie apparemment

Les types arithmétiques (3.9.1), types d'énumération, types de pointeur, pointeur sur les types de membre (3.9.2), std :: nullptr_t et les versions cv-qualifiées de ces types (3.9.3) sont collectivement appelés types scalaires.

Je ne vois pas en quoi la déclaration est ambiguë dans ce cas. Il me semble que, que le premier ou le deuxième argument soit évalué en premier, i se termine par -1, Et les deux arguments sont également -1.

Quelqu'un peut-il s'il vous plaît clarifier?


MISE À JOUR

J'apprécie vraiment toute la discussion. Jusqu'ici, j'aime beaucoup réponse de @ harmic car cela expose les pièges et les subtilités de la définition de cette déclaration malgré la simplicité de son apparence au premier abord. @ acheong87 signale certains problèmes lors de l'utilisation de références, mais je pense que cela est orthogonal à l'aspect des effets secondaires non séquencés de cette question.


SOMMAIRE

Comme cette question a attiré beaucoup d'attention, je vais résumer les principaux points/réponses. Tout d’abord, permettez-moi une petite digression pour préciser que "pourquoi" peut avoir des significations étroitement liées mais légèrement différentes, à savoir "pour quoi cause", "pour quoi raison", et "pour quoi but". Je vais regrouper les réponses selon laquelle de ces significations de "pourquoi" ils ont adressé.

pour quelle cause

La réponse principale ici provient de Paul Draper , avec Martin J apportant une réponse similaire mais pas aussi complète. La réponse de Paul Draper se résume à

C'est un comportement indéfini car il n'est pas défini ce que le comportement est.

La réponse est globalement très bonne en termes d’explication de la norme C++. Il aborde également certains cas liés d'UB, tels que f(++i, ++i); et f(i=1, i=-1);. Dans le premier des cas liés, il n'est pas clair si le premier argument doit être i+1 Et le deuxième i+2 Ou inversement; dans le second cas, il n'est pas clair si i devrait être 1 ou -1 après l'appel de la fonction. Ces deux cas sont UB car ils relèvent de la règle suivante:

Si un effet secondaire sur un objet scalaire n'est pas séquencé par rapport à un autre effet secondaire sur le même objet scalaire, le comportement est indéfini.

Par conséquent, f(i=-1, i=-1) est également UB car il relève de la même règle, bien que l'intention du programmeur soit (IMHO) évidente et non ambiguë.

Paul Draper explicite également dans sa conclusion que

Cela aurait-il pu être défini comme comportement? Oui. Était-ce défini? Non.

ce qui nous amène à la question de "pour quelle raison/but était f(i=-1, i=-1) laissée comme un comportement indéfini?"

pour quelle raison/but

Bien que la norme C++ comporte des omissions (peut-être négligentes), de nombreuses omissions sont bien motivées et ont un objectif spécifique. Bien que je sache que le but est souvent "soit de faciliter le travail du compilateur-écrivain", soit de "code plus rapide", . Je voulais surtout savoir s'il y avait une bonne raison de partir f(i=-1, i=-1) en tant que UB.

harmic et supercat fournissent les réponses principales qui fournissent une raison pour l'UB. Harmic souligne qu'un compilateur optimiseur qui pourrait diviser les opérations d'affectation apparemment atomiques en plusieurs instructions machine et qu'il pourrait intercaler davantage ces instructions pour obtenir une vitesse optimale. Cela pourrait conduire à des résultats très surprenants: i finit par -2 dans son scénario! Ainsi, harmic montre comment l’attribution multiple de même valeur à une variable peut avoir des effets pervers si les opérations ne sont pas séquencées.

supercat fournit un exposé connexe sur les pièges liés à la tentative d'obtenir que f(i=-1, i=-1) fasse ce à quoi il devrait ressembler. Il souligne que sur certaines architectures, il existe des restrictions strictes contre plusieurs écritures simultanées à la même adresse mémoire. Un compilateur pourrait avoir du mal à comprendre si nous avions affaire à quelque chose de moins trivial que f(i=-1, i=-1).

davidf fournit également un exemple d'instructions d'entrelacement très similaires à celles de harmic.

Bien que chacun des exemples de harmic, supercat et davidf soit quelque peu artificiel, pris ensemble, ils servent toujours à fournir une raison tangible pour laquelle f(i=-1, i=-1) devrait être un comportement indéfini.

J’ai accepté la réponse de harmic parce que c’était le meilleur moyen de répondre à toutes les questions de pourquoi, même si la réponse de Paul Draper concernait mieux la partie "pour quelle cause".

autres réponses

JohnB souligne que si nous considérons des opérateurs d'assignation surchargés (au lieu de simples scalaires), nous pouvons également avoir des problèmes.

267
Nicu Stiurca

Comme les opérations ne sont pas séquencées, rien ne dit que les instructions qui effectuent l’affectation ne peuvent pas être entrelacées. Cela peut être optimal, en fonction de l'architecture du processeur. La page référencée dit ceci:

Si A n'est pas séquencé avant B et B n'est pas séquencé avant A, alors deux possibilités existent:

  • les évaluations de A et B ne sont pas séquencées: elles peuvent être effectuées dans n'importe quel ordre et peuvent se chevaucher (au sein d'un seul thread d'exécution, le compilateur peut imbriquer les instructions de la CPU qui comprennent A et B)

  • les évaluations de A et de B sont séquencées de manière indéterminée: elles peuvent être effectuées dans n'importe quel ordre mais ne peuvent pas se chevaucher: soit A sera complet avant B, soit B sera complet avant A. L'ordre peut être inverse la prochaine fois que la même expression est évalué.

En soi, cela ne semble pas poser de problème - en supposant que l'opération en cours stocke la valeur -1 dans un emplacement de mémoire. Mais rien ne dit non plus que le compilateur ne puisse pas optimiser cela en un ensemble d'instructions distinct ayant le même effet, mais qui pourrait échouer si l'opération était entrelacée avec une autre opération sur le même emplacement de mémoire.

Par exemple, imaginez qu'il soit plus efficace de mettre à zéro la mémoire, puis de la décrémenter, par rapport au chargement de la valeur -1 in. Ensuite ceci:

f(i=-1, i=-1)

pourrait devenir:

clear i
clear i
decr i
decr i

Maintenant, j'ai -2.

C'est probablement un exemple fictif, mais c'est possible.

343
harmic

Premièrement, "objet scalaire" signifie un type tel que int, float ou un pointeur (voir Qu'est-ce qu'un objet scalaire en C++? ).


Deuxièmement, il peut sembler plus évident que

f(++i, ++i);

aurait un comportement indéfini. Mais

f(i = -1, i = -1);

est moins évident.

Un exemple légèrement différent:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";

Quelle assignation est arrivée "dernier", i = 1, ou i = -1? Ce n'est pas défini dans la norme. Vraiment, cela signifie que i pourrait être 5 (voir la réponse de harmic pour une explication tout à fait plausible de la manière dont cela pourrait être le cas). Ou vous programmez pourrait segfault. Ou reformatez votre disque dur.

Mais maintenant, vous demandez: "Et mon exemple? J'ai utilisé la même valeur (-1) pour les deux assignations. Qu'est-ce qui pourrait ne pas être clair à ce sujet? "

Vous avez raison ... sauf ce que décrit le comité de normalisation C++.

Si un effet secondaire sur un objet scalaire n'est pas séquencé par rapport à un autre effet secondaire sur le même objet scalaire, le comportement est indéfini.

Ils pourraient ont créé une exception spéciale pour votre cas particulier, mais ils ne l'ont pas fait. (Et pourquoi devraient-ils? À quoi cela pourrait-il servir?) Donc, i pourrait toujours être 5. Ou votre disque dur peut être vide. La réponse à votre question est donc la suivante:

C'est un comportement indéfini car il n'est pas défini son comportement.

(Cela mérite d'être souligné, car de nombreux programmeurs pensent que "non défini" signifie "aléatoire" ou "imprévisible". Cela ne veut pas dire que cela n'est pas défini par la norme. Le comportement pourrait être cohérent à 100%, tout en restant indéfini .)

Cela aurait-il pu être défini comme comportement? Oui. Était-ce défini? Par conséquent, il est "indéfini".

Cela dit, "non défini" ne signifie pas qu'un compilateur formatera votre disque dur ... cela signifie qu'il pourrait et que ce serait toujours un compilateur conforme aux normes. De manière réaliste, je suis sûr que g ++, Clang et MSVC feront tous ce que vous attendiez. Ils ne seraient tout simplement pas "obligés".


Une question différente pourrait être Pourquoi le comité de normalisation C++ a-t-il choisi de rendre cet effet secondaire sans séquence?. Cette réponse impliquera l’histoire et les opinions du comité. Ou Qu'y at-il de bien à ce que cet effet secondaire ne soit pas séquencé en C++?, ce qui permet toute justification, que ce soit ou non le raisonnement réel du comité de normalisation. Vous pouvez poser ces questions ici ou à programmers.stackexchange.com.

209
Paul Draper

Une raison pratique de ne pas faire exception aux règles simplement parce que les deux valeurs sont identiques:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

Considérons le cas, cela a été autorisé.

Maintenant, quelques mois plus tard, il est nécessaire de changer

 #define VALUEB 2

Apparemment inoffensif, n'est-ce pas? Et pourtant, soudainement, prog.cpp ne compilerait plus. Cependant, nous pensons que la compilation ne devrait pas dépendre de la valeur d'un littéral.

En bout de ligne: la règle ne fait pas exception à la règle car une compilation réussie dépendrait de la valeur (plutôt du type) d'une constante.

MODIFIER

@ HeartWare a souligné que des expressions constantes de la forme A DIV B ne sont pas autorisés dans certaines langues, lorsque B est égal à 0 et entraîne l’échec de la compilation. Par conséquent, le changement d'une constante peut provoquer des erreurs de compilation à un autre endroit. Qui est, à mon humble avis, malheureux. Mais il est certainement bon de limiter de telles choses à l'inévitable.

27
Ingo

La confusion est que le stockage d'une valeur constante dans une variable locale n'est pas une instruction atomique sur chaque architecture sur laquelle le C est conçu pour être exécuté. Le processeur sur lequel le code s'exécute est plus important que le compilateur dans ce cas. Par exemple, sur ARM où chaque instruction ne peut pas porter une constante complète de 32 bits, stocker un int dans une variable nécessite plus d'une instruction. Exemple avec ce pseudo-code où vous ne pouvez stocker que 8 bits à la fois et doit travailler dans un registre 32 bits, i est un int32:

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last

Vous pouvez imaginer que si le compilateur veut l'optimiser, il peut entrelacer deux fois la même séquence et vous ne savez pas quelle valeur sera écrite dans i; et disons qu'il n'est pas très intelligent:

reg = 0xFF;
reg |= 0xFF00;
reg |= 0xFF0000;
reg = 0xFF;
reg |= 0xFF000000;
i = reg; // writes 0xFF0000FF == -16776961
reg |= 0xFF00;
reg |= 0xFF0000;
reg |= 0xFF000000;
i = reg; // writes 0xFFFFFFFF == -1

Cependant, dans mes tests, gcc est assez aimable pour reconnaître que la même valeur est utilisée deux fois et ne la génère qu'une seule fois et ne fait rien de bizarre. J'obtiens -1, -1 Mais mon exemple est toujours valable car il est important de considérer que même une constante peut ne pas être aussi évidente que cela semble être.

12
davidf

Le comportement est généralement spécifié comme indéfini s'il existe une raison concevable pour laquelle un compilateur qui essayait d'être "utile" pourrait faire quelque chose qui provoquerait un comportement totalement inattendu.

Dans le cas où une variable est écrite plusieurs fois sans que rien ne garantisse que les écritures ont lieu à des moments distincts, certains types de matériel peuvent permettre l'exécution simultanée de plusieurs opérations de "stockage" sur différentes adresses à l'aide d'une mémoire à double accès. Cependant, certaines mémoires à double accès interdisent expressément le scénario dans lequel deux magasins atteignent la même adresse simultanément, que les valeurs écrites correspondent ou non. Si un compilateur pour une telle machine remarque deux tentatives d'écriture sans séquence de la même variable, il peut refuser de compiler ou s'assurer que les deux écritures ne peuvent pas être planifiées simultanément. Mais si l'un des accès ou les deux se font via un pointeur ou une référence, le compilateur ne sera peut-être pas toujours en mesure de dire si les deux écritures peuvent atteindre le même emplacement de stockage. Dans ce cas, il est possible que les écritures soient programmées simultanément, ce qui provoque une interruption matérielle lors de la tentative d'accès.

Bien sûr, le fait que quelqu'un puisse implémenter un compilateur C sur une telle plate-forme ne suggère pas qu'un tel comportement ne devrait pas être défini sur des plates-formes matérielles lors de l'utilisation de magasins de types suffisamment petits pour être traités de manière atomique. Essayer de stocker deux valeurs différentes de manière non séquencée pourrait être étrange si un compilateur n'en est pas conscient; par exemple, étant donné:

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  Zoo(v);
  Zoo(v);
}

si le compilateur inscrit l'appel à "moo" et peut dire qu'il ne modifie pas "v", il peut stocker un 5 à v, puis un 6 à * p, puis passer 5 à "Zoo", puis passer le contenu de v à "Zoo". Si "Zoo" ne modifie pas "v", il ne devrait pas y avoir de transmission de valeurs différentes aux deux appels, mais cela pourrait quand même se produire de toute façon. D'autre part, dans les cas où les deux magasins écriraient la même valeur, une telle bizarrerie ne pourrait pas se produire et la plupart des plates-formes n'auraient aucune raison valable pour une implémentation de faire quelque chose de bizarre. Malheureusement, certains auteurs de compilateur n'ont besoin d'aucune excuse pour des comportements idiots au-delà de "parce que la norme le permet", de sorte que même ces cas ne sont pas sécuritaires.

11
supercat

Le fait que le résultat serait le même dans la plupart des mises en œuvre dans le cas this est fortuit; l'ordre d'évaluation n'est pas encore défini. Considérez f(i = -1, i = -2): ici, l'ordre est important. La seule raison pour laquelle cela n'a pas d'importance dans votre exemple est le fait que les deux valeurs sont -1.

Étant donné que l'expression est spécifiée comme ayant un comportement indéfini, un compilateur conforme malicieux peut afficher une image inappropriée lorsque vous évaluez f(i = -1, i = -1) et interrompez l'exécution - tout en restant considéré comme complètement correct. Heureusement, aucun compilateur à ma connaissance ne le fait.

9
Amadan

Il me semble que la seule règle concernant le séquençage de l'expression d'argument de fonction est la suivante:

3) Lors de l’appel d’une fonction (que la fonction soit ou non inline et que la syntaxe d’appel de fonction soit explicite ou non), tout calcul de valeur et effet secondaire associé à une expression d’argument, ou à l’expression postfix désignant la fonction appelée, est séquencés avant l'exécution de chaque expression ou déclaration dans le corps de la fonction appelée.

Cela ne définit pas le séquencement entre les expressions d'arguments, nous nous retrouvons donc dans ce cas:

1) Si un effet secondaire sur un objet scalaire n’est pas séquencé par rapport à un autre effet secondaire sur le même objet scalaire, le comportement est indéfini.

En pratique, sur la plupart des compilateurs, l’exemple que vous avez cité fonctionnera correctement (par opposition à "l’effacement de votre disque dur" et à d’autres conséquences théoriques sur le comportement indéfini).
Il s’agit toutefois d’un passif, car il dépend du comportement spécifique du compilateur, même si les deux valeurs attribuées sont identiques. De plus, évidemment, si vous tentiez d'attribuer des valeurs différentes, les résultats seraient "véritablement" indéfinis:

void f(int l, int r) {
    return l < -1;
}
auto b = f(i = -1, i = -2);
if (b) {
    formatDisk();
}
8
Martin J.

C++ 17 définit des règles d'évaluation plus strictes. En particulier, il séquence les arguments de la fonction (bien que dans un ordre non spécifié).

N5659 §4.6:15
Évaluations [~ # ~] a [~ # ~] et [~ # ~] b [~ # ~] sont séquencés de façon indéterminée lorsque [~ # ~] a [~ # ~] est séquence avant [~ # ~] b [~ # ~] ou [~ # ~] b [~ # ~] est séquencé avant [~ # ~] a [~ # ~] , mais il n’est pas précisé lequel. [ Remarque : Les évaluations séquentielles indéterminées ne peuvent pas se chevaucher, mais peuvent être exécutées en premier. - note finale ]

N5659 § 8.2.2:5
L’initialisation d’un paramètre, y compris tous les calculs de valeur et effets secondaires associés, est indéterminée par rapport à celle de tout autre paramètre.

Il permet quelques cas qui seraient UB avant:

f(i = -1, i = -1); // value of i is -1
f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one
8
AlexD

L'opérateur d'affectation peut être surchargé. Dans ce cas, l'ordre peut être important:

struct A {
    bool first;
    A () : first (false) {
    }
    const A & operator = (int i) {
        first = !first;
        return * this;
    }
};

void f (A a1, A a2) {
    // ...
}


// ...
A i;
f (i = -1, i = -1);   // the argument evaluated first has ax.first == true
5
JohnB

Ceci répond simplement au "je ne suis pas sûr de ce que" objet scalaire "pourrait signifier en plus de quelque chose comme un int ou un float".

J'interpréterais le "objet scalaire" comme une abréviation de "objet de type scalaire" ou simplement "variable de type scalaire". Alors, pointer, enum (constante) sont de type scalaire.

Ceci est un article MSDN de types scalaires .

2
Peng Zhang

En fait, il y a une raison pour ne pas dépendre du fait que le compilateur vérifiera que i se voit attribuer deux fois la même valeur, de sorte qu'il est possible de le remplacer par une seule affectation. Et si nous avons des expressions?

void g(int a, int b, int c, int n) {
    int i;
    // hey, compiler has to prove Fermat's theorem now!
    f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
}
1
polkovnikov.ph