函数的基本概念:从加运算说起

函数定义

在数学上,我们已经习惯书写 1+2+3+41 + 2 + 3 + 4 这种表达式。尽管看起来非常普通,但这个表达式里其实已经包含了非常多函数式编程的重要思想。

好吧,你可能会说,这里面哪有函数呢?其实 ++ 就是一个二元函数,可以等价为 add⁡\operatorname{add}

x+y=add⁡(x,y) x + y = \operatorname{add}(x, y)

add⁡\operatorname{add} 输入两个实数,输出一个实数。我们可以用符号表示为

add⁡:(R,R)→R \operatorname{add}: (\mathbb{R},\mathbb{R}) \to \mathbb{R}

在 Javascript 里可以这样定义

function add(x, y) {
  return x + y;
}

或者使用箭头函数

const add = (x, y) => x + y;

有了 add 后,就可以改写原来的表达式

add(1, add(2, add(3, 4)));

可以看到,虽然表达式变得更加复杂,但计算结果是一样的。

不过,这里存在一些细微的差异:计算顺序不相同。++ 通常默认为左结合,即从左到右,从 11 到 44;而我们的函数嵌套表达式其实是从右到左,从 44 到 11。如果要改为从 11 到 44,需要写成如下形式

add(add(add(1, 2), 3), 4);

函数的类型

++、−-(减运算)、×\times、÷\div 都是二元函数,即接受两个参数并返回一个值。它们可以统一表示为

f:(R,R)→R f: (\mathbb{R},\mathbb{R}) \to \mathbb{R}

这里需要区分几个概念

  • 声明定义域:函数声明的输入集合
  • 实际定义域:函数实际能够接受的输入集合
  • 陪域:函数声明的输出集合
  • 值域:函数实际能够取到的输出集合

在数学上通常不区分声明定义域和实际定义域,而只说定义域——此时指的其实是实际定义域,因为数学函数要求在定义域的每个元素上都有定义;而值域在数学里也比陪域更多见。

但在函数式编程里,更常用的是声明定义域和陪域,函数实际的输入集合与输出集合反而较少关心——因为这些可能涉及程序的运行时状态,不方便静态分析。

上面的函数中,除 ÷\div 外声明的集合都和实际的集合相同;而 ÷\div 的第二个操作数是除数,不能为 00,所以它的实际定义域是 (R,R∗)(\mathbb{R},\mathbb{R}^*)。其中 R∗\mathbb{R}^* 表示 R\mathbb{R} 除去 00 这一元素,即 R∗=R∖{ 0 }\mathbb{R}^*=\mathbb{R} \setminus \set{0}。

而 −-(取负运算)、sin⁡\sin、cos⁡\cos、log⁡\log(底数为 ee)、exp⁡\exp(底数为 ee)都是一元函数,可以表示为

f:R→R f: \mathbb{R} \to \mathbb{R}

这里声明的集合与实际的集合之间也存在区别

  • log⁡\log 只能作用在正实数上,因此实际的输入集合是 R+\mathbb{R^+}
  • exp⁡\exp 的结果只可能为正实数,因此实际的输出集合是 R+\mathbb{R}^+
  • sin⁡\sin 和 cos⁡\cos 的结果绝对值不大于 11,因此实际的输出集合是 [−1,1][-1, 1]

在实际编程中,我们会面对非常多的类型。

比如在 TypeScript 中就有 number / string / boolean / null / undefined 等基础类型,T[] / [T1, T2] / { p1: T1; p2: T2 } / T1 | T2 等复合类型,以及直接把字面量作为类型的字面量类型。函数同样也有类型,它的类型由参数类型和返回值类型共同决定

function parseInteger(s: string) {
  return /^\d+$/.test(s) ? Number(s) : "请输入非负整数";
}

上述函数的类型就是 string => number | "请输入非负整数",即参数类型为 string,值类型可能是 number,也可能是字面量 "请输入非负整数"。

函数类型的主要用处,就是推导函数之间能否组合,以及组合后得到的函数是什么类型。

函数的组合

现实世界中的函数是无穷无尽的,因为函数之间可以组合,而函数的组合可以得到新的函数。

我们先考虑数学中的例子。我们定义一个一元函数

f(x)=x+1x2−1 f(x) = \frac{x + 1}{x^2 - 1}

使用 add、sub、mul、div 改写,以便更清晰地看出函数组合的本质

function f(x) {
  return div(add(x, 1), sub(mul(x, x), 1));
}

其中 mul 的值被作为 sub 的参数,而 add 和 sub 的值又作为 div 的参数。这就引出了函数组合需要满足的要求:只有当一个函数的返回值可以匹配另一个函数的参数时,才可以把前者的输出作为后者的输入。

上述例子中,输出是可以匹配输入的。

div 第二个参数实际只接受 R∗\mathbb{R}^*,而这个位置是 sub 的返回值,因此 sub 的返回值被限制为 R∗\mathbb{R}^*。但在不考虑 div 的情况下,子表达式 sub(mul(x, x), 1) 实际的输出集合是 [−1,+∞)[-1, +\infty)。这两个集合并不相同,在数学上,我们可以用差集判断哪些输入非法,用交集判断哪些输入合法

  • 由于 [−1,+∞)∖R∗={ 0 }[-1, +\infty) \setminus \mathbb{R}^* = \set{0},因此 { 0 }\set{0} 不能用作 div 的输入或 sub 的输出
  • 由于 [−1,+∞)∩R∗=[−1,0)∪(0,+∞)[-1, +\infty) \cap \mathbb{R}^*= [-1, 0) \cup (0, +\infty),因此 [−1,0)∪(0,+∞)[-1, 0) \cup (0, +\infty) 可以用作 div 的输入和 sub 的输出

经过一番推理后,我们最终可以确定这个函数实际的输入和输出集合为

f:R∖{ −1,1 }→R∖{ 0,−0.5 } f: \mathbb{R} \setminus \set{-1, 1} \to \mathbb{R} \setminus \set{0, -0.5}

现在我们构造一个特殊的例子,看看当输出不匹配输入时会发生什么

f(x)=log⁡(−exp⁡(x)) f(x)= \log(-\exp(x))
  1. exp⁡\exp 的实际输入和输出为 R→R+\mathbb{R} \to \mathbb{R}^+,因此 R\mathbb{R} 被映射到 R+\mathbb{R}^+
  2. −- 的输入接上 exp⁡\exp 的输出 R+\mathbb{R}^+,而 −- 在 R+\mathbb{R}^+ 上的像集为 R−\mathbb{R}^-,因此 R+\mathbb{R}^+ 被映射到 R−\mathbb{R}^-
  3. log⁡\log 的输入接上 −- 的输出 R−\mathbb{R}^-,但 log⁡\log 实际只接受 R+\mathbb{R}^+,而 R+∩R−=∅\mathbb{R}^+ \cap \mathbb{R}^- = \empty,因此不存在合法的输入

最后我们发现,任何 x∈Rx \in \mathbb{R} 都无法作为 ff 的输入。因此在实数范围内,这种组合是失败的。用数学的说法,这叫定义域为空。

在实际编程里,函数类型的推导与之类似。

比如 TypeScript 可以根据函数声明的参数类型,自动推导出返回值的类型;但这种类型推导显然不会像刚才的分析那样对类型中的每个值都进行验证,而只会在声明的输入类型上进行类型层面的推导。

这是一个 TypeScript 中的例子

const getLength = (s: string): number => s.length;
const isBig = (n: number): boolean => n > 32;

这两个函数分别对应类型

getLength:string→numberisBig:number→boolean \begin{align*} \text{getLength} &: \text{string} \to \text{number} \\ \text{isBig} &: \text{number} \to \text{boolean} \end{align*}

如果把 getLength 的返回值作为 isBig 的参数

const isLongString = (s: string) => isBig(getLength(s));

由于两者类型都是 number,因此可以进行组合,并且 TypeScript 能够推断出 isLongString 的类型应该为

isLongString:string→boolean \text{isLongString}: \text{string} \to \text{boolean}

但反过来把 isBig 的返回值作为 getLength 的参数就存在问题

const bad = (n: number) => getLength(isBig(n));

这里 isBig 的返回值是 boolean 而 getLength 的参数为 string,类型不匹配。因此无法这样组合。

描述函数的符号:范畴论、类型论、集合论

数学中常用的语言是集合论,但在函数式编程里,更常用的是类型论和范畴论。

本文不会详细介绍范畴论和类型论,不过作为一种入门直觉,可以用集合论进行概念上的类比

范畴论类型论集合论
对象类型集合
态射项映射

比如指数函数 exp⁡(x)=ex\exp(x) = e^x,在集合论里这是一个从实数集合 R\mathbb{R} 到正实数集合 R+\mathbb{R}^+ 的映射

exp⁡:R→R+ \exp: \mathbb{R} \to \mathbb{R}^+

在类型论里,这是函数类型 Real→PositiveReal\text{Real} \to \text{PositiveReal} 中的一个项

⟦exp⁡⟧:Real→PositiveReal \llbracket \exp \rrbracket: \mathsf{Real} \to \mathsf{PositiveReal}

或者表示为

x:Real⊢exp⁡(x):PositiveReal x:\mathsf{Real} \vdash \exp(x):\mathsf{PositiveReal}

而在 Set\mathbf{Set} 范畴中,这是从对象 AA 到对象 BB 的态射

exp⁡∈HomSet(A,B) \exp\in \mathrm{Hom}_{\mathbf{Set}}(A,B)

在笛卡尔闭范畴里,还可以表示为从全局元素 11 到指数对象 BAB^A 的态射

⟦exp⁡⟧:1→BA \llbracket \exp\rrbracket:1\to B^A

函数复合与管道:将多个函数连接成一个函数

管道和函数复合密切相关。我们先介绍函数复合。

函数复合的数学定义

数学上,函数复合是一种特殊的函数组合。有这样一组一元函数

f:A→Bg:B→Ch:C→D \begin{align*} f: A \to B \\ g: B \to C \\ h: C \to D \\ \end{align*}

我们希望先计算 ff,再计算 gg,最后计算 hh,即

A→fB→gC→hD A \xrightarrow{f} B \xrightarrow{g} C \xrightarrow{h} D

那么这个组合出来的函数需要这样写

h(g(f(x))) h(g(f(x)))

如果再多计算几个函数,这种嵌套会变得非常复杂,难以阅读。数学上可以使用函数复合来简化此时的函数组合

h∘g∘f:A→D h \circ g \circ f: A \to D

复合的顺序是从右到左,且满足结合律

h∘(g∘f)=(h∘g)∘f h\circ(g\circ f) = (h\circ g)\circ f

函数复合得到的结果是一个新的函数。这个新的函数可以接受一个参数,等价于原先的写法

