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

50

Cet article Stack Overflow répertorie une liste assez complète de situations dans lesquelles la spécification de langage C / C ++ déclare être un "comportement non défini". Cependant, je veux comprendre pourquoi d'autres langages modernes, tels que 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 ++)?

Sisir
la source
3
Et pourtant, cet article SO fait référence à un comportement indéfini, même dans les spécifications Java!
gbjbaanb le
"Pourquoi le C ++ a-t-il un" comportement indéfini "" Malheureusement, cela semble être une de ces questions auxquelles il est difficile de répondre de manière objective, au-delà de la déclaration ", car, pour des raisons X, Y et / ou Z (qui peuvent toutes être nullptr) non on se donnait la peine de définir le comportement en écrivant et / ou en adoptant une spécification proposée ". : c
code_dredd
Je contesterais la prémisse. Au moins C # a un code "non sécurisé". Microsoft écrit "En un sens, l'écriture de code non sécurisé ressemble beaucoup à l'écriture de code C dans un programme C #" et donne des exemples de raisons pour lesquelles on voudrait le faire: pour accéder à du matériel ou au système d'exploitation et gagner en rapidité. C’est pour ça que C a été inventé (bon sang, ils ont écrit l’OS en C!), Alors voilà.
Peter - Réintégrer Monica le

Réponses:

72

Un 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 étaient de grandes réalisations et accueillaient avec enthousiasme les améliorations apportées à la programmation alternative: langage machine ou programmation en langage assembleur. Les problèmes rencontrés étaient bien connus et des langages de haut niveau ont été spécialement inventés pour résoudre ces problèmes connus. (L'enthousiasme à l'époque était si grand que les HLL ont parfois été saluées comme "la fin de la programmation" - comme si désormais nous n'aurions plus qu'à écrire de manière triviale ce que nous voulions et que le compilateur ferait tout le travail réel.)

Ce n'est que plus tard que nous avons réalisé les problèmes plus récents posés par la nouvelle approche. Être distant de la machine sur laquelle le code est exécuté signifie qu'il est plus probable que des choses ne fassent pas ce que nous attendons d'eux. Par exemple, l'allocation d'une variable laisserait généralement la valeur initiale indéfinie; cela n'était pas considéré comme un problème, car vous n'alloueriez pas de variable si vous ne vouliez pas en conserver une valeur, n'est-ce pas? Ce n'était sûrement pas trop attendre 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 larges et les structures plus complexes rendues possibles par des systèmes de programmation plus puissants, de nombreux programmeurs commettaient effectivement de telles omissions de temps en temps, et le comportement indéfini qui en résultait devenait un problème majeur. Même aujourd'hui, la majorité des fuites de sécurité mineures à horribles sont le résultat d'un comportement indéfini sous une forme ou une autre. (La raison en est que, habituellement, un comportement non défini est en fait très défini par les éléments du niveau informatique inférieur, et les attaquants qui comprennent ce niveau peuvent utiliser cette marge de manœuvre pour faire en sorte qu'un programme ne fasse pas que des choses inattendues, mais ils ont l' intention.)

Depuis que nous en avons pris conscience, il y a eu une volonté générale de bannir les comportements indéfinis des langages de haut niveau, et Java a été particulièrement complet à ce sujet (ce qui était relativement facile, car il était conçu pour fonctionner de toute façon sur une machine virtuelle spécifiquement conçue à cet effet). Les langages plus anciens tels que C ne peuvent pas être facilement installés comme cela sans perdre la compatibilité avec la quantité énorme de code existant.

Edit: Comme indiqué, l'efficacité est une autre raison. Un comportement indéfini signifie que les rédacteurs de compilateur disposent de beaucoup de marge de manœuvre pour exploiter l'architecture cible, de sorte que chaque implémentation bénéficie de la mise en œuvre la plus rapide possible de chaque fonctionnalité. C'était plus important sur les machines sous-alimentées d'hier qu'aujourd'hui, quand le salaire d'un programmeur est souvent le goulot d'étranglement pour le développement de logiciels.

