web-dev-qa-db-fra.com

Qu'est-ce qu'un foncteur contravariant?

Le type me souffle:

class Contravariant (f :: * -> *) where
  contramap :: (a -> b) -> f b -> f a

Alors j'ai lu this , mais contrairement au titre, je n'étais plus éclairé.

Quelqu'un peut-il donner une explication de ce qu'est un foncteur contravariant et quelques exemples?

35
rityzmon

Du point de vue d'un programmeur, l'essence de la fonction est de pouvoir facilement adapter les choses. Ce que je veux dire par "adapter" ici, c'est que si j'ai un f a Et j'ai besoin d'un f b, J'aimerais un adaptateur qui s'adaptera à mon f a Dans mon f b - trou en forme.

Il semble intuitif que si je peux transformer un a en b, je pourrais peut-être transformer un f a En un f b. Et en effet, c'est le modèle qu'incarne la classe Functor de Haskell; si je fournis une fonction a -> b, fmap me permet d'adapter f a en choses f b, sans se soucier de ce que f implique.1

Bien sûr, parler de types paramétrés comme list-of-x [x], Maybe y Ou IO z Ici, et la chose que nous pouvons changer avec nos adaptateurs est le x, y ou z dans ceux-ci. Si nous voulons la flexibilité pour obtenir un adaptateur à partir de n'importe quelle fonction possible a -> b Alors bien sûr, la chose que nous adaptons doit être également applicable à tout type possible.

Ce qui est moins intuitif (au début), c'est qu'il existe certains types qui peuvent être adaptés presque exactement de la même manière que ceux fonctionnels, mais ils sont "à l'envers"; pour ceux-ci, si nous voulons adapter un f a pour combler le besoin d'un f b, nous devons en fait fournir une fonction b -> a, pas une a -> b!

Mon exemple concret préféré est en fait le type de fonction a -> r (A pour argument, r pour résultat); toutes ces absurdités abstraites ont un sens parfait lorsqu'elles sont appliquées à des fonctions (et si vous avez fait une programmation substantielle, vous avez presque certainement utilisé ces concepts sans connaître la terminologie ou leur application), et les deux notions sont si évidentes double dans ce contexte.

Il est bien connu que a -> r Est un foncteur dans r. C'est logique; si j'ai un a -> r et j'ai besoin d'un a -> s, alors je pourrais utiliser une fonction r -> s pour adapter ma fonction d'origine simplement en post-traitant le résultat.2

Si, d'autre part, j'ai une fonction a -> r Et ce dont j'ai besoin est un b -> r, Il est alors clair que je peux répondre à mon besoin en prétraitant les arguments avant de les transmettre à la fonction d'origine. Mais avec quoi les prétraiter? La fonction d'origine est une boîte noire; peu importe ce que je fais, il attend toujours des entrées a. J'ai donc besoin de transformer mes valeurs b en valeurs a qu'il attend: mon adaptateur de prétraitement a besoin d'une fonction b -> a.

Ce que nous venons de voir, c'est que le type de fonction a -> r Est un foncteur covariant dans r, et un foncteur 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, Tandis que lorsque nous adaptons l'argument d'une fonction, le type d'argument change "dans la direction opposée" à l'adaptateur .

Fait intéressant, l'implémentation de la fonction-résultat fmap et de la fonction-argument contramap sont presque exactement la même chose: juste la composition de la fonction (l'opérateur .)! La seule différence est 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 pertinente; (covariant) le mappage sur le résultat d'une fonction est la composition à gauche (post-composition si nous voulons prendre une vue "ceci-arrive-après-cela"), tandis que le mappage contravariant sur l'argument d'une fonction est la composition à droite (pré- composition).

Cette intuition se généralise assez bien; si une structure f x peut nous donner des valeurs de type x (tout comme une fonction a -> r nous donne r valeurs, au moins potentiellement), il pourrait s'agir d'une covariante Functor dans x, et nous pourrions utiliser une fonction x -> y pour l'adapter à étant un f y. Mais si une structure f x reçoit des valeurs de type x de notre part (encore une fois, comme un a -> r de la fonction] de type a), alors il pourrait s'agir d'un foncteur Contravariant et nous aurions besoin d'utiliser une fonction y -> x pour l'adapter à un f y.