(h∘g∘f)(x)=h(g(f(x))) (h \circ g \circ f)(x) = h(g(f(x)))

管道的数学定义

而管道几乎就只是给函数复合换了个符号和顺序:管道的顺序是从左到右,而函数复合是从右到左

h∘g∘f⇔f∣g∣h h \circ g \circ f \Leftrightarrow f \mid g \mid h

而最初送入管道的值,通常就放到管道的最左边

(h∘g∘f)(x)⇔x∣f∣g∣h (h \circ g \circ f)(x) \Leftrightarrow x \mid f \mid g \mid h

管道在编程语言里比较常见的符号是 | 和 |>。另外一些语言的链式调用 object.f().g().h() 也可以看作一种管道符号,虽然写法上是面向对象的,但计算模型上却是函数式的。

管道示例:Bash

管道出现在许多语言里。比如 Bash 的管道

cat server.log \
  | grep "ERROR" \
  | sort \
  | uniq -c

这可以被理解为

uniq_count(sort(grep_error(cat("server.log"))));

每个函数(bash 中的术语是命令)的输入是文本,输出也是文本,因此每个函数都是

Text→Text \text{Text} \to \text{Text}

这满足了函数复合的要求——前一个函数的输出匹配下一个函数的输入。因此各种 UNIX 工具才可以自由组合。

管道示例:SQL

一些 SQL 方言也支持管道语法,比如 GoogleSQL

FROM users
|> WHERE age >= 18
|> AGGREGATE COUNT(*) AS user_count
   GROUP BY age
|> WHERE user_count >= 100
|> ORDER BY age DESC
|> LIMIT 10;

SQL 的每个函数(SQL 中的术语是操作)都接收表,然后返回表

Table→Table \text{Table} \to \text{Table}

于是 GoogleSQL 里的操作就可以自由组合,不会发生普通 SQL 里 AGGREGATE 聚合之后再筛选时只能用 HAVING 不能用 WHERE 的问题。

管道示例:jq

jq 语言里也存在大量的管道

.users[]
| select(.age >= 18)
| .name

jq 的每个函数(jq 中的术语是过滤器)都接受 JSON,然后返回零个、一个或多个 JSON

JSON→Stream⁡(JSON) \text{JSON} \to \operatorname{Stream}(\text{JSON})

因此 jq 中有时候需要用 [filter] 等方法让 Stream⁡(JSON)\operatorname{Stream}(\text{JSON}) 重新变回 JSON\text{JSON},以便后续的管道能够将输入当成单个 JSON 处理。

管道示例:Haskell

Haskell 里可以使用 & 作为管道操作符

import Data.Function ((&))

3 & (+1) & (*2) & show

或者不用标准库,自己实现一个管道 |>

x |> f = f x

3 |> (+1) |> (*2) |> show

不过 Haskell 中的函数复合更值得拿出来说明

sumSquaresOfEvens :: [Int] -> Int
sumSquaresOfEvens = sum . map (^2) . filter even

Haskell 的管道从左到右,但函数复合从右到左,且语法完全照搬数学上的写法,连符号都很相似

sum⁡∘map(square)⁡∘filter(even)⁡ \operatorname{sum} \circ \operatorname{map(square)} \circ \operatorname{filter(even)}

这些函数的类型分别为

filter even :: [Int] -> [Int]
map (^2)    :: [Int] -> [Int]
sum         :: [Int] -> Int

复合的完整过程为

[Int]→filter⁡[Int]→map⁡[Int]→sum⁡Int [\text{Int}] \xrightarrow{\operatorname{filter}} [\text{Int}] \xrightarrow{\operatorname{map}} [\text{Int}] \xrightarrow{\operatorname{sum}} \text{Int}

所以这些函数可以把输入输出连接起来,复合成一个函数。

管道示例:Java

虽然 Java 是一门面向对象语言,但链式调用本身却非常的函数式

users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .sorted()
    .toList();

这里的实际含义是,在 users 对象上调用 stream(),然后再在 stream() 返回的对象上调用 filter(User::isActive),并重复这个过程到 toList() 调用完成。虽然语法建立在面向对象的基础上,但思维却非常的函数式

Users→filter⁡ActiveUsers→map⁡Names→sort⁡SortedNames \text{Users} \xrightarrow{\operatorname{filter}} \text{ActiveUsers} \xrightarrow{\operatorname{map}} \text{Names} \xrightarrow{\operatorname{sort}} \text{SortedNames}

这完全就是前面提到的管道。

管道示例:JavaScript

在 JavaScript 里同样有链式调用

[1, 2, 3, 4, 5, 6]
  .filter((n) => n % 2 === 0)
  .map((n) => n * 10)
  .reduce((a, b) => a + b, 0);
// 120

其中的类型变化是 number[] => number[] => number[] => number。

JavaScript 目前还没有原生的管道语法,不过可以自己手动实现一个简易的 pipe

function pipe(...fns) {
  return (x) => fns.reduce((v, fn) => fn(v), x);
}

这里 pipe 本身是一个函数,它的参数是函数,返回值也是函数。这叫做高阶函数——我们马上就会讲到。pipe 调用后返回一个函数,再调用返回的函数就能得到值

pipe(Math.floor, Math.sqrt, Math.round)(3.7); // 2
pipe(Number, Math.sqrt, Math.round)(" 42 "); // 6

如果想让 pipe 的第一个参数是进入管道的初始值,可以这样修改

function pipe(x, ...fns) {
  return fns.reduce((v, fn) => fn(v), x);
}

然后调用

pipe(3.7, Math.floor, Math.sqrt, Math.round); // 2
pipe(" 42 ", Number, Math.sqrt, Math.round); // 6

高阶函数:函数族、泛函与算子

接下来要介绍的就是函数式编程中相对抽象,但非常重要的概念——高阶函数。

高阶函数指的是参数或返回值中含有函数的函数。为了建立数学直觉,我们先通过三类常见的数学对象——函数族、泛函和算子——来理解高阶函数。

函数族

函数族的输入是数,输出是函数。

在数学上,典型的函数族就是对数函数族

Log⁡:a↦log⁡a \operatorname{Log}: a \mapsto \log_a

对数函数族接受一个底数,然后返回一个对数函数。不过数学上对底数有一些要求,因此其实际类型为

Log⁡:R+∖{ 1 }→(R+→R) \operatorname{Log}: \mathbb{R}^+ \setminus \set{1} \to (\mathbb{R}^+ \to \mathbb{R})

所有对数函数都可以通过 Log⁡\operatorname{Log} 函数族生成出来。而对于那些常用的对数函数,数学中都有别名

Log⁡(e)=log⁡e=ln⁡Log⁡(10)=log⁡10=lg⁡ \begin{align*} \operatorname{Log}(e) &= \log_e = \ln \\ \operatorname{Log}(10) &= \log_{10} = \lg \end{align*}

对返回的函数进行调用就能得到值,这可以等价为对原来的函数族调用两次

log⁡ax=Log⁡(a)(x) \log_a x = \operatorname{Log}(a)(x)

指数函数族也可以这样表示,通过接受底数,返回一个指数函数

Exp⁡:a↦ax \operatorname{Exp}: a \mapsto a^x

实际类型可以表示为

Exp⁡:R+→(R→R+) \operatorname{Exp}: \mathbb{R}^+ \to (\mathbb{R} \to \mathbb{R}^+)

在 JavaScript 里可以如下定义函数族

const Log = (a) => (x) => Math.log(x) / Math.log(a);

然后通过这个函数族生成一连串函数并调用

const ln = Log(Math.E);
const lg = Log(10);

lg(1000);
Log(10)(1000);

Haskell 可以这样写

logFamily :: Floating a => a -> a -> a
logFamily a x = log x / log a

ln = logFamily (exp 1)
log10 = logFamily 10

log10 1000
logFamily 10 1000

当然对于 Haskell,标准库里其实已经有了对数函数族 logBase,直接用就行

log10 = logBase 10
log10 1000
logBase 10 1000

泛函

泛函的输入是函数,输出是数。

在数学上,典型的泛函就是定积分

∫ab:f↦F(b)−F(a) \int_{a}^{b}: f \mapsto F(b) - F(a)

数学上通常用 C(R)C(\mathbb{R}) 表示 R→R\mathbb{R} \to \mathbb{R} 上的连续函数,因此定积分泛函的类型为

∫ab:C(R)→R \int_{a}^b: C(\mathbb{R}) \to \mathbb{R}

一个定积分泛函的示例如下

I01(f)=∫01f(x) I_0^1(f) = \int_0^1 f(x)

把这个泛函作用于函数,就能得到其在 [0,1][0, 1] 区间上的积分

I01(2x)=∫012x=1 I_0^1(2x) = \int_0^1 2x = 1

Haskell 的标准库里没有积分,我们先实现一个简单的数值积分

integrate :: Double -> Double -> (Double -> Double) -> Double
integrate a b f =
  let n  = 10000
      dx = (b - a) / fromIntegral n
  in sum [f (a + (fromIntegral i + 0.5) * dx) * dx | i <- [0 .. n - 1]]

然后可以这样表示区间 [0,1][0, 1] 上的定积分泛函

integralFrom0To1 :: (Double -> Double) -> Double
integralFrom0To1 f = integrate 0 1 f

最后给这泛函一个函数,就会返回其在 [0,1][0, 1] 上定积分的结果。由于数值积分的精度问题,结果不完全准确

ghci> integralFrom0To1 (\x -> 2 * x)
0.9999999999999998
ghci> integralFrom0To1 sin
0.45969769432340096
ghci> integralFrom0To1 cos
0.8414709851585067

算子

算子的输入和输出都是函数。

在数学上,典型的算子就是求导算子

D:f↦f′ D: f \mapsto f'

数学上通常用 C1(R)C^1(\mathbb{R}) 表示 R→R\mathbb{R} \to \mathbb{R} 上的一阶连续可微函数。因此求导算子的类型为

D:C1(R)→C(R) \mathcal{D}: C^1(\mathbb{R}) \to C(\mathbb{R})

求导算子接受一个函数,然后返回其导函数,比如

D:x2↦2x \mathcal{D}: x^2 \mapsto 2x

变限积分也是一个算子,这是一种上限或下限不固定的积分。比如变上限积分

∫ax:f↦F(x)−F(a) \int_a^x: f \mapsto F(x) - F(a)

其类型是

∫ax:C(R)→C1(R) \int_a^x: C(\mathbb{R}) \to C^1(\mathbb{R})

变限积分接受一个函数,返回其原函数。比如

∫0x:2x↦x2 \int_{0}^{x}: 2x \mapsto x^2

在 Haskell 的标准库里没有微分,我们先定义一个简单的数值微分

