web-dev-qa-db-fra.com

Quand les types supérieurs sont-ils utiles?

Je fais du dev en F # depuis un moment et j'aime ça. Cependant, un mot à la mode que je connais n'existe pas en F # est les types de type supérieur. J'ai lu des documents sur les types de type supérieur, et je pense que je comprends leur définition. Je ne sais tout simplement pas pourquoi ils sont utiles. Quelqu'un peut-il fournir des exemples de ce que les types de type supérieur facilitent dans Scala ou Haskell, qui nécessitent des solutions de contournement en F #? Aussi pour ces exemples, quelles seraient les solutions de contournement sans types de type supérieur (ou vice-versa en F #)? Peut-être que je suis tellement habitué à travailler autour de lui que je ne remarque pas l'absence de cette fonctionnalité.

(Je pense) que j'obtiens cela au lieu de myList |> List.map f ou myList |> Seq.map f |> Seq.toList les types plus élevés vous permettent d'écrire simplement myList |> map f et il renverra un List. C'est super (en supposant que c'est correct), mais cela semble un peu mesquin? (Et cela ne pourrait-il pas être fait simplement en autorisant la surcharge de fonctions?) Je convertis généralement en Seq de toute façon, puis je peux convertir en ce que je veux par la suite. Encore une fois, je suis peut-être trop habitué à travailler dessus. Mais y a-t-il un exemple où les types de type supérieur vraiment vous sauvent soit en frappant les touches soit en sécurité de type?

83
lobsterism

