Quelles sont les mises en garde de l'implémentation de types fondamentaux (comme int) en tant que classes?

27

Lors de la conception et implenting un langage de programmation orienté objet, à un un point doit faire un choix sur la mise en œuvre des types fondamentaux (comme int, float, doubleou équivalents) sous forme de classes ou autre chose. De toute évidence, les langages de la famille C ont tendance à ne pas les définir comme des classes (Java a des types primitifs spéciaux, C # les implémente comme des structures immuables, etc.).

Je peux penser à un avantage très important lorsque les types fondamentaux sont implémentés en tant que classes (dans un système de types avec une hiérarchie unifiée): ces types peuvent être des sous-types Liskov appropriés du type racine. Ainsi, nous évitons de compliquer le langage avec la boxe / unboxing (explicite ou implicite), les types de wrapper, les règles de variance spéciales, le comportement spécial, etc.

Bien sûr, je peux comprendre en partie pourquoi les concepteurs de langage décident de leur façon: les instances de classe ont tendance à avoir une surcharge spatiale (car les instances peuvent contenir une vtable ou d'autres métadonnées dans leur disposition de mémoire), que les primitives / structures n'ont pas besoin de avoir (si la langue ne permet pas l'héritage sur ceux-ci).

L'efficacité spatiale (et l'amélioration de la localisation spatiale, en particulier dans les grands tableaux) est-elle la seule raison pour laquelle les types fondamentaux ne sont souvent pas des classes?

J'ai généralement supposé que la réponse était oui, mais les compilateurs ont des algorithmes d'analyse d'échappement et ils peuvent donc déduire s'ils peuvent (sélectivement) omettre la surcharge spatiale lorsqu'une instance (n'importe quelle instance, pas seulement un type fondamental) se révèle être strictement local.

Est-ce que ce qui précède est faux ou y a-t-il autre chose qui me manque?

Theodoros Chatzigiannakis
la source

Réponses:

19

Oui, cela revient à peu près à l'efficacité. Mais vous semblez sous-estimer l'impact (ou surestimer le fonctionnement de diverses optimisations).

Tout d'abord, il ne s'agit pas seulement de "surcharge spatiale". Rendre les primitives en boîte / allouées en tas a également des coûts de performance. Il y a une pression supplémentaire sur le GC pour allouer et collecter ces objets. Cela va doublement si les "objets primitifs" sont immuables, comme ils devraient l'être. Ensuite, il y a plus d'échecs de cache (à la fois en raison de l'indirection et parce que moins de données tiennent dans une quantité donnée de cache). De plus, le simple fait que "charger l'adresse d'un objet, puis charger la valeur réelle à partir de cette adresse" nécessite plus d'instructions que "charger la valeur directement".

Deuxièmement, l'analyse des fuites n'est pas une poussière de fée plus rapide. Cela ne s'applique qu'aux valeurs qui, bien, n'échappent pas. Il est certainement agréable d'optimiser les calculs locaux (tels que les compteurs de boucles et les résultats intermédiaires des calculs) et cela donnera des avantages mesurables. Mais une bien plus grande majorité de valeurs vivent dans les domaines des objets et des tableaux. Certes, ceux-ci peuvent être eux-mêmes soumis à une analyse d'échappement, mais comme il s'agit généralement de types de référence mutables, leur alias présente un défi important pour l'analyse d'échappement, qui doit maintenant prouver que ces alias (1) n'échappent pas non plus , et (2) ne font pas de différence dans le but d'éliminer les allocations.

Étant donné que l'appel à n'importe quelle méthode (y compris les getters) ou le passage d'un objet en argument à toute autre méthode peut aider l'objet à s'échapper, vous aurez besoin d'une analyse interprocédurale dans tous les cas, sauf les plus triviaux. C'est beaucoup plus cher et compliqué.

Et puis il y a des cas où les choses s'échappent vraiment et ne peuvent pas être raisonnablement optimisées. Beaucoup d'entre eux, en fait, si vous considérez la fréquence à laquelle les programmeurs C ont du mal à allouer des tas. Lorsqu'un objet contenant un int s'échappe, l'analyse d'échappement cesse de s'appliquer également à l'int. Dites adieu aux champs primitifs efficaces .

Cela rejoint un autre point: les analyses et optimisations nécessaires sont sérieusement compliquées et constituent un domaine de recherche actif. On peut se demander si une implémentation de langage a atteint le degré d'optimisation que vous proposez, et même si c'est le cas, cela a été un effort rare et herculéen. Il est certainement plus facile de se tenir sur les épaules de ces géants que d'être un géant vous-même, mais c'est encore loin d'être trivial. Ne vous attendez pas à des performances compétitives à tout moment au cours des premières années, voire jamais.

Cela ne veut pas dire que de telles langues ne peuvent pas être viables. Ils le sont clairement. Ne présumez pas que ce sera ligne par ligne aussi vite que les langues avec des primitives dédiées. En d'autres termes, ne vous trompez pas avec des visions d'un compilateur suffisamment intelligent .


la source
Quand je parlais de l'analyse d'échappement, je voulais aussi dire l'allocation au stockage automatique (cela ne résout pas tout, mais comme vous le dites, cela résout certaines choses). J'avoue également que j'avais sous-estimé la mesure dans laquelle les champs et l'alias pouvaient faire échouer l'analyse d'échappement plus souvent. Les erreurs de cache sont la chose qui m'inquiétait le plus lorsque je parlais d'efficacité spatiale, alors merci de vous en occuper.
Theodoros Chatzigiannakis
@TheodorosChatzigiannakis J'inclus la modification de la stratégie d'allocation dans l'analyse d'échappement (car honnêtement, cela semble être la seule chose pour laquelle il est jamais utilisé).
Concernant votre deuxième paragraphe: les objets n'ont pas toujours besoin d'être alloués en tas ou de types de référence. En fait, lorsqu'ils ne le sont pas, cela rend les optimisations nécessaires relativement faciles. Voir les objets alloués par pile de C ++ pour un premier exemple, et le système de propriété de Rust pour un moyen de faire cuire l'analyse d'échappement directement dans le langage.
amon
@amon Je sais, et j'aurais peut-être dû clarifier cela, mais il semble que OP ne s'intéresse qu'aux langages Java et C # où l'allocation de tas est presque obligatoire (et implicite) en raison de la sémantique de référence et des conversions sans perte entre les sous-types. Bon point sur Rust utilisant ce qui revient à échapper à l'analyse!
@delnan Il est vrai que je m'intéresse principalement aux langues qui résument les détails du stockage, mais n'hésitez pas à inclure tout ce que vous pensez être pertinent, même s'il n'est pas applicable dans ces langues.
Theodoros Chatzigiannakis
27

L'efficacité spatiale (et l'amélioration de la localisation spatiale, en particulier dans les grands tableaux) est-elle la seule raison pour laquelle les types fondamentaux ne sont souvent pas des classes?

Non.

L'autre problème est que les types fondamentaux ont tendance à être utilisés par les opérations fondamentales. Le compilateur doit savoir que cela int + intne va pas être compilé en un appel de fonction, mais en une instruction CPU élémentaire (ou un octet-code équivalent). À ce stade, si vous avez l' intobjet en tant qu'objet normal, vous devrez de toute façon déballer efficacement la chose.

Ce type d'opérations n'est pas non plus vraiment agréable avec le sous-typage. Vous ne pouvez pas envoyer à une instruction CPU. Vous ne pouvez pas envoyer d' une instruction CPU. Je veux dire que le point entier du sous-typage est que vous pouvez utiliser un Doù vous pouvez B. Les instructions du processeur ne sont pas polymorphes. Pour que les primitives le fassent, vous devez encapsuler leurs opérations avec une logique de répartition qui coûte plusieurs fois la quantité d'opérations comme simple ajout (ou autre). L'avantage d'avoir intfait partie de la hiérarchie de types devient un peu discutable lorsqu'elle est scellée / finale. Et cela ignore tous les maux de tête avec la logique de répartition pour les opérateurs binaires ...

Fondamentalement, les types primitifs devraient avoir beaucoup de règles spéciales sur la façon dont le compilateur les gère et sur ce que l'utilisateur peut faire avec leurs types de toute façon , il est donc souvent plus simple de simplement les traiter comme complètement distincts.

Telastyn
la source
4
Découvrez l'implémentation de l'un des langages typés dynamiquement qui traitent les entiers et tels que les objets. L'instruction CPU primitive finale peut très bien être cachée dans une méthode (surcharge d'opérateur) dans la seule implémentation de classe quelque peu privilégiée de la bibliothèque d'exécution. Les détails seraient différents avec un système de type statique et un compilateur, mais ce n'est pas un problème fondamental. Au pire, cela rend les choses encore plus lentes.
3
int + intpeut être un opérateur régulier au niveau du langage qui invoque une instruction intrinsèque qui est garantie de compiler (ou de se comporter comme) l'opération d'addition d'entier CPU native. L'avantage d' inthériter de objectn'est pas seulement la possibilité d'hériter d'un autre type int, mais aussi la possibilité de intse comporter comme un objectsans boxe. Considérez les génériques C #: vous pouvez avoir la covariance et la contravariance, mais elles ne s'appliquent qu'aux types de classe - les types de structure sont automatiquement exclus, car ils ne peuvent objectpasser que par la boxe (implicite, générée par le compilateur).
Theodoros Chatzigiannakis
3
@delnan - bien sûr, bien que d'après mon expérience avec les implémentations de type statique, puisque chaque appel non-système se résume aux opérations primitives, avoir des frais généraux a un impact dramatique sur les performances - ce qui à son tour a un effet encore plus dramatique sur l'adoption.
Telastyn
@TheodorosChatzigiannakis - génial, donc vous pouvez obtenir la variance et la contravariance sur les types qui n'ont pas de sous-super / type utile ... Et l'implémentation de cet opérateur spécial pour appeler l'instruction CPU le rend toujours spécial. Je ne suis pas en désaccord avec l'idée - j'ai fait des choses très similaires dans mes langages de jouets, mais j'ai trouvé qu'il y a des problèmes pratiques lors de la mise en œuvre qui ne rendent pas les choses aussi propres que vous vous attendez.
Telastyn
1
@TheodorosChatzigiannakis L'intégration au-delà des frontières de la bibliothèque est certainement possible, bien qu'il s'agisse d'un autre élément de la liste de courses "Optimisations haut de gamme que j'aimerais avoir". Je me sens obligé de souligner cependant qu'il est notoirement délicat de se mettre complètement à droite sans être si conservateur qu'il est inutile.
4