derivative :: (Double -> Double) -> (Double -> Double)
derivative f = \x ->
    let h = 1e-6
    in (f (x + h) - f (x - h)) / (2 * h)

然后对 x2x^2 运用求导算子

square' = derivative (\x -> x * x)

得到的就是其导函数 2x2x。不过由于数值微分的精度问题,不完全准确

ghci> square' 2
4.000000000115023
ghci> square' 3
6.000000000838668
ghci> square' 4
8.000000000230045
ghci> square' 5
10.00000000139778

对于变上限积分也一样。还是使用之前编写的数值积分

integrate :: Double -> Double -> (Double -> Double) -> Double
integrate a b f =
  let n  = 10000
      dx = (b - a) / fromIntegral n
  in sum [f (a + (fromIntegral i + 0.5) * dx) * dx | i <- [0 .. n - 1]]

这里第一个参数是下限,第二个参数是上限,第三个参数是函数。我们要交换一下变量顺序,让下限的后一个参数是函数

integralFromLower :: Double -> (Double -> Double) -> (Double -> Double)
integralFromLower a f x = integrate a x f

现在我们就得到了变上限积分算子

integralFromLower0 :: (Double -> Double) -> (Double -> Double)
integralFromLower0 = integralFromLower 0

然后对 2x2x 运用变上限积分算子

square = integralFromLower0 (\x -> 2 * x)

得到的函数就是 x2x^2。不过由于数值积分的精度问题,结果不完全准确

ghci> square 2
3.999999999999999
ghci> square 3
9.0
ghci> square 4
15.999999999999996
ghci> square 5
25.0

更一般的高阶函数

前面提到的只是数学上比较典型的高阶函数,而实际的高阶函数远远不止这几种。

比如,数学上的不定积分接受一个函数,然后返回其原函数族

∫:f↦F(x)+C \int: f \mapsto F(x)+C

这里 F(x)+CF(x)+C 就是之前提到的函数族,指定了参数 CC 后才能得到唯一的原函数。因此 F(x)+CF(x)+C 的类型为 R→C1(R)\mathbb{R} \to C^1(\mathbb{R})。

而不定积分返回这个函数族,类型可写为

∫:C(R)→(R→C1(R)) \int: C(\mathbb{R}) \to (\mathbb{R} \to C^1(\mathbb{R}))

比如

∫:2x↦x2+C \int: 2x \mapsto x^2 + C

在指定 CC 后就能确定唯一的原函数。

对于代码示例,我们沿用之前在 Haskell 里实现的简单数值积分

integrate :: Double -> Double -> (Double -> Double) -> Double
integrate a b f =
  let n  = 10000
      dx = (b - a) / fromIntegral n
  in sum [f (a + (fromIntegral i + 0.5) * dx) * dx | i <- [0 .. n - 1]]

这里第一个参数是下限,第二个参数是上限,第三个参数是函数。我们需要修改一下参数顺序

integralIndefinite :: (Double -> Double) -> Double -> Double -> Double
integralIndefinite f c x = integrate 0 x f + c

我们可以测试几个示例

ghci> cosF = integralIndefinite cos 0
ghci> cosF 0
0.0
ghci> cosF 1
0.8414709851585067
ghci> cosF 2
0.9092974283411775
ghci> cosF pi
1.0728180496744066e-16
ghci> cosF (pi/2)
1.0000000010280823

在编程里,高阶函数非常多见。像前面提到的函数复合 pipe 就是一个高阶函数,接受两个函数,返回复合后的函数,其类型可以表示为

pipe⁡:(A→B)×(B→C)→(A→C) \operatorname{pipe}: (A \to B) \times (B \to C) \to (A \to C)

后文将要提到的柯里化 curry 和反柯里化 uncurry 也是高阶函数。可以说,编程中所有对函数做变换的函数,以及用来构造函数的函数,都是高阶函数。

柯里化:把多元函数转为一连串的一元函数

柯里化的数学定义

对于一般的二元函数

f:A×B→C f: A \times B \to C

可以被柯里化为

curry⁡(f):A→(B→C) \operatorname{curry}(f): A \to (B \to C)

也可以把已经柯里化的函数

g:A→(B→C) g: A \to (B \to C)

反柯里化为

uncurry⁡(g):A×B→C \operatorname{uncurry}(g): A \times B \to C

用集合论解释就是,从 A×BA \times B 映射到 CC 的所有函数组成的函数空间,与从 AA 映射到 Hom⁡(B,C)={ f∣f:B→C }\operatorname{Hom}(B, C)=\Set{f\mid f: B \to C} 的所有函数组成的函数空间同构

Hom⁡(A×B,C)≅Hom⁡(A,Hom⁡(B,C)) \operatorname{Hom}(A \times B, C) \cong \operatorname{Hom}(A, \operatorname{Hom}(B, C))

而在范畴论黑话里,这通常表述为:在笛卡尔闭范畴 C\mathcal C 上,对每个对象 BB,积函子 (−×B):C→C(-\times B): \mathcal C \to \mathcal C 是幂函子 (−)B:C→C(-)^B: \mathcal C \to \mathcal C 的左伴随

(−×B)⊣(−)B (-\times B)\dashv (-)^B

正如前面说到的,curry⁡\operatorname{curry} 本身也是一个高阶函数:接受未柯里化的函数,然后返回柯里化的函数。其类型可以表示为

curry⁡:(A×B→C)→(A→B→C) \operatorname{curry}: (A \times B \to C) \to (A \to B \to C)

更一般地,任意一个多元函数

f:A1×A2×⋯ An→B f: A_1 \times A_2 \times \cdots \ A_n \to B

都可以被柯里化为一连串一元函数

curry⁡(f):A1→A2→⋯→An→B \operatorname{curry}(f): A_1 \to A_2 \to \cdots \to A_n \to B

柯里化示例:对数和指数

对于这样一个表达式

log⁡ax \log_a x

显然我们需要同时知道 aa 和 xx 的值,才能得知结果。因此,对数运算其实是一个二元函数

Log⁡(a,x)=log⁡ax \operatorname{Log}(a, x) = \log_a x

我们定义

R>0≠1=R+∖{ 1 } \mathbb R_{>0}^{\neq 1}=\mathbb{R}^+ \setminus \set{1}

把参数 (R,R)(\mathbb{R}, \mathbb{R}) 改写成笛卡尔积的形式 R×R\mathbb{R} \times \mathbb{R},于是 Log⁡\operatorname{Log} 的类型为

Log⁡:R>0≠1×R+→R \operatorname{Log}: \mathbb R_{>0}^{\neq 1} \times \mathbb{R}^+ \to \mathbb{R}

但此前介绍函数族的时候,我们已经发现:我们通常都会固定底数,得到那些常用的对数函数

Log⁡(e)=log⁡e=ln⁡ \operatorname{Log}(e) = \log_e = \ln

在这里 Log⁡\operatorname{Log} 接受一个参数,然后返回一个函数,其类型为

Log⁡:R>0≠1→R+→R \operatorname{Log}: \mathbb R_{>0}^{\neq 1} \to \mathbb{R}^+ \to \mathbb{R}

虽然这和我们最开始定义的类型不一样,但显然 Log⁡\operatorname{Log} 仍然表示相同的运算!因此我们可知这两种类型是同构的

(R>0≠1×R+→R)≅(R>0≠1→R+→R) (\mathbb R_{>0}^{\neq 1} \times \mathbb{R}^+ \to \mathbb{R}) \cong (\mathbb R_{>0}^{\neq 1} \to \mathbb{R}^+ \to \mathbb{R})

也就是说,原先的二元函数 Log⁡\operatorname{Log},现在变成了一连串一元函数。

现在,我们可以只调用一次,得到一个常用的对数函数;也可以进行多次调用,直接得到结果

Log⁡(2)(8)=log⁡28 \operatorname{Log}(2)(8) = \log_2 8

指数函数也是类似的。对于如下表达式

ax a^x

需要同时知道 aa 和 xx 的值才能计算出结果。因此指数运算是二元函数

Exp⁡(a,x)=ax \operatorname{Exp}(a, x) = a^x

但通过柯里化,我们可以先固定底数,得到那些常见的指数函数

Exp⁡(e)=ex=exp⁡ \operatorname{Exp}(e) = e^x = \exp

柯里化示例:四则运算

四则运算这些二元函数也全都可以柯里化。就以加法为例,其通常被认为是一个二元函数

add⁡:R×R→R \operatorname{add}: \mathbb{R} \times \mathbb{R} \to \mathbb{R}

但通过柯里化,加法被转换为一连串一元函数

add⁡:R→(R→R) \operatorname{add}: \mathbb{R} \to (\mathbb{R} \to \mathbb{R})

用 JavaScript 表示就是

// 未柯里化
const add = (x, y) => x + y;

// 柯里化
const add = (x) => (y) => x + y;

然后我们就可以一次只传一个参数,或者通过多次调用的方式一次传多个参数

const add5 = add(5);
add5(3);

add(5)(3);

柯里化示例:JavaScript

我们可以在 JavaScript 里自己实现一个简易的 curry

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }

    return function (...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}

我们定义一个三元函数,测试一下效果

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6
curriedAdd(1)(2, 3); // 6
curriedAdd(1, 2, 3); // 6

柯里化示例:Haskell

Haskell 的函数默认就是柯里化的形式,因此

add :: Int -> Int -> Int
add x y = x + y

表示类型

add⁡:Int→Int→Int \operatorname{add}: \text{Int} \to \text{Int} \to \text{Int}

Haskell 的函数类型是右结合的,因此 Int -> Int -> Int 实际解析为

Int -> (Int -> Int)

Haskell 的函数应用是左结合的,因此 add 3 4 实际解析为

(add 3) 4

并且 Haskell 的标准库里已经实现了一个 curry 和 uncurry

ghci> :t curry
curry :: ((a, b) -> c) -> a -> b -> c
ghci> :t uncurry
uncurry :: (a -> b -> c) -> (a, b) -> c

柯里化的编程实践

柯里化不仅在数学上有非常优美的形式,而且在编程里也有很多好处。

显然,一个最直观的用途就是——让多元函数可以进入单参数的管道。

多元函数需要多个参数,因此在单参数的管道里,多元函数没办法把自己的输入接到任何函数的输出上。而柯里化可以把多元函数转变为一连串的一元函数,从而能够进入管道。

回顾之前提到的 Bash 管道示例

cat server.log \
  | grep "ERROR" \
  | sort \
  | uniq -c

在函数式的视角,我们可以认为 grep 其实是一个二元函数:第一个参数是模式字符串,第二个参数是待匹配的文本。而我们指定了第一个参数后,得到了一个一元函数,这个函数只需输入待匹配的文本,而文本现在就可以通过管道传进来。

