web-dev-qa-db-fra.com

Pourquoi les langages statiques forts OOP) empêchent-ils d'hériter des primitives?

Pourquoi est-ce correct et surtout attendu:

abstract type Shape
{
   abstract number Area();
}

concrete type Triangle : Shape
{
   concrete number Area()
   {
      //...
   }
}

... alors que ce n'est pas OK et que personne ne se plaint:

concrete type Name : string
{
}

concrete type Index : int
{
}

concrete type Quantity : int
{
}

Ma motivation est de maximiser l'utilisation du système de type pour la vérification de l'exactitude à la compilation.

PS: oui, j'ai lu this et le wrapping est une solution de contournement hacky.

53
Den

Je suppose que vous pensez à des langages comme Java et C #?

Dans ces langages, les primitives (comme int) sont fondamentalement un compromis pour les performances. Ils ne prennent pas en charge toutes les fonctionnalités des objets, mais ils sont plus rapides et avec moins de surcharge.

Pour que les objets prennent en charge l'héritage, chaque instance doit "savoir" au moment de l'exécution de quelle classe elle est une instance. Sinon, les méthodes remplacées ne peuvent pas être résolues au moment de l'exécution. Pour les objets, cela signifie que les données d'instance sont stockées en mémoire avec un pointeur vers l'objet de classe. Si ces informations devaient également être stockées avec des valeurs primitives, les besoins en mémoire augmenteraient. Une valeur entière de 16 bits nécessiterait ses 16 bits pour la valeur et en outre une mémoire de 32 ou 64 bits pour un pointeur vers sa classe.

Outre la surcharge de mémoire, vous vous attendez également à pouvoir remplacer les opérations courantes sur les primitives telles que les opérateurs arithmétiques. Sans sous-typage, les opérateurs comme + peut être compilé en une simple instruction de code machine. Si elle pouvait être remplacée, vous auriez besoin de résoudre des méthodes au moment de l'exécution, une opération beaucoup plus coûteuse. (Vous savez peut-être que C # prend en charge la surcharge d'opérateur - mais ce n'est pas la même chose. La surcharge d'opérateur est résolue au moment de la compilation, il n'y a donc pas de pénalité d'exécution par défaut.)

Les chaînes ne sont pas des primitives mais elles sont toujours "spéciales" dans la façon dont elles sont représentées en mémoire. Par exemple, ils sont "internés", ce qui signifie que deux chaînes de caractères littéraux qui sont égales peuvent être optimisées pour la même référence. Cela ne serait pas possible (ou du moins beaucoup moins efficace) si les instances de chaîne devaient également garder une trace de la classe.

Ce que vous décrivez serait certainement utile, mais sa prise en charge nécessiterait une surcharge de performances pour chaque utilisation de primitives et de chaînes, même si elles ne profitent pas de l'héritage.

Le langage Smalltalk le fait (je crois) permet le sous-classement des entiers. Mais lorsque Java a été conçu, Smalltalk a été considéré comme trop lent, et le surcoût d'avoir tout un objet a été considéré comme l'une des principales raisons. Java sacrifié certains élégance et pureté conceptuelle pour de meilleures performances.

83
JacquesB

Ce que certains langages proposent n'est pas le sous-classement, mais le sous-typage. Par exemple, Ada vous permet de créer des types dérivés ou sous-types. La section Ada Programming/Type System mérite d'être lue pour comprendre tous les détails. Vous pouvez restreindre la plage de valeurs, ce que vous souhaitez la plupart du temps:

 type Angle is range -10 .. 10;
 type Hours is range 0 .. 23; 

Vous pouvez utiliser les deux types en tant que nombres entiers si vous les convertissez explicitement. Notez également que vous ne pouvez pas utiliser l'un à la place d'un autre, même lorsque les plages sont structurellement équivalentes (les types sont vérifiés par des noms).

 type Reference is Integer;
 type Count is Integer;

Les types ci-dessus sont incompatibles, même s'ils représentent la même plage de valeurs.

