什么是逆变函子?

rit*_*mon 34 haskell functor

这种类型让我大吃一惊:

class Contravariant (f :: * -> *) where
  contramap :: (a -> b) -> f b -> f a
Run Code Online (Sandbox Code Playgroud)

然后我读了这个,但与标题相反,我不再开悟了.

有人可以解释逆变函子是什么以及一些例子吗?

Ben*_*Ben 37

从程序员的角度来看,函子的本质是能够轻松地适应事物.我在这里"适应"的意思是,如果我有一个f a,我需要一个f b,我想要一个适合我f af b形状孔的适配器.

这似乎直观的,如果我可以把一个a成一个b,我也许能转f af b.事实上,这就是Haskell Functor班级所体现的模式; 如果我提供一个a -> b功能,然后fmap让我适应f a的事情到f b的事情,而不必担心任何f涉及.1

当然,谈论paramterised类型,如列表的-X [x],Maybe yIO z在这里,我们得到我们的适配器来改变的事情是x,yz在那些.如果我们希望灵活地从任何可能的功能中获得适配器,a -> b那么我们正在调整的东西必须同样适用于任何可能的类型.

不那么直观(起初)是有些类型可以调整几乎与功能相同的方式,只有它们是"向后"; 对于这些,如果我们想要调整f a以满足f b我们实际需要提供b -> a功能的需求,而不是a -> b一个!

我最喜欢的具体例子实际上是函数类型a -> r(一个用于参数,r用于结果); 当应用于函数时,所有这些抽象的废话都很有意义(如果你已经完成了任何实质性的编程,你几乎肯定会在不知道术语或它们有多广泛适用的情况下使用这些概念),这两个概念是如此明显在这种情况下,彼此是双重的.

众所周知,它a -> r是一个算符r.这是有道理的; 如果我有一个a -> r并且我需要一个a -> s,那么我可以使用一个r -> s函数来简单地通过后处理结果来调整我的原始函数.2

另一方面,如果我有一个a -> r函数,我需要的是一个函数b -> r,那么很明显我可以通过在将参数传递给原始函数之前预先处理参数来解决我的需求.但我该如何预处理呢?原来的功能是一个黑盒子; 无论我做什么,总是期待a投入.所以我需要将我的b值转换为a它所期望的值:我的预处理适配器需要一个b -> a函数.

我们刚刚看到的是函数类型a -> r是一个协变函子r,并且是一个逆变函子a.我认为这可以说我们可以调整函数的结果,并且结果类型"随着适配器r -> s而改变" ,而当我们调整函数的参数时,参数类型会"转向"与适配器"相反".

有趣的是,功能结果的实现fmap和功能参数contramap几乎是完全一样的东西:只是功能组成(.运营商)!唯一的区别在于您构成适配器功能的哪一方: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 (.)
Run Code Online (Sandbox Code Playgroud)

我认为每个块的第二个定义最具洞察力; (共变)映射到函数的结果是左边的组合(如果我们想要采用"此后发生的那个"视图,后组合),而在函数的参数上进行逆向映射是右侧的组合(前组成).

这种直觉很好地说明了; 如果一个f x结构可以给我们类型的值x(就像一个a -> r功能让我们r的价值观,至少潜在的),这可能是一个协变Functorx,我们可以使用一个x -> y功能,把它改编成是一个f y.但是如果一个f x结构从我们这里接收到类型的值x(再次,就像a -> r函数的类型参数一样a),那么它可能是一个Contravariant仿函数,我们需要使用一个y -> x函数来使它适应一个f y.

我觉得有趣的是,当你从源/目的地的实施者而不是呼叫者的角度思考时,这种"来源是协变的,目的地是逆变的"直觉反转.如果我试图实现一个f x接收x值,我可以"适应我自己的界面",让我得到的工作y值而不是(同时仍呈现"接收x值"界面,我的来电)通过使用x -> y功能.通常我们不这么认为; 即使f x我的实施者想到调整我正在调用的东西而不是"调整我的调用者界面给我".但这是你可以采取的另一种观点.

