上一篇:自由幺半群
原文地址:https://bartoszmilewski.com/2...
是时候谈谈集合了。数学家们对集合论是又爱又恨。它是数学中的汇编语言——至少它常常是。范畴论在某种程度上尝试跳出集合论,一个众所周知得事实就是不存在所有集合的集合,但所有集合的范畴,Set,是存在的。这就很好。另一方面,我们假定一个范畴中任意两个对象之间的态射构成一个集合。我们还叫它hom集。为了公平,还有一种范畴论中态射并不构成集合。取而代之的是它们是另一个范畴中的对象。这些范畴使用的不是hom集而是hom对象,它们被称作富范畴。然而,接下来,我们还将只看那些使用可爱的原始形式的hom集的范畴。
富范畴的原文为enriched category,我没有找到它对应的中文。译者注。
集合是你能找到的最接近范畴对象的平凡的家伙了。一个集合有元素,但你不能对这些元素说更多的事。如果你有一个有限集,那么你能数出他们来。你可以用基数大致对无限集的元素计数。比如,自然数集小于实数集,即使它们都是无限多的。但是,可能有些让人惊讶的是,有理数集和自然数集一样大。
除此之外,所有有关集合的信息都能表现在它们之间的函数上——尤其是叫做同构的那些可逆的家伙。同构的集合在任何意义下都是完全一样的。在我引起基础数学家们的愤怒之前,让我解释一下:相等和同构的区别具有非常根本的重要性。实际上它是数学最新的分支,同伦类型论(the Homotopy Type Theory ,HoTT)关心的主要问题之一。我提到HoTT是因为它是灵感来源于计算的一门纯粹的数学理论,而它主要的支持者之一,Vladimir Voevodsky,主要是在研究Coq定理证明辅助器时顿悟的。数学和编程的互动是双向的。
关于集合的重要的一课是,不想元素那样,集合是可以比较的。比如,我们可以说一个给定的自然变换的集合同构于某个态射的集合,因为一个集合就只是一个集合。在这个例子里同构就只是说对于一个集合里的每个自然变换另一个集合里都有唯一的一个态射与之对应,反之亦然。他们可以互相配对。如果苹果和橘子是不同的范畴里的对象,那你不能比较苹果和橘子,但你可以比较苹果的集合和橘子的集合。通常把一个范畴问题转换成一个集合论的问题会给我们一些必要的直观或者甚至让我们证明有价值的定理。
Hom函子
每个范畴都天生具有一个到Set的典型映射族。这些映射实际上是函子,所以它们维持范畴的结构。让我们构造一个这样的映射。
我们固定C的一个对象a
然后选择另一个对象x
,hom集C(a, x)
是个集合,也就是Set的一个对象。当我们保持a
固定不变而变化x
时,C(a, x)
也会在Set中变动。因此我们有了个从a
到Set的映射。
如果我们想强调我们正把hom集看作以它的第二个参数作自变量的映射,就使用记号:
C(a, -)
其中连接号充当了该参数的占位符。
对象的映射可以容易地拓展到态射的映射。我们取C中两个任意对象x
和y
的态射f
。在我们刚刚定义的映射下,对象x
被映为集合C(a, x)
而y
被映为C(a, y)
。如果该映射要成为一个函子,f
必须被映为这两个集合之间的一个函数:
C(a, x) -> C(a, y)
让我们逐点定义这个函数,逐点就是分别对每个取值分别处理。这里我们应该选取C(a, x)
中的一个任意元素——我们叫它h
好了。首尾相连的态射是可复合的。h
的尾和f
的头就是这种匹配的情况,所以它们的复合:
f ∘ h :: a -> y
是一个a
到y
的态射。因此它是C(a, y)
的一个家伙。
我们刚刚找到了从C(a, x)
到C(a, y)
的函数,它是f
的像。如果不至于引起歧义,我们把这个提升后的函数写作:
C(a, f)
它在态射h
上的行为是:
C(a, f) h = f ∘ h
由于在任意范畴里都可以这样构造,它肯定也可以在Haskell类型范畴中实现,这个hom函子就是众所周知的Reader
函子:
type Reader a x = a -> x
instance Functor (Reader a) where
fmap f h = h . h
现在我们考虑一下,如果把固定hom集的源点改为固定靶点,会发生什么。换句话说,我们问是否映射
C(-, a)
也是个函子。它是,但它不再是协变的,它是逆变的。这是因为同样的首尾相连的态射的配对会导致f
后复合而不是C(a, -)
的那种前复合。
前复合对应的英文为precomposition,后复合对应的英文为postcomposition,f
前复合就是说复合时f
在前,f
后复合就是说复合时f
在后。
我们已经看到了Haskell中的这个逆变函子。我们叫它Op
:
type Op a x = x -> a
instance Contravariant (Op a) where
contramap f h = h . f
最后,如果我们让两个对象都可变,就得到了逆变协变函子(profunctor)C(-, =)
,它的第一个参数是逆变的,第二个参数是协变的(为了强调两个参数可以独立地变化,我们用双连接号作为第二个占位符)。我们之前已经看过这个逆变协变函子了,是在我们讲到函子性的时候:
instance Profunctor (->) where
dimap ab cd bc = cd . bc . ab
lmap = flip (.)
rmap = (.)
在第一部分的翻译中,profunctor被译为“副函子”,我认为不妥,于是找到了逆变协变函子这样的叫法。译者注。
重要的教训是这个观察在任意范畴都成立:把对象映为hom集是具有函子性的。既然逆变性等价于一个反范畴的映射,我们就可以把这件事简写为:
C(-, =) :: C^op x C -> Set
可表函子
我们已经看到,选择C中的每一个对象a
,我们获得一个从C到Set的函子。这种指向Set的结构保持的映射通常被称为一个表示。我们把C中的对象和态射表示为Set的集合和函数。
函子C(a, -)
本身通常说是可表的。更一般地,任意与选择了某个a
的hom函子自然同构的函子F
被称作可表的。这种函子一定是指向Set的,因为C(a, -)
指向Set。
我之前说过我们经常把同构看成同一的。更一般地,我们把一个范畴中同构的对象看作同一的。这是因为对象除了到其他对象(和他们自己)的态射关系外没有其他的结构了。
比如说,我们之前谈到过幺半群范畴,Mon,它最开始就是用集合建模的。但是我们很小心地选取了那些保持了集合的幺半群结构的函数作为态射。所以如果Mon的两个对象是同构的,意味着它们之间有一个可逆的态射。它们精确地具有相同的结构。如果我们看看这些态射所基于的集合和函数,我们将会看到一个幺半群的单位元被映射到了另一个的单位元,并且两个元素的积被映射为它们的像的积。
同样的推断可以用在函子上。两个范畴间的所有函子构成了一个范畴,而自然变换扮演了态射的角色。所以如果在两个函子间存在可逆的自然变换,那么它们是同构的,并且能够被视为同一的。
让我们从这种视角分析一下可表函子的定义。因为F
是可表的,我们需要:有C中的一个对象a
;一个从C(a, -)
到F
的自然变换α
;另一个反过来的自然变换β
;并且它们的复合是恒等自然变换。
让我们看看α
在某个对象x
上的分量。这是一个Set中的函数:
α_x :: C(a, x) -> F x
这个变换的自然性条件告诉我们,对于任意从x
到y
的态射f
,下面的四方图是交换的:
F f ∘ α_x = α_y ∘ C(a, f)
在Haskell中,我们可以用多态函数代替自然变换:
alpha :: forall x. (a -> x) -> F x
其中有可选的forall
量词。由于参数化多态(这是一个我之前提到的免费定理的一个),自然性条件
fmap f . alpha = alpha . fmap f
会自动满足。注意,左式的fmap
是定义在函子F
上的,而右边的那个是定义在reader函子上的。因为reader的fmap
就只是做了函数前复合,我们甚至可以再精确些。作用在C(a, x)
的一个元素h
上时,自然性条件简化为:
fmap f (alpha h) = alpha (f . h)
另一个变换β
是反方向走的:
beta :: forall x. F x -> (a -> x)
它必须遵守自然性条件,并且它必须是α
的逆:
α ∘ β = id = β ∘ α
我们之后将会看到从C(a, -)
到任意指向Set的函子的自然变换总会存在(米田引理),但它不一定可逆。
让我给你个Haskell的例子吧——列表函子并且用Int
作为a
。这里是能实现这件事的一个自然变换:
alpha :: forall x. (Int -> x) -> [x]
alpha h = map h [12]
我任取了数字12并且用它创建了个单例列表。接着我就能在这个列表上fmap
函数h
得到一个h
返回值类型的列表。(实际上这种变换的数目和整数列表的数目一样多。)
自然性条件就等价于map
(fmap
的列表版本)的可复合性:
map f (map h [12]) = map (f . h) [12]
但是要是我们试图寻找逆变换,我们不得不把任意类型x
的列表变成一个返回x
的函数:
beta :: forall x. [x] -> (Int -> x)
你可能会考虑从列表中检索出一个x
,比如,用head
,但它不能应用在空列表上。注意没有哪个a
的选择(以代替Int
)可以让它工作。所以列表函子并不是可表的。
还记得我们说过Haskell函子有点像容器吗?同样地我们可以把可表函子看作把函数调用(hom集的成员在Haskell中仅仅就是函数)后的忆存结果存储起来的容器。表示对象,也就是C(a, -)
里的类型a
被看作关键字类型,用它我们可以得到一个函数的表值。我们所称作α
的变换叫tabulate
,它的逆,β
,叫index
。这是一个(略微简化的)Representable
类的定义:
class Representable f where
type Rep f :: *
tabulate :: (Rep f -> x) -> f x
index :: f x -> Rep f -> x
忆存一词英文为"memoize",我没有找到相应的中文。它专指计算机领域将函数的运行结果保存下来,下次调用时直接取出而不重新计算的加速方法。
注意表示类型,也就是我们的a
,这里被叫做Rep f
,是Representable
定义的一部分。星号只是说Rep f
是个类型(而不是类型构造子,或者其他更奇怪的)
无限列表,或者叫流,是不可以为空的,它是可表的。
data Stream x = Cons x (Stream x)
你可以把他们看成一个接受Integer
参数的函数的忆存值。(严格地讲,我应当用非负自然数,但我不想让我的代码更复杂。)
为了tabulate
这样一个函数,你得创造一个值的无限流。当然,这能做到仅仅是因为Haskell是惰性的。值只有在需要时才被求值。你可以用index
得到忆存值。
instance Representable Stream where
type Rep Stream = Integer
tabulate f = Cons (f 0) (tabulate (f . (+1)))
index (Cons b bs) n = if n == 0 then b else index bs (n-1)
这很有趣,你可以只实现一个忆存表就覆盖任意返回类型的函数的族。
逆变函子的可表性也是类似定义的,只不过我们固定了C(-, a)
的第二个参数。或者等价地说,我们可以考虑从C_op
到Set的函子,因为C_op(a, -)
就是C(-, a)
。
可表性有个有趣的变形。回忆hom集可以在笛卡尔闭范畴里被看成指数对象。hom集C(a, x)
就等价于x_a
,并且一个可表函子F
我们可以写成:
-_a = F
让我们两边取对数,这只是随便看看:
a = log F
当然,这只是个纯粹的公式变换,但如果你知道些对数的性质,那就会很有用了。尤其是,可以证明作用于积类型的函子可以表示成和类型,而和类型的函子并不都是可表的(例子:列表函子)。
最后,注意到一个可表函子给我们提供了同样一件事情的两种不同实现——一种是函数,一种是数据结构。它们有着完全相同的内容——同样的值会被同样的关键字检索到。这就是我之前谈到的“同一”的意思。只要两个自然同构的函子所涉及的内容是是同一的,那它们就是同一的。另一方面,这两种表示方式经常会用不同方式实现因此可能有完全不同的性能。忆存被用来提升性能所以可能使运行时间大大缩减。在实践中,能对相同的隐性计算过程创造不同的表达方式是非常有价值的。所以,很让人惊喜的是,即使现在并没有关心性能,范畴论也为探索其他可能的有实际价值的实现提供了足够的机会。
挑战
- 证明hom函子把C中的恒等态射映为Set中对应的恒等函数。
- 证明
Maybe
不是可表的。 -
Reader
函子可表吗? - 使用
Stream
的表示,忆存平方函数。 - 证明
Stream
的tabulate
和index
确实是互逆的。(提示:使用归纳法) -
函子:
Pair a = Pair a a
是可表的。你能不能猜到表示它的类型是什么?实现
tabulate
和index
。
参考文献
- The Catsters video about representable functors.
致谢
感谢Gershom Bazerman检查我的数学和逻辑,以及André van Meulebrouck在整个系列中的编辑上的帮助。
下一篇:米田引理