web-dev-qa-db-fra.com

Philosophie derrière un comportement indéfini

Les spécifications C\C++ laissent de côté un grand nombre de comportements ouverts aux compilateurs à 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:

Ma question n'est pas de savoir ce qu'est un comportement indéfini, ou est-ce vraiment mauvais. Je connais les dangers et la plupart des citations de comportement non définies pertinentes de la norme, alors veuillez ne pas publier de réponses sur la gravité de la situation. Cette question concerne la philosophie derrière la suppression de tant de comportements ouverts pour l'implémentation du compilateur.

J'ai lu un excellent article de blog qui indique que la performance est la principale raison. Je me demandais si la performance est le seul critère pour l'autoriser, ou y a-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 façon dont un comportement indéfini particulier fournit suffisamment d'espace pour que le compilateur soit optimisé, veuillez les énumérer. Si vous connaissez d'autres facteurs que les performances, veuillez appuyer votre réponse avec suffisamment de détails.

Si vous ne comprenez pas la question ou si vous ne disposez pas de suffisamment de preuves/sources pour étayer votre réponse, veuillez ne pas publier de réponses à large spéculation.

59
Alok Save

Tout d'abord, je noterai que bien que je ne mentionne que "C" ici, la même chose s'applique également à peu près également au C++.

Le commentaire mentionnant Godel était en partie (mais seulement en partie) pertinent.

Quand vous y arrivez, le comportement indéfini dans les normes C est en grande partie simplement en soulignant la frontière entre ce que la norme tente de définir et ce qu'elle ne fait pas.

Les théorèmes de Godel (il y en a deux) disent essentiellement 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 qu'il a traité é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é élevée pour la plupart des concepteurs de langage. Dans le 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 qui soit prouvablement complet et cohérent. Le fait de savoir qu'une telle chose est impossible peut avoir facilité un peu le recul, la respiration un peu et la détermination des limites de ce qu'ils essaieraient de définir.

