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

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

函数定义

好吧,你可能会说,这里面哪有函数呢?其实 ++ 就是一个二元函数,可以等价为 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,
      // ...
    ),
  ),
);

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

不过,这里其中有一些细微的差异:计算顺序不相同。++ 通常默认为左结合,即从左到右,从 11100100;而我们的函数表达式其实是从右到左,从 10010011

函数的类型

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

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

不过 ÷\div 的第二个操作数不能为 00,所以它更准确的表示为 f:(R,R)Rf: (\mathbb{R},\mathbb{R}^*) \to \mathbb{R}。其中 R\mathbb{R}^* 表示 R\mathbb{R} 除去 {0}\{0\} 中元素的差集,也就是 R=R{0}\mathbb{R}^*=\mathbb{R} \setminus \{0\}

-(取负运算)、sin\sincos\coslog\log(底数为自然对数)、exp\exp(底数为自然对数)都是一元函数,可以表示为

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

同样的,log\log 只能作用在正实数上,因此更准确的表示为 f:R+Rf: \mathbb{R^+} \to \mathbb{R}exp\exp 的结果只可能为正实数,所以更准确的表示为 f:RR+f: \mathbb{R} \to \mathbb{R}^+

这种表示其实就可以作为函数的类型。

在实际编程中,我们会面对非常多的类型。比如在 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+1x21 f(x) = \frac{x + 1}{x^2 - 1}

使用 addsubmuldiv 改写后能够更清晰地看出函数组合的本质

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

其中 mul 的值被作为 sub 的参数,而 addsub 的值又作为 div 的参数。这就引出了函数组合需要满足的要求:只有当上一个函数的输出可以作为下一个函数的输入时,这种组合才是可能的。

上述例子中,输出是可以匹配输入的。div 的第二个参数是 R\mathbb{R}^*,值是 sub 的值,而 sub 可能返回的值的范围是 [1,+)[-1, +\infty),而 div 第二个参数的。由于 [1,+)R={0}[-1, +\infty) \setminus \mathbb{R}^* = \{0\} 不为空集,因此存在不能用作输入的值;同样因为 [1,+)R=[1,0](0,+)[-1, +\infty) \cap \mathbb{R}^*=[-1, 0] \cap (0, +\infty) 不为空集,所以存在可以用作输入的值;

经过一番推理后,我们可以确定这个函数的类型为

f:R{1,1}R{0,0.5} f: \mathbb{R} \setminus \{-1, 1\} \to \mathbb{R} \setminus \{ 0, -0.5 \}

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

f(x)=log(exp(x)) f(x)= \log(-\exp(x))
  1. 首先 exp\exp 的值变换为 RR+\mathbb{R} \to \mathbb{R}^+
  2. 然后 - 的输入接上 exp\exp 的输出,值变换为 R+R\mathbb{R}^+ \to \mathbb{R}^-
  3. 最后 log\log 的输入接上 - 的输出 R\mathbb{R}^-,但 log\log 只定义在 R+\mathbb{R}^+ 上,且 R+R=\mathbb{R}^+ \cap \mathbb{R}^- = \empty

最后我们发现,任何 xRx \in \mathbb{R} 都无法作为 ff 的输入。因此在实数范围内,这种组合是失败的,我们没有得到新的函数。

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

比如在 TypeScript 中

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

分别对应类型

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

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

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

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

lengthSquared:stringboolean \text{lengthSquared}: \text{string} \to \text{boolean}

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

const bad = (n) => getLength(isBig(s));

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

函数的复合

函数复合是一种特殊的函数组合,通常用于一组输入和输出可以匹配的函数。

还是先在纯数学中举例。我们有这样一组一元函数

f:ABg:BCh:CD \begin{align*} f: A \to B \\ g: B \to C \\ h: C \to D \\ \end{align*}

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

AfBgChD A \xrightarrow{f} B \xrightarrow{g} C \xrightarrow{h} D

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

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

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

hgf:AD h \circ g \circ f: A \to D

复合的顺序是从右到左。函数复合是直接对函数进行计算,得到的结果是一个新的函数。在这个函数上输入原来的参数,就可以得到之前组合后的结果

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

有部分编程语言可以类似这样简化函数复合的写法。最常见的语法就是管道——几乎就是把函数复合换了个符号,只不过管道的顺序是从左到右,而函数复合是从右到左

hgffgh h \circ g \circ f \Leftrightarrow f \mid g \mid h

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

