原文见 http://bartoszmilewski.com/20...
-> 上一篇:『Kleisli 范畴』
沿着箭头
古希腊剧作家 Euripides 曾说过:『每个人都像他尽力维护的同伴』。我们被我们的人际关系所定义。在范畴论中更是这样。如果想刻画范畴中的一个对象,只能通过描述这个对象与其他对象(或其自身)之间的关系模式来实现。
范畴论中有一个常见的构造,叫做泛构造(Universal Construction),它就是通过对象之间的关系来定义对象,其方式之一就是拮取一个模式——由对象与态射构成的一种特殊的形状,然后在范畴中观察它的各个方面。如果这个模式很常见而且范畴也很大,你就会有大量的机会命中它。技巧是如何对这些命中机会进行排序,并选出最好的那个。
这个过程就类似于我们进行网络搜索时所采用的方式。一次查询就是一个模式。一次非常宽泛的查询会召来大量的命中。其中有些可能是你所关注的,其他的则不是。为了消减无关的命中,你会对查询进行优化,来提高它的命中精度。最终,搜索引擎会对命中的结果划分等级,很有可能你想要的结果就位于命中结果等级序列的顶端。
初始对象
最简单的形状是单个的对象。显然,这种形状有许多实例,因为给定的范畴中有许多对象。可选对象过多。我们需要将它们划分等级,去寻找处于最上层的那个对象。这意味着我们要根据态射来划分对象的等级(译注:否则范畴中还有什么?)。如果将态射想象为箭头,它们可能会形成一张网,在这张网中,箭头从范畴的一端流向另一端。在有序的范畴中会出现这种情况,例如偏序范畴。可以将对象先后次序的概念推广一下,即:如果说对象 a 比对象 b 更靠前,那么一定存在一个箭头(态射)是从 a 到 b 的。如果有一个对象,它发出的箭头指向所有其他的对象,那么这个对象就叫做初始对象。显然,对于一个给定的范畴,可能无法保证其中初始对象的存在,这还好说,更大的问题是其中可能有很多个初始对象:召来的东西都挺不错,但是精度不够。不过,可以从有序的范畴那里得到一些启示——任意两个对象之间,它们只允许最多存在 1 个箭头:小于或等于另外一个对象。经过有序范畴的引导,我们这样定义初始对象:
初始对象,这种对象有且仅有一个态射指向范畴中的任意一个对象。
然而,即使这样定义初始对象也无法担它的唯一性(如果它存在),但是这个定义能担保最好的一件事是:在同构意义下,它具有唯一性。同构(Isomorphism)在范畴论中非常重要,过会儿再谈它。现在,我们只需要认定,初始对象的定义主要是为了让初始对象在同构意义下具备唯一性。
在此,给出几个与初始对象有关的例子:偏序集(通常称为 poset)的初始对象是那个最小的对象。有些偏序集没有初始对象——例如整数集。
在集合范畴中,初始对象是是空集。记住,在 Haskell 中,空集相当于 Void
类型(C++ 中没有相对应的类型),而且从 Void
到任意类型的多态函数是唯一的,它叫 absurd
:
absurd :: Void -> a
它是一个态射族,因为它的存在,Void
才会成为类型范畴中的初始对象。
终端对象
继续考察单对象模式,现在改变一下划分对象等级的方式。如果有一个态射从对象 b 到对象 a,那么认为 a 比 b 更靠后。我们要在范畴中寻找比任何其他对象都靠后的那个对象,并且坚持认定它也具备着这样的唯一性:
终端对象,这种对象有且仅有一个态射来自范畴中的任意对象。
同样,终端对象在同构意义下具有唯一性。同构是什么鬼,后面会讲。来看几个例子。在偏序集内,如果存在着终端对象,那么它是那个最大的对象。在集合范畴中,终端对象是一个单例。我们已经讨论过单例,它们相当于 C++ 中的 void
类型,也相当于 Haskell 中的 unit 类型 ()
。单例就是只有一个值的类型,在 C++ 中是隐匿的,在 Haskell 中是显式的。我们已经确定,有且仅有一个纯函数,它从任意类型到 unit 类型:
unit :: a -> ()
unit _ = ()
因此,对于单例而言,终端对象的所有条件都是能够满足的。
注意,上面示例中,唯一性的条件是非常重要的,因为有些集合(实际上是除了空集的所有集合)会有来自其他集合的态射。例如,有一个布尔类型的函数(谓词),它的定义适合所有类型:
yes :: a -> Bool
yes _ = True
但是 Bool
并不是一个终端对象,因为至少还存在一个同样是适合所有类型的布尔类型的函数:
no :: a -> Bool
no _ = False
坚持唯一性能够得到足够的精度,可以将终端对象的定义紧缩为一种类型。
对偶
你肯定已经注意到了初始对象与终端对象是对称的。二者之间唯一的区别是态射的方向。事实上,对于任意范畴 C,我们总能定义一个相反的范畴 C',只需将 C 中的箭头都反转一下,然后再重新定义一下态射的复合方式,那么相反的范畴就能够自然满足所有的范畴法则。如果原始态射 f::a->b
和 g::b->c
用 h=g∘f
复合为 h::a->c
,那么逆向的态射 f'::b->a
与 g'::c->b
可通过 h'=f'∘g'
复合为 h'::c->a
。恒等箭头保持不变。
对偶是范畴的一个非常重要的性质,因为它能够让数学家在范畴论中的工作事半功倍。你所提出的每一种构造,都有其对偶的构造;你所证明的任何一个定理,都会得到它的一个免费版本。相反的范畴中的构造,通常冠以『co(余)』,因此就有了 product(积)与coproduct(余积),monad(单子) 与 comonad(余单子),cone(锥)与 cocone(余锥)等等。没有 cocomonad(余余单子),因为箭头反转两次又回到初始状态。
一个范畴中的终端对象,在相反范畴中就是初始对象。
同构
作为程序猿,我们深知相等不是那么容易定义。两个对象相等是什么意思?它们是占据相同的位置(指针相等)么?或者它们所有成员的值相等么?两个复数,如果其中一个用实部与虚部来表示,另一个用模与幅角来表示,它们是相等的么?你可能会认为数学家能够描述出相等的意义,但实际上他们也做不到。他们不得不定义出来多种相等,有命题相等、内涵相等、外延相等,还有拓扑类型理论中的路径相等之类。于是,就出现了一种弱化的相等概念——同构。
直觉上,同构的对象看上去是一样的,也就是说它们有相同的形状。这意味着一个对象的每一个部分都能与另一个对象的某一个部分形成一对一的映射,直到我们的『仪器』能够检测出这两个对象是彼此的拷贝。在数学上,这意味着存在一个从对象 a 到对象 b 的映射,同时也存在着一个从对象 b 到对象 a 的映射,这两个映射是互逆的。在范畴论中,我们用态射取代了映射。一个同构是一个可逆的态射;或者是一对互逆的态射。
我们可以通过复合与恒等来理解互逆:如果态射 g 与态射 f 的复合结果是恒等态射,那么 g 是 f 的逆。这体现为以下两个方程,因为两个态射存在两种复合形式:
f . g = id
g . f = id
前面我说过,初始(终端)对象在同构意义下具有唯一性,我的意思就是任意两个初始(终端)对象都是同构的。这实际上很容易看出来。假设两个初始对象 $i_1$ 与 $i_2$,因为 $i_1$ 是初始对象,因此有唯一的态射 f 从 $i_1$ 到 $i_2$,同理也有唯一的态射 g 从 $i_2$ 到 $i_1$。这两个态射的复合结果是什么?
图中所有的态射都具有唯一性
g∘f
肯定是从 $i_1$ 到 $i_1$ 的态射,因为 $i_1$ 是初始对象,因此它只容许一个从 $i_1$ 到 $i_1$ 的态射的存在。因为我们是在一个范畴中,我们知道从 $i_1$ 到 $i_1$ 的态射就是一个恒等态射,因此 g∘f
等于恒等态射。同理,f∘g
的结果也是恒等态射。这样就证明了 f 与 g 是互逆的,因此两个初始对象就是同构的。
注意,在上述证明中,我们使用的是从初始对象到它本身的态射的唯一性。如果没有这个前提条件,我们就无法证明『同构意义下』这部分。但是,为什么需要 f 与 g 也是唯一的?因为初始对象不仅在同构意义下具有唯一性,而且这个同构是唯一的。理论上,两个对象之间可能存在不止一种同构关系,虽然它们未在这里出现。这种『在同构意义下具有唯一性,而且这个同构是唯一的』是所有泛构造的一个重要性质。
积
还有一个泛构造,叫做积。我们知道两个集合的笛卡尔积:序对的集合。但是积集合与成分集合(译注:参与积运算的集合),它们之间存在着什么样的连接模式?如果我们能说清楚它,那么就能够将积推广到其他范畴。
从积到每个成分,存在两个投影。在 Haskell 中,这两个函数被称为 fst
与 snd
,它们分别从序对中拮取第一与第二个成员:
fst :: (a, b) -> a
fst (x, y) = x
snd :: (a, b) -> b
snd (x, y) = y
在此,这两个函数是采用参数的模板匹配来定义的,即对于任意序对 (x, y)
,模板匹配可以从中抽取变量 x
与 y
。
这些定义可以用通配符作进一步简化:
fst (x, _) = x
snd (_, y) = y
在 C++ 中,可以使用模板函数来模拟,例如:
template
A fst(pair const &p) {
return p.first;
}
借助这看上去非常有限的知识,我们试着去定义集合范畴中对象与态射的模式,这种模式可以引导我们去构造两个集合 a 与 b 的积。这个模式由对象 c 与两个态射 p 与 q 构成,p 与 q 将 c 分别连向 a 与 b:
p :: c -> a
q :: c -> b
所有的适合这种模式的 c 都会被认为是候选积,因为这样的 c 可能有很多。
例如,从 Haskell 类型中选择 Int
与 Bool
,让它们相乘,并将 Int
与 Bool
作为候选积。
假设 Int
是候选积。Int
能够被认为是 Int
与 Bool
相乘的候选积吗?是的,它能,因为它具有以下投影:
p :: Int -> Int
p x = x
q :: Int -> Bool
q _ = True
虽然相当无聊,但它符合候选积的条件。
还有一个 (Int, Int, Bool)
,这是个三元组。它也是个合法的候选积,因为存在:
p :: (Int, Int, Bool) -> Int
p (x, _, _) = x
q :: (Int, Int, Bool) -> Bool
q (_, _, b) = b
可能你会注意到,第一个候选积太小了——它只覆盖了乘积的 Int
维,而第二个候选积又太大了——它复制了一个没必要的 Int
维。
我们还没有探索这个泛构造的其他部分:等级划分。我们希望能够比较这种模式的两个实例,也就是说对于候选积 c 与候选积 c',我们想对它们做一些比较,以便作出 c 比 c'『更好』这样的结论。如果有一个从 c' 到 c 的态射 m,虽然可以基于这个态射认为 c 比 c' 更好,但是这样还是太弱了。因为我们还希望 c 伴随的投影 p 与 q 比 c' 的 p' 与 q' 『更好』或『更泛』,这意味着可以通过态射 m 从 q 与 q 分别构造出 p' 与 q':
p' = p . m
q' = q . m
从另一个角度来看,这些方程像是 m 因式化了 p' 与 q'。若将上面的这两个方程想象为自然数,将点符号想象为相乘,那么 m 就是 p' 与 q' 的公因式了。
上面只是为了建立一些直觉,现在来看一下伴随序对 (Int, Bool)
的两个经典的投影,fst
与 snd
,它们要比刚才我给出的那两个候选积的投影更好。
第一个候选积的 m
是:
m :: Int -> (Int, Bool)
m x = (x, True)
这个候选积的两个投影可重构为:
p x = fst (m x) = x
q x = snd (m x) = True
对于第二个候选积而言,m
似乎是唯一的:
m (x, _, b) = (x, b)
我们说了 (Int, Bool)
要比前两个候选积更好。现在给出我们的理由。问一下自己,我们能找到某个 m'
,它能够帮助我们从伴随候选积的 p
与 q
重构出 fst
与 snd
么?
fst = p . m'
snd = q . m'
对于第一个候选积,q
总是返回 True
,而我们知道存在第 2 个元素是 False
的序对,因此无法从 q
重构 snd
。
第二个候选积就不同了,我们能够在 p
或 q
运行后保留足够的信息,但是对于 fst
与 snd
而言,它们存在着多种因式化方式,因为 p
与 q
会忽略三元组的第 2 个元素,这就意味着我们的 m'
可以在第 2 个元素的位置放任意东西,例如:
m' (x, b) = (x, x, b)
或
m' (x, b) = (x, 42, b)
等等。
总而言之,对于给定的任意类型 c
,它伴随着两个投影 p
与 q
,存在着唯一的一个 m
可将 c
映射为笛卡尔积 (a, b)
,而这个 m
对 p
与 q
的因式化就是将 p
与 q
组合成序对:
m :: c -> (a, b)
m x = (p x, q x)
这样就决定了笛卡尔积 (a, b)
是最好的候选积,这意味着这种泛构造对于集合范畴是有效的,它涵盖了任意两个集合的积。
现在,我们忘记集合,使用相同的泛构造来定义任意范畴中两个对象的积。这样的积并非总是存在,但是一旦它存在,它就在同构意义下具有唯一性,而且这个同构是唯一的。
对象 a 与对象 b 的积是伴随两个投影的对象 c。对于任何其他伴随两个投影的对象 c' 而言,存在唯一的从 c' 到 c 的态射,这个态射可以因式化这两个投影。
一个高阶函数能够生成因子 m
,这个高阶函数有时被称为因子生成器。对于本文的示例中,它是这样的函数:
factorizer :: (c -> a) -> (c -> b) -> (c -> (a, b))
factorizer p q = \x -> (p x, q x)
余积
同范畴论中每个构造一样,积有一个对偶,叫做余积。将积的范式中的箭头反转,就可以得到一个对象 c,它伴随两个入射 i
与 j
——从 a 到 c 的态射与从 b 到 c 的态射。
i :: a -> c
j :: b -> c
等级也反转了:对象 c 比 c' 『更好』的条件是,存在从 c 到 c' 的态射 m,它可以因式化入射:
i' = m . i
j' = m . j
『最好』的对象就是,具有唯一的态射从其本身指向其他模式,这种对象就叫做余积,并且如果它存在,那么它就在同构意义下具有唯一性,而且这个同构是唯一的。
两个对象 a 与 b 的余积是对象 c,当且仅当 c 伴随着两个入射,而且任何一个其他的伴随两个入射的对象 c',只存在唯一的从 c 到 c' 的态射 m,并且 m 可以因式化这些入射。
在集合的范畴中,余积就是两个集合的不相交求并运算。集合 a 与集合 b 的不相交求并结果中的一个元素,要么是 a 中的元素,要么是 b 中的元素。如果两个集合有交集,那么不相交求并的结果会包含两个集合公共部分的两份拷贝。你可以将不相交求并运算结果的一个元素想象为贴着它所属集合的标签的元素。
对于程序猿而言,余积很容易理解,它不过是两种类型的带标签的联合。C++ 有联合类型,只不过它们没标签。如果你在程序中想跟踪哪个联合的成员有效,必须用枚举类型自行定义标签。例如,int
与 char const *
的一个标签化联合类型如下:
struct Contact {
enum { isPhone, isEmail } tag;
union { int phoneNum; char const * emailAddr; };
};
两个射入可以实现为构造子或函数。例如,下面是第一个射入的函数实现:
Contact PhoneNum(int n) {
Contact c;
c.tag = isPhone;
c.phoneNum = n;
return c;
}
它将一个整型类型射入 Contact
。
带标签的联合被称为变体(Variant),boost 库里实现了一个泛型的变体 boost::variant
。
在 Haskell 中,你可以将任意数据类型组合为带标签的联合,只需用竖线隔开数据构造子即可。上面 C++ 的 Contact
的示例可以翻译为:
data Contact = PhoneNum Int | EmailAddr String
这里,PhoneNum
与 EmailAddr
都是构造子(入射),也可以作为模式匹配(以后会讲)时所用的标签。例如,将电话号码构造为一个 Contact
:
helpdesk :: Contact;
helpdesk = PhoneNum 2222222
对于 Haskell 而言,与基于内建的序对而实现的『正统』积不同,『正统』的余积的既有实现是标准库中定义的 Either
数据类型:
Either a b = Left a | Right b
这个数据类型接受两个参数 a
与 b
,它还拥有两个构造子:Left
接受类型 a
的值,Right
接受类型 b
的值。
正如我们刚才所定义的积的因式生成器一样,我们也可以为余积定义一个。对于给定的候选余积 c 以及两个候选入射 i
与 j
,为 Either
生成因式函数的的因式生成器可定义为:
factorizer :: (a -> c) -> (b -> c) -> Either a b -> c
factorizer i j (Left a) = i a
factorizer i j (Right b) = j b
非对称
我们已经见识了两种对偶结构:终端对象可由初始对象的箭头反转而获得,余积可由积的箭头的反转而获得。不过,在集合的范畴中,初始对象与终端对象有着显著的区别,余积与积也有着显著区别。以后会看到积的行为像是乘法运算,终端对象扮演者 1 的角色,而余积的行为更像求和运算,初始对象扮演着 0 的角色。在特殊情况下,对于有限集,积的尺寸就是各个集合的尺寸的积,而余积的尺寸是各个集合的尺寸之和。
这一切都表明了集合的范畴不会随箭头的反转而出现对称性。
注意,空集可以向任意一个集合发出唯一的态射(absurd
函数),但是它没有其他集合发来的态射。单例集合拥有任意集合发来的唯一的态射,但它也能向任一集合(除了空集)发出态射。由终端对象发出的态射在拮取其他集合中的元素方面扮演了重要的角色(空集没有元素,因此没什么东西可拮取的)。
单例作为积与它作为余积有着天壤之别。要将单例 ()
作为品质低劣的候选积,需要给它配备两个投影 p
与 q
。由于积是泛构造,因此存在一个从 ()
到积的态射 m
。这个态射从积集合中选出一个元素——序对,它也因式化了两个投影:
p = fst . m
q = snd . m
这两个投影作用于单例的值 ()
,上面那两个方程就变为:
p () = fst (m ())
q () = snd (m ())
因为 m ()
是 m
从积集合中拮取的元素,p
所拮取的是第一个参与积运算的集合中的元素,结果是 p ()
,而 q
所拮取的是第二各参与积运算的集合中的元素,结果是 q ()
。这完全符合我们对积的理解,即参与积运算的集合中的元素形成积集合中的序对。
若单例作为候选的余积,就不会它作为候选的积那样简单了。我们可以通过投影从单例中抽取元素,但是向单例入射就显得没有意义了,因为它们的『源』在入射时会丢失。从真正的余积到作为候选余积的单例的态射也不是唯一的。集合的范畴,从初始对象的方向去看,与从终端对象的方向去看,是有显著差异的。
这其实不是集合的性质,而是是我们在 Set 中作为态射使用的函数的性质。函数,通常是非对称的,下面我来解释一下。
函数是建立在它的定义域(Domain)上的(在编程中,称之为全函数),它不必覆盖余定义域(Codomain,译注:可能叫陪域更正式一些)。我们已经看到了一些极端的例子(实际上,所有定义域是空集的函数都是极端的):定义域是单例的函数,意味着它只在余定义域上选择了一个元素。若定义域的尺度远小于余域的尺度,我们通常认为这样的函数是将定义域嵌入余定义域中了。例如,我们可以认为,定义域是单例的函数,它将单例嵌入到了余定义域中。我将这样的函数称为嵌入函数,但是数学家给从相反的角度进行命名:覆盖了余定义域的函数称为满射(Surjective)函数或映成(Onto)函数。
函数的非对称性也表现为,函数可以将定义域中的许多元素映射为余定义域上的一个元素,也就是说函数坍缩了。一个极端的例子是函数使整个集合坍缩为一个单例,unit
函数干的就是这事。这种坍缩只能通过函数的复合进行混成。两个坍缩函数的复合,其坍缩能力要强过二者单兵作战。数学家为非坍缩函数取了个名字:内射(Injective)或一对一(One-to-one)映射。
当然,有许多函数即不是嵌入的,也不是坍缩的。它们被称为双射(Bijection)函数,它们是完全对称的,因为它们是可逆的。在集合范畴中,同构就是双射的。
$$ \cdot $$
挑战
1.
证明终端对象在同构意义下具有唯一性,而且这个同构是唯一的。2.
偏序集内的两个对象的积是什么?提示:使用泛构造。3.
偏序集内的两个对象的余积是什么?4.
用你喜欢的语言(Haskell 除外),实现与 Haskell 的 Either
等价的泛型类型。5.
证明 Either
是比 int
更好的余积。int
伴随的两个入射是:
int i(int n) { return n; }
int j(bool b) { return b? 0: 1; }
提示:定义一个函数
int m(Either const & e);
用它因式化 i
与 j
。
6.
继续上一个问题,如何证明 int
不会比 Either
更好?
7.
依然继续上述第 5 个问题:下面的入射怎么样?
int i(int n) {
if (n < 0) return n;
return n + 2;
}
int j(bool b) { return b? 0: 1; }
8.
针对 int
与 bool
,给出一个品质低劣的候选余积,使之有多个态射抵达 Either
。
参考文献
- The Catsters, Products and Coproducts video.
致谢
感谢 Gershom Bazerman 对这篇文档的审阅以及针对性的讨论。
-> 下一篇:『简单的代数数据类型』