Le type d'un type est donc son type simple. Par exemple, Int a kind * Ce qui signifie que c'est un type de base et peut être instancié par des valeurs. Par une définition vague de type de type supérieur (et je ne sais pas où F # trace la ligne, alors incluons-le simplement) conteneurs polymorphes sont un excellent exemple d'un type de type supérieur.

data List a = Cons a (List a) | Nil

Le constructeur de type List a le type * -> * Ce qui signifie qu'il faut lui passer un type concret pour aboutir à un type concret: List Int Peut avoir des habitants comme [1,2,3] mais List lui-même ne peut pas.

Je vais supposer que les avantages des conteneurs polymorphes sont évidents, mais il existe des types de type * -> * Plus utiles que les conteneurs. Par exemple, les relations

data Rel a = Rel (a -> a -> Bool)

ou analyseurs

data Parser a = Parser (String -> [(a, String)])

les deux ont également un type * -> *.


Nous pouvons aller plus loin dans Haskell, cependant, en ayant des types avec des types encore plus élevés. Par exemple, nous pourrions rechercher un type avec kind (* -> *) -> *. Un exemple simple de ceci pourrait être Shape qui essaie de remplir un conteneur de type * -> *.

data Shape f = Shape (f ())

[(), (), ()] :: Shape List

Ceci est utile pour caractériser Traversables dans Haskell, par exemple, car ils peuvent toujours être divisés en leur forme et leur contenu.

split :: Traversable t => t a -> (Shape t, [a])

Comme autre exemple, considérons un arbre qui est paramétré sur le type de branche qu'il a. Par exemple, un arbre normal peut être

data Tree a = Branch (Tree a) a (Tree a) | Leaf

Mais nous pouvons voir que le type de branche contient un Pair de Tree a S et nous pouvons donc extraire cette pièce du type de façon paramétrique

data TreeG f a = Branch a (f (TreeG f a)) | Leaf

data Pair a = Pair a a
type Tree a = TreeG Pair a

Ce constructeur de type TreeG a le type (* -> *) -> * -> *. Nous pouvons l'utiliser pour faire d'autres variations intéressantes comme un RoseTree

type RoseTree a = TreeG [] a

rose :: RoseTree Int
rose = Branch 3 [Branch 2 [Leaf, Leaf], Leaf, Branch 4 [Branch 4 []]]

Ou pathologiques comme un MaybeTree

data Empty a = Empty
type MaybeTree a = TreeG Empty a

nothing :: MaybeTree a
nothing = Leaf

just :: a -> MaybeTree a
just a = Branch a Empty

Ou un TreeTree

type TreeTree a = TreeG Tree a

treetree :: TreeTree Int
treetree = Branch 3 (Branch Leaf (Pair Leaf Leaf))

Un autre endroit où cela apparaît est dans les "algèbres de foncteurs". Si nous laissons tomber quelques couches d'abstrait, cela pourrait être mieux considéré comme un pli, comme sum :: [Int] -> Int. Les algèbres sont paramétrées sur functor et carrier. Le functor a le type * -> * Et le type de support * Donc tout à fait

data Alg f a = Alg (f a -> a)

a le type (* -> *) -> * -> *. Alg utile en raison de sa relation avec les types de données et les schémas de récursion construits au-dessus d'eux.

-- | The "single-layer of an expression" functor has kind `(* -> *)`
data ExpF x = Lit Int
            | Add x x
            | Sub x x
            | Mult x x

-- | The fixed point of a functor has kind `(* -> *) -> *`
data Fix f = Fix (f (Fix f))

type Exp = Fix ExpF

exp :: Exp
exp = Fix (Add (Fix (Lit 3)) (Fix (Lit 4))) -- 3 + 4

fold :: Functor f => Alg f a -> Fix f -> a
fold (Alg phi) (Fix f) = phi (fmap (fold (Alg phi)) f)

Enfin, bien qu'ils soient théoriquement possibles, je n'ai jamais vu de constructeur de type même de type supérieur. Nous voyons parfois des fonctions de ce type telles que mask :: ((forall a. IO a -> IO a) -> IO b) -> IO b, mais je pense que vous devrez creuser dans le type prologue ou la littérature typée de manière dépendante pour voir ce niveau de complexité dans les types.

77
J. Abrahamson

Considérez la classe de type Functor dans Haskell, où f est une variable de type de type supérieur:

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

Ce que cette signature de type indique, c'est que fmap change le paramètre de type d'un f de a en b, mais laisse f tel quel. Donc, si vous utilisez fmap sur une liste, vous obtenez une liste, si vous l'utilisez sur un analyseur, vous obtenez un analyseur, et ainsi de suite. Et ce sont statiques , des garanties de compilation.

Je ne connais pas F #, mais considérons ce qui se passe si nous essayons d'exprimer l'abstraction Functor dans un langage comme Java ou C #, avec héritage et génériques, mais pas plus haut génériques. Essayez d'abord:

interface Functor<A> {
    Functor<B> map(Function<A, B> f);
}

Le problème avec ce premier essai est qu'une implémentation de l'interface est autorisée à renvoyer toute classe qui implémente Functor. Quelqu'un pourrait écrire un FunnyList<A> implements Functor<A> dont la méthode map renvoie un autre type de collection, ou même quelque chose d'autre qui n'est pas du tout une collection mais qui est toujours un Functor. De plus, lorsque vous utilisez la méthode map, vous ne pouvez invoquer aucune méthode spécifique au sous-type sur le résultat, sauf si vous le convertissez au type que vous attendez réellement. Nous avons donc deux problèmes:

  1. Le système de types ne nous permet pas d'exprimer l'invariant selon lequel la méthode map renvoie toujours la même sous-classe Functor que le récepteur.
  2. Par conséquent, il n'existe aucun moyen statiquement sûr pour appeler une méthode nonFunctor sur le résultat de map.

Il existe d'autres façons plus compliquées que vous pouvez essayer, mais aucune ne fonctionne vraiment. Par exemple, vous pouvez essayer d'augmenter le premier essai en définissant des sous-types de Functor qui restreignent le type de résultat:

interface Collection<A> extends Functor<A> {
    Collection<B> map(Function<A, B> f);
}

interface List<A> extends Collection<A> {
    List<B> map(Function<A, B> f);
}

interface Set<A> extends Collection<A> {
    Set<B> map(Function<A, B> f);
}

interface Parser<A> extends Functor<A> {
    Parser<B> map(Function<A, B> f);
}

// …

Cela aide à interdire aux implémenteurs de ces interfaces plus étroites de renvoyer le mauvais type de Functor à partir de la méthode map, mais puisqu'il n'y a pas de limite au nombre d'implémentations Functor que vous pouvez avoir , il n'y a pas de limite au nombre d'interfaces plus étroites dont vous aurez besoin.

(EDIT: Et notez que cela ne fonctionne que parce que Functor<B> apparaît comme type de résultat et les interfaces enfants peuvent donc le restreindre. Donc, AFAIK, nous ne pouvons pas restreindre les deux utilisations de Monad<B> dans l'interface suivante:

interface Monad<A> {
    <B> Monad<B> flatMap(Function<? super A, ? extends Monad<? extends B>> f);
}

Dans Haskell, avec des variables de type de rang supérieur, il s'agit de (>>=) :: Monad m => m a -> (a -> m b) -> m b.)

Un autre essai consiste à utiliser des génériques récursifs pour essayer de faire en sorte que l'interface limite le type de résultat du sous-type au sous-type lui-même. Exemple de jouet:

/**
 * A semigroup is a type with a binary associative operation.  Law:
 *
 * > x.append(y).append(z) = x.append(y.append(z))
 */
interface Semigroup<T extends Semigroup<T>> {
    T append(T arg);
}

class Foo implements Semigroup<Foo> {
    // Since this implements Semigroup<Foo>, now this method must accept 
    // a Foo argument and return a Foo result. 
    Foo append(Foo arg);
}

class Bar implements Semigroup<Bar> {
    // Any of these is a compilation error:

    Semigroup<Bar> append(Semigroup<Bar> arg);

    Semigroup<Foo> append(Bar arg);

    Semigroup append(Bar arg);

    Foo append(Bar arg);

}

Mais ce type de technique (qui est plutôt mystérieux pour votre développeur ordinaire OOP développeur, sachez aussi pour votre développeur fonctionnel ordinaire)) ne peut toujours pas exprimer la contrainte Functor souhaitée soit:

interface Functor<FA extends Functor<FA, A>, A> {
    <FB extends Functor<FB, B>, B> FB map(Function<A, B> f);
}

Le problème ici est que cela ne limite pas FB à avoir le même F que FA— de sorte que lorsque vous déclarez un type List<A> implements Functor<List<A>, A>, la méthode map peut toujours retourner un NotAList<B> implements Functor<NotAList<B>, B>.

Essai final, en Java, en utilisant des types bruts (conteneurs non paramétrés):

interface FunctorStrategy<F> {
    F map(Function f, F arg);
} 

Ici, F sera instancié vers des types non paramétrés comme simplement List ou Map. Cela garantit qu'un FunctorStrategy<List> ne peut renvoyer qu'un List— mais vous avez abandonné l'utilisation des variables de type pour suivre les types d'élément des listes.

Le cœur du problème ici est que les langages comme Java et C # ne permettent pas aux paramètres de type d'avoir des paramètres. En Java, si T est une variable de type, vous pouvez écrire T et List<T>, mais non T<String>. Les types de type supérieur suppriment cette restriction, de sorte que vous puissiez avoir quelque chose comme ça (pas complètement pensé):

interface Functor<F, A> {
    <B> F<B> map(Function<A, B> f);
}

class List<A> implements Functor<List, A> {

    // Since F := List, F<B> := List<B>
    <B> List<B> map(Function<A, B> f) {
        // ...
    }

}

Et en abordant ce bit en particulier:

(Je pense) que j'obtiens cela au lieu de myList |> List.map f ou myList |> Seq.map f |> Seq.toList les types plus élevés vous permettent d'écrire simplement myList |> map f et il renverra un List. C'est super (en supposant que c'est correct), mais cela semble un peu mesquin? (Et cela ne pourrait-il pas être fait simplement en autorisant la surcharge de fonctions?) Je convertis généralement en Seq de toute façon, puis je peux convertir en ce que je veux par la suite.