Il n'y a que très peu de cas où vous avez besoin de «types fondamentaux» pour être des objets complets (ici, un objet est des données qui contiennent soit un pointeur vers un mécanisme de répartition, soit sont étiquetées avec un type qui peut être utilisé par un mécanisme de répartition):

  • Vous souhaitez que les types définis par l'utilisateur puissent hériter des types fondamentaux. Ceci n'est généralement pas souhaité car il introduit des maux de tête liés aux performances et à la sécurité. Il s'agit d'un problème de performances car la compilation ne peut pas supposer qu'une intaura une taille fixe spécifique ou qu'aucune méthode n'a été remplacée, et c'est un problème de sécurité car la sémantique de ints peut être subvertie (considérez un entier égal à n'importe quel nombre, ou qui change sa valeur plutôt que d'être immuable).

  • Vos types primitifs ont des supertypes et vous voulez avoir des variables avec le type d'un supertype d'un type primitif. Par exemple, supposez que vos ints le sont Hashableet que vous souhaitez déclarer une fonction qui accepte un Hashableparamètre qui pourrait recevoir des objets normaux mais également des ints.

    Cela peut être «résolu» en rendant ces types illégaux: supprimez le sous-typage et décidez que les interfaces ne sont pas des types mais des contraintes de type. Évidemment, cela réduit l'expressivité de votre système de type, et un tel système de type ne serait plus appelé orienté objet. Voir Haskell pour un langage qui utilise cette stratégie. C ++ est à mi-chemin car les types primitifs n'ont pas de supertypes.

    L'alternative est la boxe complète ou partielle des types fondamentaux. Le type de boxe n'a pas besoin d'être visible par l'utilisateur. Essentiellement, vous définissez un type encadré interne pour chaque type fondamental et des conversions implicites entre le type encadré et le type fondamental. Cela peut devenir gênant si les types encadrés ont une sémantique différente. Java présente deux problèmes: les types encadrés ont un concept d'identité tandis que les primitives n'ont qu'un concept d'équivalence de valeur, et les types encadrés sont annulables tandis que les primitives sont toujours valides. Ces problèmes sont complètement évitables en n'offrant pas de concept d'identité pour les types de valeur, en offrant une surcharge d'opérateur et en ne rendant pas tous les objets annulables par défaut.

  • Vous ne disposez pas de saisie statique. Une variable peut contenir n'importe quelle valeur, y compris les types ou objets primitifs. Par conséquent, tous les types primitifs doivent toujours être encadrés afin de garantir un typage fort.