Kilian Foth
la source
56
Je ne pense pas que beaucoup de membres de la communauté C seraient d'accord avec cette affirmation. Si vous souhaitez adapter C et définir un comportement indéfini (par exemple, tout réinitialiser par défaut, choisir un ordre d'évaluation pour le paramètre de fonction, etc.), la grande base de code bien conçu continuera à fonctionner parfaitement. Seul le code qui ne serait pas bien défini aujourd'hui serait perturbé. D'un autre côté, si vous ne définissez pas la définition d'aujourd'hui, les compilateurs continueront à être libres d'exploiter les nouvelles avancées en matière d'architectures de processeur et d'optimisation du code.
Christophe
13
La partie principale de la réponse ne semble pas vraiment convaincante pour moi. Je veux dire, il est fondamentalement impossible d'écrire une fonction qui ajoute en toute sécurité deux nombres (comme dans int32_t add(int32_t x, int32_t y)) en C ++. Les arguments habituels autour de celui-ci sont liés à l'efficacité, mais sont souvent entrecoupés d'arguments de portabilité (comme dans "Écrivez une fois, exécutez ... sur la plate-forme où vous l'avez écrit ... et nulle part ailleurs ;-)"). En gros, un argument pourrait donc être le suivant: certaines choses ne sont pas définies car vous ne savez pas si vous êtes sur un microcontrôleur 16 bits ou sur un serveur 64 bits (un serveur faible, mais toujours un argument)
Marco13
12
@ Marco13 D'accord - et éliminer le problème du "comportement indéfini" en adoptant un comportement défini, mais pas nécessairement ce que l'utilisateur voulait et sans avertissement le cas échéant "au lieu de" comportement indéfini ", c'est juste jouer à des jeux de code-avocats IMO .
alephzero
9
"Même aujourd'hui, la majorité des fuites de sécurité mineures à horribles sont le résultat d'un comportement indéfini sous une forme ou une autre." Citation requise. Je pensais que la plupart d'entre eux étaient des injections XYZ maintenant
Josué
34
"Un comportement indéfini est l'une de ces choses qui ont été reconnues comme une très mauvaise idée, mais rétrospectivement." C'est votre opinion. Beaucoup (moi compris) ne le partagent pas.
Courses de légèreté avec Monica
103

Fondamentalement, les concepteurs de Java et de langages similaires ne souhaitaient pas un comportement indéfini dans leur langage. C’était un compromis - permettre à un comportement indéfini d’améliorer ses performances, mais les concepteurs de langage ont privilégié la sécurité et la prévisibilité.

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 moteur d'exécution doit passer par-dessus 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 doit le peupler avant la lecture, il s’agit là 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.

JacquesB
la source
19
Excellente présentation du dilemme HLL: sécurité et facilité d’utilisation par rapport aux performances. Il n'y a pas de solution miracle: il existe des cas d'utilisation pour chaque côté.
Christophe
5
@Christophe Pour être juste, il existe de bien meilleures approches à un problème que de laisser UB devenir totalement incontesté, comme C et C ++. Vous pourriez avoir une langue sûre et gérée, avec des échappées vers un territoire dangereux, que vous pourrez appliquer lorsque cela est bénéfique. TBH, ce serait vraiment bien de pouvoir compiler mon programme C / C ++ avec un drapeau qui indique "insérez tout ce que vous avez besoin de machinerie d'exécution coûteuse, je m'en fiche, mais dites-moi tout sur l'UB qui se produit . "
Alexander
4
Un bon exemple de structure de données qui lit délibérément des emplacements non initialisés est la représentation d'ensembles clairsemés de Briggs et Torczon (par exemple, voir codingplayground.blogspot.com/2009/03/… ) L'initialisation d'un tel ensemble est O (1) en C, mais O ( n) avec l'initialisation forcée de Java.
Arch D. Robison
9
S'il est vrai que forcer l'initialisation des données rend les programmes interrompus beaucoup plus prévisibles, cela ne garantit pas le comportement souhaité: si l'algorithme s'attend à lire des données significatives tout en lisant à tort le zéro implicitement initialisé, c'est tout aussi un bogue lire des ordures. Avec un programme C / C ++, un tel bogue serait visible en exécutant le processus sous valgrind, qui indiquerait exactement où la valeur non initialisée a été utilisée. Vous ne pouvez pas utiliser valgrindde code java car le runtime effectue l'initialisation, ce qui rend valgrindles contrôles inutiles.
cmaster
5
@cmaster C'est pourquoi le compilateur C # ne vous permet pas de lire des locals non initialisés. Pas besoin de vérification à l'exécution, pas besoin d'initialisation, juste une analyse à la compilation. Cela reste toutefois un compromis: dans certains cas, vous ne disposez pas d'un moyen efficace de gérer les ramifications autour des sections locales potentiellement non affectées. En pratique, je n'ai trouvé aucun cas où ce n'était pas une mauvaise conception et qui était mieux résolu en repensant le code pour éviter les embranchements compliqués (ce qui est difficile à analyser pour les humains), mais c'est au moins possible.
Luaan le
42