柯里化能够让部分应用更方便。部分应用后文马上就会介绍,简要地说,部分应用通过提前固定一部分参数,得到一个更具体的函数。此后使用这个更具体的函数时就不需要重复传参,从而实现了参数复用。而且使用更具体的函数可以让代码更加声明式,此时只看函数名就能知道函数有什么用,不用考虑每个位置的参数是什么意思

const applyDiscount = (rate) => (price) => price * (1 - rate);
const vipDiscount = applyDiscount(0.2);

const prices = [100, 200, 300];
prices.map(vipDiscount);

函数包装:函数适配和函数装饰

函数包装指的是,创建一个新函数并在内部调用已有函数。

函数包装本身是个很宽泛的概念,我将其分为两种类型

  1. 函数适配:改变原函数的调用方式,让其符合调用方的需要
  2. 函数装饰:保持原函数的调用方式,只在外围增加额外行为

这两者之间没有特别清晰的分界,也不严格互斥,因此完全可以同时使用。

后文先介绍函数适配,再讲解函数装饰。

函数适配

柯里化本身就可以看作一种函数适配技术:调整参数结构,把多元函数变成一系列一元函数。

除此之外还有几种比较典型的函数适配技术:固定参数、重排参数、增加参数、转换返回值。当然,函数适配技术远不止这几种。

函数适配本身没什么深刻的数学原理,但体现了函数式编程中一个非常朴素又非常重要的思想:已有函数并不需要被修改;我们可以通过修改参数或返回值等操作,构造出具有新语义的函数。

函数适配技术:固定参数

固定参数也叫特化参数或者部分应用,这和柯里化密切相关。部分应用的正式定义是:在一个函数上先固定一部分参数,然后返回一个接受剩余参数的新函数。

以 ee 为底的对数函数就是一个典型的例子

Log⁡(e)=log⁡e=ln⁡ \operatorname{Log}(e) = \log_e = \ln

我们先固定了底数,此后使用 ln⁡\ln 就不需要再指定底数了。

定积分也是一个部分应用的例子。回顾我们之前定义的数值积分

integrate :: Double -> Double -> (Double -> Double) -> Double
integrate a b f =
  let n  = 10000
      dx = (b - a) / fromIntegral n
  in sum [f (a + (fromIntegral i + 0.5) * dx) * dx | i <- [0 .. n - 1]]

代码中的数值算法不重要,这里需要关注的点是:我们把积分运算定义为一个三元函数,其中第一个参数是下限,第二个参数是上限,第三个参数是被积函数,返回值是定积分的结果

I(a)(b)(f)=∫abf I(a)(b)(f)=\int_a^b f

而定积分泛函就是只固定前两个参数

-- 区间 [0,1] 上的定积分泛函
integralFrom0To1 :: (Double -> Double) -> Double
integralFrom0To1 = integrate 0 1

即

∫ab=I(a)(b) \int_a^b = I(a)(b)

函数适配技术:重排参数

重排参数指的是改变原函数的参数顺序,然后得到一个新的函数。

还是用之前定义的 integrate 举例,我们可以基于此适配一个变上限积分或者变下限积分

-- 下限为 0 的变上限积分
integralFrom0 :: (Double -> Double) -> (Double -> Double)
integralFrom0 f x = integrate 0 x f

-- 上限为 1 的变下限积分
integralTo1 :: (Double -> Double) -> (Double -> Double)
integralTo1 f x = integrate x 1 f

变上限积分固定了下限,变下限积分固定了上限,但这里我们还调整了参数顺序,从而让变上限积分和变下限积分的第一个参数是函数。

Haskell 里有多种调整参数顺序的方法,像这种重新定义是最直观的

integralFromLower a f x = integrate a x f
integralToUpper b f x = integrate x b f

对于二元函数,也可以使用 flip 交换参数顺序

integralFrom0 = flip (integrate 0)

函数适配技术:增加参数和转换返回值

增加参数指的是在原函数的基础上增加参数,而转换返回值指的是改变原函数的返回值。

继续用 integrate 举例,我们可以基于此定义一个不定积分

integralIndefinite :: (Double -> Double) -> Double -> Double -> Double
integralIndefinite f c x = (integrate 0 x f) + c

这里我们做了多件事情

  • 固定参数,下限被固定为 0
  • 重排参数,f 成为了 integralIndefinite 的第一个参数
  • 增加参数,然后把增加的参数 c 加到返回值上

把这些步骤拆开可以看得更清晰一点——我们其实就是对之前定义的变上限积分函数做了点修改:添加了一个参数,然后把这个参数加到返回值中

integralFrom0 f x = integrate 0 x f
integralIndefinite f c x = (integralFrom0 f x) + c

这里我们指定了变上限积分的下限为 0。这个下限是随便选的,因为变上限积分公式允许下限是一个任意常数

F(x)=∫axf+C F(x) = \int_a^x f + C

而不定积分就是变上限积分得到的原函数加上积分常数 CC。因此我们在 integrate 0 x f 得到的结果后加上 c。

函数装饰

函数装饰同样没什么深刻的数学原理,在编程里函数装饰只是很普通也很好理解的技术。

函数装饰通常使用函数装饰器实现,而函数装饰器可以看作一个高阶函数:接受一个函数,然后返回被装饰的函数。

后文介绍几种常见的函数装饰技术,包括前置处理、后置处理、调用拦截、调用控制、附加状态等。当然,函数装饰技术同样远不止这几种。

函数装饰技术:前置处理和后置处理

前置处理就是在函数调用前插入额外行为,后置处理就是在函数调用后插入额外行为。

在 JavaScript 里可以表示为

function decorate(f) {
  return (...args) => {
    before(args);

    const result = f(...args);

    after(result);
    return result;
  };
}

日志、计时、检查、清理等通常都会用到前置处理和后置处理。

函数装饰技术:调用拦截和调用控制

调用拦截会先判断是否调用原函数。

缓存、权限检查、短路、去重等都可以通过调用拦截实现。下面是一个用 JavaScript 实现缓存的例子

function cached(f) {
  const cache = new Map();

  return (x) => {
    if (cache.has(x)) return cache.get(x);

    const result = f(x);
    cache.set(x, result);
    return result;
  };
}

调用控制类似于调用拦截,会决定原函数何时调用、调用几次。

重试、防抖、节流、超时等都可以通过调用控制实现。下面是一个用 JavaScript 实现重试的例子

function retry(f, n) {
  return async (...args) => {
    let lastError;

    for (let i = 0; i < n; i++) {
      try {
        return await f(...args);
      } catch (error) {
        lastError = error;
      }
    }

    throw lastError;
  };
}

函数装饰技术:附加状态

附加状态可以让装饰后的函数拥有原函数没有的状态。

缓存实际上也属于附加状态这一类型。下面是用 JavaScript 实现调用计数的例子

function countCalls(f) {
  let count = 0;

  return (...args) => {
    count++;
    return f(...args);
  };
}

闭包:捕获环境中的自由变量

编程上的闭包和数学上的闭包完全不一样。

在数学上,闭包指的是:包含某个对象 SS 并且对某种运算 ∗* 封闭的最小对象

closure⁡∗(S)=⋂{ T∣S⊆T∧∀a,b∈T(a∗b∈T) } \operatorname{closure}_*(S) = \bigcap\Set{T \mid S \subseteq T \land \forall a,b \in T (a * b \in T)}

比如包含自然数并且对减法封闭的最小对象是整数,即自然数在减法运算下的闭包是整数

closure⁡−(N)=Z \operatorname{closure}_-(\mathbb{N}) = \mathbb{Z}

而编程里的闭包,其正式定义是:一个函数加上它定义时所处的词法环境。

听起来有点抽象,我们先看一个 JavaScript 示例,这个闭包示例在前面其实已经出现过,被用来实现柯里化和部分应用

const add = (x) => (y) => x + y;
const add5 = add(5);
add5(3); // 8

add 返回的函数 (y) => x + y 中参数只有 y,但函数体中却用到了 x。此时我们把 y 叫做这个函数的绑定变量,而 x 叫做自由变量。

调用 add(x) 返回的函数闭合了外层参数 x 的绑定。当调用 add(5) 时,绑定的值为 x = 5。

再来看一个示例

let x;
const add = (y) => x + y;

add(1); // NaN

x = 5;
add(1); // 6

x = 10;
add(1); // 11

当调用 add 时,函数在全局词法环境中寻找自由变量 x。第一次调用时 x 的值为 undefined,因此计算结果为 NaN;后续修改了 x 的值,由于闭包捕获的是变量的绑定而非变量的值,因此 add(1) 的返回值会随着 x 的变化而变化。

闭包可以用来实现函数的私有状态

function makeCounter() {
  let count = 0;
  return function () {
    count++;
    return count;
  };
}

const c1 = makeCounter();
c1(); // 1
c1(); // 2
c1(); // 3

const c2 = makeCounter();
c2(); // 1
c2(); // 2

调用 makeCounter() 返回的函数捕获了 makeCounter 词法作用域中的局部变量,且每次调用 makeCounter() 都能返回一个独立的闭包。

函数装饰器的实现也用到了闭包

function decorate(fn) {
  return function (...args) {
    // 前置处理
    const result = fn.apply(this, args);
    // 后置处理
    return result;
  };
}

每次调用 decorate(fn) 都会产生一个独立的闭包,而 fn 被这个闭包所捕获。

Haskell 里可以这样定义闭包

makeAdder x = \y -> x + y

add3 = makeAdder 3

其中自由变量 x = 3 被闭包 add3 捕获。

因为 Haskell 的函数都是柯里化的,所以上面的写法和下面的写法一样

makeAdder x y = x + y

函子:范畴之间的保结构映射

协变函子的数学定义

用类型论解释,一个协变函子包含两个部分:一部分是类型构造器 FF,可以应用到类型上得到新的类型

A↦F(A)B↦F(B) A \mapsto F(A) \qquad B \mapsto F(B)

另一部分是协变映射 map⁡F\operatorname{map}_F,可以应用到函数上得到新的函数

A→B↦map⁡FF(A)→F(B) A \to B \xmapsto{\operatorname{map}_F} F(A) \to F(B)

在范畴论的语言里,前者 A↦F(A)A \mapsto F(A) 是对象映射,后者 Hom⁡(A,B)↦Hom⁡(F(A),F(B))\operatorname{Hom}(A, B) \mapsto \operatorname{Hom}(F(A), F(B)) 是态射映射。

协变函子必须满足两个函子律:一个是保持恒等态射,也就是恒等函数被映射为恒等函数,这里 id⁡A\operatorname{id}_A 表示 A→AA \to A 中的恒等函数,id⁡F(A)\operatorname{id}_{F(A)} 表示 F(A)→F(A)F(A) \to F(A) 的恒等函数

