Ordre d'évaluation des indices matriciels (versus l'expression) en C

47

En regardant ce code:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

Quelle entrée de tableau est mise à jour? 0 ou 2?

Y a-t-il une partie dans la spécification de C qui indique la priorité de fonctionnement dans ce cas particulier?

Jiminion
la source
21
Cela sent le comportement indéfini. C'est certainement quelque chose qui ne devrait jamais être codé à dessein.
Fiddling Bits
1
Je suis d'accord que c'est un exemple de mauvais codage.
Jiminion
4
Quelques résultats anecdotiques: godbolt.org/z/hM2Jo2
Bob__
15
Cela n'a rien à voir avec les indices de tableau ou l'ordre des opérations. Cela a à voir avec ce que la spécification C appelle des "points de séquence", et en particulier, le fait que les expressions d'affectation ne créent PAS de point de séquence entre l'expression de gauche et celle de droite, de sorte que le compilateur est libre de faire comme il choisit.
Lee Daniel Crocker
4
Vous devez signaler une demande de fonctionnalité à clangafin que ce morceau de code déclenche un avertissement à mon humble avis.
malat

Réponses:

51

Ordre des opérandes gauche et droit

Pour effectuer l'affectation dans arr[global_var] = update_three(2), l'implémentation C doit évaluer les opérandes et, comme effet secondaire, mettre à jour la valeur stockée de l'opérande de gauche. Le paragraphe 3 de C 2018 6.5.16 (qui concerne les affectations) nous dit qu'il n'y a pas de séquencement dans les opérandes gauche et droit:

Les évaluations des opérandes ne sont pas séquencées.

Cela signifie que l'implémentation C est libre de calculer la lvalue d' arr[global_var] abord (en calculant la lvalue, nous voulons dire à quoi se réfère cette expression), puis d'évaluer update_three(2), et enfin d'attribuer la valeur de cette dernière à la première; ou pour évaluer d' update_three(2)abord, puis calculer la valeur l, puis affecter la première à la seconde; ou pour évaluer la valeur l et update_three(2)de façon mélangée, puis attribuer la valeur droite à la valeur l gauche.

Dans tous les cas, l'affectation de la valeur à la valeur l doit venir en dernier, car 6.5.16 3 dit également:

… L'effet secondaire de la mise à jour de la valeur stockée de l'opérande de gauche est séquencé après les calculs de valeur des opérandes de gauche et de droite…

Violation de séquençage

Certains pourraient réfléchir sur un comportement indéfini en raison de son utilisation global_varet de sa mise à jour séparée en violation de 6.5 2, qui dit:

Si un effet secondaire sur un objet scalaire n'est pas séquencé par rapport à un effet secondaire différent sur le même objet scalaire ou à un calcul de valeur utilisant la valeur du même objet scalaire, le comportement n'est pas défini…

Il est assez familier à de nombreux praticiens C que le comportement d'expressions telles que x + x++n'est pas défini par la norme C car ils utilisent à la fois la valeur de xet la modifient séparément dans la même expression sans séquençage. Cependant, dans ce cas, nous avons un appel de fonction, qui fournit un certain séquençage. global_varest utilisé arr[global_var]et mis à jour dans l'appel de fonction update_three(2).

6.5.2.2 10 nous dit qu'il y a un point de séquence avant que la fonction soit appelée:

Il y a un point de séquence après les évaluations de l'indicateur de fonction et les arguments réels mais avant l'appel réel…

À l'intérieur de la fonction, il global_var = val;y a une expression complète , tout comme le 3in return 3;, selon 6.8 4:

Une expression complète est une expression qui ne fait pas partie d'une autre expression, ni partie d'un déclarant ou d'un déclarant abstrait…

Ensuite, il y a un point de séquence entre ces deux expressions, toujours selon 6.8 4:

… Il existe un point de séquence entre l'évaluation d'une expression complète et l'évaluation de la prochaine expression complète à évaluer.

Ainsi, l'implémentation C peut évaluer d' arr[global_var]abord et ensuite faire l'appel de fonction, auquel cas il y a un point de séquence entre eux car il y en a un avant l'appel de fonction, ou elle peut évaluer global_var = val;dans l'appel de fonction et ensuite arr[global_var], auquel cas il y a un point de séquence entre eux car il y en a un après l'expression complète. Le comportement n'est donc pas spécifié - l'une ou l'autre de ces deux choses peut être évaluée en premier - mais il n'est pas indéfini.

Eric Postpischil
la source
24

Le résultat ici n'est pas spécifié .