Un comportement non défini permet une optimisation significative, en laissant au compilateur la latitude nécessaire pour faire quelque chose d’étrange ou d’inattendu (voire 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: C’est ce qu’on appelle couramment une 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 le périmètre (contrairement à Java). Pour la plupart des variables scalaires, cela entraînerait peu de surcharge, mais les matrices de pile et la mémoire malloc'd entraîneraient un memset du stockage, ce qui pourrait être assez coûteux, en particulier du fait 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 est indéfini. Un exemple est que "INT_MAX + 1" n'est pas garanti d'être INT_MIN. Ce comportement active certaines classes d'optimisations importantes pour certains codes. Par exemple, le fait de 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 le développement en ligne et le développement 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 lors du dépassement de capacité, ce qui permet à une large gamme d’optimisations de boucle d’entrer en jeu. Par contre, si la variable est définie sur En cas de dépassement de capacité, le compilateur doit supposer que la boucle est probablement infinie (ce qui se produit si N est INT_MAX) - ce qui désactive ensuite ces optimisations de boucle importantes. Cela concerne particulièrement les plates-formes 64 bits, car beaucoup de code utilise "int" comme variable d'induction.

Erik Eidt
la source
27
Bien sûr, la vraie raison pour laquelle le dépassement d'entier signé n'est pas défini est que, lorsque C a été développé, il y avait au moins trois représentations différentes d'entiers signés en cours d'utilisation (complément-un, complément à deux, signe-magnitude et éventuellement binaire offset) et chacun donne un résultat différent pour INT_MAX + 1. Rendre le dépassement de capacité non défini permet a + bde compiler l' add b ainstruction native dans chaque situation, plutôt que de demander éventuellement à un compilateur de simuler une autre forme d'arithmétique des entiers signés.
Mark
2
Autoriser les dépassements d'entier à se comporter de manière définie de manière vague permet des optimisations significatives dans les cas où tous les comportements possibles répondraient aux exigences de l'application . La plupart de ces optimisations seront perdues, toutefois, si les programmeurs doivent éviter à tout prix les dépassements d'entiers.
Supercat
5
@supercat C’est une autre raison pour laquelle il est plus courant d’éviter les comportements indéfinis dans les langages plus récents: le temps de programmation est beaucoup plus précieux que le temps de calcul. Le type d'optimisations que C est autorisé à faire grâce à UB est essentiellement inutile sur les ordinateurs de bureau modernes et rend le raisonnement sur le code beaucoup plus difficile (sans parler des implications en termes de sécurité). Même en code de performances critiques, vous pouvez bénéficier d'optimisations de haut niveau qu'il serait un peu plus difficile (voire même beaucoup plus difficile) de faire en C. J'ai mon propre moteur de rendu logiciel en C #, et pouvoir utiliser par exemple a HashSetest merveilleux.
Luaan le
2
@supercat: Wrt_loosely defin_, le choix logique en cas de dépassement d'entier serait d'exiger un comportement défini par l'implémentation . C'est un concept existant, et ce n'est pas un fardeau excessif pour les implémentations. La plupart s'en tireraient avec "c'est un complément à 2 avec un enveloppement", je suppose. <<pourrait être le cas difficile.
MSalters le
@MSalters Il existe une solution simple et bien étudiée qui ne constitue ni un comportement indéfini ni un comportement défini par la mise en œuvre: comportement non déterministe. C'est-à-dire que vous pouvez dire " x << yévalue à une valeur valide du type int32_tmais nous ne dirons pas laquelle". Cela permet aux implémenteurs d'utiliser la solution rapide, mais n'agit pas comme une fausse condition permettant des optimisations de style de voyage dans le temps car le non déterminisme est contraint à la sortie de cette opération - les spécifications garantissent que la mémoire, les variables volatiles, etc. ne sont pas visiblement affectées par l'évaluation de l'expression. ...
Mario Carneiro le
20

Au début de C, il y avait beaucoup de chaos. Différents compilateurs ont traité la langue différemment. Lorsqu'il était intéressant d'écrire une spécification pour le langage, cette spécification devrait être assez rétro-compatible avec le C que les programmeurs utilisaient 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 finalité particulière ou une structure de données particulière. La norme C réserve donc beaucoup de détails en tant que comportement indéfini ou spécifié par la mise en œuvre, ce qui laisse beaucoup de souplesse aux rédacteurs du compilateur. C ++ s'appuie sur C et présente également un comportement indé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 au comportement indéfini, mais crée des exigences qui peuvent être difficiles à implémenter pour une implémentation Java (par exemple, les assignations de référence doivent être atomiques ou le fonctionnement des entiers). Lorsque Java prend en charge des opérations potentiellement non sûres, elles sont généralement vérifiées par la machine virtuelle au moment de l’exécution (par exemple, certains conversions).

Amon
la source
Vous dites donc que la compatibilité avec les versions antérieures est la seule raison pour laquelle C et C ++ ne sortent pas de comportements non définis?
Sisir
3
C'est certainement l'un des plus gros, @Sisir. Même parmi les programmeurs expérimentés, vous seriez surpris de voir à quel point des choses qui ne devraient pas casser ne pause quand un compilateur modifie la façon dont il gère le comportement non défini. (Exemple: il y a eu un peu de chaos lorsque GCC a commencé à optimiser "est thisnul?" Vérifie il y a quelque temps, au motif thisqu'être nullptrest UB, et ne peut donc jamais se produire.)
Justin Time 2 Réintégrer Monica le
9
@Sisir, un autre point important est la vitesse. Au début de C, le matériel était beaucoup plus hétérogène qu'aujourd'hui. En ne spécifiant tout simplement pas ce qui se passe lorsque vous ajoutez 1 à INT_MAX, vous pouvez laisser le compilateur faire ce qui est le plus rapide pour l'architecture (par exemple, un système à complément produira -INT_MAX, tandis qu'un système à complément à deux produira INT_MIN). De même, en ne spécifiant pas ce qui se passe lorsque vous lisez au-delà de la fin d'un tableau, vous pouvez faire en sorte qu'un système doté d'une protection de la mémoire mette fin au programme, tandis qu'un autre sans la nécessité d'implémenter une vérification des limites d'exécution coûteuse.
Mark
14

Les langages JVM et .NET sont simples:

  1. Ils ne doivent pas être capables de travailler directement avec du matériel.
  2. Ils doivent uniquement travailler avec des systèmes de bureau et de serveur modernes ou des périphériques raisonnablement similaires, ou du moins des périphériques conçus pour eux.
  3. Ils peuvent imposer une récupération de place pour toute la mémoire et une initialisation forcée, assurant 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 doivent choisir la sécurité avant la performance.

Il y a de bons points pour les choix cependant:

  1. La programmation système est un jeu complètement différent et il est raisonnable d’optimiser sans compromis pour la programmation d’applications.
  2. Certes, il y a toujours moins de matériel exotique, mais les petits systèmes embarqués sont là pour rester.
  3. GC n’est pas adapté aux ressources non fongibles et offre beaucoup plus d’espace pour de bonnes performances. Et la plupart (mais pas presque toutes) les initialisations forcées peuvent être optimisées.
  4. Plus de concurrence présente des avantages, mais les comités sont synonymes de compromis.
  5. Toutes ces limites contrôles ne s'additionnent, même si la plupart peuvent être optimisés loin. Les vérifications de pointeur nul peuvent généralement être effectuées en piégeant l'accès sans aucune surcharge grâce à un espace d'adressage virtuel, même si l'optimisation est toujours inhibée.

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

Déduplicateur
la source
3
En effet. Je programme en C # pour mon travail. De temps en temps, j'atteins l'un des marteaux dangereux ( unsafemot-clé ou attributs System.Runtime.InteropServices). En gardant ce matériel pour les quelques programmeurs qui savent comment déboguer des documents non gérés et aussi peu que pratique, nous réduisons les problèmes. Plus de 10 ans se sont écoulés depuis le dernier marteau non sûr lié à la performance, mais il faut parfois le faire parce qu'il n'y a littéralement aucune autre solution.
Josué
19
Je travaille fréquemment sur une plate-forme à partir de périphériques analogiques où sizeof (char) == sizeof (court) == sizeof (int) == sizeof (float) == 1. Il effectue également l'addition saturante (donc INT_MAX + 1 == INT_MAX) et la bonne chose à propos de C est que je peux avoir un compilateur conforme qui génère un code raisonnable. Si le libellé prescrit deux compléments avec complément, chaque addition se solderait par un test et une branche, ce qui constituerait un non-début dans une partie centrée sur le DSP. Ceci est une partie de la production actuelle.
Dan Mills
5
@BenVoigt Certains d'entre nous vivent dans un monde où un petit ordinateur représente peut-être 4 Ko d'espace de code, une pile appel / retour fixe à 8 niveaux, 64 octets de RAM, une horloge à 1 MHz et coûte <0,20 USD en quantité 1 000. Un téléphone mobile moderne est un petit PC avec un stockage à peu près illimité pour tous les besoins, et peut être traité comme un PC. Tout le monde n'est pas multicœur et manque de contraintes en temps réel.
Dan Mills
2
@DanMills: Ne parlez pas des téléphones mobiles modernes ici avec les processeurs Arm Cortex A, des "téléphones à fonctions" vers 2002. Oui 192 Ko de SRAM, c'est beaucoup plus que 64 octets (ce qui n'est pas "petit" mais "minuscule"), mais 192kB n’a pas non plus été qualifié de "bureau" ni de serveur "moderne" depuis 30 ans. De plus, ces jours-ci, 20 cents vous procureront un MSP430 avec plus de 64 octets de mémoire SRAM.
Ben Voigt le
2
@BenVoigt 192kB n'est peut-être pas un ordinateur de bureau au cours des 30 dernières années, mais je peux vous assurer qu'il est tout à fait suffisant de servir des pages Web, ce qui, selon moi, en fait un serveur par la définition même du mot. Le fait est que cela représente une quantité de RAM tout à fait raisonnable (généreuse, même) pour BEAUCOUP d'applications embarquées comprenant souvent des serveurs Web de configuration. Bien sûr, je n’utilise probablement pas Amazon, mais j’utilise peut-être un réfrigérateur avec IOT Crapware sur un tel noyau (avec suffisamment de temps et d’espace). Nul besoin de langues interprétées ou JIT pour ça!
Dan Mills
8