map⁡F(id⁡A)=id⁡F(A) \operatorname{map}_F(\operatorname{id}_A) = \operatorname{id}_{F(A)}

另一个是保持复合,也就是先复合再映射和先映射再复合的结果相同

map⁡F(g∘f)=map⁡F(g)∘map⁡F(f) \operatorname{map}_F(g \circ f) = \operatorname{map}_F(g) \circ \operatorname{map}_F(f)

只有满足恒等律和复合律时 (F,map⁡F)(F, \operatorname{map}_F) 才构成一个协变函子。

逆变函子的数学定义

逆变函子和协变函子很类似,同样由类型构造器 FF 和映射 contramap⁡F\operatorname{contramap}_F 组成,只不过 contramap⁡F\operatorname{contramap}_F 是一个逆变映射——得到方向相反的函数

contramap⁡F(A→B):F(B)→F(A) \operatorname{contramap}_F(A \to B): F(B) \to F(A)

逆变函子同样要满足两个函子律,即恒等律

contramap⁡F(id⁡A)=id⁡F(A) \operatorname{contramap}_F(\operatorname{id}_A) = \operatorname{id}_{F(A)}

和复合律

contramap⁡F(g∘f)=contramap⁡F(f)∘contramap⁡F(g) \operatorname{contramap}_F(g \circ f) = \operatorname{contramap}_F(f) \circ \operatorname{contramap}_F(g)

协变函子示例:Maybe

听起来有点抽象,我们可以先看一个 TypeScript 中的例子

type Maybe<A> = { tag: "just"; value: A } | { tag: "nothing" };
// A => Maybe<A>

const mapMaybe =
  <A, B>(f: (a: A) => B) =>
  (m: Maybe<A>): Maybe<B> => {
    switch (m.tag) {
      case "nothing":
        return { tag: "nothing" };
      case "just":
        return { tag: "just", value: f(m.value) };
    }
  };
// (A => B) => Maybe<A> => Maybe<B>

这实现了一个 Maybe 函子,其中

  • Maybe 是对象映射,把 number 变成 Maybe<number>
  • mapMaybe 是态射映射,把 number -> number 变成 Maybe<number> -> Maybe<number>

然后可以如下使用

const inc = (n: number) => n + 1;
// number => number
const incMaybe = mapMaybe(inc);
// Maybe<number> => Maybe<number>

incMaybe({ tag: "just", value: 3 });
// { tag: "just", value: 4 }

incMaybe({ tag: "nothing" });
// { tag: "nothing" }

我们可以验证一下上述的示例,确实符合这两个函子律

const id = <A>(x: A): A => x;
mapMaybe(id);
// Maybe<A> => Maybe<A>

const compose =
  <A, B, C>(g: (b: B) => C, f: (a: A) => B) =>
  (a: A): C =>
    g(f(a));
const f = Math.sqrt;
const g = Math.round;
const x: Maybe<number> = { tag: "just", value: 3 };
mapMaybe(compose(g, f))(x);
// { tag: 'just', value: 2 }
compose(mapMaybe(g), mapMaybe(f))(x);
// { tag: 'just', value: 2 }

逆变函子示例:Predicate

我们可以用之前实现的协变函子 Maybe 构造一个逆变函子 Predicate

type Predicate<A> = (m: Maybe<A>) => boolean;
// A => Predicate<A>

const contramapPredicate =
  <A, B>(f: (a: A) => B) =>
  (pb: Predicate<B>): Predicate<A> =>
  (ma: Maybe<A>): boolean =>
    pb(mapMaybe(f)(ma));
// (A => B) => Predicate<B> => Predicate<A>

其中

  • Predicate 是一个类型构造器,把类型 A 变为类型 Predicate<A>,而 Predicate<A> 是一个函数类型。
  • contramapPredicate 是一个高阶函数,把函数 A => B 变为函数 Predicate<B> => Predicate<A>。

这个逆变函子可以如下使用

const lengthOf = (s: string): number => s.length;
// string => number

const predicateByLength = contramapPredicate(lengthOf);
// Predicate<number> => Predicate<string>

这里我们使用 contramapPredicate 把一个 string => number 函数变成了 Predicate<number> => Predicate<string> 函数,这个新的函数可以继续用来转换函数

const isSmallNumber: Predicate<number> = (m) => m.tag === "just" && m.value < 5;
// Predicate<number>
isSmallNumber({ tag: "just", value: 3 });
// true
isSmallNumber({ tag: "just", value: 10 });
// false
isSmallNumber({ tag: "just", value: "abc" });
// [ERROR]: Type 'string' is not assignable to type 'number'

const isShortString: Predicate<string> = predicateByLength(isSmallNumber);
// Predicate<string>
isShortString({ tag: "just", value: "abc" });
// true
isShortString({ tag: "just", value: "abcdefg" });
// false
isShortString({ tag: "just", value: 3 });
// [ERROR]: Type 'number' is not assignable to type 'string'

数学中的逆变函子:对偶函子

从向量空间 VV 到向量空间 WW 的所有线性映射构成的向量空间为

Hom⁡(V,W) \operatorname{Hom}(V, W)

设 V,WV,W 是实向量空间,它们的对偶空间定义为

V∗=Hom⁡(V,R)W∗=Hom⁡(W,R) \begin{align*} V^* &= \operatorname{Hom}(V, \mathbb R) \\ W^* &= \operatorname{Hom}(W, \mathbb R) \end{align*}

现在取一个线性映射 f:V→Wf: V\to W,对于任意一个 W∗W^* 中的余向量 ω:W→R\omega: W \to \mathbb R,ff 都可以和 ω\omega 复合

V→fW→ωR V \xrightarrow{f} W \xrightarrow{\omega} \mathbb{R}

复合的结果恰好在 V∗V^* 中

ω∘f:V→R \omega \circ f: V \to \mathbb{R}

因此,若定义 ff 的对偶 f∗f^* 为与 ff 的前复合

f∗(g)=g∘f f^*(g) = g \circ f

便能得到

ω∈W∗→f∗ω∘f∈V∗ \omega \in W^* \xrightarrow{f^*} \omega \circ f \in V^*

从而 f∗f^* 可表示为从 W∗W^* 到 V∗V^* 的映射

f∗:W∗→V∗ f^*: W^* \to V^*

和 ff 放在一起对比

V→fWV∗←f∗W∗ \begin{align*} V &\xrightarrow{f} W \\ V^* &\xleftarrow{f^*} W^* \end{align*}

因此对偶操作 (−)∗(-)^* 在对象上定义为

V↦V∗ V \mapsto V^*

在态射上定义为

f↦f∗ f \mapsto f^*

由于 f∗f^* 反转了 ff 的箭头,所以对偶是一个逆变函子。

当然,还要验证对偶满足两条函子律。首先是恒等律,即恒等映射对偶后仍是恒等映射

id⁡V∗(ω)=ω∘id⁡V=ω \operatorname{id}_V^*(\omega) = \omega\circ\operatorname{id}_V = \omega

其次是复合律,即先复合后对偶等于先对偶后复合

(g∘f)∗(ω)=ω∘(g∘f)=(ω∘g)∘f=g∗(ω)∘f=f∗(g∗(ω))=(f∗∘g∗)(ω) \begin{align*} (g\circ f)^*(\omega) &= \omega\circ(g\circ f) \\ &= (\omega\circ g)\circ f \\ &= g^*(\omega) \circ f \\ &= f^*(g^*(\omega)) \\ &= (f^* \circ g^*)(\omega) \end{align*}

我们也可以直接用范畴论定义一个逆变函子来表示对偶函子。首先固定 KK,然后定义类型构造器 FF 为

F(V)=Hom⁡(V,K) F(V) = \operatorname{Hom}(V, K)

再定义逆变映射 FF 为

f:V→WF(f):F(W)→F(V) \begin{align*} f &: V \to W \\ F(f) &: F(W) \to F(V) \end{align*}

关于 Hom⁡(f,K)\operatorname{Hom}(f, K)

虽然带入 F(f)F(f) 可以得到 Hom⁡(f,K)\operatorname{Hom}(f, K),但这其实应该理解为

F(f)=[Hom⁡(−,K)](f)F(f)=[\operatorname{Hom}(-, K)](f)

Hom⁡(−,K)\operatorname{Hom}(-, K) 本身不可分割,只是数学里习惯复用 滥用 符号。

展开 F(V)F(V) 和 F(W)F(W)

F(f):Hom⁡(W,K)→Hom⁡(V,K) F(f) : \operatorname{Hom}(W, K) \to \operatorname{Hom}(V, K)

我们取一个 g:W→Kg: W \to K 看看映射效果

F(f)(g):V→K F(f)(g): V \to K

这里 F(f)(g)F(f)(g) 的把 gg 输入变成了 ff 的输入,这恰好可以通过对 gg 进行与 ff 的前复合得到

V→fW→gK V \xrightarrow{f} W \xrightarrow{g} K

于是

F(f)(g)=Hom⁡(f,K)(g)=g∘f F(f)(g) = \operatorname{Hom}(f, K)(g) = g \circ f

这里我们并没有使用对偶符号,但显然当 K=RK=\mathbb{R} 时,逆变 Hom⁡\operatorname{Hom} 函子 Hom⁡(−,R)\operatorname{Hom}(-, \mathbb{R}) 就是对偶函子 (−)∗(-)^*。

数学中的协变函子:点函子

我们还可以试试 F(−)=Hom⁡(K,−)F(-) = \operatorname{Hom}(K, -),这也是一个函子,不过是协变函子

F(V)=Hom⁡(K,V)F(f):F(V)→F(W)F(f):Hom⁡(K,V)→Hom⁡(K,W) \begin{align*} F(V) &= \operatorname{Hom}(K, V) \\ F(f) &: F(V) \to F(W) \\ F(f) &: \operatorname{Hom}(K, V) \to \operatorname{Hom}(K, W) \end{align*}

对于 g:K→Vg: K \to V 有

F(f)(g):K→W F(f)(g): K \to W

这是对 gg 进行与 ff 的后复合

K→gV→fW K \xrightarrow{g} V \xrightarrow{f} W

于是

F(f)(g)=Hom⁡(K,f)(g)=f∘g F(f)(g) = \operatorname{Hom}(K, f)(g) = f \circ g

这就是协变 Hom⁡\operatorname{Hom} 函子 Hom⁡(K,−)\operatorname{Hom}(K, -),在数学上通常叫做点函子。

单子:自函子范畴上的幺半群对象

单子是自函子范畴上的幺半群对象。

我们先介绍自函子,再讲解幺半群,最后研究单子。

自函子