Il existe de nombreux langages qui généralisent ainsi l'idée de la fonction map, en la modélisant comme si, au fond, le mappage concernait les séquences. Cette remarque est dans cet esprit: si vous avez un type qui prend en charge la conversion vers et depuis Seq, vous obtenez l'opération de carte "gratuitement" en réutilisant Seq.map.

Dans Haskell, cependant, la classe Functor est plus générale que cela; ce n'est pas lié à la notion de séquences. Vous pouvez implémenter fmap pour les types qui n'ont pas de bonne correspondance avec les séquences, comme les actions IO, les combinateurs d'analyseurs, les fonctions, etc.:

instance Functor IO where
    fmap f action =
        do x <- action
           return (f x)

 -- This declaration is just to make things easier to read for non-Haskellers 
newtype Function a b = Function (a -> b)

instance Functor (Function a) where
    fmap f (Function g) = Function (f . g)  -- `.` is function composition

Le concept de "mappage" n'est vraiment pas lié aux séquences. Il est préférable de comprendre les lois du foncteur:

(1) fmap id xs == xs
(2) fmap f (fmap g xs) = fmap (f . g) xs

De manière très informelle:

  1. La première loi dit que le mappage avec une fonction identité/noop équivaut à ne rien faire.
  2. La deuxième loi dit que tout résultat que vous pouvez produire en mappant deux fois, vous pouvez également le produire en mappant une fois.

C'est pourquoi vous voulez que fmap conserve le type — car dès que vous obtenez des opérations map qui produisent un type de résultat différent, il devient beaucoup, beaucoup plus difficile de faire des garanties comme celle-ci.

