Les spécifications C \ C ++ laissent un grand nombre de comportements que les compilateurs peuvent implémenter à leur manière. Il y a un certain nombre de questions qui sont toujours posées ici à propos de la même chose et nous avons d'excellents articles à ce sujet:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-un-dedefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-sequence-points
Ma question ne concerne pas ce qu'est un comportement indéfini, ou est-ce vraiment mauvais. Je connais les dangers et la plupart des citations pertinentes sur le comportement non défini de la norme. Veuillez vous abstenir de poster des réponses concernant son état déplorable. Cette question concerne la philosophie qui sous-tend de supprimer autant de comportements ouverts à la mise en œuvre du compilateur.
J'ai lu un excellent article de blog qui indique que la performance est la raison principale. Je me demandais si la performance était le seul critère permettant de le permettre, ou existe-t-il d'autres facteurs qui influencent la décision de laisser les choses ouvertes pour la mise en œuvre du compilateur?
Si vous avez des exemples à citer sur la manière dont un comportement non défini particulier fournit suffisamment d'espace pour que le compilateur puisse les optimiser, veuillez les énumérer. Si vous connaissez des facteurs autres que les performances, veuillez fournir une réponse suffisamment détaillée.
Si vous ne comprenez pas la question ou si vous ne disposez pas de suffisamment de preuves / de sources pour appuyer votre réponse, veuillez ne pas publier de réponses spéculatives à grande échelle.
la source
Réponses:
Premièrement, je noterai que bien que je ne mentionne que "C", la même chose s’applique également au C ++.
Le commentaire mentionnant Godel était en partie (mais seulement en partie) pertinent.
Lorsque vous descendez à elle, un comportement non défini dans les normes C est en grande partie tout en soulignant la limite entre ce que les tentatives standards pour définir, et ce qu'il ne fonctionne pas.
Les théorèmes de Godel (il y en a deux) disent qu'il est impossible de définir un système mathématique qui peut être prouvé (par ses propres règles) à la fois complet et cohérent. Vous pouvez faire vos règles pour qu'elles soient complètes (le cas dont il s'est occupé était les règles "normales" pour les nombres naturels), ou bien vous pouvez permettre de prouver sa cohérence, mais vous ne pouvez pas avoir les deux.
Dans le cas de quelque chose comme C, cela ne s'applique pas directement - pour la plupart, la "prouvabilité" de l'exhaustivité ou de la cohérence du système n'est pas une priorité absolue pour la plupart des concepteurs de langage. En même temps, oui, ils ont probablement été influencés (au moins dans une certaine mesure) en sachant qu'il est impossible de définir un système «parfait» - un système parfaitement complet et cohérent. Sachant qu'une telle chose est impossible a peut-être rendu un peu plus facile de prendre du recul, de respirer un peu et de décider des limites de ce qu'ils essaient de définir.
Au risque d'être (encore une fois) accusé d'arrogance, je qualifierais le standard C de régi (en partie) par deux idées de base:
La première signifie que si quelqu'un définit un nouveau processeur, il devrait être possible de fournir une bonne implémentation de C, solide et utilisable, à condition que sa conception se rapproche au moins raisonnablement de quelques directives simples - en gros, si suit quelque chose sur l'ordre général du modèle de Von Neumann et fournit au moins une quantité de mémoire minimale raisonnable, qui devrait être suffisante pour permettre une implémentation en C. Pour une implémentation "hébergée" (fonctionnant sur un système d'exploitation), vous devez prendre en charge une notion qui correspond de manière assez proche aux fichiers et qui a un jeu de caractères avec un certain jeu minimal de caractères (91 requis).
Le second moyen , il devrait être possible d'écrire du code qui manipule le matériel directement, de sorte que vous pouvez écrire des choses comme les chargeurs de démarrage, les systèmes d' exploitation, logiciels embarqués qui fonctionne sans système d' exploitation, etc. Il y a en fin de compte des limites à cet égard, donc presque tout système d’exploitation pratique, chargeur de démarrage, etc., est susceptible de contenir au moins un peu de code écrit en langage assembleur. De même, même un petit système intégré est susceptible d'inclure au moins une sorte de routine de bibliothèque pré-écrite pour donner accès aux périphériques sur le système hôte. Bien qu’il soit difficile de définir une limite précise, l’intention est que la dépendance à ce code soit réduite au minimum.
Le comportement non défini dans le langage est en grande partie motivé par l'intention du langage de prendre en charge ces capacités. Par exemple, le langage vous permet de convertir un entier arbitraire en pointeur et d'accéder à tout ce qui se trouve à cette adresse. La norme n'essaie pas de dire ce qui se passera quand vous le ferez (par exemple, même la lecture de certaines adresses peut avoir des effets visibles de l'extérieur). En même temps, il ne fait aucun effort pour vous empêcher de faire de telles choses, car vous devez utiliser certains types de logiciels que vous êtes censé être capable d’écrire en C.
Il existe également un comportement indéfini lié à d'autres éléments de conception. Par exemple, une autre intention de C est de prendre en charge une compilation séparée. Cela signifie (par exemple) qu'il est prévu que vous puissiez "relier" des morceaux en utilisant un éditeur de liens qui suit à peu près ce que la plupart d'entre nous voyons comme le modèle habituel d'un éditeur de liens. En particulier, il devrait être possible de combiner des modules compilés séparément dans un programme complet sans connaissance de la sémantique du langage.
Il existe un autre type de comportement indéfini (qui est beaucoup plus courant en C ++ qu'en C) et qui existe simplement à cause des limites de la technologie du compilateur - des choses que nous connaissons fondamentalement sont des erreurs et que nous voudrions probablement que le compilateur diagnostique comme telles, mais étant donné les limites actuelles de la technologie des compilateurs, il est douteux qu’elles puissent être diagnostiquées en toutes circonstances. Bon nombre d’entre elles sont dictées par d’autres exigences, telles que la compilation séparée, il s’agit donc en grande partie d’équilibrer des exigences contradictoires. Dans ce cas, le comité a généralement opté pour une plus grande capacité, même si cela ne permet pas de diagnostiquer certains problèmes éventuels, plutôt que de limiter les capacités pour assurer que tous les problèmes possibles sont diagnostiqués.
Ces différences d' intention expliquent la plupart des différences entre C et quelque chose comme Java ou les systèmes basés sur CLI de Microsoft. Ces derniers se limitent assez explicitement à travailler avec un ensemble de matériel beaucoup plus limité ou à obliger les logiciels à émuler le matériel plus spécifique qu'ils ciblent. Ils ont également pour objectif spécifique d’ empêcher toute manipulation directe du matériel, mais vous obligent plutôt à utiliser quelque chose comme JNI ou P / Invoke (et un code écrit en quelque chose comme C) pour même tenter une telle tentative.
Pour revenir un instant aux théorèmes de Godel, nous pouvons faire un parallèle: Java et CLI ont opté pour l’alternative "cohérente en interne", tandis que C a opté pour l’alternative "complète". Bien sûr, cela est une analogie très rude - je doute que quiconque de tenter une preuve formelle de soit la cohérence interne ou l' exhaustivité dans les deux cas. Néanmoins, la notion générale correspond assez étroitement aux choix qu’ils ont pris.
la source
La raison C explique
Les avantages pour les programmes sont également importants, pas seulement pour les implémentations. Un programme qui dépend d'un comportement indéfini peut toujours être conforme s'il est accepté par une implémentation conforme. L'existence d'un comportement non défini permet à un programme d'utiliser des caractéristiques non portables explicitement marquées comme telles ("comportement non défini"), sans pour autant devenir non conforme. Les notes de justification:
Et à 1,7 il note
Ainsi, ce petit programme sale qui fonctionne parfaitement sur GCC est toujours conforme !
la source
La rapidité est particulièrement problématique par rapport à C. Si C ++ faisait certaines choses sensées, telles que l’initialisation de grands tableaux de types primitifs, il perdrait une tonne de points de repère au code C. Donc, C ++ initialise ses propres types de données, mais laisse les types C tels quels.
Les autres comportements indéfinis ne font que refléter la réalité. Un exemple est le transfert de bits avec un nombre plus grand que le type. Cela diffère réellement entre les générations de matériel de la même famille. Si vous avez une application 16 bits, le même binaire exact donnera des résultats différents sur un 80286 et un 80386. Le langage standard dit donc que nous ne savons pas!
Certains éléments sont simplement conservés tels qu’ils étaient, comme l’ordre d’évaluation des sous-expressions n’est pas spécifié. A l'origine, on pensait que cela aiderait les rédacteurs de compilateur à optimiser leurs performances. De nos jours, les compilateurs sont assez bons pour le comprendre, mais le coût de trouver toutes les places disponibles dans les compilateurs existants qui profitent de la liberté est tout simplement trop élevé.
la source
À titre d'exemple, les accès de pointeur doivent presque toujours être indéfinis et pas nécessairement pour des raisons de performances. Par exemple, sur certains systèmes, le chargement de registres spécifiques avec un pointeur générera une exception matérielle. Sous SPARC, l’accès à un objet mémoire mal aligné provoquera une erreur de bus, mais sous x86, il serait "simplement" lent. Il est difficile de spécifier le comportement dans ces cas, car le matériel sous-jacent dicte ce qui va se passer et le C ++ est portable pour de nombreux types de matériel.
Bien sûr, cela donne également au compilateur la liberté d'utiliser des connaissances spécifiques à l'architecture. Pour un exemple de comportement non spécifié, le décalage à droite des valeurs signées peut être logique ou arithmétique, en fonction du matériel sous-jacent, afin de permettre l'utilisation de l'opération de décalage disponible et de ne pas forcer son émulation logicielle.
Je pense aussi que cela rend le travail du compilateur-écrivain plutôt facile, mais je ne me souviens pas de l'exemple pour l'instant. Je l'ajouterai si je me souviens de la situation.
la source
Simple: rapidité et portabilité. Si C ++ garantit que vous obtenez une exception lorsque vous dé-référencez un pointeur non valide, il ne sera pas portable vers un matériel intégré. Si le C ++ garantissait d'autres choses, comme toujours les primitives initialisées, alors ce serait plus lent, et à l'époque de l'origine du C ++, le ralentissement était une très mauvaise chose.
la source
C a été inventé sur une machine avec des octets de 9 bits et aucune unité à virgule flottante - supposons qu’il soit obligatoire que les octets soient de 9 bits, les mots de 18 bits et que les flotteurs soient implémentés à l’aide de pré-IEEE754 aritmatic?
la source
Je ne pense pas que la première raison pour UB était de laisser de la place au compilateur pour l'optimiser, mais simplement la possibilité d'utiliser l'implémentation évidente pour les cibles à un moment où les architectures étaient plus variées que maintenant (rappelez-vous si C a été conçu sur une PDP-11 qui a une architecture quelque peu familière, le premier port était celui de Honeywell 635 qui est beaucoup moins familier - mot adressable, mots de 36 bits, octets de 6 ou 9 bits, adresses de 18 bits ... enfin au moins, il utilisait des 2 complément). Mais si l'optimisation lourde n'était pas une cible, l'implémentation évidente n'inclut pas l'ajout de vérifications d'exécution en cas de dépassement de capacité, le nombre de décalages par rapport à la taille du registre, les alias dans les expressions modifiant plusieurs valeurs.
Une autre chose prise en compte était la facilité de mise en œuvre. À l'époque, le compilateur AC comportait plusieurs passes utilisant plusieurs processus, car il n'aurait pas été possible de gérer tout ce processus (le programme aurait été trop volumineux). Il était difficile de demander une vérification de cohérence poussée - en particulier lorsque plusieurs UC étaient impliquées. (Un autre programme que les compilateurs C, lint, a été utilisé pour cela).
la source
i
etn
, de sorte quen < INT_BITS
eti*(1<<n)
ne déborderait pas, je considéreraisi<<=n;
être plus clair quei=(unsigned)i << n;
; sur de nombreuses plateformes, il serait plus rapide et plus petit quei*=(1<<N);
. Que gagne-t-on en interdisant les compilateurs?L'un des premiers cas classiques était l'addition d'entiers signés. Sur certains des processeurs utilisés, cela causerait une erreur, et sur d'autres, il continuerait simplement avec une valeur (probablement la valeur modulaire appropriée). Spécifier l'un ou l'autre cas signifierait que les programmes pour les machines avec le style arithmétique défavorisé devraient avoir un code supplémentaire, y compris une branche conditionnelle, pour quelque chose d'aussi similaire que l'addition d'un entier.
la source
int
est de 16 bits et où les décalages étendus de signes sont coûteux pourrait être calculé à l'(uchar1*uchar2) >> 4
aide d'un décalage non étendu de signes. Malheureusement, certains compilateurs étendent les inférences non seulement aux résultats, mais également aux opérandes.Je dirais que c’était moins de la philosophie que de la réalité - C a toujours été un langage multi-plateforme, et la norme doit en tenir compte et le fait qu’à la publication de toute norme, il y aura une grand nombre d'implémentations sur de nombreux matériels différents. Une norme interdisant les comportements nécessaires serait soit ignorée, soit créée par un organisme de normalisation concurrent.
la source
Certains comportements ne peuvent être définis par aucun moyen raisonnable. Je veux dire accéder à un pointeur supprimé. Le seul moyen de le détecter serait d’interdire la valeur du pointeur après la suppression (mémoriser sa valeur quelque part et ne plus permettre à une fonction d’allocation de la renvoyer). Non seulement une telle mémorisation serait excessive, mais, pour un programme de longue durée, les valeurs de pointeurs autorisées seraient épuisées.
la source
weak_ptr
et à mesure et annuler toutes les références à un pointeur qui sedelete
d ... oh, attendez, nous approchons du ramassage des ordures: /boost::weak_ptr
L'implémentation de est un très bon modèle pour commencer pour ce modèle d'utilisation. Plutôt que de suivre et d’annuler de manièreweak_ptrs
externe, unweak_ptr
simple contribue aushared_ptr
compte faible de, et le compte faible est fondamentalement un refcount du pointeur lui-même. Ainsi, vous pouvez annuler leshared_ptr
sans avoir à le supprimer immédiatement. Ce n’est pas parfait (il est toujours possible que de nombreux expirésweak_ptr
conservent le sous-jacentshared_count
sans raison valable), mais au moins ils sont rapides et efficaces.Je vais vous donner un exemple où il n’ya quasiment pas de choix sensé autre que le comportement indéfini. En principe, tout pointeur pourrait pointer sur la mémoire contenant une variable, à l'exception des variables locales que le compilateur sait ne jamais avoir eu leur adresse prise. Cependant, pour obtenir des performances acceptables sur un processeur moderne, un compilateur doit copier les valeurs de variable dans des registres. Exploiter entièrement la mémoire est un non-démarreur.
Cela vous donne essentiellement deux choix:
1) Tout effacer des registres avant tout accès via un pointeur, juste au cas où le pointeur pointe vers la mémoire de cette variable particulière. Puis chargez tout ce qui est nécessaire dans le registre, juste au cas où les valeurs auraient été modifiées via le pointeur.
2) Avoir un ensemble de règles pour lorsqu'un pointeur est autorisé à aliaser une variable et lorsque le compilateur est autorisé à supposer qu'un pointeur n'alias pas une variable.
C opte pour l’option 2, car 1 serait terrible pour la performance. Mais alors, que se passe-t-il si un pointeur alias une variable de la manière que les règles C interdisent? Etant donné que l'effet dépend de si le compilateur a effectivement enregistré la variable dans un registre, le standard C n'a aucun moyen de garantir de manière définitive des résultats spécifiques.
la source
foo
sur 42, puis appelle une méthode qui utilise un pointeur modifié illégitimement pour définirfoo
sur 44, je vois un avantage à dire que, jusqu'à la prochaine écriture "légitime"foo
, des tentatives de lecture peuvent légitimement donnez 42 ou 44, et une expression commefoo+foo
pourrait donner 86, mais je vois moins d'avantages à permettre au compilateur de faire des inférences étendues et même rétroactives, modifiant ainsi le comportement indéfini dont les comportements "naturels" plausibles auraient été bienveillants. générer du code insensé.Historiquement, le comportement non défini avait deux objectifs principaux:
Pour éviter de demander aux auteurs du compilateur de générer du code afin de gérer des conditions qui n'étaient jamais supposées se produire.
Pour permettre la possibilité qu'en l'absence de code traitant explicitement de telles conditions, les implémentations peuvent avoir différents types de comportements "naturels" qui pourraient, dans certains cas, être utiles.
À titre d’exemple simple, sur certaines plates-formes matérielles, la tentative d’addition de deux entiers signés positifs dont la somme est trop importante pour tenir dans un entier signé aboutira à un entier signé négatif. Sur d'autres implémentations, il déclenchera une interruption du processeur. Pour que la norme C oblige l'un ou l'autre comportement, les compilateurs de plateformes dont le comportement naturel diffère de la norme devraient générer du code supplémentaire pour obtenir le comportement correct - un code qui peut être plus coûteux que le code pour effectuer l'addition réelle. Pire, cela voudrait dire que les programmeurs qui souhaitaient le comportement «naturel» devraient ajouter encore plus de code pour le réaliser (et que le code supplémentaire serait encore plus coûteux que l’ajout).
Malheureusement, certains auteurs de compilateurs ont adopté la philosophie voulant que les compilateurs s’efforcent de trouver des conditions susceptibles d’évoquer un comportement indéfini et, en supposant que de telles situations ne se reproduisent jamais, en tirent des conclusions étendues. Ainsi, sur un système avec 32 bits
int
, un code donné tel que:La norme C permettrait au compilateur de dire que si q est égal à 46341 ou plus, l’expression q * q donnera un résultat trop grand pour tenir dans un
int
comportement indéfini , ce qui entraînera le comportement indéfini du compilateur. ne peut pas arriver et donc ne serait pas tenu d'incrémenter*p
si cela se produit. Si le code appelant utilise*p
comme indicateur l’annulation des résultats du calcul, l’optimisation peut avoir pour effet de prendre du code qui aurait donné des résultats sensibles sur des systèmes fonctionnant de la manière la plus imaginable avec débordement d’entier (le recouvrement peut être moche, mais serait au moins raisonnable), et l'a transformé en code qui peut se comporter de façon absurde.la source
L’efficacité est le prétexte habituel, mais quel que soit le prétexte, un comportement indéfini est une idée terrible pour la portabilité. En réalité, les comportements non définis deviennent des hypothèses non vérifiées et non vérifiées.
la source