Au risque (encore une fois) d'être accusé d'arrogance, je qualifierais la norme C de régie (en partie) par deux idées fondamentales:

  1. Le langage doit prendre en charge la plus grande variété de matériel possible (idéalement, tout le matériel "sain" jusqu'à une limite inférieure raisonnable).
  2. La langue doit prendre en charge l'écriture d'une variété de logiciels aussi large que possible pour l'environnement donné.

Le premier signifie que si quelqu'un définit un nouveau CPU, il devrait être possible de fournir une bonne implémentation solide et utilisable de C pour cela, tant que la conception se rapproche au moins raisonnablement de quelques directives simples - en gros, si elle suit quelque chose dans l'ordre général du modèle de Von Neumann, et fournit au moins une quantité minimale raisonnable de mémoire, qui devrait être suffisante pour permettre une implémentation C. Pour une implémentation "hébergée" (qui s'exécute sur un système d'exploitation), vous devez prendre en charge une notion qui correspond assez étroitement aux fichiers et avoir un jeu de caractères avec un certain jeu minimum de caractères (91 sont requis).

La seconde signifie qu'il devrait être possible d'écrire du code qui manipule directement le matériel, afin que vous puissiez écrire des choses comme des chargeurs de démarrage, des systèmes d'exploitation, des logiciels intégrés qui fonctionnent sans aucun système d'exploitation, etc. Il y a finalement certains limites à cet égard, donc presque tout système d'exploitation pratique, chargeur de démarrage, etc., est susceptible de contenir au moins un pe bit 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 routines de bibliothèque pré-écrites pour donner accès aux périphériques sur le système hôte. Bien qu'une limite précise soit difficile à définir, l'intention est que la dépendance à l'égard de ce code soit réduite au minimum.

Le comportement indéfini dans le langage est largement 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 un pointeur et d'accéder à tout ce qui se trouve à cette adresse. La norme ne tente pas de dire ce qui se passera lorsque vous le faites (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 aucune tentative pour vous empêcher de faire de telles choses, car vous avez besoin pour certains types de logiciels que vous êtes censé pouvoir écrire en C.

Il existe également un comportement indéfini entraîné par d'autres éléments de conception. Par exemple, une autre intention de C est de prendre en charge la compilation séparée. Cela signifie (par exemple) qu'il est prévu que vous puissiez "lier" des pièces ensemble en utilisant un éditeur de liens qui suit à peu près ce que la plupart d'entre nous considèrent 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++ que C), qui est présent simplement en raison des limites de la technologie du compilateur - des choses que nous savons fondamentalement être des erreurs, et que le compilateur devrait probablement diagnostiquer comme des erreurs, mais étant donné les limites actuelles de la technologie du compilateur, il est peu probable qu'elles puissent être diagnostiquées en toutes circonstances. Beaucoup d'entre eux sont dictés par les autres exigences, telles que la compilation séparée, il s'agit donc en grande partie d'équilibrer des exigences contradictoires, auquel cas le comité a généralement choisi de prendre en charge de plus grandes capacités, même si cela signifie un manque de diagnostic de certains problèmes possibles, plutôt que de limiter les capacités pour garantir que tous les problèmes possibles sont diagnostiqués.

Ces différences dans intention entraînent la plupart des différences entre C et quelque chose comme Java ou les systèmes basés sur CLI de Microsoft. Ces derniers sont assez explicitement limités à travailler avec beaucoup ensemble de matériel plus limité, ou nécessitant un logiciel pour émuler le matériel plus spécifique qu'ils ciblent. Ils ont également spécifiquement l'intention de empêcher toute manipulation directe du matériel, exigeant plutôt que vous utilisiez quelque chose comme JNI ou P/Invoke ( et du code écrit dans quelque chose comme C) pour même faire une telle tentative.

Pour en revenir un instant aux théorèmes de Godel, nous pouvons faire une sorte de parallèle: Java et CLI ont opté pour l'alternative "internally consistent", tandis que C a opté pour l'alternative "complete". Bien sûr, il s'agit d'une analogie très approximative - je doute que quiconque tente une preuve formelle de soit cohérence interne o complétude dans les deux cas. Néanmoins, la notion générale convient - assez étroitement avec les choix qu'ils ont faits.

49
Jerry Coffin

La justification de C explique

Les termes comportement non spécifié, comportement non défini et comportement défini par l'implémentation sont utilisés pour classer le résultat de l'écriture de programmes dont les propriétés ne décrivent pas, ou ne peuvent pas, complètement les propriétés de la norme. Le but de l'adoption de cette catégorisation est de permettre une certaine variété parmi les implémentations qui permet à la qualité de l'implémentation d'être une force active sur le marché ainsi que de permettre certaines extensions populaires, sans supprimer le cachet de conformité à la norme. L'annexe F du catalogue standard répertorie les comportements qui entrent dans l'une de ces trois catégories.

Un comportement non spécifié donne au réalisateur une certaine latitude dans la traduction des programmes. Cette latitude ne va pas jusqu'à ne pas traduire le programme.

Un comportement indéfini donne à l'implémenteur une licence pour ne pas détecter certaines erreurs de programme difficiles à diagnostiquer. Il identifie également les domaines d'extension linguistique possible: l'implémenteur peut étendre le langage en fournissant une définition du comportement officiellement indéfini.

Le comportement défini par l'implémentation donne à l'implémenteur la liberté de choisir l'approche appropriée, mais nécessite que ce choix soit expliqué à l'utilisateur. Les comportements désignés comme définis par l'implémentation sont généralement ceux dans lesquels un utilisateur peut prendre des décisions de codage significatives sur la base de la définition de l'implémentation. Les développeurs doivent garder à l'esprit ce critère lorsqu'ils décident de l'étendue d'une définition de mise en œuvre. Comme pour un comportement non spécifié, le simple fait de ne pas traduire la source contenant le comportement défini par l'implémentation n'est pas une réponse adéquate.

L'important est également l'avantage pour les programmes, pas seulement l'avantage pour les implémentations. Un programme qui dépend d'un comportement non dé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 fonctionnalités non portables explicitement marquées comme telles ("comportement non défini"), sans devenir non conforme. La justification note:

Le code C peut être non portable. Bien qu'il s'efforce de donner aux programmeurs la possibilité d'écrire des programmes vraiment portables, le Comité ne voulait pas forcer les programmeurs à écrire portablement, pour empêcher l'utilisation de C comme un `` assembleur de haut niveau '': la capacité d'écrire du code spécifique à la machine est l'une des forces de C.C'est ce principe qui motive largement la distinction entre programme strictement conforme et programme conforme (§1.7).

Et à 1,7, il note

La triple définition de la conformité est utilisée pour élargir la population des programmes conformes et faire la distinction entre les programmes conformes utilisant une seule implémentation et les programmes conformes portables.

Un programme strictement conforme est un autre terme pour un programme portable au maximum. Le but est de donner au programmeur une chance de se battre pour créer des programmes C puissants qui sont également très portables, sans dégrader des programmes C parfaitement utiles qui s'avèrent ne pas être portables. Ainsi l'adverbe strictement.

Ainsi, ce petit programme sale qui fonctionne parfaitement sur GCC est toujours conforme!

21

La vitesse est particulièrement problématique par rapport à C. Si C++ faisait des choses qui pourraient être sensées, comme l'initialisation de grands tableaux de types primitifs, il perdrait une tonne de benchmarks en code C. C++ initialise donc ses propres types de données, mais laisse les types C tels qu'ils étaient.

D'autres comportements indéfinis reflètent simplement la réalité. Un exemple est le décalage de bits avec un nombre supérieur au type. Cela diffère en fait 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. Donc, la norme de langue dit que nous ne savons pas!

Certaines choses sont simplement conservées telles qu'elles étaient, comme l'ordre d'évaluation des sous-expressions non spécifié. À l'origine, cela était censé aider les rédacteurs de compilateurs à mieux optimiser. De nos jours, les compilateurs sont assez bons pour le comprendre de toute façon, mais le coût de trouver tous les endroits dans les compilateurs existants qui profitent de la liberté est tout simplement trop élevé.

15
Bo Persson

À titre d'exemple, les accès aux pointeurs doivent presque être indéfinis et pas nécessairement uniquement 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. Sur SPARC l'accès à un objet mémoire mal aligné provoquera une erreur de bus, mais sur x86 ce serait "juste" lent. Il est difficile de spécifier le comportement dans ces cas car le matériel sous-jacent dicte ce qui sera arriver, et C++ est portable à tant de 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 selon le matériel sous-jacent, pour permettre l'utilisation de l'opération de décalage disponible et ne pas forcer l'émulation logicielle de celle-ci.

Je crois également que cela facilite le travail du compilateur-rédacteur, mais je ne me souviens pas de l'exemple pour l'instant. Je vais l'ajouter si je me souviens de la situation.

7
Mark B

Simple: vitesse et portabilité. Si C++ garantissait que vous obteniez une exception lorsque vous supprimez la référence d'un pointeur non valide, il ne serait pas portable sur le matériel intégré. Si C++ garantissait d'autres choses comme des primitives toujours initialisées, alors ce serait plus lent, et à l'époque de l'origine de C++, plus lent était vraiment une très mauvaise chose.

6
DeadMG

C a été inventé sur une machine avec des octets de 9 bits et sans unité à virgule flottante - supposons qu'il ait exigé que les octets soient de 9 bits, des mots de 18 bits et que les flottants doivent être implémentés en utilisant l'aritmatique pré IEEE754?

4
Martin Beckett

Je ne pense pas que la première raison pour UB était de laisser la place au compilateur pour l'optimiser, mais juste la possibilité d'utiliser l'implémentation évidente pour les cibles à un moment où les architectures avaient plus de variété que maintenant (rappelez-vous si C a été conçu sur un PDP-11 qui a une architecture quelque peu familière, le premier port était Honeywell 635 qui est beaucoup moins familier - adressable par mot, utilisant des mots de 36 bits, des octets de 6 ou 9 bits, des adresses de 18 bits. bien au moins il utilisait le complément à 2). Mais si l'optimisation intensive n'était pas une cible, l'implémentation évidente n'inclut pas l'ajout de vérifications au moment de l'exécution pour le débordement, le nombre de décalages sur la taille du registre, qui alias dans les expressions modifiant plusieurs valeurs.

Une autre chose prise en compte était la facilité de mise en œuvre. Un compilateur C à l'époque était à plusieurs passes en utilisant plusieurs processus car avoir un seul processus gérerait tout n'aurait pas été possible (le programme aurait été trop volumineux). Il n'était pas question de demander un contrôle de cohérence important, surtout quand cela impliquait plusieurs UC. (Un autre programme que les compilateurs C, lint, a été utilisé pour cela).

4
AProgrammer

L'un des premiers cas classiques a été signé par addition entière. Sur certains des processeurs utilisés, cela provoquerait une erreur, et sur d'autres, cela continuerait avec une valeur (probablement la valeur modulaire appropriée). La spécification de l'un ou l'autre cas signifierait que les programmes pour les machines avec le style arithmétique défavorable devraient avoir du code supplémentaire, y compris une branche conditionnelle, pour quelque chose d'aussi similaire qu'une addition d'entiers.

3
David Thornley

Je dirais qu'il s'agissait moins de philosophie que de réalité - C a toujours été un langage multiplateforme, et la norme doit refléter cela et le fait qu'au moment où une norme sera publiée, il y aura un grand nombre d'implémentations sur de nombreux matériels différents. Une norme interdisant un comportement nécessaire serait soit ignorée, soit produirait un organisme de normalisation concurrent.

2
jmoreno

Certains comportements ne peuvent être définis par aucun moyen raisonnable. Je veux dire accéder à un pointeur supprimé. La seule façon de le détecter serait d'interdire la valeur du pointeur après la suppression (en mémorisant sa valeur quelque part et en ne permettant plus à aucune fonction d'allocation de la renvoyer). Non seulement une telle mémorisation serait exagérée, mais pour un programme de longue durée entraînerait un dépassement des valeurs de pointeurs autorisées.

