Le polymorphisme de type retour (uniquement) dans Haskell est-il une bonne chose?


29

Une chose avec laquelle je n'ai jamais tout à fait accepté dans Haskell est de savoir comment vous pouvez avoir des constantes et des fonctions polymorphes dont le type de retour ne peut pas être déterminé par leur type d'entrée, comme

class Foo a where
    foo::Int -> a

Certaines des raisons pour lesquelles je n'aime pas cela:

Transparence référentielle:

"Dans Haskell, étant donné la même entrée, une fonction retournera toujours la même sortie", mais est-ce vraiment vrai? read "3"renvoie 3 lorsqu'il est utilisé dans un Intcontexte, mais renvoie une erreur lorsqu'il est utilisé dans un (Int,Int)contexte , disons . Oui, vous pouvez affirmer que cela readprend également un paramètre de type, mais l'implicité du paramètre de type lui fait perdre une partie de sa beauté à mon avis.

Restriction de monomorphisme:

L'une des choses les plus ennuyeuses à propos de Haskell. Corrigez-moi si je me trompe, mais toute la raison du MR est que le calcul qui semble partagé peut ne pas être dû au fait que le paramètre type est implicite.

Type par défaut:

Encore une fois, l'une des choses les plus ennuyeuses à propos de Haskell. Cela se produit par exemple si vous passez le résultat de fonctions polymorphes dans leur sortie à des fonctions polymorphes dans leur entrée. Encore une fois, corrigez-moi si je me trompe, mais cela ne serait pas nécessaire sans les fonctions dont le type de retour ne peut pas être déterminé par leur type d'entrée (et les constantes polymorphes).

Ma question est donc (courir le risque d'être estampillée comme une "question de discussion"): Serait-il possible de créer un langage de type Haskell où le vérificateur de type interdit ce type de définitions? Si oui, quels seraient les avantages / inconvénients de cette restriction?

Je peux voir quelques problèmes immédiats:

Si, disons, 2n'avait que le type Integer, 2/3ne taperait plus la vérification avec la définition actuelle de /. Mais dans ce cas, je pense que les classes de types avec des dépendances fonctionnelles pourraient venir à la rescousse (oui, je sais que c'est une extension). De plus, je pense qu'il est beaucoup plus intuitif d'avoir des fonctions qui peuvent prendre différents types d'entrée, que d'avoir des fonctions qui sont restreintes dans leurs types d'entrée, mais nous leur passons juste des valeurs polymorphes.

La saisie de valeurs comme []et Nothingme semble être un écrou plus difficile à casser. Je n'ai pas pensé à un bon moyen de les gérer.

Réponses:


37

En fait, je pense que le polymorphisme de type retour est l'une des meilleures caractéristiques des classes de type. Après l'avoir utilisé pendant un certain temps, il m'est parfois difficile de revenir à la modélisation de style OOP où je ne l'ai pas.

Considérez le codage de l'algèbre. Dans Haskell, nous avons une classe de type Monoid(ignorant mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

Comment pourrions-nous coder cela comme une interface dans un langage OO? La réponse courte est que nous ne pouvons pas. C'est parce que le type de polymorphisme memptyest (Monoid a) => aaka, de type retour. Avoir la capacité de modéliser l'algèbre est incroyablement utile OMI. *

Vous commencez votre message avec la plainte concernant la "transparence référentielle". Cela soulève un point important: Haskell est un langage axé sur les valeurs. Ainsi, les expressions comme read 3ne doivent pas être comprises comme des choses qui calculent des valeurs, elles peuvent également être comprises comme des valeurs. Cela signifie que le vrai problème n'est pas le polymorphisme de type retour: ce sont les valeurs de type polymorphe ( []et Nothing). Si le langage doit les avoir, alors il doit vraiment avoir des types de retour polymorphes pour la cohérence.