62
Luis Casillas

Je ne veux pas répéter des informations dans d'excellentes réponses déjà ici, mais il y a un point clé que j'aimerais ajouter.

Vous n'avez généralement pas besoin de types de type supérieur pour implémenter une monade ou un foncteur particulier (ou un foncteur applicatif, ou une flèche, ou ...). Mais ce faisant, il manque surtout le point.

En général, j'ai constaté que lorsque les gens ne voient pas l'utilité des foncteurs/monades/whatevers, c'est souvent parce qu'ils pensent à ces choses une à la fois . Les opérations Functor/monad/etc n'ajoutent vraiment rien à une seule instance (au lieu d'appeler bind, fmap, etc. je pourrais simplement appeler les opérations que j'avais l'habitude de implémenter bind, fmap, etc.). Ce que vous voulez vraiment pour ces abstractions, c'est que vous puissiez avoir du code qui fonctionne de manière générique avec any functor/monad/etc.

Dans un contexte où un tel code générique est largement utilisé, cela signifie que chaque fois que vous écrivez une nouvelle instance de monade, votre type accède immédiatement à un grand nombre d'opérations utiles qui ont déjà été écrites pour vous. C'est le point de voir des monades (et des foncteurs, et ...) partout; non pas pour pouvoir utiliser bind plutôt que concat et map pour implémenter myFunkyListOperation (ce qui ne me rapporte rien en soi), mais plutôt que lorsque vient d'avoir besoin de myFunkyParserOperation et myFunkyIOOperation Je peux réutiliser le code que j'ai vu à l'origine en termes de listes car il est en fait générique monade.

Mais pour abstraire un type paramétré comme une monade avec la sécurité de type , vous avez besoin de types de type supérieur (comme expliqué dans d'autres réponses ici).

27
Ben

Pour une perspective plus spécifique à .NET, j'ai écrit un blog post à ce sujet il y a quelque temps. Le nœud du problème est que, avec des types de type supérieur, vous pouvez potentiellement réutiliser les mêmes blocs LINQ entre IEnumerables et IObservables, mais sans types de type supérieur, cela est impossible.

Le plus proche que vous pourriez obtenir (je l'ai compris après avoir posté le blog) est de créer votre propre IEnumerable<T> et IObservable<T> et les a étendus à partir d'un IMonad<T>. Cela vous permettrait de réutiliser vos blocs LINQ s'ils sont notés IMonad<T>, mais ce n'est plus sûr car il vous permet de mélanger et de faire correspondre IObservables et IEnumerables dans le même bloc, ce qui peut sembler intrigant pour l'activer, mais vous obtenir simplement un comportement indéfini.

J'ai écrit un post plus tard sur la façon dont Haskell rend cela facile. (Un no-op, vraiment - restreindre un bloc à un certain type de monade nécessite du code; activer la réutilisation est la valeur par défaut).

15
Dax Fohl

L'exemple le plus utilisé de polymorphisme de type supérieur dans Haskell est l'interface Monad. Functor et Applicative sont de type supérieur de la même manière, donc je vais montrer Functor afin d'afficher quelque chose de concis.

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

Examinons maintenant cette définition, en examinant comment la variable de type f est utilisée. Vous verrez que f ne peut pas signifier un type qui a une valeur. Vous pouvez identifier des valeurs dans cette signature de type car ce sont des arguments et des résultats d'une fonction. Ainsi, les variables de type a et b sont des types qui peuvent avoir des valeurs. Les expressions de type sont également f a et f b. Mais pas f lui-même. f est un exemple de variable de type supérieur. Étant donné que * est le type de types qui peuvent avoir des valeurs, f doit avoir le type * -> *. Autrement dit, il prend un type qui peut avoir des valeurs, car nous savons d'après l'examen précédent que a et b doivent avoir des valeurs. Et nous savons aussi que f a et f b doit avoir des valeurs, il renvoie donc un type qui doit avoir des valeurs.

Cela fait du f utilisé dans la définition de Functor une variable de type supérieur.

Les interfaces Applicative et Monad en ajoutent plus, mais elles sont compatibles. Cela signifie qu'ils travaillent sur des variables de type avec kind * -> * ainsi que.

Travailler sur des types de type supérieur introduit un niveau d'abstraction supplémentaire - vous n'êtes pas limité à la création d'abstractions sur des types de base. Vous pouvez également créer des abstractions sur des types qui modifient d'autres types.

13
Carl