Du point de vue d'un programmeur, l'essence de la notion de foncteur est de pouvoir facilement adapter choses. Ce que j'entends par "adapter" ici, c'est que si j'ai une f a
et j'ai besoin d'un f b
Je voudrais un adaptateur qui s'adapterait à mon f a
dans mon f b
-trou en forme d'étoile.
Il semble intuitif que si je peux transformer un a
en un b
que je puisse être capable de transformer un f a
dans un f b
. Et en effet, c'est le modèle que le langage Haskell Functor
si je fournis une classe a -> b
fonction alors fmap
me permet de m'adapter f a
des choses dans f b
les choses, sans se soucier de ce que f
implique. 1
Bien sûr, en parlant de types paramétrés comme list-of-x [x]
, Maybe y
ou IO z
ici, et ce que nous pouvons changer avec nos adaptateurs, c'est le x
, y
ou z
dans celles-ci. Si nous voulons avoir la flexibilité d'obtenir un adaptateur de n'importe quelle fonction possible a -> b
alors bien sûr, la chose que nous adaptons doit être également applicable à tous les types possibles.
Ce qui est moins intuitif (au début), c'est qu'il y a des types qui peuvent être adaptés presque exactement de la même manière que les types fonctionnels, sauf qu'ils sont "à l'envers" ; pour ceux-ci, si nous voulons adapter un type f a
pour répondre au besoin d'un f b
nous devons en fait fournir un b -> a
et non une fonction a -> b
un !
Mon exemple concret préféré est en fait le type de fonction a -> r
(a pour argument, r pour résultat) ; tout ce non-sens abstrait prend tout son sens lorsqu'il est appliqué aux fonctions (et si vous avez fait un peu de programmation, vous avez certainement utilisé ces concepts sans en connaître la terminologie ou sans savoir à quel point ils sont largement appliqués), et les deux notions sont si manifestement duales l'une de l'autre dans ce contexte.
Il est assez bien connu que a -> r
est un foncteur dans r
. Cela a du sens ; si j'ai un a -> r
et j'ai besoin d'un a -> s
alors je pourrais utiliser un r -> s
pour adapter ma fonction originale en post-traitant simplement le résultat. 2
Si, d'un autre côté, j'ai une a -> r
et ce dont j'ai besoin est une fonction b -> r
Mais là encore, il est clair que je peux répondre à mon besoin en prétraitant les arguments avant de les transmettre à la fonction originale. Mais avec quoi dois-je les prétraiter ? La fonction d'origine est une boîte noire ; quoi que je fasse, elle s'attend toujours à ce que a
entrées. Donc je dois tourner mon b
dans le a
qu'il attend : mon adaptateur de prétraitement a besoin d'une valeur de b -> a
fonction.
Ce que nous venons de voir, c'est que le type de fonction a -> r
est un covariant dans r
et un contravariant dans a
. Je pense que cela signifie que nous pouvons adapter le résultat d'une fonction, et que le type de résultat "change avec" l'adaptateur. r -> s
alors que lorsque nous adaptons l'argument d'une fonction, le type de l'argument change "dans la direction opposée" à l'adaptateur.
Il est intéressant de noter que l'implémentation de la fonction-résultat fmap
et l'argument-fonction contramap
sont presque exactement la même chose : juste la composition de fonctions (le .
opérateur) ! La seule différence est de savoir de quel côté vous composez la fonction adaptateur : 3
fmap :: (r -> s) -> (a -> r) -> (a -> s)
fmap adaptor f = adaptor . f
fmap adaptor = (adaptor .)
fmap = (.)
contramap' :: (b -> a) -> (a -> r) -> (b -> r)
contramap' adaptor f = f . adaptor
contramap' adaptor = (. adaptor)
contramap' = flip (.)
Je considère que la deuxième définition de chaque bloc est la plus perspicace ; le mappage (covariant) sur le résultat d'une fonction est une composition à gauche (post-composition si nous voulons adopter un point de vue "this-happens-after-that"), tandis que le mappage contravariant sur l'argument d'une fonction est une composition à droite (pré-composition).
Cette intuition se généralise assez bien ; si une f x
structure peut donnez-nous valeurs de type x
(tout comme un a -> r
nous donne r
du moins potentiellement), il pourrait s'agir d'une valeur covariante. Functor
en x
et nous pourrions utiliser un x -> y
pour l'adapter en tant que fonction f y
. Mais si un f x
structure reçoit valeurs de type x
de nous (encore une fois, comme un a -> r
l'argument de la fonction de type a
), alors il pourrait s'agir d'un Contravariant
et nous devrions utiliser un foncteur y -> x
pour l'adapter à la fonction f y
.
Je trouve intéressant de réfléchir au fait que cette intuition "les sources sont covariantes, les destinations sont contravariantes" s'inverse lorsque l'on se place du point de vue d'une exécutant de la source/destination plutôt qu'un appelant. Si j'essaie de mettre en œuvre un f x
qui reçoit x
valeurs que je peux "adapter ma propre interface" afin de pouvoir travailler avec y
à la place (tout en présentant toujours le message "reçoit x
valeurs " à mes interlocuteurs) en utilisant une interface x -> y
fonction. Habituellement, nous ne pensons pas de cette manière ; même en tant qu'implémenteur de la fonction f x
Je pense à adapter les choses que j'appelle plutôt qu'à "adapter l'interface de mon appelant à moi". Mais c'est une autre perspective que vous pouvez adopter.
La seule utilisation dans le monde semi-réel que j'ai fait de Contravariant
(par opposition à l'utilisation implicite de la contravariance des fonctions dans leurs arguments en utilisant la composition à droite, ce qui est très courant) était pour un type Serialiser a
qui pourrait sérialiser x
valeurs. Serialiser
devait être un Contravariant
plutôt qu'un Functor
; étant donné que je peux sérialiser les Foos, je peux aussi sérialiser les Bars si je peux Bar -> Foo
. 4 Mais quand vous réalisez que Serialiser a
est en fait a -> ByteString
cela devient évident ; je ne fais que répéter un cas particulier de l'approche de la a -> r
exemple.
Dans la programmation fonctionnelle pure, il n'est pas très utile d'avoir quelque chose qui "reçoit des valeurs" sans qu'il ne donne également quelque chose en retour, de sorte que tous les foncteurs contravariants ont tendance à ressembler à des fonctions, mais presque toute structure de données simple qui peut contenir des valeurs d'un type arbitraire sera un foncteur covariant dans ce paramètre de type. C'est pourquoi Functor
a volé le bon nom très tôt et est utilisé partout (enfin, cela et cela Functor
a été reconnu comme un élément fondamental de la Monad
qui était déjà largement utilisé avant Functor
a été défini comme une classe en Haskell).
Dans le domaine de l'OO impératif, je pense que les foncteurs contravariants sont beaucoup plus courants (mais ils ne sont pas abstraits dans un cadre unifié comme celui de l'OO impératif). Contravariant
), bien qu'il soit également très facile de faire en sorte que la mutabilité et les effets secondaires signifient qu'un type paramétré ne peut tout simplement pas être un foncteur (communément : votre conteneur standard de a
qui est à la fois lisible et inscriptible est à la fois un émetteur et un récepteur de a
et plutôt que de signifier qu'il est à la fois covariant et contravariant, il s'avère que cela signifie qu'il n'est ni l'un ni l'autre).
1 Le site Functor
instance de chaque individu f
dit comment appliquer des fonctions arbitraires à la forme particulière de cette f
sans se soucier des types particuliers f
est appliqué ; une belle séparation des préoccupations.
2 Ce foncteur est également une monade, équivalente à la fonction Reader
monad. Je ne vais pas m'étendre sur les foncteurs en détail ici, mais étant donné le reste de mon post, une question évidente serait "est-ce que le a -> r
type également une sorte de monade contravariante en a
alors ?". Malheureusement, la contravariance ne s'applique pas aux monades (cf. Existe-t-il des monades contravariantes ? ), mais il existe un analogue contravariant de Applicative
: https://hackage.haskell.org/package/contravariant-1.4/docs/Data-Functor-Contravariant-Divisible.html
3 Notez que mon contramap'
ici ne correspond pas à l'actuel contramap
de Contravariant
telle qu'elle est implémentée en Haskell ; vous ne pouvez pas faire de la a -> r
une instance réelle de Contravariant
dans le code Haskell simplement parce que le a
n'est pas le dernier paramètre de type de (->)
. Conceptuellement cela fonctionne parfaitement bien, et vous pouvez toujours utiliser un wrapper newtype pour échanger les paramètres du type et en faire une instance (le contravariant définit le type Op
dans ce but précis).
4 Au moins pour une définition de "sérialiser" qui n'inclut pas nécessairement la possibilité de reconstruire la barre plus tard, puisque cela sérialiserait la barre à l'identique du Foo auquel elle correspond, sans possibilité d'inclure une quelconque information sur la nature de la correspondance.
2 votes
Premier résultat sur Google pour "contravariant functor example" : un article de blog sur le
contravariant
paquet par Tom Ellis sur le site d'Oliver Charles qui décrit à la fois un exemple trivial et un exemple plus élaboré et utile d'un foncteur contravariant.