自函子是从一个范畴到它自身的协变函子。设有一个范畴 C\mathcal C,如果协变函子

F:C→C F: \mathcal C \to \mathcal C

把 C\mathcal C 中的对象映射到 C\mathcal C 中的对象,把 C\mathcal C 中的态射映射到 C\mathcal C 中的态射,并且满足恒等律和复合律,那么 FF 就叫做 C\mathcal C 上的自函子。

前文提到的协变函子 Maybe 就是一个自函子。假设范畴为 Set\mathbf{Set},于是

  • 对象是集合 A,B,CA, B, C,可表示为 A∈Ob(Set)A \in \mathrm{Ob}(\mathbf{Set})
  • 态射是集合之间的函数 f,g,hf, g, h,可表示为 f∈Mor(Set)f \in \mathrm{Mor}(\mathbf{Set})

态射通常会明确写出来源和目标 f∈Hom⁡Set(A,B)f \in \operatorname{Hom}_\mathbf{Set}(A, B),后者还等于 Hom⁡Set(A,B)=BA\operatorname{Hom}_\mathbf{Set}(A, B)=B^A。

在 Set\mathbf{Set} 范畴里,Maybe(A)\text{Maybe}(A) 可以定义为

Maybe(A)={ Just(a)  |  a∈A }+{ Nothing } \text{Maybe}(A) = \Set{\text{Just}(a) | a \in A} + \set{\text{Nothing}}

比如当 A={ 1,2,3 }A = \set{1, 2, 3} 时

Maybe(A)={ Just(1),Just(2),Just(3),Nothing } \text{Maybe}(A) = \set{\text{Just}(1), \text{Just}(2), \text{Just}(3), \text{Nothing}}

这里的关键在于,只要 AA 是一个集合,那么 Maybe(A)\text{Maybe}(A) 仍然是一个集合,因此对象映射后仍然在同一个范畴中。

类似地,Maybe(f)\text{Maybe}(f) 在 Set\mathbf{Set} 范畴里可以定义为

Maybe(f)(Nothing)=NothingMaybe(f)(Just(a))=Just(f(a)) \begin{align*} \text{Maybe}(f)(\text{Nothing}) &= \text{Nothing} \\ \text{Maybe}(f)(\text{Just(a)}) &= \text{Just}(f(a)) \end{align*}

只要

f∈Hom⁡Set(A,B) f\in \operatorname{Hom}_{\mathbf{Set}}(A,B)

那么

Maybe(f)∈Hom⁡Set(Maybe(A),Maybe(B)) \text{Maybe}(f) \in \operatorname{Hom}_{\mathbf{Set}}(\text{Maybe}(A), \text{Maybe}(B))

由于 Maybe(A)\text{Maybe}(A) 在 Set\mathbf{Set} 范畴中,因此 Maybe(f)\text{Maybe}(f) 仍然是同一个范畴里的态射。

但 Predicate 由于是逆变函子,所以通常不被认为是自函子。尽管在 Set\mathbf{Set} 范畴里,Predicate(A)\text{Predicate}(A) 和 Predicate(f)\text{Predicate}(f) 仍然映射到同一个范畴。

自函子范畴

自函子范畴的对象是自函子,态射是自函子间的映射。

对于范畴 C\mathcal{C},所有自函子 F:C→CF: \mathcal{C} \to \mathcal{C} 本身可以组成一个新的范畴,即自函子范畴

[C,C] [\mathcal C,\mathcal C]

在这个范畴中

  • 对象是自函子 F,G,HF,G,H
  • 态射是从自函子到自函子的映射 α:F⇒G,β:G⇒H\alpha: F \Rightarrow G, \beta: G \Rightarrow H

从自函子到自函子的映射,也可以看成一族定义在原范畴的每个对象上的态射。对每个对象 X∈CX \in \mathcal{C} 有

αX:F(X)→G(X) \alpha_X: F(X) \to G(X)

从自函子到自函子的映射之间可以复合

β∘α:F⇒H \beta \circ \alpha: F \Rightarrow H

在每个分量上仍然为

(β∘α)X=βX∘αX (\beta \circ \alpha)_X = \beta_X \circ \alpha_X

自函子之间也可以复合

F∘G:C→C F \circ G: \mathcal{C} \to \mathcal{C}

恒等函子和任何函子复合都得到原函子

F∘Id⁡C=FId⁡C∘F=F F \circ \operatorname{Id}_\mathcal{C} = F \qquad \operatorname{Id}_\mathcal{C} \circ F = F

当从自函子到自函子的映射满足自然性条件时,就可以被称为自然变换。

具体地说,这要求

G(f)∘αX=αY∘F(f) G(f) \circ \alpha_X = \alpha_Y \circ F(f)

即

F(X)→αXG(X)↓F(f)↓G(f)F(Y)→αYG(Y) \begin{array}{ccc} F(X) & \xrightarrow{\alpha_X} & G(X) \\ \downarrow_{F(f)} & & \downarrow_{G(f)} \\ F(Y) & \xrightarrow{\alpha_Y} & G(Y) \end{array}

幺半群

幺半群是一个三元组,包含集合、二元运算和单位元。

一个幺半群可表示为 (M,∗,e)(M, *, e),其中

  1. MM 为集合
  2. ∗:M×M→M*: M \times M \to M 为二元运算
  3. e∈Me \in M 为单位元,也可叫做幺元

幺半群要满足两条律

  • 结合律
    (a∗b)∗c=a∗(b∗c) (a * b) * c = a * (b * c)
  • 单位元律
    e∗a=a∗e=a e * a = a * e = a

幺半群和抽象代数上的几个概念非常接近

  • 半群:只有结合律
  • 幺半群:半群 + 单位元
  • 群:幺半群 + 每个元素都有逆元
  • 交换幺半群:幺半群 + 满足交换律

数学上有非常多的幺半群例子

集合运算单位元
整数加法0
整数乘法1
字符串拼接""
列表拼接[]
布尔值逻辑与 &&True
布尔值逻辑或 ||False
自映射函数函数复合恒等函数
方阵矩阵乘法单位矩阵

在 Haskell 中,幺半群 Monoid 是在半裙 Semigroup 的基础上定义的

ghci> :i Monoid
type Monoid :: * -> Constraint
class Semigroup a => Monoid a where
  mempty :: a
  mappend :: a -> a -> a
  mconcat :: [a] -> a
ghci> :i Semigroup
type Semigroup :: * -> Constraint
class Semigroup a where
  (<>) :: a -> a -> a

其中

  • mempty 是单位元

    ghci> mempty :: String
    ""
    ghci> mempty :: [Int]
    []
  • mappend 是二元运算,可以使用符号 <> 替代

    ghci> "abc" <> "d"
    "abcd"
    ghci> [1,2,3] <> [4,5]
    [1,2,3,4,5]
  • mconcat 是合并操作,等价于对列表中的项连续应用 <>

    ghci> mconcat ["a", "bc", "def"]
    "abcdef"
    ghci> mconcat [[1], [2,3], [4,5,6]]
    [1,2,3,4,5,6]

幺半群对象

在范畴论里,幺半群是幺半范畴中的对象。

幺半范畴是一个三元组 (V,⊗,I)(\mathcal{V}, \otimes, I),其中

  1. V\mathcal{V} 是一个范畴
  2. ⊗:V×V→V\otimes: \mathcal{V} \times \mathcal{V} \to \mathcal{V} 是一个双函子,作用在 V\mathcal{V} 中的两个对象上,得到 V\mathcal{V} 中的一个对象
  3. I∈Ob(V)I \in \mathrm{Ob}(\mathcal{V}) 是 V\mathcal{V} 中关于 ⊗\otimes 运算的单位对象

严格来说,幺半范畴还有额外的结构。此处略去结合子、单位子及其相干性条件。

幺半群对象也是一个三元组 (M,μ,η)(M, \mu, \eta) ,其中

  1. M∈Ob(V)M \in \mathrm{Ob}(\mathcal{V}) 是一个对象
  2. μ∈Mor(V):M⊗M→M\mu \in \mathrm{Mor}(\mathcal{V}): M \otimes M \to M 是一个态射
  3. η∈Mor(V):I→M\eta \in \mathrm{Mor}(\mathcal{V}): I \to M 也是一个态射

幺半群对象要满足结合律

μ∘(μ⊗id⁡M)=μ∘(id⁡M⊗μ) \mu\circ(\mu\otimes \operatorname{id}_M)=\mu\circ(\operatorname{id}_M \otimes\mu)

以及单位律

μ∘(η⊗id⁡M)=id⁡Mμ∘(id⁡M⊗η)=id⁡M \begin{align*} \mu\circ(\eta\otimes \operatorname{id}_M) &= \operatorname{id}_M \\ \mu\circ(\operatorname{id}_M\otimes\eta) &= \operatorname{id}_M \end{align*}

而普通幺半群可以看成笛卡尔幺半范畴 (Set,×,{ ∗ })(\mathbf{Set}, \times, \set{*}) 上的幺半群对象,其中

  • Set\mathbf{Set} 为集合范畴
  • ×\times 为张量积
  • { ∗ }\set{*} 为单点集合

这个幺半范畴中的幺半群对象为 (M,μ,η)(M, \mu, \eta),其中

  • M∈Ob(Set)M \in \mathrm{Ob}(\mathbf{Set}) 是一个集合,也是集合范畴中的对象
  • μ:M×M→M\mu: M \times M \to M 是集合元素间的二元运算,把集合 MM 与自身的张量积中的元素映射到集合 MM 中;数学上习惯记为 μ(x,y)=x∗y\mu(x, y)= x*y
  • η:{ ∗ }→M\eta: \set{*} \to M 为单位态射,把任意单点集合 { ∗ }\set{*} 映射到集合 MM 中;数学上更习惯使用 e=η(∗)e=\eta(*) 这个单位元

因此换成数学上更习惯的写法后就得到了 (M,∗,e)(M, *, e) 这种表示

单子的数学定义

单子是自函子范畴

([C,C],∘,Id⁡C) ([\mathcal{C}, \mathcal{C}], \circ, \operatorname{Id}_C)

上的幺半群对象

(T,μ,η) (T, \mu, \eta)

其中

  • T:C→CT: \mathcal C\to\mathcal C 是 Ob([C,C])\mathrm{Ob}([\mathcal{C}, \mathcal{C}]) 中的自函子
  • μ:T∘T⇒T\mu: T \circ T \Rightarrow T 是 Mor([C,C])\mathrm{Mor}([\mathcal{C}, \mathcal{C}]) 中的自然变换,把函子与自身的复合映射到原函子
  • η:Id⁡C⇒T\eta: \operatorname{Id}_{\mathcal{C}} \Rightarrow T 也是 Mor([C,C])\mathrm{Mor}([\mathcal{C}, \mathcal{C}]) 中的自然变换,把恒等函子映射到原函子