Je trouve intéressant de refléter que cette "sources sont covariantes, les destinations sont contravariantes", l'intuition s'inverse lorsque vous pensez du point de vue d'un réalisateur du source/destination plutôt qu'un appelant. Si j'essaye d'implémenter un f x Qui reçoit x valeurs je peux "adapter ma propre interface" afin J'arrive à travailler avec des valeurs y à la place (tout en présentant l'interface "reçoit x valeurs" à mes appelants) en utilisant une fonction x -> y. Habituellement, nous ne pensons pas de cette façon; même en tant qu'implémenteur du 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 semi-réelle que j'ai faite 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 tapez Serialiser a qui pourrait sérialiser les valeurs x. Serialiser devait être un Contravariant plutôt qu'un Functor; étant donné que je peux sérialiser Foos, je peux également sérialiser Bars si je peux Bar -> Foo.4 Mais quand vous réalisez que Serialiser a Est fondamentalement a -> ByteString Cela devient évident; Je ne fais que répéter un cas particulier de l'exemple a -> r.

En programmation fonctionnelle pure, il n'y a pas beaucoup d'utilité à avoir quelque chose qui "reçoit des valeurs" sans qu'il donne aussi quelque chose en retour, donc tous les foncteurs contravariants ont tendance à ressembler à des fonctions, mais presque toute structure de données simple qui peut contenir des valeurs de type arbitraire le fera être un foncteur covariant dans ce paramètre de type. C'est pourquoi Functor a volé tôt le bon nom et est utilisé partout (enfin, cela et cela Functor a été reconnu comme une partie fondamentale de Monad, qui était déjà largement utilisé avant que Functor ne soit défini comme une classe dans Haskell).

En impératif OO je crois que les foncteurs contravariants peuvent être significativement plus communs (mais pas abstraits avec un cadre unifié comme Contravariant), bien qu'il soit également très facile d'avoir une mutabilité et des effets secondaires signifie qu'un type paramétré ne peut tout simplement pas être un foncteur (généralement: 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 dire que c'est à la fois covariant et contravariant, cela signifie que ce n'est ni l'un ni l'autre).


1 L'instance Functor de chaque individu f indique comment appliquer des fonctions arbitraires à la forme particulière de cette f, sans se soucier des types particuliers f est en cours appliqué à; une belle séparation des préoccupations.

2 Ce foncteur est également une monade, équivalente à la monade Reader. Je ne vais pas aller plus loin que les foncteurs en détail ici, mais étant donné le reste de mon post, une question évidente serait "est-ce que le type a -> r Est aussi une sorte de monade contravariante dans a alors? ". Malheureusement, la contravariance ne s'applique pas aux monades (voir Y a-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 au contramap réel de Contravariant tel qu'implémenté dans Haskell; vous ne pouvez pas faire de a -> r une instance réelle de Contravariant dans le code Haskell simplement parce que 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 de type et en faire une instance (la contravariante définit le Op type exactement à cette fin).

4 Au moins pour une définition de "sérialiser" qui n'inclut pas nécessairement la possibilité de reconstruire le Bar plus tard, car il sérialiserait le a Bar de manière identique au Foo auquel il a été mappé sans aucun moyen d'inclure des informations sur le mappage.

39
Ben

Tout d'abord, la réponse de @ haoformayor est excellente, alors considérez-la plus comme un addendum qu'une réponse complète.

Définition

Une façon dont j'aime penser à Functor (co/contravariant) est en termes de diagrammes. La définition se reflète dans les suivantes. (J'abrège contramap avec cmap)

      covariant                           contravariant
f a ─── fmap φ ───▶ f b             g a ◀─── cmap φ ─── g b
 ▲                   ▲               ▲                   ▲
 │                   │               │                   │
 │                   │               │                   │
 a ────── φ ───────▶ b               a ─────── φ ──────▶ b

Remarque: le seul changement dans ces deux définitions est la flèche en haut (enfin, et les noms pour que je puisse les appeler des choses différentes).

Exemple