Bien que l'ordre des opérations dans une expression, qui dicte la façon dont les sous-expressions sont regroupées, soit bien défini, l'ordre d' évaluation n'est pas spécifié. Dans ce cas, cela signifie que l'un ou l'autre global_varpourrait être lu en premier ou l'appel à update_threepourrait se produire en premier, mais il n'y a aucun moyen de savoir lequel.

Il n'y a pas de comportement indéfini ici car un appel de fonction introduit un point de séquence, comme le fait chaque instruction de la fonction, y compris celle qui la modifie global_var.

Pour clarifier, la norme C définit le comportement non défini dans la section 3.4.3 comme:

comportement indéfini

comportement, lors de l'utilisation d'une construction de programme non portable ou erronée ou de données erronées, pour laquelle la présente Norme internationale n'impose aucune exigence

et définit le comportement non spécifié dans la section 3.4.4 comme:

comportement non spécifié

utilisation d'une valeur non spécifiée ou d'un autre comportement lorsque la présente Norme internationale offre deux possibilités ou plus et n'impose aucune autre exigence sur laquelle est choisie dans tous les cas

La norme indique que l'ordre d'évaluation des arguments de fonction n'est pas spécifié, ce qui signifie dans ce cas que soit arr[0]défini sur 3 ou arr[2]défini sur 3.