xfgh x \mid f \mid g \mid h

比如 Bash 的管道

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

这可以被理解为

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

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

TextText \text{Text} \to \text{Text}

这满足了函数复合的要求——前一个函数的输出匹配下一个函数的输入。而管道就是把前一个函数的输出作为下一个函数的输入,这就是一种更清晰的、减少函数嵌套的函数复合语法。

再比如 jq 的管道

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

jq 的每个函数都输入 JSON 对象,然后输出 JSON 对象

JSONJSON \text{JSON} \to \text{JSON}

甚至 SQL 也能支持管道语法,比如 GoogleSQL

FROM users
|> WHERE age >= 18
|> SELECT name, age
|> ORDER BY age DESC
|> LIMIT 10;

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

TableTable \text{Table} \to \text{Table}

而在最纯粹的函数式语言 Haskell 里,函数复合的语法更是直接

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

Haskwll 的函数复合是从右到左而非从左到右,完全照搬数学上的写法,连符号都很相似

summap(square)filter(even) \text{sum} \circ \text{map(square)} \circ \text{filter(even)}

显然,summap (^2)filter even 这些函数都接受数字返回数字

NumberNumber \text{Number} \to \text{Number}

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

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

export function pipe(...fns) {
  return (value) => fns.reduce((value, fn) => fn(value), value);
}

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

pipe 返回的函数只有一个参数 valuepipe 会首先把自身参数中的第一个函数作用在这个 value 上,得到的值会被第二个函数作用,然后新的值再被第三个函数作用,一直重复直到最后一个函数作用完成。

reduce 会在后续详细介绍。这里只需要知道,reduce 有两个参数:第二个参数是初始值,第一个参数是函数。而这个函数的第一个参数是上次计算的结果(未计算时就是初始值),第二个参数是调用 reduce 的数组中的一个元素。

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

我猜你应该在尝试弄懂 reduce 各参数的含义时被绕晕了。坏消息是,我们已经介绍完了函数的基本概念,后续的内容都将开始变得抽象。而好消息是,如果逐渐地深入,那么理解这些概念其实并不困难。我相信当你真正体会到函数式编程中那些数学理论的美妙时,也会无可救药地为之着迷。

高阶函数指的是参数或返回值中含有函数的函数。典型的高阶函数有三种:函数族、泛函与算子。

函数族

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

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

Log:aloga \operatorname{Log}: a \mapsto \log_a

其类型为

Log:R+{1}(R+R) \operatorname{Log}: \mathbb{R}^+ \setminus \{1\} \to (\mathbb{R}^+ \to \mathbb{R})

对数函数族接受一个底数,然后返回一个对数函数。

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

Log(e)=loge=lnLog(10)=log10=lg \begin{align*} \operatorname{Log}(e) &= \log_e = \ln \\ \operatorname{Log}(10) &= \log_{10} = \lg \end{align*}

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

logax=Log(a)(x) \log_a x = \operatorname{Log}(a)(x)

虽然后一种写法在数学上不太常见,但在函数式编程里却非常重要。后面提到的柯里化就和函数族密切相关。

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

Exp:aax \operatorname{Exp}: a \mapsto a^x

类型可以表示为

Exp:R+{1}(RR+) \operatorname{Exp}: \mathbb{R}^+ \setminus \{1\} \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:fF(b)F(a) \int_{a}^{b}: f \mapsto F(b) - F(a)

不考虑积分区间和可积性等问题,其类型为

ab:(RR)R \int_{a}^b: (\mathbb{R} \to \mathbb{R}) \to \mathbb{R}

拉格朗日泛函就是一个用定积分定义的泛函

L(f)=01f(x)dx L(f) = \int_0^1 f(x) \mathrm{d}x

把这个泛函作用于函数,就能得到一个数

L(2x)=012xdx=1 L(2x) = \int_0^1 2x \mathrm{d}x = 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]]

然后可以这样表示拉格朗日泛函

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

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

ghci> l (\x -> 2 * x)
0.9999999999999998
ghci> l (\x -> 3 * x)
1.5000000000000004
ghci> l (\x -> 3 * x * x)
0.9999999974999992

算子

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

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

D:ff D: f \mapsto f'

同样不考虑可微性,其类型为

D:(RR)(RR) \mathcal{D}: (\mathbb{R} \to \mathbb{R}) \to (\mathbb{R} \to \mathbb{R})

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

D:x22x \mathcal{D}: x^2 \mapsto 2x

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