(Mais vous pouvez utiliser Unchecked_Conversion; ne le dites pas aux gens que je vous ai dit)

20
coredump

Je pense que cela pourrait très bien être une question X/Y. Points saillants de la question ...

Ma motivation est de maximiser l'utilisation du système de type pour la vérification de l'exactitude à la compilation.

... et de votre commentaire élaborant:

Je ne veux pas pouvoir substituer l'un à l'autre implicitement.

Excusez-moi si je manque quelque chose, mais ... Si ce sont vos objectifs, pourquoi diable parlez-vous de l'héritage? La substituabilité implicite est ... comme ... tout ça. Vous savez, le principe de substitution de Liskov?

Ce que vous semblez vouloir, en réalité, c'est le concept d'un `` typedef fort '' - par lequel quelque chose `` est '' par exemple un int en termes de plage et de représentation mais ne peut pas être substitué dans des contextes qui attendent un int et vice-versa. Je suggérerais de rechercher des informations sur ce terme et quelle que soit la langue choisie. Encore une fois, c'est à peu près littéralement le contraire de l'héritage.

Et pour ceux qui pourraient ne pas aimer une réponse X/Y, je pense que le titre pourrait toujours être responsable en référence au LSP. Les types primitifs sont primitifs parce qu'ils font quelque chose de très simple, et c'est tout ce qu'ils font. Leur permettre d'être hérités et ainsi rendre infinis leurs effets possibles conduirait au mieux à une grande surprise et à une violation fatale du LSP au pire. Si je peux supposer avec optimisme Thales Pereira ne me dérangera pas de citer this commentaire phénoménal:

Il y a le problème supplémentaire que si quelqu'un pouvait hériter d'Int, vous auriez un code innocent comme "int x = y + 2" (où Y est la classe dérivée) qui écrit maintenant un journal dans la base de données, ouvre une URL et ressusciter en quelque sorte Elvis. Les types primitifs sont censés être sûrs et avec un comportement bien défini plus ou moins garanti.

Si quelqu'un voit un type primitif, dans une langue saine, il présume à juste titre qu'il fera toujours sa seule petite chose, très bien, sans surprise. Les types primitifs n'ont pas de déclarations de classe disponibles qui signalent si elles peuvent ou non être héritées et ont leurs méthodes remplacées. S'ils l'étaient, ce serait très surprenant en effet (et briserait totalement la rétrocompatibilité, mais je suis conscient que c'est une réponse en arrière à "pourquoi X n'a-t-il pas été conçu avec Y").

... bien que, comme Mooing Duck l'a souligné en réponse, les langages qui autorisent la surcharge de l'opérateur permettent à l'utilisateur de se confondre dans une mesure similaire ou égale s'il le veut vraiment, il est donc douteux que ce dernier argument soit valable . Et je vais arrêter de résumer les commentaires des autres maintenant, hé.

17
underscore_d

Dans les langages statiques forts courants OOP, le sous-typage est principalement considéré comme un moyen d'étendre un type et de remplacer les méthodes actuelles du type.

Pour ce faire, les "objets" contiennent un pointeur sur leur type. Il s'agit d'une surcharge: le code d'une méthode qui utilise une instance Shape doit d'abord accéder aux informations de type de cette instance, avant de connaître la méthode Area() à appeler.

Une primitive a tendance à n'autoriser que des opérations pouvant se traduire par des instructions en langage machine unique et ne portant aucune information de type avec elles. Rendre un entier plus lent pour que quelqu'un puisse le sous-classer était assez peu attrayant pour empêcher toutes les langues qui le faisaient de devenir mainstream.

Donc, la réponse à:

Pourquoi les langages statiques forts OOP) empêchent-ils d'hériter des primitives?