dbush
la source
«Un appel de fonction introduit un point de séquence» est insuffisant. Si l'opérande gauche est évalué en premier, cela suffit, car le point de séquence sépare alors l'opérande gauche des évaluations de la fonction. Mais, si l'opérande gauche est évalué après l'appel de fonction, le point de séquence dû à l'appel de la fonction n'est pas entre les évaluations de la fonction et l'évaluation de l'opérande gauche. Vous avez également besoin du point de séquence qui sépare les expressions complètes.
Eric Postpischil
2
@EricPostpischil Dans la terminologie pré-C11, il y a un point de séquence à l'entrée et à la sortie d'une fonction. Dans la terminologie C11, l'ensemble du corps de fonction est séquencé de façon indéterminée par rapport au contexte d'appel. Ce sont les deux spécifiant la même chose, en utilisant simplement des termes différents
MM
C'est absolument faux. L'ordre d'évaluation des arguments de l'affectation n'est pas spécifié. Quant au résultat de cette affectation particulière, c'est la création d'un tableau avec un contenu peu fiable, à la fois non portable et intrinsèquement faux (incompatible avec la sémantique ou l'un des résultats escomptés). Un cas parfait de comportement indéfini.
kuroi neko
1
@kuroineko Ce n'est pas parce que la sortie peut varier que le comportement n'est pas défini automatiquement. La norme a des définitions différentes pour un comportement indéfini par rapport à un comportement non spécifié, et dans cette situation, c'est cette dernière.
dbush
@EricPostpischil Vous avez ici des points de séquence (de l'annexe C informative C11): "Entre les évaluations du désignateur de fonction et les arguments réels dans un appel de fonction et l'appel réel. (6.5.2.2)", "Entre l'évaluation d'une expression complète et la prochaine expression complète à évaluer ... / - / ... l'expression (facultative) dans une instruction de retour (6.8.6.4) ". Et bien, à chaque point-virgule aussi, car c'est une expression complète.
Lundin
1

J'ai essayé et j'ai mis à jour l'entrée 0.

Cependant, selon cette question: le côté droit d'une expression sera-t-il toujours évalué en premier

L'ordre d'évaluation n'est pas spécifié et n'est pas séquencé. Je pense donc qu'un code comme celui-ci devrait être évité.

Mickael B.
la source
J'ai également eu la mise à jour à l'entrée 0.
Jiminion
1
Le comportement n'est pas indéfini mais non spécifié. Évidemment, dépendre de l'un ou de l'autre doit être évité.
Antti Haapala
@AnttiHaapala que j'ai édité
Mickael B.
1
Hmm ah et ce n'est pas non séquencé mais séquencé de façon indéterminée ... 2 personnes debout au hasard dans une file d'attente sont séquencées indéterminément. Neo à l'intérieur de l'agent Smith n'est pas séquencé et un comportement indéfini se produira.
Antti Haapala
0

Comme il est peu logique d'émettre du code pour une affectation avant d'avoir une valeur à attribuer, la plupart des compilateurs C émettent d'abord du code qui appelle la fonction et enregistrent le résultat quelque part (registre, pile, etc.), puis ils émettent du code qui écrit cette valeur dans sa destination finale et, par conséquent, ils liront la variable globale après sa modification. Appelons cela «l'ordre naturel», défini par aucune norme mais par pure logique.

Pourtant, dans le processus d'optimisation, les compilateurs tenteront d'éliminer l'étape intermédiaire de stockage temporaire de la valeur quelque part et tenteront d'écrire le résultat de la fonction aussi directement que possible vers la destination finale et dans ce cas, ils devront souvent lire l'index en premier , par exemple vers un registre, pour pouvoir déplacer directement le résultat de la fonction vers le tableau. Cela peut entraîner la lecture de la variable globale avant sa modification.

Il s'agit donc essentiellement d'un comportement indéfini avec la très mauvaise propriété qui est très probable que le résultat sera différent, selon que l'optimisation est effectuée et à quel point cette optimisation est agressive. C'est votre tâche en tant que développeur de résoudre ce problème en codant:

int idx = global_var;
arr[idx] = update_three(2);

ou codage:

int temp = update_three(2);
arr[global_var] = temp;

En règle générale, à moins que les variables globales ne le soient const(ou ne le sont pas, mais vous savez qu'aucun code ne les modifiera jamais comme effet secondaire), vous ne devez jamais les utiliser directement dans le code, comme dans un environnement multi-thread, même cela peut être indéfini:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

Puisque le compilateur peut le lire deux fois et qu'un autre thread peut changer la valeur entre les deux lectures. Pourtant, encore une fois, l'optimisation obligerait définitivement le code à le lire une seule fois, vous pouvez donc à nouveau avoir des résultats différents qui dépendent désormais également du timing d'un autre thread. Ainsi, vous aurez beaucoup moins de maux de tête si vous stockez des variables globales dans une variable de pile temporaire avant utilisation. Gardez à l'esprit que si le compilateur pense que cela est sûr, il optimisera probablement même cela et utilisera plutôt la variable globale directement, donc au final, cela ne fera aucune différence dans les performances ou l'utilisation de la mémoire.

(Juste au cas où quelqu'un demanderait pourquoi quelqu'un le ferait à la x + 2 * xplace de 3 * x- sur certains processeurs, l'ajout est ultra-rapide et la multiplication par une puissance deux aussi, car le compilateur les transformera en décalages de bits ( 2 * x == x << 1), mais la multiplication avec des nombres arbitraires peut être très lente , ainsi au lieu de multiplier par 3, vous obtenez un code beaucoup plus rapide en décalant les bits x par 1 et en ajoutant x au résultat - et même cette astuce est effectuée par les compilateurs modernes si vous multipliez par 3 et activez l'optimisation agressive à moins que ce ne soit une cible moderne CPU où la multiplication est aussi rapide que l'addition depuis lors, l'astuce ralentirait le calcul.)

Mecki
la source
2
Ce n'est pas un comportement indéfini - la norme répertorie les possibilités et l'une d'elles est choisie à tout moment
Antti Haapala
Le compilateur ne se transformera pas 3 * xen deux lectures de x. Il pourrait lire x une fois, puis appliquer la méthode x + 2 * x sur le registre dans
lequel
6
@Mecki "Si vous ne pouvez pas dire quel est le résultat en regardant simplement le code, le résultat n'est pas défini" - le comportement non défini a une signification très spécifique en C / C ++, et ce n'est pas ça. D'autres répondants ont expliqué pourquoi cette instance particulière n'est pas spécifiée , mais pas indéfinie .
marcelm
3
J'apprécie l'intention de jeter un peu de lumière sur les composants internes d'un ordinateur, même si cela dépasse le cadre de la question initiale. Cependant, UB est un jargon C / C ++ très précis et doit être utilisé avec précaution, en particulier lorsque la question porte sur la technicité d'un langage. Vous pourriez envisager d'utiliser le terme "comportement non spécifié" approprié à la place, ce qui améliorerait considérablement la réponse.
kuroi neko
2
@Mecki " Undefined a une signification très spéciale dans la langue anglaise " ... mais dans une question étiquetée language-lawyer, où la langue en question a sa propre "signification très spéciale" pour undefined , vous allez seulement semer la confusion en n'utilisant pas la définition de la langue.
TripeHound
-1

Edit global: désolé les gars, je suis tout excité et j'ai écrit beaucoup de bêtises. Juste un vieux coup de gueule.

Je voulais croire que C avait été épargné, mais hélas depuis C11, il a été mis au même niveau que C ++. Apparemment, savoir ce que le compilateur fera avec les effets secondaires dans les expressions nécessite maintenant de résoudre une petite énigme mathématique impliquant un ordre partiel des séquences de code basé sur "est situé avant le point de synchronisation de".

Il se trouve que j'ai conçu et mis en œuvre quelques systèmes embarqués critiques en temps réel à l'époque de K&R (y compris le contrôleur d'une voiture électrique qui pourrait envoyer des personnes s'écraser contre le mur le plus proche si le moteur n'était pas contrôlé, un industriel de 10 tonnes). robot qui pourrait écraser les gens en une pâte s'il n'est pas correctement commandé, et une couche système qui, bien qu'inoffensive, aurait quelques dizaines de processeurs sucer leur bus de données à sec avec moins de 1% de frais généraux du système).

Je suis peut-être trop sénile ou stupide pour faire la différence entre indéfini et non spécifié, mais je pense que j'ai encore une assez bonne idée de ce que signifient l'exécution simultanée et l'accès aux données. À mon avis sans doute informé, cette obsession du C ++ et maintenant des gars C avec leurs langages familiers prenant en charge les problèmes de synchronisation est un rêve de pipe coûteux. Soit vous savez ce qu'est une exécution simultanée, et vous n'avez besoin d'aucun de ces gadgets, soit vous ne l'avez pas, et vous rendriez service au monde en général sans essayer de jouer avec.

Tout ce chargement d'abstractions de barrière de mémoire alléchantes est simplement dû à un ensemble temporaire de limitations des systèmes de cache multi-CPU, qui peuvent tous être encapsulés en toute sécurité dans des objets de synchronisation de système d'exploitation communs comme, par exemple, les mutex et les variables de condition C ++ des offres.
Le coût de cette encapsulation n'est qu'une infime baisse des performances par rapport à ce qu'une utilisation d'instructions CPU spécifiques à grain fin pourrait réaliser dans certains cas.
Le volatilemot clé (ou un#pragma dont-mess-with-that-variablemalgré tout, en tant que programmeur système, le soin) aurait suffi pour dire au compilateur d'arrêter de réorganiser les accès à la mémoire. Le code optimal peut facilement être produit avec des directives asm directes pour saupoudrer le pilote de bas niveau et le code du système d'exploitation avec des instructions spécifiques au processeur ad hoc. Sans une connaissance approfondie du fonctionnement du matériel sous-jacent (système de cache ou interface de bus), vous êtes de toute façon obligé d'écrire du code inutile, inefficace ou défectueux.

Un ajustement minutieux du volatilemot - clé et de Bob aurait été tout le monde, sauf l'oncle des programmeurs de bas niveau les plus durs. Au lieu de cela, le gang habituel des monstres mathématiques C ++ a eu une journée de terrain pour concevoir une autre abstraction incompréhensible, cédant à leur tendance typique à concevoir des solutions à la recherche de problèmes inexistants et à confondre la définition d'un langage de programmation avec les spécifications d'un compilateur.

Seulement cette fois, le changement nécessaire pour défigurer un aspect fondamental de C aussi, puisque ces "barrières" devaient être générées même en code C de bas niveau pour fonctionner correctement. Cela, entre autres, a fait des ravages dans la définition des expressions, sans aucune explication ni justification que ce soit.

En conclusion, le fait qu'un compilateur puisse produire un code machine cohérent à partir de ce morceau absurde de C n'est qu'une conséquence éloignée de la façon dont les gars C ++ ont fait face aux incohérences potentielles des systèmes de cache de la fin des années 2000.
Cela a fait un terrible gâchis d'un aspect fondamental de C (définition de l'expression), de sorte que la grande majorité des programmeurs C - qui ne se soucient pas des systèmes de cache, et à juste titre - sont maintenant obligés de s'appuyer sur des gourous pour expliquer la différence entre a = b() + c()et a = b + c.

Essayer de deviner ce qu'il adviendra de ce tableau malheureux est une perte nette de temps et d'efforts de toute façon. Indépendamment de ce que le compilateur en fera, ce code est pathologiquement incorrect. La seule chose responsable à en faire est de l'envoyer au bac.
Conceptuellement, les effets secondaires peuvent toujours être retirés des expressions, avec l'effort trivial de laisser explicitement la modification se produire avant ou après l'évaluation, dans une déclaration distincte.
Ce type de code merdique aurait pu être justifié dans les années 80, lorsque vous ne pouviez pas vous attendre à ce qu'un compilateur optimise quoi que ce soit. Mais maintenant que les compilateurs sont devenus plus intelligents que la plupart des programmeurs, il ne reste plus qu'un morceau de code merdique.

Je ne comprends pas non plus l’importance de ce débat indéfini / non spécifié. Soit vous pouvez compter sur le compilateur pour générer du code avec un comportement cohérent, soit vous ne le pouvez pas. Que vous appeliez cela non défini ou non spécifié semble être un point discutable.

À mon avis, sans doute éclairé, C est déjà suffisamment dangereux dans son état K&R. Une évolution utile consisterait à ajouter des mesures de sécurité de bon sens. Par exemple, en utilisant cet outil d'analyse de code avancé, les spécifications forcent le compilateur à implémenter pour générer au moins des avertissements sur le code bonkers, au lieu de générer silencieusement un code potentiellement peu fiable à l'extrême.
Mais à la place, les gars ont décidé, par exemple, de définir un ordre d'évaluation fixe en C ++ 17. Désormais, chaque imbécile logiciel est activement incité à mettre délibérément des effets secondaires dans son code, profitant de la certitude que les nouveaux compilateurs géreront avec empressement l'obscurcissement de manière déterministe.

K&R était l'une des véritables merveilles du monde informatique. Pour vingt dollars, vous avez obtenu une spécification complète de la langue (j'ai vu des personnes seules écrire des compilateurs complets en utilisant simplement ce livre), un excellent manuel de référence (la table des matières vous pointe généralement dans quelques pages de la réponse à votre question), et un manuel qui vous apprendrait à utiliser la langue de manière sensée. Complétez avec des justifications, des exemples et des mots judicieux d'avertissement sur les nombreuses façons dont vous pourriez abuser du langage pour faire des choses très, très stupides.

Détruire cet héritage pour si peu de gains me semble une perte cruelle. Mais encore une fois, je pourrais très bien ne pas voir le point complètement. Peut-être qu'une âme aimable pourrait me diriger vers un exemple de nouveau code C qui tire un avantage significatif de ces effets secondaires?

kuroi neko
la source
C'est un comportement indéfini s'il y a des effets secondaires sur le même objet dans la même expression, C17 6.5 / 2. Ceux-ci ne sont pas séquencés selon C17 6.5.18 / 3. Mais le texte de 6.5 / 2 "Si un effet secondaire sur un objet scalaire n'est pas séquencé par rapport à un effet secondaire différent sur le même objet scalaire ou à un calcul de valeur utilisant la valeur du même objet scalaire, le comportement n'est pas défini." ne s'applique pas, car le calcul de la valeur à l'intérieur de la fonction est séquencé avant ou après l'accès à l'index du tableau, que l'opérateur d'affectation ait lui-même des opérandes non séquencés.
Lundin
L'appel de fonction agit un peu comme "un mutex contre l'accès non séquencé", si vous voulez. Similaire à la merde d'opérateur de virgule obscure comme 0,expr,0.
Lundin
Je pense que vous croyiez les auteurs de la norme quand ils ont dit « comportement non défini donne la licence de implementor ne pas attraper certaines erreurs de programme qui sont difficiles à diagnostiquer identifie également les zones d'extension de langage conforme possible:. L'implémenteur peut augmenter la langue en fournissant un définition du comportement officiellement indéfini . " et a déclaré que la norme n'était pas censée rabaisser les programmes utiles qui n'étaient pas strictement conformes. Je pense que la plupart des auteurs de la norme auraient pensé qu'il était évident que les gens cherchant à écrire des compilateurs de qualité ...
supercat
... devraient chercher à utiliser UB comme une opportunité de rendre leurs compilateurs aussi utiles que possible pour leurs clients. Je doute que quiconque ait pu imaginer que les rédacteurs du compilateur l'auraient utilisé comme excuse pour répondre aux plaintes de "Votre compilateur traite ce code moins utilement que tout le monde" avec "C'est parce que la Norme ne nous oblige pas à le traiter utilement, et les implémentations qui traitent utilement les programmes dont le comportement n'est pas prescrit par la norme ne font que promouvoir l'écriture de programmes défectueux ".
supercat
Je ne vois pas l'intérêt de votre remarque. S'appuyer sur un comportement spécifique au compilateur est une garantie de non-portabilité. Cela nécessite également une grande confiance dans le fabricant du compilateur, qui pourrait interrompre n'importe laquelle de ces "définitions supplémentaires" à tout moment. La seule chose qu'un compilateur peut faire est de générer des avertissements, qu'un programmeur sage et compétent pourrait décider de gérer des erreurs similaires. Le problème que je vois avec ce monstre ISO est qu'il rend légitime un code aussi atroce que l'exemple de l'OP (pour des raisons extrêmement peu claires, par rapport à la définition K&R d'une expression).
kuroi neko