Java et C # se caractérisent par un fournisseur dominant, du moins tôt dans leur développement. (Sun et Microsoft respectivement). C et C ++ sont différents; ils ont eu plusieurs implémentations concurrentes depuis le début. C a particulièrement fonctionné sur des plates-formes matérielles exotiques, aussi. En conséquence, il y avait une variation entre les implémentations. Les comités ISO qui ont normalisé le C et le C ++ pourraient se mettre d’accord sur un grand dénominateur commun, mais aux confins où les mises en œuvre diffèrent, les normes laissent une marge de manœuvre pour la mise en oeuvre.

Cela est également dû au fait que le choix d’un comportement peut être coûteux pour des architectures matérielles qui privilégient un autre choix - l’endianisme est le choix évident.

MSalters
la source
Que signifie littéralement «grand dénominateur commun» ? Parlez-vous de sous-ensembles ou de sur-ensembles? Voulez-vous vraiment dire assez de facteurs en commun? S'agit-il du plus petit multiple commun ou du plus grand facteur commun? C'est très déroutant pour nous, les robots qui ne parlons pas le jargon de la rue, mais les mathématiques. :)
tchrist le
@tchrist: Le comportement commun est un sous-ensemble, mais ce sous-ensemble est assez abstrait. Dans de nombreux domaines non spécifiés par la norme commune, les mises en œuvre réelles doivent faire un choix. Maintenant, certains de ces choix sont assez clairs et donc définis par la mise en œuvre, mais d'autres sont plus vagues. La disposition de la mémoire au moment de l'exécution est un exemple: il doit y avoir un choix, mais la façon dont vous la documentez n'est pas claire.
MSalters
2
Le C original a été fabriqué par un gars. Il avait déjà beaucoup d'UB, à dessein. Les choses ont certainement empiré avec la popularité de C, mais UB était là dès le début. Pascal et Smalltalk avaient beaucoup moins d'UB et ont été développés à peu près au même moment. Le principal avantage de C était qu’il était extrêmement facile à porter - tous les problèmes de portabilité étaient délégués au programmeur d’application: P J'ai même porté un compilateur C simple sur mon CPU (virtuel); faire quelque chose comme LISP ou Smalltalk aurait été un effort beaucoup plus grand (même si j'avais un prototype limité pour un runtime .NET :).
Luaan le
@Luaan: S'agirait-il de Kernighan ou de Ritchie? Et non, il n'avait pas de comportement indéfini. Je sais que j'ai la documentation originale du compilateur au pochoir AT & T sur mon bureau. L'implémentation a fait ce qu'elle a fait. Il n'y avait pas de distinction entre comportement indéterminé et indéterminé.
MSalters
4
@MSalters Ritchie a été le premier gars. Kernighan a rejoint (pas beaucoup) plus tard. Eh bien, il n’avait pas de «comportement indéfini», car ce terme n’existait pas encore. Mais il a eu le même comportement que l'on appellerait aujourd'hui indéfini. Puisque C n'avait pas de spécification, même "non spécifié" est un étirement :) C'est juste quelque chose qui importait peu au compilateur, et les détails revenaient aux programmeurs d'applications. Il n'était pas conçu pour produire des applications portables , seul le compilateur devait être facile à porter.
Luaan le
6

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