1
Tadeusz Kopec

Historiquement, le comportement indéfini avait deux objectifs principaux:

  1. Pour éviter d'obliger les auteurs du compilateur à générer du code pour gérer des conditions qui ne devaient jamais se produire.

  2. Pour tenir compte de la possibilité qu'en l'absence de code pour gérer explicitement de telles conditions, les implémentations peuvent avoir différents types de comportements "naturels" qui, dans certains cas, seraient 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 grande pour tenir dans un entier signé produira un entier signé négatif particulier. Sur d'autres implémentations, il déclenchera une interruption du processeur. Pour que la norme C rende obligatoire l'un ou l'autre comportement, les compilateurs des plates-formes dont le comportement naturel diffère de la norme devraient générer du code supplémentaire pour produire le comportement correct - code qui peut être plus cher que le code pour effectuer l'ajout proprement dit. Pire, cela signifierait que les programmeurs qui voulaient le comportement "naturel" devraient ajouter encore plus de code supplémentaire pour y parvenir (et que le code supplémentaire serait à nouveau plus cher que l'addition).

Malheureusement, certains auteurs de compilateurs ont adopté la philosophie selon laquelle les compilateurs devraient s'efforcer de trouver des conditions susceptibles d'évoquer un comportement indéfini et, en supposant que de telles situations ne se produisent jamais, en tirer des déductions étendues. Ainsi, sur un système avec int 32 bits, un code donné comme:

uint32_t foo(uint16_t q, int *p)
{
  if (q > 46340)
    *p++;
  return q*q;
}

la norme C permettrait au compilateur de dire que si q est 46341 ou plus, l'expression q * q donnera un résultat trop grand pour tenir dans un int, provoquant par conséquent un comportement indéfini, et par conséquent le compilateur serait en droit de supposer que cela ne peut pas se produire et ne serait donc pas tenu d'incrémenter *p si c'est le cas. Si le code appelant utilise *p comme indicateur de rejet 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 qui fonctionnent de presque n'importe quelle manière imaginable avec un débordement d'entier (le piégeage peut être moche, mais serait au moins raisonnable), et l'a transformé en code qui peut se comporter de manière absurde.

1
supercat

Je vais vous donner un exemple où il n'y a pratiquement pas d'autre choix judicieux que le comportement indéfini. En principe, n'importe quel pointeur pourrait pointer vers la mémoire contenant n'importe quelle variable, à l'exception des variables locales que le compilateur peut savoir qui n'ont jamais eu leur adresse prise. Cependant, pour obtenir des performances acceptables sur un processeur moderne, un compilateur doit copier les valeurs des variables dans des registres. Le fonctionnement entièrement hors de la mémoire n'est pas un démarreur.

Cela vous donne essentiellement deux choix:

1) Videz tout 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. Ensuite, chargez tout ce dont vous avez besoin dans le registre, juste au cas où les valeurs auraient été modifiées via le pointeur.

2) Avoir un ensemble de règles pour quand un pointeur est autorisé à alias une variable et quand le compilateur est autorisé à supposer qu'un pointeur n'a pas d'alias une variable.

C opte pour l'option 2, car 1 serait terrible pour les performances. Mais alors, que se passe-t-il si un pointeur alias une variable d'une manière interdite par les règles C? Étant donné que l'effet dépend de si le compilateur a effectivement stocké la variable dans un registre, il n'y a aucun moyen pour la norme C de garantir définitivement des résultats spécifiques.

0
David Schwartz