上一篇:声明式编程
原文地址:https://bartoszmilewski.com/2...
似乎在范畴论中所有事情都是相互联系的,并且都可以用很多角度观察。就拿积的泛构造来举例吧,既然我们已经对函子和自然变换了解更多了,我们能不能简化积的泛构造,或者也许,推广它?让我们试试。
积的构造始于选取我们想要构造积的两个对象a
和b
。但选择对象究竟是什么意思?我们能否用更加范畴论的语言重新描述这个动作?两个对象构成了一个模式——非常简单的模式。我们可以把这种模式抽象成一个范畴——一个非常简单的范畴,即便如此也是个范畴。我们称之为2。它包含了两个对象,1和2,并且除了必要的两个恒等态射外没有其他态射。现在我们把在C中选取两个对象重述为定义了一个从2到C的函子D。函子把对象映为对象,所以它的像就只是两个对象而已(或者如果这个函子坍缩了对象的话,也可以是一个,那也是ok的)。它也会映射态射——这里就仅仅是把恒等态射映为恒等态射。
这种方式的好处是建立在范畴的概念上,从而避免了诸如“选择对象”这样不精确的,狩猎采集时代的祖先们就在用的描述。不仅如此,它还顺带了一个易推广的好处,因为完全可以用些比2更加复杂的范畴来定义我们的模式。
不过,先让我们继续(构造积)。定义一个积的下一步是选取候选对象c
。同样地,我们可以用一个从单例范畴出发的函子来重述这个选取。如果我们用Kan延拓(Kan extension,中文不知道叫什么,有知道的童鞋告诉我啊,译者注)的话,确实不会有什么问题,可我们还不能讲Kan延拓,所以我们用另一种技巧:从同一个2范畴到C的常函子Δ。在C中选取一个c
可以用Δc。记住,Δc把所有对象都映为c
并且把所有态射都映为idc。
现在我们有了两个从2到C的函子Δc和D,所以完全可以求求它们之间的自然变换。因为2中只有两个对象,那么一个自然变换就会有两个分量。2的对象1被Δc映为c
而被D映为a
。所以Δc和D之间的自然变换在1上的分量就是从c
到a
的态射。我们把它记作p
。同样地,第二个分量是从c
到b
的态射q
——b
是2的对象2在D下的像。这就像极了我们之前原始的积的定义中的那两个投影。所以我们不再说选取对象和投影这样的话了,而是说选取函子和自然变换。在这种简单的情形下变换的自然性以一种平凡的方式满足了,因为2中根本没有非平凡的态射(除了恒等态射)。
如果把这种结构推广到不是2的范畴——例如,包含了非平凡态射的范畴——那么就要加上Δc和D自然变换的自然性条件。我们把这样的变换叫做一个锥,因为Δ的像就像锥/金字塔的顶点,而锥的腰由自然变换的分量组成。而D的像就是这样一个锥的底。
一般来说,为了构造一个锥,我们会从一个定义了某种模式的范畴I出发。它是一个小范畴,通常来说都是有限的。我们选取一个从I到C的函子D称之(或D的像)为一个图表。我们选取C中的某个c
作为锥的顶点,并用它定义从I到C的常函子Δc。然后,Δc到D的自然变换就是我们的锥了。对于一个有限大的I,锥就只是一族连接c
到图表的态射:而图表是I在D下的像。
自然性需要保证图中所有的三角形(也就是金字塔的面)是交换的。事实上,选择I的任意态射f
,函子D会把它映为C中的一个态射D f
,它是某个三角形的底。而常函子Δc会把f
映为c
上的恒等态射。Δ把态射的两个端压为一个对象,然后四方交换图就变成了交换三角形。这个三角形的两臂就是自然变换的分量。
这就是一个锥了。我们感兴趣的是那个泛锥(universal cone)——就像我们从积定义中选了一个最普适的对象那样。
做这件事有很多方法。比如,给定一个函子D,我们可以定义一个锥范畴。范畴中的对象就是锥。然而并不是C中的每个c
都可以成为一个锥的顶点,因为从Δc和D之间可能不存在自然变换。
要让其成为一个范畴,我们还需要定义锥之间的态射。而它们会被锥顶点之间的态射所完全决定。但并不是所有这样的态射都可以。回忆一下,在我们的积的构造里,我们引入了一个条件:候选对象(顶点)之间的态射必须是各个投影的公因子。比如:
p' = p . m
q' = q . m
在一般的情形下,这个条件就是说那些以“因子态射”作为一条边的三角形都要可交换。
连接两个锥的可交换三角形,h是因子态射(这里,比较低的锥是更“泛”的,它以Lim D
为顶点)上面这句话是图注。由于思否的markdown基本不支持html,之后我的图注都会以引用来代替。(第一部分的译者也是如此做的)译者注。
我们就把这些因子态射作为锥范畴中的态射。很容易检查这些态射可以复合,并且(锥范畴的)恒等态射就是恒等因子态射。因此锥可以形成一个范畴。
现在我们就可以定义泛锥了。他就是锥范畴的终端对象。终端对象的定义是说所有其他对象到它都有一个唯一的态射。具体来说,就是其他锥的顶点都有一个唯一的因子态射指向泛锥的顶点。这个泛锥就称作图表D的极限,Lim D
(在印刷体里,你会经常看到在Lim
符号下面有个指向I的左箭头)。通常,为了方便,我们就把泛锥的顶点叫极限(或极限对象)
关于
Lim
下的左箭头,译者目前没有在网上找到相应的印刷体资料,但左箭头总觉得怪怪的。猜测很有可能其实是右箭头。译者注。
直觉上来说极限把整个图表的性质体现在单个的对象上。例如,我们的双对象图表的极限就是两个对象的积。积(和两个投影一起)包含了两个对象的所有信息,并且它是泛的,也就是它不包含冗余信息。
作为自然同构的极限
仍然有一些东西是现在这个极限的定义不能满足的。我的意思是,现在的极限定义已经可以使用了,但我们需要连接任意两个锥的三角形们的交换性条件。如果我们能把它替换成某个自然性条件的话就优雅多了。但这要怎么操作呢?
我们不再讨论单独的锥而是关注一整个锥集(实际上,是锥范畴)。如果极限存在(——说的清楚点——现在不能保证它一定存在),那么这些锥当中就有那个泛锥。对于每个其他的锥我们有一个唯一的因子态射来把它的顶点c
映为泛锥的顶点Lim D
。(实际上,我可以省略“其他”这个词,因为有恒等态射把泛锥映到它自己,即泛锥本身平凡地因式化了自己。)让我重复一下重点:给定任意一个锥,那就有唯一的某个具体的态射。我们就有了从锥到具体的态射的映射,并且是一一映射。
这个具体的态射是hom集C(c, Lim D)
里的某个家伙。这个hom集里的其他伙伴就很不幸了,我的意思是它们不能因式化锥之间的映射。我们想要做的是对于每一个c
,能够从集合C(c, Lim D)
里挑出一个态射——一个能够满足特殊的交换性条件的态射。这听起来不是很像定义一个自然变换吗?确实是这样!
但和这个变换相关的函子是什么?
一个是c
到集合C(c, Lim D)
的映射。这是一个从C到Set的函子——它把对象映为集合。
这其实是一个逆变函子。接下来是定义它在态射上的行为:我们取一个c'
到c
的态射f
:
f :: c' -> c
我们的函子把c'
变成了C(c', Lim D)
。要定义函子在f
上的行为(换句话说,要提升f
),我们必须定义C(c, Lim D)
和C(c', Lim D)
之间的映射。我们选取C(c, Lim D)
中的一个元素u
然后看看能否把它映为C(c', Lim D)
里的某个元素。hom集的元素是态射,所以我们有:
u :: c -> Lim D
我们把u
在f
前复合一下就得到了:
u . f :: c' -> Lim D
这就是C(c', Lim D)
的一个元素了——所以我们确实找到了一个态射的映射:
contramap :: (c' -> c) -> (c -> Lim D) -> (c' -> Lim D)
contramap f u = u . f
注意逆变函子的特征:c
和c'
的顺序是反的。
要定义一个自然变换,我们还需要另一个从C映到Set的函子。但这一次我们考虑锥的集合。锥就是自然变换而已,所以我们来看看自然变换的集合Nat(Δ_c, D)
。从c
到这个特殊的自然变换集之间的映射是一个(逆变)函子。我们怎么证明?同样,让我们定义它对态射的行为:
f :: c' -> c
f
的提升应该是关于I到C的两个函子间自然变换(全体)的映射:
Nat(Δ_c, D) -> Nat(Δ_c', D)
我们怎么样映射自然变换(全体)呢?每个自然变换都是一种态射的选取——也就是它的分量——每个I的元素选一个态射。某个α(Nat(Δ_c, D)
里的一个家伙)在a
(I的一个对象)上的分量是一个态射:
α_a :: Δ_c(a) -> D a
或者,带入常函子Δ的定义,
α_a :: c -> D a
给定f
和α,我们必须构造一个Nat(Δ_c', D)
的元素β,它在a
的分量也应该是一个映射
β_a :: c' -> D a
我们很容易就可以把前者在f
前复合一下来得到后者:
β_a = α_a . f
把这些分量合起来就是一个自然变换也是很容易证明的。
给定一个态射f
,我们就能这样按分量构造出两个自然变换间的映射。而这个映射就定义了该函子的contramap
:
c -> Nat(Δ_c, D)
我所做的只是向你展示了两个C到Set的(逆变)函子。我还没有做任何假设——这些函子总是存在的。
附带一提,第一个函子在范畴论中扮演了重要的角色,我们会在讲到米田引理的时候再次看到它。这种从任意范畴C到Set的逆变函子有个名字:“预层”。第一个还是一个可表预层。第二个函子也是个预层。
现在我们有两个函子了。我们可以聊聊它们的自然变换了。好,不再啰嗦别的,结论是:一个I到C的函子D
有一个极限Lim D
当且仅当我刚刚定义的这两个函子间有个自然同构。
C(c, Lim D) ≃ Nat(Δ_c, D)
我来提醒你一下自然同构是什么。自然同构就是一个每个分量都是一个同构——也就是一个可逆的态射——的自然变换。
我不打算证明这个陈述。如果不用冗长的证明,那这个过程非常直接。当处理自然变换时,你通常会关注分量,它们就是些态射。这里,因为两个函子的终点都是Set,所以自然同构的分量就会是些函数,它们是些高阶函数,因为它们从hom集映到自然变换的集合。又一次,你可以分析一个函数,看看它对它的参数做了什么:这里的参数是个态射——一个C(c, Lim D)
的家伙——它的结果是个自然变换——一个Nat(Δ_c, D)
的家伙,或者是我们说的锥。然后,这个自然变换的分量又是一些态射。所以做到底之后它们都是态射,如果你能跟踪它们,你就能证明这个结论。
最重要的结果是这个同构的自然性条件就刚刚好是锥映射的交换性条件。
作为一些更具诱惑力的东西的铺垫,我们说集合Nat(Δ_c, D)
可以看成是函子范畴里的hom集;所以我们的自然同构关联了两个hom集,这揭露一种甚至更普遍的关系,伴随。
极限的例子
我们已经看到了范畴积就是由一个简单的叫做2的范畴生成的图表的极限。
有一个甚至更为简单的极限的例子:终端对象。你的第一反应可能是想用单例范畴导出终端对象,但事实比那还简单:终端对象是由空范畴生成的极限。一个空范畴上的函子不会选择任何对象,所以锥就仅仅缩成了顶点。泛锥就是那个从其他顶点出发有唯一的态射到达的孤单的顶点。你可以看到这就是终端对象的定义。
下一个有意思的极限叫等化子。它是一个由含有两个对象的范畴生成的极限,这个范畴在那两个对象上有两个平行态射(以及,一定会有的,恒等态射)。这个范畴在C中选择了一个由两个对象a
和b
以及两个态射f
和g
组成的图表:
f :: a -> b
g :: a -> b
为了通过这个图标构建一个锥,我们必须加上顶点c
和两个投影:
p :: c -> a
q :: c -> b
我们有两个必须交换的三角形:
q = f . p
q = g . p
这告诉我们q
可以被其中一个式子唯一地决定,比如q = f . p
,并且我们可以把它从图中省略。因此剩下的就只有一个条件:
f . p = g . p
考虑这个条件的方法如下,如果我们只关注Set,那么函数p的像会选择a
的一个子集。当限制在这个子集上时,f
和g
是相等的。
比如,取a
为由x
和y
参数化的二维平面,取b
为实直线,然后取:
f (x, y) = 2 * y + x
g (x, y) = y - x
这两个函数的等化子就是实数集(也就是那个顶点c
)和函数:
p t = (t, (-2) * t)
注意(p t)
定义了二维平面上的一条直线。在这条直线上,这两个函数相等。
当然,还有其他的集合c'
和函数p'
可以满足等式:
f . p' = g . p'
但它们会被p
惟一地因式化。比如说,我们可以取单例集()
作为c'
和函数
p' () = (0, 0)
这是一个好的锥,因为 f (0, 0) = g (0, 0)
。但它并不是泛的,因为它可以通过h
唯一的因式化:
p' = p . h
h () = 0
因此一个等化子就能解形如f x = g x
的方程。但它更加一般,因为它是从对象和态射的角度定义的,而不是用代数学。
一个更加一般的解方程的思路体现在另一个极限上——回拉。我们仍然有想要让其相等的两个态射,但这次它们的定义域不同。我们从一个形状像1->2<-3
的三对象范畴出发。这个范畴对应的图表有三个对象a
,b
和c
,以及两个态射:
f :: a -> b
g :: c -> b
这个图表通常被称作一个余扩张。
在这个图表上构造的锥由一个顶点d
和三个态射组成:
p :: d -> a
q :: d -> c
r :: d -> b
交换性条件是说r
被其他态射完全地决定了,可以从图中略去。所以我们就只剩下下面的条件:
g . q = f . p
一个回拉是具有下面这种形状的泛锥。
像刚刚那样,如果你只关注集合的话,你就可以把对象d
看成一些分别来自a
和c
的元素的序对,对它们来说f
在第一个分量上作用的结果与g
在第二个分量上的相等。如果这么说还是有些一般,考虑一个特殊的情形:g
是常数函数g _ = 1.23
(假定b
是实数集)。也就是你实际上要解方程:
f x = 1.23
这时,c
的选择无关紧要(只要它不是空集就行),所以我们可以取单例集。集合a
,比方说,可以是个三维向量的集合,而f
计算向量长度。那么这个回拉就是序对(v, ())
的集合,其中v
是长度是1.23的向量(也就是方程sqrt (x^2+y^2+z^2) = 1.23
的解),而()
是单例集的那个无趣的元素。
但回拉还有更一般的应用,编程中也有这样的应用。比如,把C++的类想成一个范畴,其中的态射是连接子类和父类的箭头。我们会把继承想成一种传递性,所以如果C继承了B而B继承了A那么C继承了A(毕竟,你给创建一个指向C指针就需要一个指向A的指针)。并且,我们假定C继承了C自己,所以对每个类我们都有了恒等态射。这样子类就和子类型匹配了。C++也支持多继承,所以你可以构造出一个菱形继承表,B和C继承了A,第四个类D多继承了B和C。一般来说,D会有两个A的拷贝,但这不是我们所期望的;但你可以利用虚继承来只得到一个A的拷贝。
让D成为这个图表的一个回拉究竟是什么意思?这是说任意一个多继承B和C的类E也是D的一个子类。这一点在子类型没有实质意义的C++里并不能直接表示出(C++编译器不会推断这种类关系——它需要“鸭子类型”)。但我们可以放下子类型关系,转而问从E到D的一个转换是否安全。如果D是B和C的最最瘦的结合方式,没有新增数据和方法的重载,那么这种转换就是安全的。当然,如果B和C的某些方法有命名冲突的话,就没有这样的回拉了。
回拉在类型推导里还有更出色的应用。我们经常会有求两个表达式的一致类型的需要。比如,假如编译器想要推断下面的函数的类型:
twice f x = f (f x)
编译器要先给所有变量和子表达式赋初始类型。特别地,它可以赋成:
f :: t0
x :: t1
f x :: t2
f (f x) :: t3
这时编译器会推断出:
twice :: t0 -> t1 -> t3
编译器也会基于函数应用的规则想出一个约束的集合:
t0 = t1 -> t2 —— 因为f作用在了x上
t0 = t2 -> t3 —— 因为f作用在了(f x)上
这些约束必须在寻找一组类型(或类型变量)时同时(一致地)满足,当把这些未知类型带入这两个表达式时,能够得到同一种类型。一个这样的代换是:
t1 = t2 = t3 = Int
twice :: (Int -> Int) -> Int -> Int
但是呢,明显,这不是最一般的那个。最一般的代换可以用一个回拉获得。我不会讲那些细节,因为它们超出了这本书的范围,但你可以相信这个结果应该是:
twice :: (t -> t) -> t -> t
其中t
是一个自由的类型变量。
余极限
像范畴论中所有的构造一样,极限在反范畴中也有一个对偶的样子。你把一个锥的所有箭头的方向都反转一下,就得到了一个余锥,而最泛的那个就叫余极限。注意这种反转也会影响因子态射,它现在从那个最泛的余锥射到其他余锥。
余锥和连接两个顶点的因子态射。
余极限的典型例子就是余积,它对应的是我们之前在积的定义中用到的2范畴生成的图表。
积和余积各自以不同的方式体现了一对对象的本质。
就像终端对象是一个极限一样,初始对象是对应由空范畴生成的图表的余极限。
回拉的对偶叫做外推。它基于一个由范畴1<-2->3
生成的叫做扩张的图表。
连续性
我早前说过,从函子从不破坏既有的联系(态射)这一点上说,它非常接近范畴中的连续映射的想法。一个从范畴C到范畴C'的连续函子F的实际定义还包括了一个保持极限的条件。每个C中的图表D
可以通过简单地复合两个函子映射为C'中的图表F ∘ D
。F
的连续性条件是说,如果图表D
有极限Lim D
,那么图表F ∘ D
也有一个极限,并且等于F (Lim D)
。
注意,因为函子把态射映为态射,把复合映为复合,一个锥的像也总是一个锥。一个交换的三角形夜总会映为一个交换的三角形(因为函子保持复合)。这一点对因子态射也是成立的:因子态射的像也是一个因子态射。所以每个函子都是几乎连续的。可能出错的是唯一性条件。C'中的因子态射可能不唯一。C'中也可能会有一些在C中不成立的其他的“更好的锥”。
hom函子就是一个连续函子的例子。回忆一下hom函子C(a, b)
,它在第一个变量上是逆变的,在第二个变量上是协变的。换句话说,它是一个函子:
C^op x C -> Set
看过第一章的朋友应该见过C op,它是范畴C的通过把所有箭头反向导出的反范畴。
固定它的第二个参数,hom集函子(这时它是个可表预层)会把C的余极限映为Set的极限;固定它的第一个参数,它就把极限映为极限。
在Haskell中,一个hom函子就是将两个类型映为一个函数类型的映射,所以它就仅仅是一个参数化的函数类型。当我们固定第二个参数时,比如取为String
,我们得到了逆变函子:
newtype ToSring a = ToString (a -> String)
instance Contravariant ToString where
contramap f (ToString g) = ToString (g. f)
连续性是说ToString
应用到一个余极限上时,比如说余积Either b c
,就会得到一个极限;在现在这种情况下就是两个函数类型的积:
ToString (Either b c) ~ (b -> String, c -> String)
这没错,Either b c
上的任意函数都会用一个带有两种情形的case语句来实现,这要提供一对函数。
类似地,当固定hom集的第一个参数时,我们得到了熟悉的reader函子。它的连续性是说,比如吧,任何返回一个积的函数都等价于一个函数的积;具体来说就是:
r -> (a, b) ~ (r -> a, r -> b)
我知道你现在在想什么:你不需要范畴论就可以想出它们,并且你是对的!尽管如此,这些结果只用一些不涉及到比特和字节、处理器架构、编译技术或甚至lambda演算的基本原理就能得到还是让我震撼。
如果你对“极限”和“连续性”的命名由来很好奇的话,其实它们是对应的微积分观念的一个推广。在微积分中,极限和连续性是用开集来定义的。开集定义了拓扑,而拓扑构成了一个范畴(偏序集)。
挑战
- 你会如何在C++的类范畴中描述外推?
- 证明恒等函子
Id :: C -> C
的极限是初始对象。[事实上,Id
对应的锥只有一个,它自动就成了最一般的那个,译者注] - 一个给定集合的所有子集构成了一个范畴。如果一个子集是另外一个子集的子集,那么它们之间就有一个箭头作为态射(即态射定义为“包含于”,译者注)。在这个范畴里两个集合的回拉是什么?外推呢?初始对象和终端对象呢?
- 你能不能猜一猜等化子是什么?
- 证明,在一个具有终端对象的范畴里,指向终端对象的回拉是积。
- 类似地,证明从初始对象(如果存在的话)指出的外推是余积。
致谢
感谢Gershom Bazerman检查我的数学和逻辑,和André van Meulebrouck在编辑上的帮助。
下一篇:自由幺半群