web-dev-qa-db-fra.com

Pourquoi C ++ a-t-il un «comportement indéfini» (UB) et d'autres langages comme C # ou Java n'en a pas?

This Stack Overflow post répertorie une liste assez complète de situations dans lesquelles la spécification du langage C/C++ déclare être un "comportement indéfini". Cependant, je veux comprendre pourquoi d'autres langages modernes, comme C # ou Java, n'ont pas le concept de "comportement indéfini". Cela signifie-t-il que le concepteur du compilateur peut contrôler tous les scénarios possibles (C # et Java) ou non (C et C++)?

52
Sisir

n comportement indéfini est l'une de ces choses qui ont été reconnues comme une très mauvaise idée seulement rétrospectivement.

Les premiers compilateurs ont été de grandes réalisations et ont accueilli avec joie les améliorations par rapport à l'alternative - programmation en langage machine ou en langage assembleur. Les problèmes étaient bien connus et des langages de haut niveau ont été inventés spécifiquement pour résoudre ces problèmes connus. (L'enthousiasme à l'époque était si grand que les HLL étaient parfois salués comme "la fin de la programmation" - comme si à partir de maintenant nous n'aurions qu'à écrire trivialement ce que nous voulions et que le compilateur ferait tout le vrai travail.)

Ce n'est que plus tard que nous avons réalisé les nouveaux problèmes qui sont venus avec la nouvelle approche. Être éloigné de la machine réelle sur laquelle le code s'exécute signifie qu'il y a plus de possibilité que les choses ne fassent pas silencieusement ce que nous attendions d'elles. Par exemple, l'allocation d'une variable laisse généralement la valeur initiale indéfinie; cela n'était pas considéré comme un problème, car vous n'alloueriez pas une variable si vous ne vouliez pas y contenir de valeur, n'est-ce pas? Ce n'était sûrement pas trop de s'attendre à ce que les programmeurs professionnels n'oublient pas d'attribuer la valeur initiale, n'est-ce pas?

Il s'est avéré qu'avec les bases de code plus grandes et les structures plus compliquées qui sont devenues possibles avec des systèmes de programmation plus puissants, oui, de nombreux programmeurs commettraient en effet de telles oublis de temps en temps, et le comportement indéfini résultant est devenu un problème majeur. Aujourd'hui encore, la majorité des fuites de sécurité, de minuscules à horribles, sont le résultat d'un comportement indéfini sous une forme ou une autre. (La raison en est que généralement, un comportement indéfini est en fait très bien défini par des choses au niveau inférieur suivant sur l'informatique, et les attaquants qui comprennent ce niveau peuvent utiliser cette marge de manœuvre pour faire un programme non seulement des choses non intentionnelles, mais exactement les choses ils ont l'intention.)

Depuis que nous avons reconnu cela, il y a eu une tendance générale à bannir les comportements indéfinis des langages de haut niveau, et Java a été particulièrement approfondi à ce sujet (ce qui était relativement facile car il a été conçu pour fonctionner sur son propre machine virtuelle spécialement conçue de toute façon.) Les langages plus anciens comme C ne peuvent pas facilement être mis à niveau comme ça sans perdre la compatibilité avec la grande quantité de code existant.

Edit: Comme indiqué, l'efficacité est une autre raison. Un comportement indéfini signifie que les rédacteurs du compilateur ont beaucoup de latitude pour exploiter l'architecture cible afin que chaque implémentation s'en tire avec l'implémentation la plus rapide possible de chaque fonctionnalité. Cela était plus important sur les machines sous-alimentées d'hier qu'aujourd'hui, lorsque le salaire du programmeur est souvent le goulot d'étranglement pour le développement de logiciels.

72
Kilian Foth

Fondamentalement parce que les concepteurs de Java et les langages similaires ne voulaient pas de comportement indéfini dans leur langage. C'était un compromis - autoriser un comportement non défini a le potentiel d'améliorer les performances, mais les concepteurs de langage ont priorisé la sécurité et prévisibilité plus élevée.

Par exemple, si vous allouez un tableau en C, les données ne sont pas définies. En Java, tous les octets doivent être initialisés à 0 (ou une autre valeur spécifiée). Cela signifie que le runtime doit passer sur le tableau (une opération O(n)), tandis que C peut effectuer l'allocation en un instant. Donc C sera toujours plus rapide pour de telles opérations.

Si le code utilisant le tableau va le remplir de toute façon avant la lecture, il s'agit essentiellement d'un effort inutile pour Java. Mais dans le cas où le code est lu en premier, vous obtenez des résultats prévisibles en Java mais des résultats imprévisibles en C.

103
JacquesB

Un comportement non défini permet une optimisation significative, en donnant au compilateur la latitude de faire quelque chose d'étrange ou d'inattendu (ou même normal) à certaines limites ou dans d'autres conditions.

Voir http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

Utilisation d'une variable non initialisée: Ceci est communément appelé source de problèmes dans les programmes C et il existe de nombreux outils pour les détecter: des avertissements du compilateur aux analyseurs statiques et dynamiques. Cela améliore les performances en n'exigeant pas que toutes les variables soient initialisées à zéro lorsqu'elles entrent dans la portée (comme Java le fait). Pour la plupart des variables scalaires, cela entraînerait peu de surcharge, mais les tableaux de pile et malloc'd la mémoire entraînerait un memset du stockage, ce qui pourrait être assez coûteux, d'autant plus que le stockage est généralement complètement écrasé.


Débordement d'entier signé: si l'arithmétique sur un type 'int' (par exemple) déborde, le résultat n'est pas défini. Un exemple est que "INT_MAX + 1" n'est pas garanti d'être INT_MIN. Ce comportement active certaines classes d'optimisations qui sont importantes pour certains codes. Par exemple, savoir que INT_MAX + 1 n'est pas défini permet d'optimiser "X + 1> X" en "vrai". Connaître la multiplication "ne peut pas" déborder (car cela ne serait pas défini) permet d'optimiser "X * 2/2" en "X". Bien que cela puisse sembler trivial, ce genre de choses est généralement exposé par l'incrustation et l'expansion macro. Une optimisation plus importante que cela permet est pour les boucles "<=" comme ceci:

for (i = 0; i <= N; ++i) { ... }

Dans cette boucle, le compilateur peut supposer que la boucle itérera exactement N + 1 fois si "i" n'est pas défini en cas de débordement, ce qui permet à un large éventail d'optimisations de boucle de se déclencher. En revanche, si le est définie pour boucler en cas de débordement, le compilateur doit alors supposer que la boucle est peut-être infinie (ce qui se produit si N est INT_MAX) - ce qui désactive ensuite ces importantes optimisations de boucle. Cela affecte particulièrement les plates-formes 64 bits car tant de code utilise "int" comme variables d'induction.

42
Erik Eidt

Au début de C, il y avait beaucoup de chaos. Différents compilateurs ont traité le langage différemment. Lorsqu'il y avait intérêt à écrire une spécification pour le langage, cette spécification devait être assez rétrocompatible avec le C sur lequel les programmeurs s'appuyaient avec leurs compilateurs. Mais certains de ces détails ne sont pas portables et n'ont pas de sens en général, par exemple en supposant une disposition particulière ou une disposition de données. La norme C réserve donc beaucoup de détails en tant que comportement indéfini ou spécifié par l'implémentation, ce qui laisse beaucoup de flexibilité aux rédacteurs du compilateur. C++ s'appuie sur C et présente également un comportement non défini.

Java a essayé d'être un langage beaucoup plus sûr et beaucoup plus simple que C++. Java définit la sémantique du langage en termes de machine virtuelle complète. Cela laisse peu de place pour un comportement indéfini, en revanche cela rend les exigences qui peuvent être difficiles pour un Java implémentation à faire (par exemple que les affectations de référence doivent être atomiques, ou comment les entiers fonctionnent). Où Java prend en charge les opérations potentiellement dangereuses, elles sont généralement vérifiées par la machine virtuelle au moment de l'exécution (par exemple , quelques moulages).

20
amon

Les langages JVM et .NET sont simples:

  1. Ils n'ont pas besoin de pouvoir travailler directement avec le matériel.
  2. Ils ne doivent fonctionner qu'avec des systèmes de bureau et de serveur modernes ou des appareils raisonnablement similaires, ou au moins des appareils conçus pour eux.
  3. Ils peuvent imposer un ramasse-miettes pour toute la mémoire et une initialisation forcée, garantissant ainsi la sécurité du pointeur.
  4. Ils ont été spécifiés par un seul acteur qui a également fourni la mise en œuvre définitive unique.
  5. Ils choisissent la sécurité plutôt que la performance.

Il y a cependant de bons points pour les choix:

  1. La programmation des systèmes est un jeu de balle complètement différent, et l'optimisation sans compromis pour la programmation d'applications est plutôt raisonnable.
  2. Certes, il y a toujours du matériel moins exotique, mais les petits systèmes embarqués sont là pour rester.
  3. GC est mal adapté aux ressources non fongibles et échange beaucoup plus d'espace pour de bonnes performances. Et la plupart (mais pas la quasi-totalité) des initialisations forcées peuvent être optimisées.
  4. Il y a des avantages à accroître la concurrence, mais les commissions signifient un compromis.
  5. Toutes ces vérifications des limites faire s'additionnent, même si la plupart peuvent être optimisées. Les vérifications de pointeur nul peuvent principalement être effectuées en interceptant l'accès pour zéro surcharge grâce à l'espace d'adressage virtuel, bien que l'optimisation soit toujours inhibée.

Lorsque des trappes d'échappement sont fournies, celles-ci invitent à revenir à un comportement non défini à part entière. Mais au moins, elles ne sont généralement utilisées que dans quelques tronçons très courts, qui sont donc plus faciles à vérifier manuellement.

14
Deduplicator

Java et C # sont caractérisés par un fournisseur dominant, au moins au début de leur développement. (Sun et Microsoft respectivement). C et C++ sont différents; ils ont eu plusieurs implémentations concurrentes dès le début. C fonctionnait également sur des plates-formes matérielles exotiques. En conséquence, il y avait des variations entre les implémentations. Les comités ISO qui ont normalisé le C et le C++ pourraient convenir d'un grand dénominateur commun, mais aux limites où les mises en œuvre diffèrent, les normes laissaient de la place pour la mise en œuvre.

Cela est également dû au fait que le choix d'un comportement peut être coûteux sur des architectures matérielles qui sont biaisées vers un autre choix - l'endianité est le choix évident.

8
MSalters

La vraie raison se résume à une différence fondamentale d'intention entre C et C++ d'une part, et Java et C # (pour seulement quelques exemples) d'autre part. Pour des raisons historiques, une grande partie de la discussion ici parle de C plutôt que de C++, mais (comme vous le savez probablement déjà) C++ est un descendant assez direct de C, donc ce qu'il dit à propos de C s'applique également à C++.

Bien qu'elles soient largement oubliées (et leur existence parfois même niée), les toutes premières versions d'UNIX ont été écrites en langage assembleur. Une grande partie (sinon la seule) de l'objectif initial de C était le portage UNIX du langage d'assemblage vers un langage de niveau supérieur. Une partie de l'intention était d'écrire autant de système d'exploitation que possible dans une langue de niveau supérieur - ou de le regarder dans l'autre sens, afin de minimiser la quantité qui devait être écrite en langage d'assemblage.

Pour ce faire, C devait fournir presque le même niveau d'accès au matériel que le langage d'assemblage. Le PDP-11 (par exemple) a mappé les registres d'E/S à des adresses spécifiques. Par exemple, vous lirez un emplacement de mémoire pour vérifier si une touche a été enfoncée sur la console système. Un bit a été défini à cet emplacement lorsqu'il y avait des données en attente de lecture. Vous devez ensuite lire un octet à partir d'un autre emplacement spécifié pour récupérer le code ASCII de la touche qui a été enfoncée.

De même, si vous vouliez imprimer des données, vous vérifieriez un autre emplacement spécifié et lorsque le périphérique de sortie serait prêt, vous écririez vos données encore un autre emplacement spécifié.

Pour prendre en charge l'écriture de pilotes pour de tels périphériques, C vous a permis de spécifier un emplacement arbitraire à l'aide d'un type entier, de le convertir en pointeur et de lire ou écrire cet emplacement en mémoire.

Bien sûr, cela a un problème assez grave: toutes les machines sur terre n'ont pas leur mémoire disposée de manière identique à un PDP-11 du début des années 1970. Ainsi, lorsque vous prenez cet entier, le convertissez en un pointeur, puis lisez ou écrivez via ce pointeur, personne ne peut fournir de garantie raisonnable sur ce que vous allez obtenir. Juste pour un exemple évident, la lecture et l'écriture peuvent correspondre à des registres séparés dans le matériel, donc vous (contrairement à la mémoire normale) si vous écrivez quelque chose, puis essayez de le relire, ce que vous lisez peut ne pas correspondre à ce que vous avez écrit.

Je peux voir quelques possibilités qui restent:

  1. Définissez une interface avec tout le matériel possible - spécifiez les adresses absolues de tous les emplacements que vous souhaitez lire ou écrire pour interagir avec le matériel de quelque manière que ce soit.
  2. Interdisez ce niveau d'accès et décrivez que quiconque veut faire de telles choses doit utiliser le langage de l'Assemblée.
  3. Autorisez les utilisateurs à le faire, mais laissez-leur le soin de lire (par exemple) les manuels du matériel qu'ils ciblent et d'écrire le code en fonction du matériel qu'ils utilisent.

Parmi ceux-ci, 1 semble suffisamment absurde pour que cela ne vaille pas la peine d'être approfondi. 2 est essentiellement de jeter l'intention de base de la langue. Cela laisse la troisième option comme essentiellement la seule qu’ils pourraient raisonnablement envisager.

Un autre point qui revient assez fréquemment est la taille des types entiers. C prend la "position" que int doit être la taille naturelle suggérée par l'architecture. Donc, si je programme un VAX 32 bits, int devrait probablement être 32 bits, mais si je programme un Univac 36 bits, int devrait probablement être 36 bits (et bientôt). Il n'est probablement pas raisonnable (et même impossible) d'écrire un système d'exploitation pour un ordinateur 36 bits en utilisant uniquement des types dont la taille est garantie par des multiples de 8 bits. Peut-être que je suis juste superficiel, mais il me semble que si j'écrivais un système d'exploitation pour une machine 36 bits, je voudrais probablement utiliser un langage qui prend en charge un type 36 bits.

D'un point de vue linguistique, cela conduit à un comportement encore plus indéfini. Si je prends la plus grande valeur pouvant tenir sur 32 bits, que se passera-t-il lorsque j'ajouterai 1? Sur un matériel 32 bits typique, il va se renverser (ou peut-être jeter une sorte de panne matérielle). D'un autre côté, s'il fonctionne sur du matériel 36 bits, il suffit d'en ajouter un. Si la langue doit prendre en charge l'écriture de systèmes d'exploitation, vous ne pouvez garantir aucun des deux comportements - vous devez à peu près autoriser à la fois la taille des types et le comportement du débordement à varier de l'un à l'autre.

Java et C # peuvent ignorer tout cela. Ils ne sont pas destinés à prendre en charge l'écriture de systèmes d'exploitation. Avec eux, vous avez deux choix. L'une consiste à faire en sorte que le matériel prenne en charge ce qu'ils demandent - car ils exigent des types de 8, 16, 32 et 64 bits, il suffit de créer du matériel qui prend en charge ces tailles. L'autre possibilité évidente est que le langage ne s'exécute que sur d'autres logiciels qui fournissent l'environnement qu'ils souhaitent, quel que soit le matériel sous-jacent.

Dans la plupart des cas, ce n'est pas vraiment un choix non plus. Au contraire, de nombreuses implémentations font un peu des deux. Vous exécutez normalement Java sur une machine virtuelle Java exécutée sur un système d'exploitation. Le plus souvent, l'OS est écrit en C et la JVM en C++. Si la JVM s'exécute sur un processeur ARM, il est fort probable que le processeur inclue les extensions Jazelle d'ARM, pour adapter le matériel plus étroitement aux besoins de Java, donc moins de travail à faire dans le logiciel, et le Java le code s'exécute plus rapidement (ou moins lentement de toute façon).

Résumé

C et C++ ont un comportement indéfini, car personne n'a défini d'alternative acceptable qui leur permette de faire ce qu'ils sont censés faire. C # et Java adoptent une approche différente, mais cette approche cadre mal (voire pas du tout) avec les objectifs de C et C++. En particulier, ni l'un ni l'autre ne semble fournir un moyen raisonnable d'écrire un logiciel système (tel qu'un système d'exploitation) sur le matériel le plus choisi arbitrairement. Les deux dépendent généralement des installations fournies par les logiciels système existants (généralement écrits en C ou C++) pour faire leur travail.

6
Jerry Coffin

Les auteurs de la norme C s'attendaient à ce que leurs lecteurs reconnaissent quelque chose qu'ils pensaient être évident, et y ont fait allusion dans leur justification publiée, mais ils n'ont pas dit franchement: le comité ne devrait pas avoir besoin d'ordonner aux rédacteurs du compilateur de répondre aux besoins de leurs clients, puisque les clients devraient mieux connaître que le Comité quels sont leurs besoins. S'il est évident que les compilateurs pour certains types de plates-formes sont censés traiter une construction d'une certaine manière, personne ne devrait se soucier de savoir si la norme dit que la construction invoque un comportement indéfini. L'échec de la norme à imposer aux compilateurs conformes de traiter utilement un morceau de code n'implique nullement que les programmeurs devraient être prêts à acheter des compilateurs qui ne le font pas.

Cette approche de la conception de langage fonctionne très bien dans un monde où les rédacteurs de compilateurs doivent vendre leurs produits à des clients payants. Il s'effondre complètement dans un monde où les auteurs de compilateurs sont isolés des effets du marché. Il est douteux que les conditions de marché appropriées existeront jamais pour diriger une langue de la manière dont ils ont dirigé celle qui est devenue populaire dans les années 1990, et encore plus douteux que tout concepteur de langage sensé veuille s'appuyer sur de telles conditions de marché.

4
supercat

C++ et c ont tous deux des normes descriptives (les versions ISO, de toute façon).

Qui n'existent que pour expliquer le fonctionnement des langues et pour fournir une référence unique sur ce qu'est la langue. En règle générale, les fournisseurs de compilateurs et les rédacteurs de bibliothèques ouvrent la voie et certaines suggestions sont incluses dans la norme ISO principale.

Java et C # (ou Visual C #, que je suppose que vous voulez dire) ont des normes normatives . Ils vous disent ce qui est définitivement dans la langue à l'avance, comment cela fonctionne et ce qui est considéré comme un comportement autorisé.

Plus important que cela, Java a en fait une "implémentation de référence" dans Open-JDK. (Je pense que Roslyn compte comme l'implémentation de référence Visual C #, mais n'a pas pu trouver une source pour cela.)

Dans le cas de Java, s'il y a une ambiguïté dans la norme, et Open-JDK le fait d'une certaine manière. La façon dont Open-JDK le fait est la norme.

3
bobsburner

Un comportement indéfini permet au compilateur de générer du code très efficace sur une variété d'architectures. La réponse d'Erik mentionne l'optimisation, mais cela va au-delà.

Par exemple, les débordements signés sont un comportement indéfini en C. Dans la pratique, le compilateur était censé générer un simple opcode d'addition signé pour que le CPU s'exécute, et le comportement serait ce que ce CPU particulier faisait.

Cela a permis à C de très bien fonctionner et de produire du code très compact sur la plupart des architectures. Si la norme avait spécifié que les entiers signés devaient déborder d'une certaine manière, les processeurs qui se comportaient différemment auraient eu besoin de beaucoup plus de génération de code pour une simple addition signée.

C'est la raison d'une grande partie du comportement non défini en C, et pourquoi des choses comme la taille de int varient selon les systèmes. Int dépend de l'architecture et est généralement sélectionné pour être le type de données le plus rapide et le plus efficace, plus grand qu'un char.

À l'époque où C était nouveau, ces considérations étaient importantes. Les ordinateurs étaient moins puissants, ayant souvent une vitesse de traitement et une mémoire limitées. C était utilisé là où les performances étaient vraiment importantes, et les développeurs devaient comprendre comment les ordinateurs fonctionnaient assez bien pour savoir quels seraient ces comportements indéfinis sur leurs systèmes particuliers.

Les langages ultérieurs tels que Java et C # ont préféré éliminer le comportement indéfini par rapport aux performances brutes.

1
user