Bien qu'ils soient en grande partie oubliés (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 uniquement) de l'objectif initial de C était de transférer UNIX du langage d'assemblage à un langage de niveau supérieur. Une partie de l’intention était d’écrire le plus possible le système d’exploitation dans un langage de niveau supérieur - ou de le regarder dans l’autre sens, afin de minimiser la quantité qui devait être écrite en langage assembleur.

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

De même, si vous souhaitez imprimer des données, vous devez vérifier un autre emplacement spécifié et, lorsque le périphérique de sortie est prêt, vous écrivez vos données dans un autre emplacement spécifié.

Pour prendre en charge l’écriture de pilotes pour de tels périphériques, C vous permettait de spécifier un emplacement quelconque en utilisant un type entier, de le convertir en pointeur et de lire ou d’écrire cet emplacement en mémoire.

Bien sûr, cela pose un problème assez sérieux: toutes les machines sur terre n’ont pas leur mémoire identique à celle d’un PDP-11 du début des années 1970. Ainsi, lorsque vous prenez cet entier, que vous le convertissez en un pointeur, puis que vous lisez ou écrivez via ce pointeur, personne ne peut fournir de garantie raisonnable quant à ce que vous allez obtenir. Juste pour un exemple évident, la lecture et l’écriture peuvent mapper des registres séparés dans le matériel. Ainsi, 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 nous laissent:

  1. Définir une interface pour 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. Interdire ce niveau d'accès et décréter que quiconque veut faire de telles choses doit utiliser le langage assembleur.
  3. Autorisez les utilisateurs à le faire, mais laissez-leur le soin de lire (par exemple) les manuels du matériel ciblé et écrivez le code correspondant au matériel utilisé.