ax:fF(x)F(a) \int_a^x: f \mapsto F(x) - F(a)

在不考虑可积性时,其类型也是

ax:(RR)(RR) \int_a^x: (\mathbb{R} \to \mathbb{R}) \to (\mathbb{R} \to \mathbb{R})

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

0x:2xx2 \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

柯里化:把多元函数变成一连串一元函数

柯里化示例:不定积分、变限积分和定积分

前面定义定积分和变限积分的时候其实已经多次出现了不定积分。在数学上,不定积分接受一个函数,返回其原函数族

:fFC \int: f \mapsto F_C

FCF_C 数学上更常见的写法是 F(x)+CF(x)+C,但这里我们将 CC 看作一个参数,只有指定了这个参数,才能确定函数族中的一个函数。

不考虑可积性时,不定积分的类型为

:(RR)(R(RR)) \int: (\mathbb{R} \to \mathbb{R}) \to (\mathbb{R} \to (\mathbb{R} \to \mathbb{R}))

比如

:2xx2+C \int: 2x \mapsto x^2 + C

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

我们把不定积分 I=I = \int 定义为一个三元函数:第一个参数是下限,第二个参数是上限,第三个参数是被积函数,返回值是定积分的结果。

I:(R,R,(RR))R I: (\mathbb{R}, \mathbb{R}, (\mathbb{R} \to \mathbb{R})) \to \mathbb{R}

这里复杂的嵌套看着很不舒服。我们用 C(R)C(\mathbb{R}) 表示函数,并把参数改写成笛卡尔积的形式

I:R×R×C(R)R I: \mathbb{R} \times \mathbb{R} \times C(\mathbb{R}) \to \mathbb{R}

仔细研究一下从不定积分到定积分的这个过程,我们会发现一件很有趣的事情:不定积分固定了下限后就成了变上限积分 Ia=axI_a = \int_a^x;而变上限积分再固定下限,或者不定积分固定上下限,就成了定积分 Iab=abI_a^b = \int_a^b

I(a)=IaI(a)(b)=Ia(b)=Iab \begin{align*} I(a) &= I_a \\ I(a)(b) &= I_a(b) = I_a^b \end{align*}

因此,虽然 II 是一个三元函数,但我们并不需要为每个参数都进行赋值才能得到结果。当我们为只指定第一个参数时,我们得到了变上限积分;而变上限积分也可以看作一个二元函数,第一个参数是上限,第二个参数是函数

I(a)=Ia:R×C(R)R I(a) = I_a: \mathbb{R} \times C(\mathbb{R}) \to \mathbb{R}

此时 II 变成了接受一个参数,返回变上限积分的一元函数,其类型为

I:R(R×C(R)R) I: \mathbb{R} \to (\mathbb{R} \times C(\mathbb{R}) \to \mathbb{R})

这和我们最开始看到的类型 R×R×C(R)\mathbb{R} \times \mathbb{R} \times C(\mathbb{R}) 不一样,但显然,这里的 II 仍然是不定积分!因此我们可知这两种类型是同构的

(R×R×C(R)R)(R(R×C(R)R)) (\mathbb{R} \times \mathbb{R} \times C(\mathbb{R}) \to \mathbb{R}) \cong (\mathbb{R} \to (\mathbb{R} \times C(\mathbb{R}) \to \mathbb{R}))

这就是柯里化:把多元函数变成一元函数。

类似地,我们可以继续为变上限积分再指定一个参数,或者为不定积分指定两个参数,就得到了定积分。

I(a,b)=Ia(b)=Iab:C(R)R I(a,b) = I_a(b) = I_a^b: C(\mathbb{R}) \to \mathbb{R}

此时 II 是一个二元函数,接受两个参数返回定积分泛函;IaI_a 是一个一元函数,接受一个参数返回定积分泛函。它们的类型分别为

I:R×R(C(R)R)Ia:R(C(R)R) \begin{align*} I &: \mathbb{R} \times \mathbb{R} \to (C(\mathbb{R}) \to \mathbb{R}) \\ I_a &: \mathbb{R} \to (C(\mathbb{R}) \to \mathbb{R}) \end{align*}

由前面可知,Ia=I(a)I_a=I(a),所以这里还可以认为 II 接受一个参数,然后返回一个一元函数 IaI_a,而 IaI_a 接受一个参数然后返回一个泛函

I:R(R(C(R)R)) I: \mathbb{R} \to (\mathbb{R} \to (C(\mathbb{R}) \to \mathbb{R}))

