Classes de type vs interfaces d'objet

33

Je ne pense pas comprendre les classes de types. J'ai lu quelque part que penser que les classes de type sont des "interfaces" (de OO) implémentées par un type est faux et trompeur. Le problème, c'est que j'ai du mal à les voir comme quelque chose de différent et que c'est faux.

Par exemple, si j'ai une classe de type (en syntaxe Haskell)

class Functor f where
  fmap :: (a -> b) -> f a -> f b

En quoi est-ce différent de l'interface [1] (en syntaxe Java)

interface Functor<A> {
  <B> Functor<B> fmap(Function<B, A> fn)
}

interface Function<Return, Argument> {
  Return apply(Argument arg);
}

Une différence possible à laquelle je peux penser est que l'implémentation de la classe de type utilisée lors d'une invocation donnée n'est pas spécifiée, mais déterminée à partir de l'environnement, par exemple en examinant les modules disponibles pour une implémentation de ce type. Cela semble être un artefact de mise en œuvre qui pourrait être traité dans un langage OO; comme le compilateur (ou le moteur d’exécution) pourrait rechercher un wrapper / extender / monkey-patcher qui expose l’interface nécessaire sur le type.

Qu'est-ce que je rate?

[1] Notez que l' f aargument a été supprimé fmapcar étant donné qu'il s'agit d'un langage OO, vous appelez cette méthode sur un objet. Cette interface suppose que l' f aargument a été corrigé.

oconnor0
la source

Réponses:

46

Dans leur forme de base, les classes de types ressemblent quelque peu aux interfaces d'objet. Cependant, à bien des égards, ils sont beaucoup plus généraux.

  1. Le dispatch est sur des types, pas des valeurs. Aucune valeur n'est requise pour l'exécuter. Par exemple, il est possible d'effectuer une répartition sur le type de résultat de la fonction, comme dans la Readclasse de Haskell :

    class Read a where
      readsPrec :: Int -> String -> [(a, String)]
      ...
    

    Un tel envoi est clairement impossible dans OO conventionnel.

  2. Les classes de types s'étendent naturellement à plusieurs envois, simplement en fournissant plusieurs paramètres:

    class Mul a b c where
      (*) :: a -> b -> c
    
    instance Mul Int Int Int where ...
    instance Mul Int Vec Vec where ...
    instance Mul Vec Vec Int where ...
    
  3. Les définitions d'instance sont indépendantes des définitions de classe et de type, ce qui les rend plus modulaires. Un type T du module A peut être ajouté à une classe C du module M2 sans modifier la définition de l'une ou de l'autre, simplement en fournissant une instance dans le module M3. En mode OO, cela nécessite des fonctionnalités de langage plus ésotériques (et moins OO-ish) telles que les méthodes d'extension.

  4. Les classes de types sont basées sur le polymorphisme paramétrique et non sur le sous-typage. Cela permet une saisie plus précise. Considérons par exemple

    pick :: Enum a => a -> a -> a
    pick x y = if fromEnum x == 0 then y else x
    

    contre.

    pick(x : Enum, y : Enum) : Enum = if x.fromEnum() == 0 then y else x
    

    Dans le premier cas, application pick '\0' 'x'a un type Char, alors que dans le dernier cas, tout ce que vous saurez sur le résultat serait qu'il s'agit d'un Enum. (C'est également la raison pour laquelle la plupart des langages orientés objet intègrent aujourd'hui un polymorphisme paramétrique.)

  5. La question des méthodes binaires est étroitement liée. Ils sont complètement naturels avec les classes de types:

    class Ord a where
      (<) :: a -> a -> Bool
      ...
    
    min :: Ord a => a -> a -> a
    min x y = if x < y then x else y
    

    Avec le sous-typage seul, l' Ordinterface est impossible à exprimer. Vous avez besoin d'une forme plus compliquée, récursive ou d'un polymorphisme paramétrique appelé "quantification liée à F" pour le faire avec précision. Comparez Java Comparableet son utilisation:

    interface Comparable<T> {
      int compareTo(T y);
    };
    
    <T extends Comparable<T>> T min(T x, T y) {
      if (x.compareTo(y) < 0)
        return x;
      else
        return y;
    }
    