L'exemple que j'ai toujours en tête quand je parle de ces fonctions - et puis un exemple de f serait type F a = forall r. r -> a (ce qui signifie que le premier argument est arbitraire mais fixe r), ou en d'autres termes toutes les fonctions avec une entrée commune. Comme toujours, l'instance de (covariant) Functor est juste fmap ψ φ = ψ. φ`.

Où la (contravariante) Functor est toutes les fonctions avec un résultat commun - type G a = forall r. a -> r ici l'instance Contravariant serait cmap ψ φ = φ . ψ.

Mais qu'est-ce que ça signifie

φ :: a -> b et ψ :: b -> c

généralement donc (ψ . φ) x = ψ (φ x) ou x ↦ y = φ x et y ↦ ψ y est logique, ce qui est omis dans l'instruction pour cmap c'est qu'ici

φ :: a -> b mais ψ :: c -> a

alors ψ ne peut pas prendre le résultat de φ mais il peut transformer ses arguments en quelque chose φ peut utiliser - donc x ↦ y = ψ x et y ↦ φ y est le seul choix correct.

Cela se reflète dans les diagrammes suivants, mais ici, nous avons résumé l'exemple des fonctions avec une source/cible commune - à quelque chose qui a la propriété d'être covariant/contravariant, ce que vous voyez souvent en mathématiques et/ou haskell.

                 covariant
f a ─── fmap φ ───▶ f b ─── fmap ψ ───▶ f c
 ▲                   ▲                   ▲
 │                   │                   │
 │                   │                   │
 a ─────── φ ──────▶ b ─────── ψ ──────▶ c


               contravariant
g a ◀─── cmap φ ─── g b ◀─── cmap ψ ─── g c
 ▲                   ▲                   ▲
 │                   │                   │
 │                   │                   │
 a ─────── φ ──────▶ b ─────── ψ ──────▶ c

Remarque:

En mathématiques, vous avez généralement besoin d'une loi pour appeler quelque chose de foncteur.

        covariant
   a                        f a
  │  ╲                     │    ╲
φ │   ╲ ψ.φ   ══▷   fmap φ │     ╲ fmap (ψ.φ)
  ▼    ◀                   ▼      ◀  
  b ──▶ c                f b ────▶ f c
    ψ                       fmap ψ

       contravariant
   a                        f a
  │  ╲                     ▲    ▶
φ │   ╲ ψ.φ   ══▷   cmap φ │     ╲ cmap (ψ.φ)
  ▼    ◀                   │      ╲  
  b ──▶ c                f b ◀─── f c
    ψ                       cmap ψ

ce qui revient à dire

fmap ψ . fmap φ = fmap (ψ.φ)

tandis que

cmap φ . cmap ψ = cmap (ψ.φ)
15
epsilonhalbe

Tout d'abord, une note sur notre ami, la classe Functor

Vous pouvez considérer Functor f Comme une affirmation selon laquelle a n'apparaît jamais en "position négative". Il s'agit d'un terme ésotérique pour cette idée: notez que dans les types de données suivants, le a semble agir comme une variable "résultat".

  • newtype IO a = IO (World -> (World, a))

  • newtype Identity a = Identity a

  • newtype List a = List (forall r. r -> (a -> List a -> r) -> r)

Dans chacun de ces exemples, a apparaît en position positive. Dans un certain sens, le a pour chaque type représente le "résultat" d'une fonction. Il pourrait être utile de penser à a dans le deuxième exemple comme () -> a. Et il peut être utile de se rappeler que le troisième exemple est équivalent à data List a = Nil | Cons a (List a). Dans les rappels comme a -> List -> r Le a apparaît en position négative mais le rappel lui-même est en position négative donc négatif et négatif multiplier pour être positif.

Ce schéma de signature des paramètres d'une fonction est développé dans ce merveilleux article de blog .

Notez maintenant que chacun de ces types admet un Functor. Ce n'est pas une erreur! Les foncteurs sont censés modéliser l'idée de foncteurs covariants catégoriels, qui "préservent l'ordre des flèches", c'est-à-dire f a -> f b Par opposition à f b -> f a. Dans Haskell, les types où a n'apparaît jamais en position négative admettent toujours Functor. Nous disons que ces types sont covariants sur a.

En d'autres termes, on pourrait valablement renommer la classe Functor en Covariant. Ils sont une seule et même idée.

La raison pour laquelle cette idée est formulée si étrangement avec le mot "jamais" est que a peut apparaître à la fois dans un emplacement positif et négatif, auquel cas nous disons que le type est invariant sur a. a ne peut également jamais apparaître (comme un type fantôme), auquel cas nous disons que le type est à la fois covariant et contravariant sur a - bivariant.

Retour à Contravariant

Donc, pour les types où a n'apparaît jamais en position positive, nous disons que le type est contravariant dans a. Chacun de ces types Foo a Admettra un instance Contravariant Foo. Voici quelques exemples, extraits du package contravariant:

  • data Void a (a est fantôme)
  • data Unit a = Unit (a est de nouveau fantôme)
  • newtype Const constant a = Const constant
  • newtype WriteOnlyStateVariable a = WriteOnlyStateVariable (a -> IO ())
  • newtype Predicate a = Predicate (a -> Bool)
  • newtype Equivalence a = Equivalence (a -> a -> Bool)

Dans ces exemples, a est soit bivariant soit simplement contravariant. a n'apparaît jamais ou est négatif (dans ces exemples artificiels a apparaît toujours devant une flèche, ce qui est extrêmement simple à déterminer). Par conséquent, chacun de ces types admet un instance Contravariant.

Un exercice plus intuitif serait de plisser les yeux sur ces types (qui présentent une contravariance) puis de plisser les yeux sur les types ci-dessus (qui présentent une covariance) et de voir si vous pouvez comprendre une différence dans la signification sémantique de a. Peut-être que cela est utile, ou peut-être que c'est encore un tour de passe-passe abstrus.

Quand ces informations pourraient-elles être utiles? Laissez-nous par exemple vouloir partitionner une liste de cookies par quel type de puces ils ont. Nous avons un chipEquality :: Chip -> Chip -> Bool. Pour obtenir un Cookie -> Cookie -> Bool, Nous évaluons simplement runEquivalence . contramap cookie2chip . Equivalence $ chipEquality.

Assez verbeux! Mais résoudre le problème de la verbosité induite par un nouveau type devra être une autre question ...

Autres ressources (ajoutez des liens ici au fur et à mesure que vous les trouvez)

14
hao

Je sais que cette réponse ne sera pas aussi profondément académique que les autres, mais elle est simplement basée sur les implémentations courantes de contravariantes que vous rencontrerez.

Tout d'abord, un conseil: ne lisez pas le type de fonction contraMap en utilisant la même métaphore mentale pour f que lorsque vous lisez le bon vieux _ Functor map.

Vous savez ce que vous pensez:

"une chose qui contient (ou produit) un t"

... lorsque vous lisez un type comme f t?

Eh bien, vous devez arrêter de faire ça, dans ce cas.

Le foncteur Contravariant est "le dual" du foncteur classique donc, quand vous voyez f a Dans contraMap, vous devriez penser à la métaphore "dual":

f t Est une chose qui CONSOMME a t

Maintenant, le type de contraMap devrait commencer à avoir un sens:

contraMap :: (a -> b) -> f b ...

... faites une pause là, et le type est parfaitement sensible:

  1. Une fonction qui "produit" un b.
  2. Une chose qui "consomme" un b.

Le premier argument cuit le b. Le deuxième argument mange le b.

C'est logique, non?

Maintenant, finissez d'écrire le type:

contraMap :: (a -> b) -> f b -> f a

Donc, à la fin, cette chose doit produire un "consommateur de a".

Eh bien, nous pouvons sûrement construire cela, étant donné que notre premier argument est une fonction qui prend un a en entrée.

Une fonction (a -> b) Devrait être un bon bloc de construction pour construire un "consommateur de a".

Donc, contraMap vous permet essentiellement de créer un nouveau "consommateur", comme ceci (avertissement: symboles composés entrants):

(takes a as input / produces b as output) ~~> (consumer of b)

  • À gauche de mon symbole composé: Le premier argument de contraMap (c'est-à-dire (a -> b)).
  • À droite: le deuxième argument (c'est-à-dire f b).
  • Le tout collé: la sortie finale de contraMap (une chose qui sait consommer un a, c'est-à-dire f a).
7
Alexander