我们发现,我们已经写了四种不同的 II 类型,而这些都是同构的!

R×R×C(R)RR(R×C(R)R)R×R(C(R)R)R(R(C(R)R)) \begin{align*} \mathbb{R} \times \mathbb{R} \times C(\mathbb{R}) &\to \mathbb{R} \\ \mathbb{R} &\to (\mathbb{R} \times C(\mathbb{R}) \to \mathbb{R}) \\ \mathbb{R} \times \mathbb{R} &\to (C(\mathbb{R}) \to \mathbb{R}) \\ \mathbb{R} &\to (\mathbb{R} \to (C(\mathbb{R}) \to \mathbb{R})) \end{align*}

最后这一种类型完全柯里化了,此时 II 可以接受一个参数 I(a)I(a),可以接受两个参数 I(a)(b)I(a)(b),也可以接受三个参数 I(a)(b)(f)I(a)(b)(f),调用起来非常灵活!

我们继续沿用之前在 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]]

Haskell 的函数会自动柯里化。这里第一个参数是下限,第二个参数是上限,第三个参数是函数,返回值是定积分结果。

如果传入第一个参数,那么我们得到的是一个变上限积分,不过这个变上限积分的第一个参数是上限,第二个参数才是函数

integrate a
  :: Double -> (Double -> Double) -> Double

如果我们想让变上限积分的第一个参数是函数,那么就要交换一下变量顺序。最直观的方法就是像之前展示的那样,重新定义一个函数

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

这里非常明确的表示了把 integrate 的第二个参数和第三个参数交换位置,这样 integralFromLower a 得到的函数,其第一个参数就是函数了

integralFromLower a
  :: (Double -> Double) -> Double -> Double

对于二元函数也可以直接使用 flip 交换变量顺序。integrate a 返回的就是一个二元函数,此时用 flip 也是可行的

integralFromLower a = flip (integrate a)

如果想要变下限积分也可以使用类似的方法,只需要把上限变成第一个参数,函数变成第二个参数,下限变成第三个参数,这样接受一个参数后返回的函数,其上限固定且第一个参数就是函数

integralToUpper :: Double -> (Double -> Double) -> (Double -> Double)
integralToUpper b f x = integrate x b f

如果想要不定积分,其第一个参数是函数,然后返回一个函数族,那么要如下修改变量

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

下限 00 是随便选的,因为变上限积分公式允许下限是一个任意常数

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

这里不能把 c 像前面那样放到 0 的位置,因为 integrate c x f 实际指的是

cxf \int_c^x f

于是 integralIndefinite f 返回的是一个以上限为变量的函数 F(x)=cxfF(x)=\int_c^x f,而当 x=cx=c 时得到 F(c)=ccf=0F(c)=\int_c^c f = 0,此时 cc 的含义不再是积分常数而变成了原函数的零点。

最后定积分就更简单了,指定上限和下限即可,这里 ab 都要是常数

integralDefinite :: (Double -> Double) -> Double
integralDefinite f = integrate a b f

柯里化的数学定义

不过不定积分的例子有点太抽象了,我么可以看一个更简单的例子。加法是一个二元函数

add:R×RR \operatorname{add}: \mathbb{R} \times \mathbb{R} \to \mathbb{R}

我们可以通过柯里化让其变成一元函数

add:R(RR) \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)

对于一般的二元函数

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

可以被柯里化为

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

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

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

反柯里化为

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

用集合论解释就是,从 A×BA \times B 映射到 CC 的所有函数组成的函数空间,与从 AA 映射到 CB={ff:BC}C^B=\{f\mid f: B \to C\} 的所有函数组成的函数空间同构

Hom(A×B,C)Hom(A,CB) \operatorname{Hom}(A \times B, C) \cong \operatorname{Hom}(A, C^B)

而在范畴论黑话里,这通常表述为:在笛卡尔闭范畴 C\mathcal C 上,对每个对象 BB,积函子

(×B):CCAA×B (-\times B):\mathcal C\to\mathcal C \qquad A\mapsto A\times B

是幂函子

()B:CCCCB (-)^B:\mathcal C\to\mathcal C \qquad C\mapsto C^B

的左伴随

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

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

f:A1×A2× AnB f: A_1 \times A_2 \times \cdots \ A_n \to B

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

f:A1A2AnB f: A_1 \to A_2 \to \cdots \to A_n \to B

柯里化的编程优势