Les langages qui ont un typage statique font bien d'utiliser des types primitifs dans la mesure du possible et ne retiennent que les types encadrés en dernier recours. Bien que de nombreux programmes ne soient pas extrêmement sensibles aux performances, il existe des cas où la taille et la composition des types primitifs sont extrêmement pertinentes: pensez à la compression de nombres à grande échelle où vous devez insérer des milliards de points de données en mémoire. Passer de doubleàfloatpourrait être une stratégie d'optimisation de l'espace viable en C, mais elle n'aurait pratiquement aucun effet si tous les types numériques sont toujours encadrés (et donc gaspillent au moins la moitié de leur mémoire pour un pointeur de mécanisme de répartition). Lorsque des types primitifs encadrés sont utilisés localement, il est assez simple de supprimer la boxe en utilisant les intrinsèques du compilateur, mais il serait à courte vue de parier les performances globales de votre langage sur un «compilateur suffisamment avancé».

amon
la source
An intn'est guère immuable dans toutes les langues.
Scott Whitlock
6
@ScottWhitlock Je vois pourquoi vous pourriez penser cela, mais en général, les types primitifs sont des types de valeur immuables. Aucune langue sensée ne vous permet de modifier la valeur du chiffre sept. Cependant, de nombreux langages vous permettent de réaffecter une variable qui contient une valeur d'un type primitif à une valeur différente. Dans les langages de type C, une variable est un emplacement de mémoire nommé et agit comme un pointeur. Une variable n'est pas la même que la valeur vers laquelle elle pointe. Une intvaleur est immuable, mais pas une intvariable.
amon
1
@amon: Pas de langage sensé; juste Java: thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Mason Wheeler
get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer mais cela ressemble à une programmation basée sur un prototype, qui est définitivement une POO.
Michael
1
@ScottWhitlock la question est de savoir si si vous avez alors int b = a, vous pouvez faire quelque chose pour b qui changera la valeur de a. Il y a eu quelques implémentations de langage où cela est possible, mais cela est généralement considéré comme pathologique et indésirable, contrairement à faire la même chose pour un tableau.
Random832
2