Parmi ceux-ci, 1 semble suffisamment absurde pour que nous n’ayions pas besoin de poursuivre la discussion. 2 consiste essentiellement à jeter l’intention fondamentale de la langue. Cela laisse la troisième option essentiellement la seule option qu’ils pourraient raisonnablement envisager.

Un autre point qui revient assez souvent est la taille des types entiers. C prend la "position" qui intdevrait être la taille naturelle suggérée par l'architecture. Donc, si je programme un VAX 32 bits, cela intdevrait probablement être 32 bits, mais si je programme un Univac 36 bits, cela intdevrait probablement être 36 bits (et ainsi de suite). 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 être un multiple de 8 bits. Je suis peut-être superficiel, mais il me semble que si j'écrivais un système d'exploitation pour une machine 36 bits, je préférerais probablement utiliser un langage prenant en charge le type 36 bits.

Du point de vue de la langue, cela conduit à un comportement encore plus indéfini. Si je prends la plus grande valeur qui puisse tenir dans 32 bits, qu’arrivera-t-il si j’ajoute 1? Sur un matériel 32 bits typique, il va basculer (ou éventuellement renvoyer une sorte de défaillance matérielle). D'un autre côté, s'il fonctionne sur du matériel 36 bits, il vous suffira ... d'en ajouter un. Si le langage prend en charge l’écriture de systèmes d’exploitation, vous ne pouvez garantir aucun comportement: vous devez autoriser à la fois la taille des types et le comportement du débordement à varier.