我所做的唯一半现实世界的使用Contravariant(而不是通过使用右侧组合隐式地使用函数的逆变量,这是非常常见的)是一种Serialiser a可以序列化x值的类型.Serialiser必须是一个Contravariant而不是一个Functor; 鉴于我可以序列化Foos,如果可以,我也可以序列化Bars Bar -> Foo.4但是当你意识到这Serialiser a基本上a -> ByteString是显而易见的; 我只是重复一个例子的a -> r特例.

在纯函数式编程中,没有太多用于拥有"接收值"的东西而没有它也会回馈所有逆变函子往往看起来像函数,但几乎任何可以包含任意类型值的直接数据结构都将是该类型参数的协变函子.这就是为什么Functor早期偷走好名字并在整个地方使用的原因(好吧,那Functor被认为是一个基本的部分Monad,之前Functor已被广泛使用,被定义为Haskell中的一个类).

在势在必行的OO中,我认为逆变函子可能会更加常见(但不会像使用统一框架那样抽象Contravariant),尽管它也很容易产生可变性和副作用,这意味着参数化类型根本不可能是一个函子(通常:你的标准容器a既可读又可写,既是发射器又是接收器a,而不是意味着它既有协变性又有逆变性,事实证明它既不是也不是.


1Functor每个人的实例f说明如何将任意函数应用于特定形式f,而不必担心f应用的特定类型; 一个很好的关注点分离.

2这个仿函数也是一个monad,相当于Readermonad.我不会在这里详细讨论仿函数,但考虑到我的帖子的其余部分,一个显而易见的问题是" a -> r那种类型也是某种逆变monad a吗?".不幸的是,逆变法不适用于monad(参见是否存在逆变monad?),但有一个逆变模拟Applicative:https://hackage.haskell.org/package/contravariant-1.4/docs/Data-Functor-Contravariant-Divisible html的

3.注意,我contramap'在这里不符合实际的contramap,从Contravariant作为在Haskell实施; 你不能简单地在Haskell代码中创建a -> r一个实际的实例,Contravariant因为a它不是最后一个类型的参数(->).从概念上讲,它可以很好地工作,并且您始终可以使用newtype包装器来交换类型参数并使其成为一个实例(逆变器Op为此目的定义了类型).

4至少对于"序列化"的定义,它不一定包括以后能够重建Bar,因为它会将一个Bar序列化为与它映射到的Foo相同,而无法包含有关映射是什么的任何信息. .


eps*_*lbe 14

首先,@ haoformayor的答案非常好,所以请考虑这个更多的附录而不是完整的答案.

定义

我喜欢考虑Functor(co/contravariant)的一种方式是图表.该定义反映在以下内容中.(我缩写contramapcmap)

      covariant                           contravariant
f a ??? fmap ? ???? f b             g a ???? cmap ? ??? g b
 ?                   ?               ?                   ?
 ?                   ?               ?                   ?
 ?                   ?               ?                   ?
 a ?????? ? ???????? b               a ??????? ? ??????? b
Run Code Online (Sandbox Code Playgroud)

注意:这两个定义中唯一的变化是顶部的箭头(嗯和名称,所以我可以将它们称为不同的东西).

的例子中谈到那些当我总是有在头是功能-然后的一个例子f将是type F a = forall r. r -> a(这意味着第一个参数是任意的而非固定的r),或者换句话说具有共同输入所有的功能.像往常一样,(协变)的实例Functor只是fmap ? ?=ψ.φ`.

其中(逆变)Functor是具有共同结果的所有函数 - type G a = forall r. a -> r这里的Contravariant实例是 cmap ? ? = ? . ?.

但到底是什么意思呢

? :: a -> b? :: b -> c

通常因此(? . ?) x = ? (? x)x ? y = ? xy ? ? y有意义的,什么是在声明中省略的cmap是,在这里

? :: a -> b? :: c -> a

所以?不能取结果,?但它可以将其参数转换为?可以使用的东西- 因此x ? y = ? x并且y ? ? y是唯一正确的选择.

这反映在下面的图表中,但是在这里我们已经对具有共同源/目标的函数的示例进行了抽象 - 具有协变/逆变属性的东西,这是您经常在数学和/或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
Run Code Online (Sandbox Code Playgroud)

备注:

在数学中,你通常需要一个法则来称呼函子.

        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 ?
Run Code Online (Sandbox Code Playgroud)

这相当于说

fmap ? . fmap ? = fmap (?.?)
Run Code Online (Sandbox Code Playgroud)

cmap ? . cmap ? = cmap (?.?)
Run Code Online (Sandbox Code Playgroud)


hao*_*hao 13

首先,关于我们的朋友,Functor类的说明

你可以把它Functor f看作一个a永远不会出现在"消极位置" 的断言.这是这个想法的一个深奥术语:请注意,在以下数据类型中,它a似乎充当"结果"变量.

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

  • newtype Identity a = Identity a

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

在这些例子a中的每一个都出现在积极的位置.在某种意义上a,每种类型代表一个函数的"结果".a在第二个例子中可能会有所帮助() -> a.并且可能有助于记住第三个例子相当于data List a = Nil | Cons a (List a).在这样的回调a -> List -> ra是否定的位置出现,但回调本身是否定的位置,这样的负面和消极的乘法是积极的.

这个精彩的博客文章详细阐述了这个用于签署函数参数的方案.

现在请注意,这些类型中的每一种都承认一个Functor.那不是错!函数用于模拟分类协变函子的概念,它"保持箭头的顺序",即f a -> f b相反f b -> f a.在Haskell中,a从不出现在负面位置的类型总是承认Functor.我们说这些类型是协变的a.

换句话说,可以有效地将Functor类重命名为Covariant.他们是同一个想法.

这个想法用"从不"这个词如此奇怪地表达的原因是它a可以出现在正面和负面的位置,在这种情况下我们说类型是不变的a.a也可能永远不会出现(例如幻像类型),在这种情况下我们说类型是协变的和逆变的a- 双变量.

回到逆变

因此,对于a从未出现在积极位置的类型,我们说类型是逆变的a.每种类型Foo a都会承认instance Contravariant Foo.以下是一些从contravariant包中获得的示例:

  • data Void a(a是幽灵)
  • data Unit a = Unit(又a是幽灵)
  • 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)

在这些例子a中是双变量或仅仅是逆变.a要么从不出现,要么是否定的(在这些人为的例子中a总是出现在箭头之前,因此确定这是非常简单的).结果,这些类型中的每一种都承认了instance Contravariant.

更直观的练习是眯着眼睛看这些类型(表现出逆差),然后眯着眼睛看上面的类型(表现出协方差),看看你是否能够直观地理解其语义a.也许这是有帮助的,或者它可能只是仍然深奥的手法.

什么时候这些实际上有用?举个例子,我们想要根据他们拥有的芯片类别来划分cookie列表.我们有一个chipEquality :: Chip -> Chip -> Bool.为了获得Cookie -> Cookie -> Bool,我们只是评估runEquivalence . contramap cookie2chip . Equivalence $ chipEquality.

非常详细!但解决新型引起的冗长问题将是另一个问题......

其他资源(在您找到它们时添加链接)


Ale*_*der 5

我知道这个答案不会像其他答案那样深入学术,但这只是基于您会遇到的逆变的常见实现。

首先,提示:不要像阅读优秀的Functor时那样contraMap使用相同的心理隐喻来阅读函数类型。fmap

您知道您的想法:

包含(或产生)“的东西t

...当您阅读类似的文字时f t

好吧,在这种情况下,您需要停止这样做。

逆函子是经典函子的“对偶”,因此,当您看到f acontraMap,您应该想到“对偶”隐喻:

f t消耗一个东西的东西t

现在contraMap的类型应该开始有意义了:

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

...在那儿暂停一下,这种类型非常明智:

  1. “产生” a的功能b
  2. 一种“消耗” a的东西b

第一个论点就是做饭b。第二个论点吃了b

有道理吧?

现在完成写类型:

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

那么到底这个东西必须产生一个“ 消费a”。

好吧,考虑到我们的第一个参数是一个将a a作为输入的函数,因此我们肯定可以构建它。

函数(a -> b)应该是构建“消费者a” 的良好构建块。

因此contraMap基本上可以让您创建一个新的“消费者”,如下所示(警告:组成传入的符号):

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

  • 在我的组合符号的左侧contraMap:(即(a -> b))的第一个参数。
  • 右侧:第二个参数(即f b)。
  • 整个东西粘在一起:(contraMap一个知道如何使用a,即的东西)的最终输出f a

  • 令人惊讶的是,这是迄今为止最好的“初学者”答案。太感谢了! (2认同)