选定一个函子,μ\mu 的分量为

μX:(T∘T)(X)→T(X) \mu_X: (T \circ T)(X) \to T(X)

这也可以写为 (T∘T)(X)=T2(X)=T(T(X))(T \circ T)(X) = T^2(X) = T(T(X))。

η\eta 的分量为

ηX:X→T(X) \eta_X: X\to T(X)

单子同样满足三条律

  1. 左单位律,其中 ηT(X):T(X)→T(T(X))\eta_{T(X)}: T(X) \to T(T(X))

    μX∘ηT(X)=id⁡T(X) \mu_X \circ \eta_{T(X)} = \operatorname{id}_{T(X)}
  2. 右单位律,其中 T(ηX):T(X)→T(T(X))T(\eta_X): T(X) \to T(T(X))

    μX∘T(ηX)=id⁡T(X) \mu_X \circ T(\eta_X) = \operatorname{id}_{T(X)}
  3. 结合律

    μX∘T(μX)=μX∘μT(X) \mu_X\circ T(\mu_X)=\mu_X\circ \mu_{T(X)}

单子示例:List

定义函子 List\text{List},其对象映射是

X↦List(X) X \mapsto \text{List}(X)

态射映射是

X→Y↦List(X)→List(Y) X \to Y \mapsto \text{List}(X) \to \text{List}(Y)

然后定义自然变换 η\eta

ηX:X→List(X) \eta_X: X \to \text{List}(X)

其作用是把元素 x∈Xx\in X 映射到 [x]∈List(X)[x] \in \text{List}(X)。在 Haskell 里通常写为

ghci> (:[]) 1
[1]

以及自然变换 μ\mu

μX:List(List(X))→List(X) \mu_X: \text{List}(\text{List}(X)) \to \text{List}(X)

其作用是把多个嵌套的 List\text{List} 变成单个 List\text{List}。在 Haskell 里可以通过 concat 实现

ghci> concat [[1,2],[3,4]]
[1,2,3,4]

单子:可组合且带上下文的计算

虽然单子在数学上的定义非常抽象,但单子在编程中要好理解的多。

可以把单子当成可组合且带上下文的计算,这里的上下文可以是:可能失败、可能没有值、异步、携带状态、产生日志、执行副作用等。而单子能够把带上下文的计算串起来,同时把上下文处理的重复代码隐藏掉。

扩展陪域:将部分函数变成全函数

对于数学上的函数

f(x)=1x f(x)=\frac1x

在 x=0x=0 时没有定义。

数学上通常表示为

f:R∖{ 0 }→R f: \mathbb R \setminus \set{0} \to\mathbb R

但在编程里,这种表示方法不便于推导类型。常用的解决方法有两种

  1. 定义子集类型,即从一个无限类型中排除有限或无限个值,得到一个新的、更窄的类型。但这需要语言本身支持,而大多数语言都没有这一特性。比如下面的 TypeScript 示例,类型检查可以通过

    type NonZeroNumber = Exclude<number, 0>;
    const x: NonZeroNumber = 0;
    // 类型检查可以通过
    
  2. 扩展陪域,修改返回值类型,把可能失败这一情况直接反映到类型中

    type Maybe<A> = { tag: "just"; value: A } | { tag: "nothing" };
    
    function f(x: number): Maybe<number> {
      if (x === 0) return { tag: "nothing" };
      return {
        tag: "just",
        value: 1 / x,
      };
    }

后一种做法,可以认为是先把原函数当作部分函数,即并非对声明类型的每个值都有定义

f:R→R f: \mathbb{R} \to \mathbb{R}

然后再扩展陪域变成全函数

f:R→Maybe(R) f: \mathbb{R} \to \text{Maybe}(\mathbb{R})

即