Java et C # peuvent ignorer tout cela. Ils ne sont pas conçus pour prendre en charge l'écriture de systèmes d'exploitation. Avec eux, vous avez plusieurs choix. L’une consiste à faire en sorte que le matériel supporte ce qu’ils exigent - 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 la langue ne s'exécute que sur d'autres logiciels fournissant 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. Au contraire, de nombreuses implémentations font un peu des deux. Vous exécutez normalement Java sur une machine virtuelle Java s'exécutant sur un système d'exploitation. Le plus souvent, le système d'exploitation est écrit en C et la JVM en C ++. Si la machine virtuelle Java s'exécute sur un processeur ARM, il est fort probable que le processeur intègre les extensions Jazelle d'ARM, afin d'adapter le matériel aux besoins de Java. Il est donc inutile de faire des logiciels et le code Java s'exécute plus rapidement (ou moins). lentement, quand même).

Sommaire

C et C ++ ont un comportement indéfini, car personne n'a défini d'alternative acceptable lui permettant de faire ce qu'il est censé faire. C # et Java adoptent une approche différente, mais cette approche correspond mal (voire pas du tout) aux objectifs de C et C ++. En particulier, ni l'un ni l'autre ne semble constituer un moyen raisonnable d'écrire un logiciel système (tel qu'un système d'exploitation) sur le matériel le plus arbitrairement choisi. Les deux dépendent généralement des fonctionnalités fournies par le logiciel système existant (généralement écrit en C ou C ++) pour effectuer leur travail.

Jerry Coffin
la source
4

Les auteurs de la norme C s'attendaient à ce que leurs lecteurs reconnaissent quelque chose qui, à leur avis, était évident, et qui était mentionné dans la justification publiée, mais n'a pas dit carrément: le Comité ne devrait pas avoir besoin de commander des rédacteurs de compilateur pour répondre aux besoins de leurs clients, puisque les clients devraient savoir mieux que le Comité quels sont leurs besoins. S'il est évident que les compilateurs de 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 cette construction appelle le comportement non défini. L'échec de la norme à imposer aux compilateurs conformes de traiter utilement un morceau de code ne signifie nullement que les programmeurs devraient être disposés à acheter des compilateurs qui ne le font pas.

Cette approche de la conception linguistique 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 rédacteurs de compilateurs sont isolés des effets du marché. Il est douteux que les conditions de marché appropriées existeront jamais pour orienter une langue de la même façon que celle qui était devenue populaire dans les années 90, et encore plus de doute qu'un concepteur de langage sain d'esprit voudrait compter sur de telles conditions de marché.

supercat
la source
Je pense que vous avez décrit quelque chose d'important ici, mais cela m'échappe. Pourriez-vous clarifier votre réponse? Surtout le deuxième paragraphe: il est dit que les conditions actuelles et les conditions antérieures sont différentes, mais je ne les comprends pas; qu'est-ce qui a changé exactement? En outre, le "chemin" est maintenant différent du précédent; peut-être expliquer cela aussi?
Anatolyg
4
Il semble que votre campagne remplace tous les comportements indéfinis par des comportements indéterminés ou que quelque chose de plus contraint continue à être puissant.
Déduplicateur
1
@anatolyg: Si ce n'est déjà fait, lisez le document intitulé C Rationale (justification de type C99 dans Google). Les lignes 23 à 29 parlent du "marché" et la page 13, lignes 5 à 8, de ce que l’on veut faire en matière de transférabilité. Comment pensez-vous qu'un patron d'une entreprise de compilateur commercial réagirait si un rédacteur de compilateur disait aux programmeurs qui se plaignaient que l'optimiseur avait cassé le code que tous les autres compilateurs géraient utilement que leur code était "cassé" parce qu'il effectuait des actions non définies par la norme, et a refusé de le soutenir parce que cela favoriserait le maintien ...
Supercat
1
... utilisation de telles constructions? Un tel point de vue est facilement apparent sur les cartes de support de clang et de gcc et a permis d’empêcher le développement d’intrinsèques qui pourraient faciliter l’optimisation beaucoup plus facilement et en toute sécurité que les langages brisés que gcc et clang veulent soutenir.
Supercat
1
@supercat: Vous perdez votre souffle en vous plaignant auprès des vendeurs de compilateurs. Pourquoi ne pas adresser vos préoccupations aux comités de langue? S'ils sont d'accord avec vous, un errata sera émis, que vous pourrez utiliser pour vaincre les équipes de compilation. Et ce processus est beaucoup plus rapide que le développement d'une nouvelle version du langage. Mais s’ils ne sont pas d’accord, vous obtiendrez au moins les raisons réelles, alors que les rédacteurs du compilateur vont répéter (encore et encore): "Nous n’avons pas désigné ce code comme étant cassé, cette décision a été prise par le comité de la langue et nous suivez leur décision. "
Ben Voigt le
3

