Oui, une question très simple à première vue. Mais si vous prenez le temps d'y réfléchir jusqu'à la fin, vous entrez dans les profondeurs de la théorie des types incommensurable. Et la théorie des types vous regarde également.
Tout d'abord, bien sûr, vous avez déjà correctement compris que F # n'a pas de classes de type, et c'est pourquoi. Mais vous proposez une interface Mappable
. Ok, regardons ça.
Disons que nous pouvons déclarer une telle interface. Pouvez-vous imaginer à quoi ressemblerait sa signature?
type Mappable =
abstract member map : ('a -> 'b) -> 'f<'a> -> 'f<'b>
Où f
est le type implémentant l'interface. Oh, attendez! F # n'a pas ça non plus! Voici f
une variable de type de type supérieur, et F # n'a pas du tout de type supérieur. Il n'y a aucun moyen de déclarer une fonction f : 'm<'a> -> 'm<'b>
ou quelque chose comme ça.
Mais ok, disons que nous avons aussi surmonté cet obstacle. Et maintenant , nous avons une interface Mappable
qui peut être mis en œuvre par List
, Array
, Seq
et l'évier de la cuisine. Mais attendez! Nous avons maintenant une méthode au lieu d'une fonction, et les méthodes ne se composent pas bien! Regardons l'ajout de 42 à chaque élément d'une liste imbriquée:
// Good ol' functions:
add42 nestedList = nestedList |> List.map (List.map ((+) 42))
// Using an interface:
add42 nestedList = nestedList.map (fun l -> l.map ((+) 42))
Regardez: maintenant nous devons utiliser une expression lambda! Il n'existe aucun moyen de passer cette .map
implémentation à une autre fonction en tant que valeur. Effectivement, la fin des "fonctions en tant que valeurs" (et oui, je sais, utiliser un lambda n'a pas l'air très mal dans cet exemple, mais croyez-moi, ça devient très moche)
Mais attendez, nous n'avons toujours pas fini. Maintenant qu'il s'agit d'un appel de méthode, l'inférence de type ne fonctionne pas! Étant donné que la signature de type d'une méthode .NET dépend du type de l'objet, le compilateur n'a aucun moyen d'inférer les deux. Il s'agit en fait d'un problème très courant pour les débutants lors de l'interopérabilité avec les bibliothèques .NET. Et le seul remède est de fournir une signature de type:
add42 (nestedList : #Mappable) = nestedList.map (fun l -> l.map ((+) 42))
Oh, mais ce n'est pas encore suffisant! Même si j'ai fourni une signature pour nestedList
lui-même, je n'ai pas fourni de signature pour le paramètre lambda l
. Quelle devrait être cette signature? Diriez-vous que cela devrait l'être fun (l: #Mappable) -> ...
? Oh, et maintenant nous sommes finalement arrivés aux types de rang N, comme vous le voyez, #Mappable
est un raccourci pour "tout type 'a
tel que 'a :> Mappable
" - c'est-à-dire une expression lambda qui est elle-même générique.
Ou, alternativement, nous pourrions revenir à la bienveillance supérieure et déclarer le type de nestedList
plus précisément:
add42 (nestedList : 'f<'a<'b>> where 'f :> Mappable, 'a :> Mappable) = ...
Mais ok, mettons de côté l'inférence de type pour l'instant et revenons à l'expression lambda et à la façon dont nous ne pouvons plus passer en map
tant que valeur à une autre fonction. Disons que nous étendons un peu la syntaxe pour permettre quelque chose comme ce que fait Elm avec les champs d'enregistrement:
add42 nestedList = nestedList.map (.map ((+) 42))
Quel serait le type .map
? Il faudrait que ce soit un type contraint , comme dans Haskell!
.map : Mappable 'f => ('a -> 'b) -> 'f<'a> -> 'f<'b>
Wow OK. Mis à part le fait que .NET ne permet même pas à de tels types d'exister, nous venons de récupérer les classes de types!
Mais il y a une raison pour laquelle F # n'a pas de classes de type en premier lieu. De nombreux aspects de cette raison sont décrits ci-dessus, mais une façon plus concise de le dire est: la simplicité .
Pour vous voyez, c'est une pelote de laine. Une fois que vous avez des classes de type, vous devez avoir des contraintes, une plus grande gentillesse, un rang N (ou au moins un rang 2), et avant de le savoir, vous demandez des types imprédicatifs, des fonctions de type, des GADT et tous les reste.
Mais Haskell paie un prix pour tous les cadeaux. Il s'avère qu'il n'y a aucun bon moyen de déduire tout cela. Les types de type supérieur fonctionnent, mais les contraintes ne fonctionnent pas déjà. Rang-N - n'en rêve même pas. Et même lorsque cela fonctionne, vous obtenez des erreurs de type que vous devez avoir un doctorat pour comprendre. Et c'est pourquoi dans Haskell, vous êtes gentiment encouragé à mettre des signatures de type sur tout. Eh bien, pas tout - tout , mais vraiment presque tout. Et là où vous ne mettez pas de signatures de type (par exemple à l'intérieur let
et where
) - surprise-surprise, ces endroits sont en fait monomorphisés, vous êtes donc essentiellement de retour dans le pays F # simpliste.
En F #, en revanche, les signatures de type sont rares, principalement pour la documentation ou pour l'interopérabilité .NET. En dehors de ces deux cas, vous pouvez écrire un gros programme complexe en F # et ne pas utiliser une seule fois une signature de type. L'inférence de type fonctionne correctement, car il n'y a rien de trop complexe ou ambigu pour qu'elle puisse être gérée.
Et c'est le gros avantage de F # sur Haskell. Oui, Haskell vous permet d'exprimer des choses super complexes d'une manière très précise, c'est bien. Mais F # vous permet d'être très délirant, presque comme Python ou Ruby, et toujours avoir le compilateur vous attraper si vous trébuchez.