函数的基本概念:从加运算说起
在数学上,我们已经习惯书写 这种表达式。尽管看起来非常普通,但这个表达式里其实已经包含了非常多函数式编程的重要思想。
函数定义
好吧,你可能会说,这里面哪有函数呢?其实 就是一个二元函数,可以等价为
输入两个实数,输出一个实数。我们可以用符号表示为
在 Javascript 里可以这样定义
function add(x, y) {
return x + y;
}
或者使用箭头函数
const add = (x, y) => x + y;
有了 add 后,就可以改写原来的表达式。为了更可读,我们对代码进行缩进
add(
1,
add(
2,
add(
3,
// ...
),
),
);
可以看到,虽然表达式变得更加复杂,但计算结果是一样的。
不过,这里其中有一些细微的差异:计算顺序不相同。 通常默认为左结合,即从左到右,从 到 ;而我们的函数表达式其实是从右到左,从 到 。
函数的类型
、(减运算)、、 都是二元函数,即接受两个参数并返回一个值。它们可以统一表示为
不过 的第二个操作数不能为 ,所以它更准确的表示为 。其中 表示 除去 中元素的差集,也就是 。
而 (取负运算)、、、(底数为自然对数)、(底数为自然对数)都是一元函数,可以表示为
同样的, 只能作用在正实数上,因此更准确的表示为 ; 的结果只可能为正实数,所以更准确的表示为 。
这种表示其实就可以作为函数的类型。
在实际编程中,我们会面对非常多的类型。比如在 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,也可能是字面量 "请输入非负整数"。
函数类型的主要用处,就是推导函数之间能否组合,以及组合后会得到的函数会是什么类型。
函数的组合
现实世界中的函数是无穷无尽的,因为函数之间可以组合,而组合后可以得到一个新的函数。
不过我们先考虑纯数学中的例子。我们定义一个一元函数
使用 add、sub、mul、div 改写后能够更清晰地看出函数组合的本质
function f(x) {
return div(add(x, 1), sub(mul(x, x), 1));
}
其中 mul 的值被作为 sub 的参数,而 add 和 sub 的值又作为 div 的参数。这就引出了函数组合需要满足的要求:只有当上一个函数的输出可以作为下一个函数的输入时,这种组合才是可能的。
上述例子中,输出是可以匹配输入的。div 的第二个参数是 ,值是 sub 的值,而 sub 可能返回的值的范围是 ,而 div 第二个参数的。由于 不为空集,因此存在不能用作输入的值;同样因为 不为空集,所以存在可以用作输入的值;
经过一番推理后,我们可以确定这个函数的类型为
现在我们构造一个特殊的例子,看看当输出不匹配输入时会发生什么
- 首先 的值变换为
- 然后 的输入接上 的输出,值变换为
- 最后 的输入接上 的输出 ,但 只定义在 上,且
最后我们发现,任何 都无法作为 的输入。因此在实数范围内,这种组合是失败的,我们没有得到新的函数。
在实际编程里,函数类型的推导与之类似。
比如在 TypeScript 中
const getLength = (s: string): number => s.length;
const isBig = (n: number): boolean => n > 32;
分别对应类型
如果把 getLength 的返回值作为 isBig 的参数
const isLongString = (s) => isBig(getLength(s));
由于两者类型都是 number,因此可以进行组合,并且能够推断出 isLongString 的类型应该为
但反过来把 isBig 的返回值作为 getLength 的参数就存在问题
const bad = (n) => getLength(isBig(s));
这里 isBig 的返回值是 boolean 而 getLength 的参数为 string,类型不匹配。因此无法这样组合。
函数的复合
函数复合是一种特殊的函数组合,通常用于一组输入和输出可以匹配的函数。
还是先在纯数学中举例。我们有这样一组一元函数
我们希望先计算 ,再计算 ,最后计算 ,即
那么这个组合出来的函数需要这样写
如果再多计算几个函数,这种嵌套会变得非常复杂,难以阅读。数学中用办法简化此时的函数组合,就是函数复合
复合的顺序是从右到左。函数复合是直接对函数进行计算,得到的结果是一个新的函数。在这个函数上输入原来的参数,就可以得到之前组合后的结果
有部分编程语言可以类似这样简化函数复合的写法。最常见的语法就是管道——几乎就是把函数复合换了个符号,只不过管道的顺序是从左到右,而函数复合是从右到左
而最初送入管道的值,通常就放到管道的最左边
比如 Bash 的管道
cat server.log \
| grep "ERROR" \
| sort \
| uniq -c
这可以被理解为
uniq_count(sort(grep_error(cat("server.log"))));
每个函数(bash 中的术语是命令)的输入是文本,输出也是文本,因此每个函数都是
这满足了函数复合的要求——前一个函数的输出匹配下一个函数的输入。而管道就是把前一个函数的输出作为下一个函数的输入,这就是一种更清晰的、减少函数嵌套的函数复合语法。
再比如 jq 的管道
.users[]
| select(.age >= 18)
| .name
jq 的每个函数都输入 JSON 对象,然后输出 JSON 对象
甚至 SQL 也能支持管道语法,比如 GoogleSQL
FROM users
|> WHERE age >= 18
|> SELECT name, age
|> ORDER BY age DESC
|> LIMIT 10;
SQL 的每个函数(SQL 中的术语是操作)都接收表,然后返回表
而在最纯粹的函数式语言 Haskell 里,函数复合的语法更是直接
sumSquaresOfEvens :: [Int] -> Int
sumSquaresOfEvens = sum . map (^2) . filter even
Haskwll 的函数复合是从右到左而非从左到右,完全照搬数学上的写法,连符号都很相似
显然,sum、map (^2)、filter even 这些函数都接受数字返回数字
因此这些函数可以把输入输出连接起来,复合成一个函数。
JavaScript 目前还没有原生的管道语法,不过可以自己手动实现一个简易的 pipe
export function pipe(...fns) {
return (value) => fns.reduce((value, fn) => fn(value), value);
}
这里 pipe 本身是一个函数,它的参数是函数,返回值也是函数。这叫做高阶函数——我们马上就会讲到。
pipe 返回的函数只有一个参数 value。pipe 会首先把自身参数中的第一个函数作用在这个 value 上,得到的值会被第二个函数作用,然后新的值再被第三个函数作用,一直重复直到最后一个函数作用完成。
reduce 会在后续详细介绍。这里只需要知道,reduce 有两个参数:第二个参数是初始值,第一个参数是函数。而这个函数的第一个参数是上次计算的结果(未计算时就是初始值),第二个参数是调用 reduce 的数组中的一个元素。
高阶函数:函数族、泛函与算子
我猜你应该在尝试弄懂 reduce 各参数的含义时被绕晕了。坏消息是,我们已经介绍完了函数的基本概念,后续的内容都将开始变得抽象。而好消息是,如果逐渐地深入,那么理解这些概念其实并不困难。我相信当你真正体会到函数式编程中那些数学理论的美妙时,也会无可救药地为之着迷。
高阶函数指的是参数或返回值中含有函数的函数。典型的高阶函数有三种:函数族、泛函与算子。
函数族
函数族的输入是数,输出是函数。
在数学上,典型的函数族就是对数函数族
其类型为
对数函数族接受一个底数,然后返回一个对数函数。
所有对数函数都可以通过 函数族生成出来。而对于那些常用的对数函数,数学中都有别名
对返回的函数进行调用就能得到值,这可以等价为对原来的函数族调用两次
虽然后一种写法在数学上不太常见,但在函数式编程里却非常重要。后面提到的柯里化就和函数族密切相关。
指数函数族也可以这样表示,通过接受底数,返回一个指数函数
类型可以表示为
在 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
泛函
泛函的输入是函数,输出是数。
在数学上,典型的泛函就是定积分
不考虑积分区间和可积性等问题,其类型为
拉格朗日泛函就是一个用定积分定义的泛函
把这个泛函作用于函数,就能得到一个数
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
最后给这泛函一个函数,就会返回其在 上定积分的结果。由于数值积分的精度问题,结果不完全准确
ghci> l (\x -> 2 * x)
0.9999999999999998
ghci> l (\x -> 3 * x)
1.5000000000000004
ghci> l (\x -> 3 * x * x)
0.9999999974999992
算子
算子的输入和输出都是函数。
在数学上,典型的算子就是求导
同样不考虑可微性,其类型为
求导算子接受一个函数,然后返回其导函数,比如
变限积分也是一个算子,这是一种上限或下限不固定的积分。比如变上限积分
在不考虑可积性时,其类型也是
变限积分接受一个函数,返回其原函数。比如
在 Haskell 的标准库里没有微分,我们先定义一个简单的数值微分
derivative :: (Double -> Double) -> (Double -> Double)
derivative f = \x ->
let h = 1e-6
in (f (x + h) - f (x - h)) / (2 * h)
然后对 运用求导算子
square' = derivative (\x -> x * x)
得到的就是其导函数 。不过由于数值微分的精度问题,不完全准确
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
然后对 运用变上限积分算子
square = integralFromLower0 (\x -> 2 * x)
得到的函数就是 。不过由于数值积分的精度问题,不完全准确
ghci> square 2
3.999999999999999
ghci> square 3
9.0
ghci> square 4
15.999999999999996
ghci> square 5
25.0
柯里化:把多元函数变成一连串一元函数
柯里化示例:不定积分、变限积分和定积分
前面定义定积分和变限积分的时候其实已经多次出现了不定积分。在数学上,不定积分接受一个函数,返回其原函数族
数学上更常见的写法是 ,但这里我们将 看作一个参数,只有指定了这个参数,才能确定函数族中的一个函数。
不考虑可积性时,不定积分的类型为
比如
在指定 后才能确定唯一的原函数。
我们把不定积分 定义为一个三元函数:第一个参数是下限,第二个参数是上限,第三个参数是被积函数,返回值是定积分的结果。
这里复杂的嵌套看着很不舒服。我们用 表示函数,并把参数改写成笛卡尔积的形式
仔细研究一下从不定积分到定积分的这个过程,我们会发现一件很有趣的事情:不定积分固定了下限后就成了变上限积分 ;而变上限积分再固定下限,或者不定积分固定上下限,就成了定积分
因此,虽然 是一个三元函数,但我们并不需要为每个参数都进行赋值才能得到结果。当我们为只指定第一个参数时,我们得到了变上限积分;而变上限积分也可以看作一个二元函数,第一个参数是上限,第二个参数是函数
此时 变成了接受一个参数,返回变上限积分的一元函数,其类型为
这和我们最开始看到的类型 不一样,但显然,这里的 仍然是不定积分!因此我们可知这两种类型是同构的
这就是柯里化:把多元函数变成一元函数。
类似地,我们可以继续为变上限积分再指定一个参数,或者为不定积分指定两个参数,就得到了定积分。
此时 是一个二元函数,接受两个参数返回定积分泛函; 是一个一元函数,接受一个参数返回定积分泛函。它们的类型分别为
由前面可知,,所以这里还可以认为 接受一个参数,然后返回一个一元函数 ,而 接受一个参数然后返回一个泛函
我们发现,我们已经写了四种不同的 类型,而这些都是同构的!
最后这一种类型完全柯里化了,此时 可以接受一个参数 ,可以接受两个参数 ,也可以接受三个参数 ,调用起来非常灵活!
我们继续沿用之前在 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
下限 是随便选的,因为变上限积分公式允许下限是一个任意常数
这里不能把 c 像前面那样放到 0 的位置,因为 integrate c x f 实际指的是
于是 integralIndefinite f 返回的是一个以上限为变量的函数 ,而当 时得到 ,此时 的含义不再是积分常数而变成了原函数的零点。
最后定积分就更简单了,指定上限和下限即可,这里 a 和 b 都要是常数
integralDefinite :: (Double -> Double) -> Double
integralDefinite f = integrate a b f
柯里化的数学定义
不过不定积分的例子有点太抽象了,我么可以看一个更简单的例子。加法是一个二元函数
我们可以通过柯里化让其变成一元函数
用 JavaScript 表示就是
// 未柯里化
const add = (x, y) => x + y
// 柯里化
const add = x => y => x + y
然后我们就可以一次只传一个参数,或者一次传多个参数,调用方式非常灵活
const add5 = add(5)
add5(3)
add(5)(3)
对于一般的二元函数
可以被柯里化为
也可以把已经柯里化的函数
反柯里化为
用集合论解释就是,从 映射到 的所有函数组成的函数空间,与从 映射到 的所有函数组成的函数空间同构
而在范畴论黑话里,这通常表述为:在笛卡尔闭范畴 上,对每个对象 ,积函子
是幂函子
的左伴随
更一般地,任意一个多元函数
都可以被柯里化为一连串一元函数
柯里化的编程优势
好吧,虽然柯里化在数学上有非常优美的形式,但在编程里把函数柯里化,让其可以更灵活的调用,有什么好处呢?
显然,一个最直观的用途就是——让多元函数可以进入管道。
函数的输出始终是一个值(数组实际上也只是类型为数组的一个值)。但多元函数需要多个参数,因此在管道里多元函数没办法把自己的输入接到任何函数的输出上。而柯里化可以多元函数变为一元函数,从而连接进管道中。
回顾之前提到的 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
数学中的函数复合:
定义:
程序中的:
pipe(f, g, h)(x);
对应:
函数复合满足:
结合律
$$ h\circ(g\circ f)
(h\circ g)\circ f $$
恒等函数
存在:
使:
$$ 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 的数学定义
给定两个范畴:
一个函子:
包含两部分。
对对象的映射
对态射的映射
若:
则:
并且必须满足两个定律。
保持恒等态射
$$ F(\operatorname{id}_A)
\operatorname{id}_{F(A)} $$
保持复合
$$ F(g\circ f)
F(g)\circ F(f) $$
List / Array 作为例子
对象:
函数:
被提升成:
其中:
$$ List(f)([a_1,\ldots,a_n])
[f(a_1),\ldots,f(a_n)] $$
JavaScript 中就是:
xs.map(f);
因此 map 的核心作用可以概括为:
同时保持恒等与复合。
7. reduce 比 Monoid 更一般
JavaScript:
xs.reduce(f, initial);
并不要求 initial 是单位元。
一般来说:
也就是:
- :累积器类型
- :元素类型
- :每一步如何更新累积器
- 初始值只需要属于
例如:
users.reduce(
(acc, user) => ({
...acc,
[user.id]: user,
}),
{},
);
这并不是简单的幺半群折叠。
Monoid 只是一个特殊情况
如果:
满足:
$$ (a\star b)\star c
a\star(b\star c) $$
且:
那么可以:
例如求和:
所以:
Monoid 能自然地产生一种
reduce,但reduce本身不要求 Monoid。
8. Option / Result:扩展陪域
若:
只是一个部分函数,例如:
在 时没有定义。
可以把它改成 total function:
前提是:
更严格地,为避免元素碰撞,常写成不交并:
或者类型论中常写:
这里的 不是普通数值加法,而是 coproduct / sum type。
程序中:
Option<B>
Result<B, E>
正是在做这件事。
9. Monad 与错误传播
假设:
普通复合:
无法成立,因为 的输出不是 。
Monad 提供一种组合操作,通常称为:
类型:
于是:
与:
可以进行 Kleisli composition:
不会产生“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. 递归与不动点
不能直接写:
来解释“不动点”,因为这里的 已经是最终递归函数本身。
要讨论不动点,必须再高一阶。
定义一个作用于函数的算子:
定义:
$$ \Phi(g)(n)
\begin{cases} 1,&n=0\ n,g(n-1),&n>0 \end{cases} $$
注意:
- 是输入函数;
- 是输出函数;
- 此时没有递归调用 。
现在寻找一个函数 ,满足:
这样的 就是 的不动点。
代入:
$$ f(n)
\begin{cases} 1,&n=0\ n,f(n-1),&n>0 \end{cases} $$
于是:
因此 factorial 可以理解为:
其中 fixed-point operator 满足:
$$ \operatorname{fix}(\Phi)
\Phi(\operatorname{fix}(\Phi)) $$
Lambda Calculus 中的 combinator 就满足类似关系:
关键区别:
是“函数 → 函数”的高阶算子;
才是真正的递归函数。
总体对应
| 函数式编程 | 数学结构 |
|---|---|
| pure function | 映射 |
| higher-order function | 函数空间上的映射 |
| operator | 函数 函数 |
| functional | 函数 标量 |
| closure | / 参数化函数族 |
| currying | |
| composition / pipe | 函数复合 |
| map | 函子的态射映射 |
| Functor | 保持恒等与复合的范畴间映射 |
| reduce / fold | 代数上的折叠 |
| monoid reduction | fold 的重要特殊情况 |
| Option / Result | 扩展陪域 / sum type |
| bind / flatMap | Kleisli composition |
| recursion | 高阶算子的不动点 |
函数式编程最值得把握的一条主线是:
所以 FP 最深层的魅力并不只是“函数是一等公民”,而是:
这也是它与数学之间最根本的联系。