C ++ et c ont tous deux des normes descriptives (les versions ISO, en tout cas).

Ce qui n’existe que pour expliquer le fonctionnement des langues et pour fournir une référence unique sur la langue. Généralement, les éditeurs 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 #, ce que je suppose vous entendez dire) ont des normes prescriptives . Ils vous disent ce qui est dans la langue définitivement à l'avance, comment cela fonctionne et ce qui est considéré comme un comportement autorisé.

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

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

bobsburner
la source
La situation est pire que cela: je ne pense pas que le comité ait jamais atteint un consensus sur le point de savoir si c'est censé être descriptif ou normatif.
Supercat
1

Un comportement non défini permet au compilateur de générer un 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 constituent un comportement non défini en C. En pratique, le compilateur était censé générer un simple code d'opération d'addition signé à exécuter par la CPU.

Cela a permis à C de très bonnes performances et de produire un code très compact sur la plupart des architectures. Si le standard avait spécifié que les entiers signés devaient déborder d'une certaine manière, les CPU qui se comportaient différemment auraient eu besoin de beaucoup plus de code pour générer un simple ajout signé.

C’est ce qui explique en grande partie le comportement indéfini en C et explique pourquoi des facteurs tels que la taille des intfichiers varient d’un système à l’autre. Intdé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, supérieur à a char.

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

Des langages ultérieurs tels que Java et C # ont préféré éliminer les comportements indéfinis par rapport aux performances brutes.

utilisateur
la source
-5

En un sens, Java l’a également. Supposons que vous ayez donné un comparateur incorrect à Arrays.sort. Il peut jeter une exception s'il le détecte. Sinon, il va trier un tableau d'une manière qui n'est pas garantie d'être particulière.

De même, si vous modifiez une variable à partir de plusieurs threads, les résultats sont également imprévisibles.

C ++ est juste allé plus loin pour créer plus de situations non définies (ou plutôt java a décidé de définir plus d'opérations) et pour avoir un nom.

RiaD
la source
4
Ce n'est pas un comportement indéfini de la sorte dont nous parlons ici. Les "comparateurs incorrects" sont de deux types: ceux qui définissent un classement total et les autres. Si vous fournissez un comparateur qui définit de manière cohérente l'ordre relatif des éléments, le comportement est bien défini, il ne s'agit tout simplement pas du comportement souhaité par le programmeur. Si vous fournissez un comparateur incohérent dans l'ordre relatif, le comportement est toujours bien défini: la fonction de tri lève une exception (qui n'est probablement pas non plus le comportement souhaité par le programmeur).
Mark
2
En ce qui concerne la modification des variables, les conditions de concurrence ne sont généralement pas considérées comme un comportement indéfini. Je ne connais pas les détails de la façon dont Java gère les attributions aux données partagées, mais connaissant la philosophie générale du langage, je suis presque sûr que cela doit être atomique. Attribuer simultanément 53 et 71 à aserait un comportement indéfini si vous pouviez en obtenir 51 ou 73, mais si vous ne pouvez obtenir que 53 ou 71, il est bien défini.
Mark
@Mark Avec des blocs de données supérieurs à la taille de mot native du système (par exemple, une variable de 32 bits sur un système de taille de mot de 16 bits), il est possible d'avoir une architecture qui nécessite de stocker chaque partie de 16 bits séparément. (SIMD est une autre situation potentielle de ce type.) Dans ce cas, même une affectation simple au niveau du code source n’est pas nécessairement atomique, à moins que le compilateur n’ait pris une précaution particulière pour s’assurer qu’elle est exécutée de manière atomique.
un CVn