f(x)={Just(1/x)x≠0Nothingx=0 f(x)= \begin{cases} \mathrm{Just}(1/x) & x\neq0 \\ \mathrm{Nothing} & x=0 \end{cases}

Rust 里的 Option / Some / None 就分别对应了 Maybe / Just / Nothing

fn f(x: f64) -> Option<f64> {
    if x == 0.0 {
        return None;
    }

    Some(1.0 / x)
}

延拓:让扩展陪域后的函数可以继续组合

不过扩展陪域带来了一个问题:函数的返回值从 BB 变成了 T(B)T(B),这样就没办法与要求参数为 BB 的函数组合了。

比如对于函数 f:A→T(B)f: A\to T(B) 和 g:B→T(C)g:B \to T(C),普通复合

g∘f g\circ f

无法成立,因为 ff 的输出是 T(B)T(B) 而非 BB。不够我们可以利用函子的态射映射得到

T(g):T(B)→T(T(C)) T(g): T(B) \to T(T(C))

这样 T(g)T(g) 便能接到 ff 之后

A→fT(B)→T(g)T(T(C)) A \xrightarrow{f} T(B) \xrightarrow{T(g)} T(T(C))

再利用单子的自然变换 μ\mu 在 CC 上的分量

μC:T(T(C))→T(C) \mu_C: T(T(C)) \to T(C)

便能完整复合出

A→fT(B)→T(g)T(T(C))→μCT(C) A \xrightarrow{f} T(B) \xrightarrow{T(g)} T(T(C)) \xrightarrow{\mu_C} T(C)

这就是 Kleisli 复合。用符号表示为

g⋆f=μC∘T(g)∘f g \star f = \mu_C\circ T(g)\circ f

Kleisli 复合是一个高阶函数 ⋆(f,g)=g⋆f\star(f, g) = g \star f ,类型为

⋆:(A→T(B))→(B→T(C))→A→T(C) \star: (A \to T(B)) \to (B \to T(C)) \to A \to T(C)

我们也可以把 Kleisli 复合看作两步

  1. 延拓 g:B→T(C)g: B \to T(C),得到 g⋆=μC(T(g)):T(B)→T(C)g^\star = \mu_C(T(g)): T(B) \to T(C)。这也叫 Kleisli 延拓
  2. 复合 g⋆g^\star 和 ff,得到 g⋆∘f:A→T(C)g^\star \circ f: A \to T(C),这一步就是一般的函数复合

其中的 Kleisli 延拓可以看作一个高阶函数,类型为

(−)⋆:(A→T(B))→T(A)→T(B) (-)^\star: (A \to T(B)) \to T(A) \to T(B)

在函数式编程里,用于 Kleisli 延拓的操作通常被称为 bind,只不过参数的顺序和 Kleisli 延拓略有不同

bind⁡:T(A)→(A→T(B))→T(B) \operatorname{bind}: T(A) \to (A \to T(B)) \to T(B)

可以等价为

bind⁡(m,f)=f⋆(m) \operatorname{bind}(m, f) = f^\star(m)

在 Haskell 里 bind 就是 >>=,而 Kleisli 复合是 >=>

ghci> :t (>>=)
(>>=) :: Monad m => m a -> (a -> m b) -> m b

ghci> import Control.Monad ((>=>))
ghci> :t (>=>)
(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c

bind 让原函数的输入作为第一个参数,从而可以像管道那样组合函数。比如这个 Haskell 示例

half :: Int -> Maybe Int
half x =
  if even x then Just (x `div` 2)
  else Nothing

calc :: Int -> Maybe Int
calc x =
  Just x
    >>= half
    >>= half
    >>= half

结果为

calc 16
-- Just 2

calc 12
-- Nothing

而在 Rust 里 bind 是 and_then

fn half(x: i32) -> Option<i32> {
    if x % 2 == 0 {
        Some(x / 2)
    } else {
        None
    }
}

fn calc(x: i32) -> Option<i32> {
    Some(x)
        .and_then(half)
        .and_then(half)
        .and_then(half)
}

fn main() {
    println!("{:?}", calc(16)); // Some(2)
    println!("{:?}", calc(12)); // None
}

递归:不动点和初始代数

不动点

在数学上,对于一个函数 ff,如果某个 xx 满足

f(x)=x f(x) = x

那么这个 xx 就是 ff 的一个不动点。

不动点的定义可以很自然的推广到高阶函数上。

设 FF 是一个算子

F:(A→B)→(A→B) F: (A \to B) \to (A \to B)

如果函数 ff 满足

F(f)=f F(f) = f

那么同样可以说 ff 是 FF 的一个不动点。

递归函数:算子的不动点

递归函数可以看作算子的不动点。

以阶乘 fact⁡\operatorname{fact} 为例

fact⁡(n)={1n=0nfact⁡(n−1)n>0 \operatorname{fact}(n)= \begin{cases} 1 & n=0\\ n\operatorname{fact}(n-1) & n>0 \end{cases}

这里 fact⁡\operatorname{fact} 的定义中又出现了 fact⁡\operatorname{fact} 自身,所以这是递归定义。

但我们可以先定义算子 F:(N→N)→(N→N)F: (\mathbb N\to\mathbb N) \to (\mathbb N\to\mathbb N) 为

F(g)(n)={1n=0ng(n−1)n>0 F(g)(n) = \begin{cases} 1 & n=0 \\ ng(n-1) & n>0 \end{cases}

这个算子本身并没有递归定义。

然后我们再定义递归函数 fact⁡\operatorname{fact} 为 FF 的不动点

F(fact⁡)=fact⁡ F(\operatorname{fact})=\operatorname{fact}

也可以表示为

fact⁡=fix⁡(F) \operatorname{fact}=\operatorname{fix}(F)

Haskell 是惰性求值的,fix f = f (fix f) 不会在调用 fix 的时候无限展开,因此可以通过算子的不动点这一方式定义递归函数

fix f = f (fix f)

factorialF = \rec n ->
    if n == 0
    then 1
    else n * rec (n - 1)

fact = fix factorialF

但实际求值的时候函数仍然会被递归展开。当调用 fact 2 时,第一次展开得到

fact = \n ->
    if n == 0
    then 1
    else n * (fix factorialF) (n - 1)

如果此时 n == 0,那么已经求得值为 1。但此时 n == 2,因此得到

fact = 2 * (fix factorialF) 1

然后还要展开 fix factorialF

fact = 2 * (factorialF (fix factorialF)) 1

factorialF (fix factorialF) 的计算过程和第一次展开一样,得到

fact = 2 * (1 * (fix factorialF) 0)

最后再展开计算 (fix factorialF) 0 便可以得到结果。

初始代数

设 FF 是一个函子,一个 FF 代数是一个二元组

(A,α) (A, \alpha)

其中

  • AA 表示对象
  • α:F(A)→A\alpha: F(A) \to A 表示态射

FF 的初始代数也是一个 FF 代数,通常记为

(μF,in⁡) (\mu F, \operatorname{in})

对于任意其它的 FF 代数,都存在一个保结构的映射

h:μF→A h: \mu F \to A

使得如下条件成立

h∘in⁡=α∘F(h) h \circ \operatorname{in} = \alpha \circ F(h)

即

F(μF)→in⁡μF↓F(h)↓hF(A)→αA \begin{array}{ccc} F(\mu F) & \xrightarrow{\operatorname{in}} & \mu F \\ \downarrow_{F(h)} && \downarrow h \\ F(A) & \xrightarrow{\alpha} & A \end{array}

而 FF 的不动点定义为满足如下类型方程的 XX

X≅F(X) X \cong F(X)

其中 ≅\cong 是同构,表示两种类型在结构上等价。

函子的初始代数同时也是函子的不动点。

对于初始代数 (μF,in⁡)(\mu F, \operatorname{in}) 中的对象和态射分别应用 FF,得到另一个 FF 代数

(F(μF),F(in⁡)) (F(\mu F), F(\operatorname{in}))

复合 in⁡:F(μF)→μF\operatorname{in}: F(\mu F) \to \mu F 和 h:μF→F(μF)h: \mu F \to F(\mu F) 得到

in⁡∘h:μF→μF \operatorname{in} \circ h: \mu F \to \mu F

由于 μF\mu F 唯一,从而

in⁡∘h=id⁡μF \operatorname{in} \circ h = \operatorname{id}_{\mu F}

对于

h∘in⁡:F(μF)→F(μF) h \circ \operatorname{in}: F(\mu F) \to F(\mu F)

可知

h∘in⁡=F(in⁡)∘F(h)=F(in⁡∘h) h \circ \operatorname{in} = F(\operatorname{in}) \circ F(h) = F(\operatorname{in} \circ h)

再代入 in⁡∘h=id⁡μF\operatorname{in} \circ h = \operatorname{id}_{\mu F} 得到

h∘in⁡=F(id⁡μF)=id⁡F(μF) h \circ \operatorname{in} = F(\operatorname{id}_{\mu F}) = \operatorname{id}_{F(\mu F)}

结合 in⁡∘h=id⁡μF\operatorname{in} \circ h = \operatorname{id}_{\mu F} 和 h∘in⁡=id⁡F(μF)h \circ \operatorname{in} = \operatorname{id}_{F(\mu F)} 可知二者互为逆态射,因此

F(μF)≅μF F(\mu F) \cong \mu F

这恰好就是不动点的定义。

递归数据类型:函子的初始代数

递归数据类型可以看作函子的初始代数。

以自然数 Nat\text{Nat} 为例

Nat≅1+Nat \text{Nat} \cong 1 + \text{Nat}

这里 Nat\text{Nat} 的定义中再次引用了 Nat\text{Nat} 自身,因此这是一个递归数据类型。其中

  • 11 是单位类型,表示只有一个值的类型
  • ++ 是和类型,表示一种新类型,其值要么是左侧类型的值,要么是右侧类型的值。

和类型提供两个典范注入

  • 由 ++ 的左侧得到 inl⁡:1→1+Nat\operatorname{inl}: 1 \to 1 + \text{Nat},由同构 αNat:1+Nat→≅Nat\alpha_{\text{Nat}}: 1 + \text{Nat} \xrightarrow{\cong} \text{Nat} 得到 αNat∘inl⁡:1→Nat\alpha_{\text{Nat}} \circ \operatorname{inl}: 1 \to \text{Nat}
  • 由 ++ 的右侧得到 inr⁡:Nat→1+Nat\operatorname{inr}: \text{Nat} \to 1 + \text{Nat},同样由同构得到 αNat∘inr⁡:Nat→Nat\alpha_{\text{Nat}} \circ \operatorname{inr}: \text{Nat} \to \text{Nat}

由于 11 只有一个值,记为 ∗*,通过 αNat∘inl⁡\alpha_{\text{Nat}} \circ \operatorname{inl} 得到值 αNat(inl(*)⁡)\alpha_{\text{Nat}}(\operatorname{inl(*)}),我们可以将其记为

Zero=αNat(inl(*)⁡) \text{Zero} = \alpha_{\text{Nat}}(\operatorname{inl(*)})

同理,对于 n:Natn: \text{Nat},通过 αNat∘inr⁡\alpha_{\text{Nat}} \circ \operatorname{inr} 得到值 αNat(inr⁡(n))\alpha_{\text{Nat}}(\operatorname{inr}(n))。我们可以将这个构造方式记为

Succ⁡(n)=αNat(inr(n)⁡) \operatorname{Succ}(n) = \alpha_{\text{Nat}}(\operatorname{inr(n)})

事实上这就是皮亚诺公理的一部分:一个自然数要么是 Zero\text{Zero},要么是另一个自然数 nn 的后继 Succ⁡(n)\operatorname{Succ}(n)。

我们也可以将其视为函子的初始代数。首先定义函子 FF 为

F(X)=1+X F(X) = 1 + X

取集合 AA 作为对象,映射 α:1+A→A\alpha: 1 + A \to A 作为态射,便得到 FF 代数

(A,α) (A, \alpha)

而对任意 (A,α)(A, \alpha),都存在唯一的代数同态

h:N→A h: \mathbb{N} \to A

使得

h∘αN=α∘F(h)=α∘(1+h) h \circ \alpha_{\mathbb{N}} = \alpha \circ F(h) = \alpha \circ (1 + h)

这个唯一的 hh 可以递归定义为

h(αN(inl⁡(∗)))=α((1+h)(inl⁡(∗)))=α(inl⁡(∗))h(αN(inr⁡(n)))=α((1+h)(inr⁡(n)))=α(inr⁡(h(n))) \begin{align*} h(\alpha_{\mathbb{N}}(\operatorname{inl}(*))) &= \alpha((1 + h)(\operatorname{inl}(*))) \\ &= \alpha(\operatorname{inl}(*)) \\ h(\alpha_{\mathbb{N}}(\operatorname{inr}(n))) &= \alpha((1 + h)(\operatorname{inr}(n))) \\ &= \alpha(\operatorname{inr}(h(n))) \end{align*}

从而 (N,αN)(\mathbb{N}, \alpha_{\mathbb{N}}) 是 FF 的初始代数。

公式里通常直接写为

Nat=μX.(1+X) \text{Nat} = \mu X.(1 + X)

其中

  • XX 是一个临时的类型变量
  • 1+X1 + X 是一个类型表达式
  • μX.(1+X)\mu X.(1 + X) 表示取 X≅1+XX \cong 1 + X 这个类型方程的最小不动点——这恰好就是初始 FF 代数的载体

再比如列表类型的递归定义为

List(A)≅1+A×List(A) \text{List}(A) \cong 1 + A \times \text{List}(A)

这里的 ×\times 表示积类型,新类型的每个值中都包含左侧类型的值和右侧类型的值。

在 Haskell 里可以写为

data List a
  = Nil
  | Cons a (List a)

其中

  • Nil 对应 11
  • | 对应 ++
  • Cons a (List a) 对应 A×List(A)A \times \text{List}(A)

用不动点表示就是

List(A)=μX.(1+A×X) \text{List}(A) = \mu X.(1 + A \times X)

在 Haskell 里可以表示为

newtype Fix f = Fix (f (Fix f))

data ListF a x
  = NilF
  | ConsF a x

type List a = Fix (ListF a)

其中

  • Fix 表示求函子的不动点
  • ListF 表示函子

折叠:把递归数据结构折叠成一个结果

折叠在编程上是把递归数据结构折叠成一个结果。

比如一个列表

[1,2,3]

要计算 0 减去其中所有元素的结果,可以看成不断向左折叠

((0 - 1) - 2) - 3

在 Haskell 里这可以使用 foldl 实现,数据会进行左结合

foldl (-) 0 [1,2,3]
-- ((0 - 1) - 2) - 3 = -6

foldl f z [x1,x2,x3] 可以理解为

f(f(f(z,x1),x2),x3) f(f(f(z, x_1),x_2),x_3)

而右折叠是

1 - (2 - (3 - 0))

在 Haskell 里这可以使用 foldr 实现,数据会进行右结合

foldr (-) 0 [1,2,3]
-- 1 - (2 - (3 - 0)) = 2

foldr f z [x1,x2,x3] 可以理解为

f(x1,f(x2,f(x3,z))) f(x_1,f(x_2,f(x_3,z)))

foldr 和 foldl 的类型分别为

ghci> :t foldr
foldr :: Foldable t => (a -> b -> b) -> b -> t a -> b
ghci> :t foldl
foldl :: Foldable t => (b -> a -> b) -> b -> t a -> b

JavaScript 里的 reduce 是左折叠,而 reduceRight 是右折叠

[1, 2, 3].reduce((acc, x) => acc - x, 0);
// ((0 - 1) - 2) - 3 = -6
[1, 2, 3].reduceRight((acc, x) => x - acc, 0);
// 1 - (2 - (3 - 0)) = 2

展开:把一个种子展开成递归数据结构

展开在编程上是把一个种子展开成递归数据结构。

比如要生成列表

[3,2,1]

可以看成一个生成器不断向右扩充列表

[]
[3]
[3,2]
[3,2,1]

在 Haskell 里使用 unfoldr 表示右展开

ghci> :t unfoldr
unfoldr :: (b -> Maybe (a, b)) -> b -> [a]

其中

  • b 表示状态,也可以认为是种子
  • (b -> Maybe (a, b)) 是一个生成器,输出 Nothing 表示停止,输出 Just (a, b') 会把 a 添加到列表中,b' 作为新的状态

可以如下编写一个倒数计数列表生成器

import Data.List (unfoldr)

countdown :: Int -> [Int]
countdown =
  unfoldr step
  where
    step 0 = Nothing
    step n = Just (n, n - 1)

正数计数列表利用闭包也不难实现

countup :: Int -> [Int]
countup end =
  unfoldr step 1
  where
    step n
      | n > end   = Nothing
      | otherwise = Just (n, n + 1)

而左展开就是不断向左扩充列表

[]
[1]
[2,`]
[3,2,1]

Haskell 的标准库里没有 unfoldl,可以自己实现一个

unfoldl :: (s -> Maybe (a, s)) -> s -> [a]
unfoldl step seed =
  go seed []
  where
    go s acc =
      case step s of
        Nothing ->
          acc

        Just (x, next) ->
          go next (x : acc)

生成倒数计数列表

countdown :: Int -> [Int]
countdown end =
  unfoldl step 1
  where
    step n
      | n > end   = Nothing
      | otherwise = Just (n, n + 1)

不过 unfoldl 有一些缺陷:因为最终项要放在最左边,因此不方便惰性求值,也无法生成无限列表。所以通常使用 unfoldr 会更好。