Faut-il pouvoir dire []est de type forall a. [a]? Je le pense. Ces fonctionnalités sont très utiles et rendent le langage beaucoup plus simple.

Si Haskell avait un sous-type, le polymorphisme []pourrait être un sous-type pour tous [a]. Le problème est que je ne connais pas de moyen de coder cela sans que le type de la liste vide soit polymorphe. Considérez comment cela serait fait dans Scala (c'est plus court que de le faire dans le langage OOP canonique à typage statique, Java)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Même ici, Nil()est un objet de type Nil[A]**

Un autre avantage du polymorphisme de type retour est qu'il rend l'intégration de Curry-Howard beaucoup plus simple.

Considérez les théorèmes logiques suivants:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

Nous pouvons trivialement les capturer comme théorèmes dans Haskell:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

Pour résumer: j'aime le polymorphisme de type retour, et je pense qu'il brise la transparence référentielle uniquement si vous avez une notion limitée de valeurs (bien que ce soit moins convaincant dans le cas d'une classe de type ad hoc). D'un autre côté, je trouve vos points sur MR et le type par défaut convaincant.


*. Dans les commentaires, ysdx souligne que ce n'est pas strictement vrai: nous pourrions réimplémenter les classes de types en modélisant l'algèbre comme un autre type. Comme le Java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

Vous devez ensuite passer des objets de ce type avec vous. Scala a une notion de paramètres implicites qui évite certains, mais selon mon expérience pas tous, de la surcharge de gestion explicite de ces choses. Mettre vos méthodes utilitaires (méthodes d'usine, méthodes binaires, etc.) sur un type délimité F se révèle être une façon incroyablement agréable de gérer les choses dans un langage OO qui prend en charge les génériques. Cela dit, je ne suis pas sûr que j'aurais gâché ce modèle si je n'avais pas d'expérience de la modélisation de choses avec des classes de caractères, et je ne suis pas sûr que d'autres personnes le feront.

Il a également des limites, hors de la boîte il n'y a aucun moyen d'obtenir un objet qui implémente la classe de types pour un type arbitraire. Vous devez soit transmettre les valeurs explicitement, utiliser quelque chose comme les implicites de Scala, soit utiliser une forme de technologie d'injection de dépendance. La vie devient laide. En revanche, il est bien que vous puissiez avoir plusieurs implémentations pour le même type. Quelque chose peut être monoïde de plusieurs façons. En outre, le fait de transporter ces structures séparément a une impression plus moderne et constructive sur le plan mathématique. Donc, même si je préfère généralement la manière Haskell de le faire, j'ai probablement exagéré mon cas.

Les classes avec polymorphisme de type retour rendent ce genre de chose facile à manipuler. Cela ne veut pas dire que c'est la meilleure façon de le faire.

**. Jörg W Mittag souligne que ce n'est pas vraiment la manière canonique de procéder à Scala. Au lieu de cela, nous suivrions la bibliothèque standard avec quelque chose de plus comme:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

Cela tire parti de la prise en charge de Scala pour les types de fond, ainsi que pour les paramètres de type covariant. Donc, Nilc'est du type Nilnon Nil[A]. À ce stade, nous sommes assez loin de Haskell, mais il est intéressant de noter comment Haskell représente le type inférieur

undefined :: forall a. a

Autrement dit, ce n'est pas le sous-type de tous les types, il est polymorphe (sp) un membre de tous les types.
Encore plus de polymorphisme de type retour.


4
"Comment pourrions-nous encoder cela comme une interface dans un langage OO?" Vous pouvez utiliser l'algèbre de première classe: interface Monoid <X> {X empty (); X ajouter (X, X); } Cependant, vous devez le transmettre explicitement (cela se fait en arrière-plan dans Haskell / GHC).
ysdx

@ysdx C'est vrai. Et dans les langages qui prennent en charge les paramètres implicites, vous obtenez quelque chose de très proche des classes de types de haskell (comme dans Scala). J'étais au courant de cela. Mon point était cependant que cela rend la vie assez difficile: je me retrouve à utiliser des conteneurs qui stockent la "typeclass" partout (beurk!). Pourtant, j'aurais probablement dû être moins hyperbolique dans ma réponse.
Philip JF

+1, réponse impressionnante. Un nitpick non pertinent, cependant: Nildevrait probablement être un case object, pas un case class.
Jörg W Mittag

@ Jörg W Mittag Ce n'est pas totalement hors de propos. J'ai édité pour répondre à votre commentaire.
Philip JF

1
Merci pour une très belle réponse. Il me faudra probablement un peu de temps pour le digérer / le comprendre.
dainichi

12

Juste pour noter une idée fausse:

"Dans Haskell, étant donné la même entrée, une fonction retournera toujours la même sortie", mais est-ce vraiment vrai? lire "3" renvoie 3 lorsqu'il est utilisé dans un contexte Int, mais renvoie une erreur lorsqu'il est utilisé dans un contexte, disons (Int, Int).

Ce n'est pas parce que ma femme conduit une Subaru et que je conduis une Subaru que nous conduisons la même voiture. Ce readn'est pas parce qu'il y a 2 fonctions nommées que c'est la même fonction. Indeed read :: String -> Intest défini dans l'instance Read de Int, où read :: String (Int, Int)est défini dans l'instance Read de (Int, Int). Par conséquent, ce sont des fonctions complètement différentes.

Ce phénomène est courant dans les langages de programmation et est généralement appelé surcharge .


6
Je pense que vous avez en quelque sorte manqué le point de la question. Dans la plupart des langages qui ont un polymorphisme ad hoc, c'est-à-dire une surcharge, la sélection de la fonction à appeler dépend uniquement des types de paramètres, pas du type de retour. Cela facilite la réflexion sur la signification des appels de fonction: commencez simplement par les feuilles de l'arborescence d'expression et progressez "vers le haut". Dans Haskell (et le petit nombre d'autres langues qui prennent en charge la surcharge de type retour), vous devez potentiellement prendre en compte l'intégralité de l'arborescence d'expression, même pour comprendre la signification d'une minuscule sous-expression.
Laurence Gonsalves

1
Je pense que vous avez parfaitement touché le nœud de la question. Même Shakespeare a dit: "Une fonction sous un autre nom fonctionnerait tout aussi bien." +1
Thomas Eding

@Laurence Gonsalves - L'inférence de type dans Haskell n'est pas référentiellement transparente. La signification du code peut dépendre du contexte dans lequel il est utilisé en raison de l'inférence de type tirant des informations vers l'intérieur. Cela ne se limite pas aux problèmes de type retour. Haskell a effectivement intégré Prolog dans son système de type. Cela peut rendre le code moins clair, mais présente également de gros avantages. Personnellement, je pense que le type de transparence référentielle qui importe le plus est ce qui se passe au moment de l'exécution, car la paresse serait impossible à gérer sans elle.
Steve314

@ Steve314 Je suppose que je n'ai pas encore vu de situation où le manque d'inférence de type référentiellement transparent ne rend pas le code moins clair. Pour raisonner sur quelque chose de complexe, il faut être capable de "morceler" mentalement les choses. Si vous me dites que vous avez un chat, je ne pense pas à un nuage de milliards d'atomes ou de cellules individuelles. De même, lors de la lecture du code, je partitionne les expressions dans leurs sous-expressions. Haskell vainc cela de deux manières: l'inférence de type "à l'envers" et la surcharge d'opérateur trop complexe. La communauté Haskell a également une aversion pour les parens, ce qui aggrave encore ces derniers.
Laurence Gonsalves

1
@LaurenceGonsalves Vous avez raison de dire que la infixfonctionnalité peut être utilisée abusivement. Mais c'est un échec des utilisateurs, alors. OTOH, la restrictivité, comme en Java, n'est pas à mon humble avis la bonne façon. Pour voir cela, ne cherchez pas plus loin que du code qui traite de BigIntegers.
Ingo

7

Je souhaite vraiment que le terme "polymorphisme de type retour" n'ait jamais été créé. Cela encourage une mauvaise compréhension de ce qui se passe. Il suffit de dire que, tout en éliminant le «polymorphisme de type retour» serait un changement extrêmement ad hoc et destructeur d'expressivité, il ne résoudrait même pas à distance les problèmes soulevés dans la question.

Le type de retour n'est en rien spécial. Considérer:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

Ce code provoque tous les mêmes problèmes que le «polymorphisme de type retour» et montre également les mêmes types de différences par rapport à la POO. Personne ne parle du "premier argument peut-être du polymorphisme de type".

L'idée clé est que l'implémentation utilise des types pour résoudre l'instance à utiliser. Les valeurs (d'exécution) de toute sorte n'ont rien à voir avec cela. En effet, cela fonctionnerait même pour les types sans valeur. En particulier, les programmes Haskell n'ont aucun sens sans leurs types. (Ironiquement, cela fait de Haskell un langage de style Eglise par opposition à un langage de style Curry. J'ai un article de blog dans les travaux qui en parle.)


«Ce code provoque tous les mêmes problèmes que le« polymorphisme de type retour »». Non, ce n'est pas le cas. Je peux regarder "foo Nothing" et déterminer quel est son type. C'est un Bool. Il n'est pas nécessaire de regarder le contexte.
dainichi

4
En fait, le code ne tape pas check car le compilateur ne sait pas ce que ac'est que dans le cas du "type de retour". Encore une fois, les types de retour n'ont rien de spécial. Nous devons connaître le type de toutes les sous-expressions. Considérez let x = Nothing in if foo x then fromJust x else error "No foo".
Derek Elkins

2
Sans parler du "polymorphisme du deuxième argument"; une fonction comme Int -> a -> Boolest, en recourbant, en fait Int -> (a -> Bool)et là, le polymorphisme dans la valeur de retour à nouveau. Si vous l'autorisez n'importe où, il doit être partout.
Ryan Reich

4

Concernant votre question sur la transparence référentielle sur les valeurs polymorphes, voici quelque chose qui peut vous aider.

Considérez le nom 1 . Il désigne souvent des objets différents (mais fixes):

  • 1 comme dans un entier
  • 1 comme dans un vrai
  • 1 comme dans une matrice d'identité carrée
  • 1 comme dans une fonction d'identité

Ici, il est important de noter que dans chaque contexte , 1est une valeur fixe. En d'autres termes, chaque paire nom-contexte dénote un objet unique . Sans le contexte, nous ne pouvons pas savoir quel objet nous dénotons. Le contexte doit donc être déduit (si possible) ou explicitement fourni. Un bon avantage (en dehors de la notation pratique) est la possibilité d'exprimer du code dans des contextes génériques.

Mais comme il ne s'agit que de notation, nous aurions pu utiliser la notation suivante à la place:

  • integer1 comme dans un entier
  • real1 comme dans un vrai
  • matrixIdentity1 comme dans une matrice d'identité carrée
  • functionIdentity1 comme dans une fonction d'identité

Ici, nous gagnons des noms explicites. Cela nous permet de dériver le contexte uniquement à partir du nom. Ainsi, seul le nom de l'objet est nécessaire pour désigner complètement un objet.

Les classes de type Haskell ont choisi l'ancien schéma de notation. Voici maintenant la lumière au bout du tunnel:

readest un nom, pas une valeur (il n'a pas de contexte), mais read :: String -> aest une valeur (il a à la fois un nom et un contexte). Ainsi, les fonctions read :: String -> Intet read :: String -> (Int, Int)sont des fonctions littéralement différentes. Ainsi, s'ils ne sont pas d'accord sur leurs contributions, la transparence référentielle n'est pas rompue.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.