Est:

  • Il y avait peu de demande
  • Et cela aurait rendu la langue trop lente
  • Le sous-typage était principalement considéré comme un moyen d'étendre un type, plutôt que comme un moyen d'obtenir une meilleure vérification de type statique (définie par l'utilisateur).

Cependant, nous commençons à obtenir des langages qui permettent une vérification statique basée sur les propriétés de variables autres que "type", par exemple F # a "dimension" et "unit" de sorte que vous ne pouvez pas, par exemple, ajouter une longueur à une zone .

Il existe également des langages qui autorisent les "types définis par l'utilisateur" qui ne changent pas (ou n'échangent pas) ce qu'un type fait, mais aident simplement à la vérification statique des types; voir la réponse de coredump.

4
Ian

Afin de permettre l'héritage avec la répartition virtuelle 8, ce qui est souvent considéré comme très souhaitable dans la conception d'applications), il faut des informations sur le type d'exécution. Pour chaque objet, certaines données concernant le type de l'objet doivent être stockées. Une primitive, par définition, n'a pas cette information.

Il existe deux langages (gérés, exécutés sur une machine virtuelle) OOP langages qui comportent des primitives: C # et Java. De nombreux autres langages ne pas ont des primitives en premier lieu, ou utilisez un raisonnement similaire pour les autoriser/les utiliser.

Les primitives sont un compromis pour les performances. Pour chaque objet, vous avez besoin d'espace pour son en-tête d'objet (en Java, généralement 2 * 8 octets sur les machines virtuelles 64 bits), plus ses champs, ainsi que le remplissage éventuel (dans Hotspot, chaque objet occupe un nombre d'octets qui est un multiple de 8). Ainsi, un int as aurait besoin d'au moins 24 octets de mémoire pour être conservé, au lieu de seulement 4 octets (en Java).

Ainsi, des types primitifs ont été ajoutés pour améliorer les performances. Ils facilitent beaucoup de choses. Qu'est-ce que a + b signifie que les deux sont des sous-types de int? Il faut ajouter une sorte d'exclusion pour choisir l'addition correcte. Cela signifie une répartition virtuelle. Avoir la possibilité d'utiliser un opcode très simple pour l'ajout est beaucoup, beaucoup plus rapide et permet des optimisations au moment de la compilation.

String est un autre cas. Les deux en Java et C #, String est un objet. Mais en C # son scellé, et en Java sa finale. Cela parce que les deux Java et C # standard nécessitent que Strings soient immuables, et leur sous-classement briserait cette immuabilité.

Dans le cas de Java, le VM peut (et fait) interner les chaînes et les "regrouper", ce qui permet de meilleures performances. Cela ne fonctionne que lorsque les chaînes sont vraiment immuables.

De plus, un rarement doit sous-classer les types primitifs. Tant que les primitives ne peuvent pas être sous-classées, il y a beaucoup de choses intéressantes que les mathématiques nous disent à leur sujet. Par exemple, nous pouvons être sûrs que l'addition est commutative et associative. C'est quelque chose que la définition mathématique des entiers nous dit. De plus, nous pouvons facilement effectuer des invariants sur des boucles via l'induction dans de nombreux cas. Si nous autorisons le sous-classement de int, nous perdons les outils que les mathématiques nous fournissent, car nous ne pouvons plus garantir que certaines propriétés tiennent. Ainsi, je dirais que la capacité pas de pouvoir sous-classer des types primitifs est en fait une bonne chose. Moins de choses que quelqu'un peut casser, plus un compilateur peut souvent prouver qu'il est autorisé à faire certaines optimisations.

4
Polygnome

Je ne sais pas si j'oublie quelque chose ici, mais la réponse est plutôt simple:

  1. La définition des primitives est la suivante: les valeurs primitives ne sont pas des objets, les types primitifs ne sont pas des types d'objets, les primitives ne font pas partie du système d'objets.
  2. L'héritage est une caractéristique du système d'objets.
  3. Ergo, les primitives ne peuvent pas participer à l'héritage.

Notez qu'il n'y a vraiment que deux langages statiques forts OOP qui même ont des primitives , AFAIK: Java et C++. (En fait, je ne suis même pas sûr de ce dernier, je ne sais pas grand-chose sur C++, et ce que j'ai trouvé lors de la recherche était déroutant.)

En C++, les primitives sont fondamentalement un héritage hérité (jeu de mots) de C. Donc, elles ne participent pas au système objet (et donc à l'héritage) car C n'a ni système objet ni héritage.

En Java, les primitives sont le résultat d'une tentative malavisée d'améliorer les performances. Les primitives sont également les seuls types de valeur dans le système, il est en fait impossible d'écrire des types de valeur en Java et il est impossible pour les objets d'être des types de valeur. Donc, à part le fait que les primitives ne participent pas au système objet et donc l'idée de "l'héritage" n'a même pas de sens, même si vous pourriez en hériter, vous ne seriez pas en mesure de maintenir la "valeur". Ceci est différent de par exemple C♯ qui possède a des types de valeur (structs), qui sont néanmoins des objets.

Une autre chose est que l'impossibilité d'hériter n'est pas non plus propre aux primitives. En C♯, structs hérite implicitement de System.Object et peut implémenter interfaces, mais ils ne peuvent ni hériter ni hériter de classes ou structs. De plus, sealedclasses ne peut pas être hérité de. En Java, finalclasses ne peut pas être hérité de.

tl; dr:

Pourquoi les langages statiques forts OOP) empêchent-ils d'hériter des primitives?

  1. les primitives ne font pas partie du système objet (par définition, si elles l'étaient, elles ne seraient pas primitives), l'idée d'héritage est liée au système objet, l'héritage ergo primitif est une contradiction en termes
  2. les primitives ne sont pas uniques, de nombreux autres types ne peuvent pas également être hérités (final ou sealed in Java or C♯, structs in C♯, case classes à Scala)
3
Jörg W Mittag

Joshua Bloch dans "Effective Java" recommande de concevoir explicitement l'héritage ou de l'interdire. Les classes primitives ne sont pas conçues pour l'héritage car elles sont conçues pour être immuables et permettre l'héritage pourrait changer cela dans les sous-classes, donc casser principe de Liskov et ce serait une source de nombreux bugs.

Quoi qu'il en soit, pourquoi this une solution de contournement hacky? Vous devriez vraiment préférer la composition à l'héritage. Si la raison est la performance, vous avez raison et la réponse à votre question est qu'il n'est pas possible de mettre toutes les fonctionnalités dans Java car il faut du temps pour analyser tous les différents aspects de l'ajout d'une fonctionnalité Par exemple Java n'avait pas de génériques avant 1.5.

Si vous avez beaucoup de patience, vous avez de la chance car il y a plan pour ajouter des classes de valeur à Java qui vous permettra de créer vos classes de valeur qui vous aideront vous augmentez les performances et en même temps cela vous donnera plus de flexibilité.

2
CodesInTheDark

Au niveau abstrait, vous pouvez inclure tout ce que vous voulez dans une langue que vous concevez.

Au niveau de l'implémentation, il est inévitable que certaines de ces choses soient plus simples à implémenter, certaines seront compliquées, certaines pourront être accélérées, certaines seront forcément plus lentes, etc. Pour tenir compte de cela, les concepteurs doivent souvent prendre des décisions difficiles et des compromis.

Au niveau de l'implémentation, l'un des moyens les plus rapides pour accéder à une variable est de trouver son adresse et de charger le contenu de cette adresse. La plupart des CPU contiennent des instructions spécifiques pour le chargement des données à partir des adresses et ces instructions doivent généralement savoir combien d'octets elles doivent charger (un, deux, quatre, huit, etc.) et où placer les données qu'elles chargent (registre unique, registre paire, registre étendu, autre mémoire, etc.). En connaissant la taille d'une variable, le compilateur peut savoir exactement quelle instruction émettre pour les utilisations de cette variable. En ne connaissant pas la taille d'une variable, le compilateur devrait recourir à quelque chose de plus compliqué et probablement plus lent.

Au niveau abstrait, le point de sous-typage est de pouvoir utiliser des instances d'un type où un type égal ou plus général est attendu. En d'autres termes, un code peut être écrit qui attend un objet d'un type particulier ou quelque chose de plus dérivé, sans savoir à l'avance ce que ce serait exactement. Et clairement, comme plus de types dérivés peuvent ajouter plus de membres de données, un type dérivé n'a pas nécessairement les mêmes besoins en mémoire que ses types de base.

Au niveau de l'implémentation, il n'y a pas de moyen simple pour qu'une variable d'une taille prédéterminée contienne une instance de taille inconnue et soit accessible d'une manière que vous appelleriez normalement efficace. Mais il existe un moyen de déplacer un peu les choses et d'utiliser une variable non pas pour stocker l'objet, mais pour identifier l'objet et laisser cet objet ailleurs. De cette façon, c'est une référence (par exemple une adresse mémoire) - un niveau supplémentaire d'indirection qui garantit qu'une variable n'a besoin que de contenir une sorte d'informations de taille fixe, tant que nous pouvons trouver l'objet à travers ces informations. Pour y parvenir, nous avons juste besoin de charger l'adresse (taille fixe), puis nous pouvons travailler comme d'habitude en utilisant les décalages de l'objet que nous savons valides, même si cet objet a plus de données à des décalages que nous ne connaissons pas. Nous pouvons le faire parce que nous ne nous soucions plus de ses besoins de stockage lorsque nous y accédons.

Au niveau abstrait, cette méthode vous permet de stocker une (référence à) string dans une variable object sans perdre les informations qui en font un string. C'est bien pour tous les types de travailler comme ça et vous pourriez également dire que c'est élégant à bien des égards.

Pourtant, au niveau de l'implémentation, le niveau supplémentaire d'indirection implique plus d'instructions et sur la plupart des architectures, il rend chaque accès à l'objet un peu plus lent. Vous pouvez permettre au compilateur d'extraire plus de performances d'un programme si vous incluez dans votre langue des types couramment utilisés qui n'ont pas ce niveau supplémentaire d'indirection (la référence). Mais en supprimant ce niveau d'indirection, le compilateur ne peut plus vous permettre de sous-taper de manière sécurisée en mémoire. En effet, si vous ajoutez plus de membres de données à votre type et que vous les affectez à un type plus général, tous les membres de données supplémentaires qui ne tiennent pas dans l'espace alloué à la variable cible seront supprimés.

L'héritage n'est généralement pas la sémantique souhaitée, car vous ne pouvez pas remplacer votre type spécial partout où une primitive est attendue. Pour emprunter à votre exemple, un Quantity + Index n'a aucun sens sémantiquement, donc une relation d'héritage est une mauvaise relation.

Cependant, plusieurs langues ont le concept d'un type de valeur qui exprime le type de relation que vous décrivez. Scala est un exemple. Un type de valeur utilise une primitive comme représentation sous-jacente, mais possède une identité de classe et des opérations différentes à l'extérieur. Cela a pour effet d'étendre un type primitif, mais il s'agit plus d'une composition que d'une relation d'héritage.

1
Karl Bielefeldt

En général

Si une classe est abstraite (métaphore: une boîte avec des trous), c'est OK (même requis d'avoir quelque chose utilisable!) Pour "remplir les trous", c'est pourquoi nous sous-classons les classes abstraites.

Si une classe est concrète (métaphore: une boîte pleine), ce n'est pas OK de modifier l'existant car si elle est pleine, elle est pleine. Nous n'avons pas de place pour ajouter quelque chose de plus à l'intérieur de la boîte, c'est pourquoi nous ne devrions pas sous-classer les classes concrètes.

Avec des primitives

Les primitives sont des classes concrètes par conception. Ils représentent quelque chose de bien connu, parfaitement défini (je n'ai jamais vu un type primitif avec quelque chose d'abstrait, sinon ce n'est plus une primitive) et largement utilisé dans le système. Permettre de sous-classer un type primitif et fournir votre propre implémentation à d'autres qui s'appuient sur le comportement conçu des primitives peut provoquer de nombreux effets secondaires et d'énormes dommages!

1
Spotted