D'autre part, les interfaces basées sur les sous- List<C>types permettent naturellement la formation de collections hétérogènes. Par exemple, une liste de types peut contenir des membres ayant différents sous-types de C(bien qu'il ne soit pas possible de récupérer leur type exact, sauf en utilisant des transferts en aval). Pour faire la même chose en fonction des classes de types, vous avez besoin de types existentiels en tant que fonctionnalité supplémentaire.

Andreas Rossberg
la source
Ah, ça a beaucoup de sens. La répartition type / valeur est probablement la grande chose à laquelle je ne pensais pas correctement. La question du polymorphisme paramétrique et d'un typage plus spécifique est logique. Je venais de rassembler dans ma tête ces interfaces et celles basées sur le sous-typage (apparemment, je pense en Java: - /).
oconnor0
Les types existentiels s'apparentent-ils à la création de sous-types Csans la présence de downscasts?
oconnor0
Genre de. Ils sont un moyen de rendre un type abstrait, c'est-à-dire de cacher sa représentation. En Haskell, si vous y attachez également des contraintes de classe, vous pouvez toujours utiliser les méthodes de ces classes, mais rien d'autre. - Les diffusions à la baisse sont en réalité une caractéristique distincte du sous-typage et de la quantification existentielle et pourraient, en principe, être ajoutées en présence de celle-ci également. Tout comme il existe des langues OO qui ne le fournissent pas.
Andreas Rossberg
PS: FWIW, les caractères génériques en Java sont des types existentiels, bien que plutôt limités et ad hoc (ce qui peut expliquer en partie la raison pour laquelle ils sont quelque peu déroutants).
Andreas Rossberg
1
@didierc, cela serait limité aux cas pouvant être entièrement résolus statiquement. De plus, pour faire correspondre les classes de types, il faudrait une forme de résolution de surcharge capable de faire la distinction en fonction du type de retour uniquement (voir le point 1).
Andreas Rossberg
6

En plus de l'excellente réponse d'Andreas, n'oubliez pas que les classes de types sont conçues pour simplifier la surcharge , ce qui affecte l'espace de noms global. Il n'y a pas de surcharge dans Haskell autre que ce que vous pouvez obtenir via les classes de types. En revanche, lorsque vous utilisez des interfaces d'objet, seules les fonctions déclarées comme prenant des arguments de cette interface devront se soucier des noms de fonction dans cette interface. Ainsi, les interfaces fournissent des espaces de noms locaux.

Par exemple, vous aviez fmapune interface d'objet appelée "Functor". Il serait parfaitement correct d'en avoir une autre fmapdans une autre interface, par exemple "Structor". Chaque objet (ou classe) peut choisir l'interface à implémenter. En revanche, en Haskell, vous ne pouvez en avoir qu’un fmapdans un contexte particulier. Vous ne pouvez pas importer les classes de type Functor et Structor dans le même contexte.

Les interfaces d'objet sont plus similaires aux signatures ML standard qu'aux classes de types.

Uday Reddy
la source
Et pourtant, il semble exister une relation étroite entre les modules ML et les classes de type Haskell. cse.unsw.edu.au/~chak/papers/DHC07.html
Steven Shaw
1

Dans votre exemple concret (avec la classe de type Functor), les implémentations de Haskell et de Java se comportent différemment. Imaginez que vous ayez peut-être le type de données et que vous souhaitiez qu'il s'agisse de Functor (il s'agit d'un type de données très populaire dans Haskell, que vous pouvez également facilement implémenter en Java). Dans votre exemple Java, vous allez obliger la classe Maybe à implémenter votre interface Functor. Vous pouvez donc écrire ce qui suit (juste du pseudo-code, car j’ai un contexte c # seulement):

Maybe<Int> val = new Maybe<Int>(5);
Functor<Int> res = val.fmap(someFunctionHere);

Notez que le restype a Functor, pas peut-être. Cela rend donc l'implémentation Java presque inutilisable, car vous perdez des informations de type concrètes et devez effectuer des transtypages. (au moins, j’ai échoué à écrire une telle implémentation où les types étaient toujours présents). Avec les classes de type Haskell, vous obtiendrez peut-être Inters.

Struhtanov
la source
Je pense que ce problème est dû au fait que Java ne prend pas en charge les types de type supérieur et n’est pas lié à la discussion sur les interfaces de classes de types. Si Java avait des types plus élevés, alors fmap pourrait très bien renvoyer un Maybe<Int>.
dcastro