好吧,虽然柯里化在数学上有非常优美的形式,但在编程里把函数柯里化,让其可以更灵活的调用,有什么好处呢?

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

函数的输出始终是一个值(数组实际上也只是类型为数组的一个值)。但多元函数需要多个参数,因此在管道里多元函数没办法把自己的输入接到任何函数的输出上。而柯里化可以多元函数变为一元函数,从而连接进管道中。

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

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

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

柯里化的另一个好处就是参数复用。通过提前固定一部分参数,得到一个更具体的函数,后续只需使用这个更具体的函数即可,不需要重复传参。而且,即使得到具体函数时使用的参数名称改了,后面的代码通常也不需要修改——只要函数名的语义仍然正确

const log = app => level => message => {
  console.log(`[${app}] [${level}] ${message}`);
};

const myAppLog = log("MyAppName");

const info = myAppLog("INFO");
const error = myAppLog("ERROR");

info("Server started");
error("Database failed");

再一个优势就是代码更加声明式,只看函数名就能知道函数有什么用,而不用考虑每个位置的参数是什么意思

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

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

4. 函数组合与 Pipe

数学中的函数复合:

gf:AC g\circ f:A\to C

定义:

(gf)(x)=g(f(x)) (g\circ f)(x)=g(f(x))

程序中的:

pipe(f, g, h)(x);

对应:

hgf h\circ g\circ f

函数复合满足:

结合律

$$ h\circ(g\circ f)

(h\circ g)\circ f $$

恒等函数

存在:

idA:AA \operatorname{id}_A:A\to A

使:

$$ f\circ\operatorname{id}

\operatorname{id}\circ f

f $$

这两个性质是后续范畴论结构的基础。


5. map 与 Functor

JavaScript:

xs.map(f);

只是 API 设计成了 method:

Array object → .map(...)

它与 JavaScript 的:

new Map();

毫无理论关系。

Map 是键值容器;Array.prototype.map 中的 map 才对应函数式编程中的 mapping。

完全可以把它改写成独立函数:

const map = (f) => (xs) => xs.map(f);

于是:

map(f)(xs);

更接近数学表示。


6. Functor 的数学定义

给定两个范畴:

C,D \mathcal C,\mathcal D

一个函子:

F:CD F:\mathcal C\to\mathcal D

包含两部分。

对对象的映射

AF(A) A\mapsto F(A)

对态射的映射

若:

f:AB f:A\to B

则:

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

并且必须满足两个定律。

保持恒等态射

$$ F(\operatorname{id}_A)

\operatorname{id}_{F(A)} $$

保持复合

$$ F(g\circ f)

F(g)\circ F(f) $$


List / Array 作为例子

对象:

AList(A) A\mapsto List(A)

函数:

f:AB f:A\to B

被提升成:

List(f):List(A)List(B) List(f):List(A)\to List(B)

其中:

$$ List(f)([a_1,\ldots,a_n])

[f(a_1),\ldots,f(a_n)] $$

JavaScript 中就是:

xs.map(f);

因此 map 的核心作用可以概括为:

(AB)(F(A)F(B)) (A\to B) \longmapsto (F(A)\to F(B))

同时保持恒等与复合。


7. reduce 比 Monoid 更一般

JavaScript:

xs.reduce(f, initial);

并不要求 initial 是单位元。

一般来说:

foldl:(A×BA)AList(B)A \operatorname{foldl}: (A\times B\to A) \to A \to List(B) \to A

也就是:

  • AA:累积器类型
  • BB:元素类型
  • A×BAA\times B\to A:每一步如何更新累积器
  • 初始值只需要属于 AA

例如:

users.reduce(
  (acc, user) => ({
    ...acc,
    [user.id]: user,
  }),
  {},
);

这并不是简单的幺半群折叠。

Monoid 只是一个特殊情况

如果:

(A,,e) (A,\star,e)

满足:

$$ (a\star b)\star c

a\star(b\star c) $$

且:

ea=ae=a e\star a=a\star e=a

那么可以:

reduce(,e) \operatorname{reduce}(\star,e)

例如求和:

(Z,+,0) (\mathbb Z,+,0)

所以:

Monoid 能自然地产生一种 reduce,但 reduce 本身不要求 Monoid。


8. Option / Result:扩展陪域

若:

f:AB f:A\rightharpoonup B

只是一个部分函数,例如:

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

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

可以把它改成 total function:

f^:AB{Failure} \hat f:A\to B\cup\{\mathrm{Failure}\}

前提是:

FailureB \mathrm{Failure}\notin B

更严格地,为避免元素碰撞,常写成不交并:

B{Failure} B\sqcup\{\mathrm{Failure}\}

或者类型论中常写:

B+E B+E

这里的 ++ 不是普通数值加法,而是 coproduct / sum type

程序中:

Option<B>
Result<B, E>

正是在做这件事。


9. Monad 与错误传播

假设:

f:AResult(B,E) f:A\to Result(B,E)
g:BResult(C,E) g:B\to Result(C,E)

普通复合:

gf g\circ f

无法成立,因为 ff 的输出不是 BB

Monad 提供一种组合操作,通常称为:

bind \operatorname{bind}

类型:

Result(A,E)(AResult(B,E))Result(B,E) Result(A,E) \to (A\to Result(B,E)) \to Result(B,E)

于是:

AResult(B,E) A\to Result(B,E)

与:

BResult(C,E) B\to Result(C,E)

可以进行 Kleisli composition:

gf:AResult(C,E) g\star f: A\to Result(C,E)

不会产生“error drilling”吗?

如果手写:

const r = f(x);

if (!r.ok) return r;

return g(r.value);

确实会产生大量样板代码。

因此实际函数式 API 会把这段逻辑封装进:

result.flatMap(g);

或:

bind(result, g);

从而写成:

parse(input).flatMap(validate).flatMap(calculate).flatMap(format);

错误传播由 flatMap/bind 自动处理。

不同语言进一步提供语法糖:

Haskell        do notation
Scala          for-comprehension
Rust           ?
Elixir         with
JavaScript     Promise.then / async-await

所以 Monad 的意义之一正是:

把“传播上下文”的样板逻辑从每个业务函数中抽离出来。


10. 递归与不动点

不能直接写:

F(n)=nF(n1) F(n)=nF(n-1)

来解释“不动点”,因为这里的 FF 已经是最终递归函数本身。

要讨论不动点,必须再高一阶。

定义一个作用于函数的算子

Φ:(NN)(NN) \Phi:(\mathbb N\to\mathbb N) \to (\mathbb N\to\mathbb N)

定义:

$$ \Phi(g)(n)

\begin{cases} 1,&n=0\ n,g(n-1),&n>0 \end{cases} $$

注意:

  • gg 是输入函数;
  • Φ(g)\Phi(g) 是输出函数;
  • 此时没有递归调用 Φ\Phi

现在寻找一个函数 ff,满足:

Φ(f)=f \Phi(f)=f

这样的 ff 就是 Φ\Phi 的不动点。

代入:

$$ f(n)

\begin{cases} 1,&n=0\ n,f(n-1),&n>0 \end{cases} $$

于是:

f(n)=n! f(n)=n!

因此 factorial 可以理解为:

f=fix(Φ) \boxed{f=\operatorname{fix}(\Phi)}

其中 fixed-point operator 满足:

$$ \operatorname{fix}(\Phi)

\Phi(\operatorname{fix}(\Phi)) $$

Lambda Calculus 中的 YY combinator 就满足类似关系:

Y(Φ)=Φ(Y(Φ)) Y(\Phi)=\Phi(Y(\Phi))

关键区别:

Φ \Phi

是“函数 → 函数”的高阶算子;

f f

才是真正的递归函数。


总体对应

函数式编程数学结构
pure function映射 ABA\to B
higher-order function函数空间上的映射
operator函数 \to 函数
functional函数 \to 标量
closureA(BC)A\to(B\to C) / 参数化函数族
curryingA×BCA(BC)A\times B\to C \cong A\to(B\to C)
composition / pipe函数复合
map函子的态射映射
Functor保持恒等与复合的范畴间映射
reduce / fold代数上的折叠
monoid reductionfold 的重要特殊情况
Option / Result扩展陪域 / sum type
bind / flatMapKleisli composition
recursion高阶算子的不动点

函数式编程最值得把握的一条主线是:

函数函数之间的组合保持组合的结构 \boxed{ \text{值} \rightarrow \text{函数} \rightarrow \text{函数之间的组合} \rightarrow \text{保持组合的结构} }

所以 FP 最深层的魅力并不只是“函数是一等公民”,而是:

程序逐渐变成一个可以用代数规律推理的对象 \boxed{\text{程序逐渐变成一个可以用代数规律推理的对象}}

这也是它与数学之间最根本的联系。