La plupart des implémentations que je connais imposent trois restrictions à ces classes qui permettent au compilateur d'utiliser efficacement les types primitifs comme représentation sous-jacente la grande majorité du temps. Ces restrictions sont:

  • Immutabilité
  • Finalité (ne peut être dérivée de)
  • Typage statique

Les situations dans lesquelles un compilateur doit placer une primitive dans un objet dans la représentation sous-jacente sont relativement rares, comme lorsqu'une Objectréférence pointe vers elle.

Cela ajoute un peu de gestion de cas spéciaux dans le compilateur, mais ce n'est pas seulement limité à un compilateur super avancé mythique. Cette optimisation se fait dans de vrais compilateurs de production dans les principales langues. Scala vous permet même de définir vos propres classes de valeur.

Karl Bielefeldt
la source
1

Dans Smalltalk, tous (int, float, etc.) sont des objets de première classe. Le seul cas particulier est que les SmallIntegers sont codifiés et traités différemment par la machine virtuelle pour des raisons d'efficacité, et donc la classe SmallInteger n'admettra pas de sous-classes (ce qui n'est pas une limitation pratique). Notez que cela ne nécessite aucune considération particulière de la part du programmeur car la distinction est circonscrite à des routines automatiques comme la génération de code ou la récupération de place.

Le Smalltalk Compiler (code source -> VM bytecodes) et le VM nativizer (bytecodes -> machine code) optimisent le code généré (JIT) afin de réduire la pénalité des opérations élémentaires avec ces objets de base.

Leandro Caniglia
la source
1

Je concevais une jauge OO et un runtime (cela a échoué pour un ensemble complètement différent de raisons).

Il n'y a rien de mal à créer des choses comme des classes vraies int; en fait, cela rend le GC plus facile à concevoir car il n'y a maintenant que 2 types d'en-têtes de tas (classe et tableau) plutôt que 3 (classe, tableau et primitif) [le fait que nous pouvons fusionner classe et tableau après que cela ne soit pas pertinent ].

Le vrai cas important, les types primitifs devraient avoir principalement des méthodes finales / scellées (+ importe vraiment, ToString pas tellement). Cela permet au compilateur de résoudre statiquement presque tous les appels aux fonctions elles-mêmes et de les aligner. Dans la plupart des cas, cela n'a pas d'importance en tant que comportement de copie (j'ai choisi de rendre l'incorporation disponible au niveau du langage [comme l'a fait .NET]), mais dans certains cas, si les méthodes ne sont pas scellées, le compilateur sera forcé de générer l'appel à la fonction utilisée pour implémenter int + int.

Joshua
la source