从 JavaScript 到 OCaml:浅浅浅总结一下函数式编程
背景
这几天突击了一下 Cornell 的 cs3110;抽了两个下午刷完了 Chapter 3,4,5 的课后习题,很有感触。结合自己浅薄的函数式编程理解和贫瘠的 JavaScript / TypeScript 开发经历,总结一下自己第一阶段的函数式编程学习经历。😃
从 JavaScript 出发
我相信,大部分人包括我自己第一次接触编程肯定不是写前端,而是在学校通过参加某些名为 “高级语言” 的课程学习诸如 Python、C、Cpp 等语言的语法。正因为如此,我习惯于这样描述函数 function
和变量 variant
(我们暂且忽略掉 C 和 Python 在类型系统上的差异):
// 这是一个函数示例
function <函数名> (<参数1 参数2 ...>)
函数体
// 这是一个变量示例
<变量类型> <变量名> = <变量初值>
这两种形式很符合人的直觉。不考虑那些复杂的程序语言设计理论,变量被我们认为是一个值可变 “mutable” 的东西,而函数与变量不同,它是一组行为的有序集合,没人会认为 x = 6
中的 x
是函数,自然也没人会认为 x = x + 1
整体是一个变量。之所以它符合人的直觉,是因为这两种 “模糊” 的定义很符合我们生活常识,变量就像个苹果,我们吃苹果的这个过程就像函数。水平不太行的课程可能到这就戛然而止了。
也正因为如此,当我第一次写 JavaScript 时,我仍热衷于写 function func() { // DO STH... }
这样的语法。我把 function
塞进了每一个需要函数的地方,比如:
document.getElementById('button').addEventListener('load', function fn() {
// DO STH...
});
后来,有人跟我说,回调函数可以使用匿名函数。我把匿名函数当作一个语法糖,写法就进化成了这样:
document.getElementById('button').addEventListener('load', function () {
// DO STH...
});
再后来,又有人跟我说,我可以使用箭头函数。我把箭头函数当作一个比匿名函数更甜的语法糖(毕竟连 function
都不用写了),写法就进一步进化成了:
document.getElementById('button').addEventListener('load', () => {
// DO STH...
});
我对这个 “温水煮青蛙” 的过程并没有什么感知,直到有一天我跟着某个教程写出了如下的表达式:
let f = (args) => { // DO STH... }
document.getElementById('button').addEventListener('load', f);
我发现事情变得奇怪了,f
的语法在变量和函数之间跳舞,如果认为 f
是一个函数,它的出现却不用紧跟着 (args)
,它更像是一个随时可以被使用的变量;如果认为 f
是一个变量,它的内容又偏偏是一个函数。真是奇怪!
如果只是这样,书写这样的表达式实际上也就是个熟能生巧的问题————大家都这么写,那我也这么写。语言的发明者、框架的开发者甚至是教程的拥有者都已经帮我打理好了一切,即使是不知道编程语言内部发生了什么,程序也能正常的运行。
直到备赛蓝桥杯 Web 赛道时,这个若隐若现的鬼魂还是从阴影中突然出现抓住了我————函数柯里化 Currying
,它要求我把一个拥有多个参数的函数转换成一个调用序列,就像这样:
fun(x1, x2, x3) ---> fun(x1)(x2)(x3)
这怎么写?我不得不去回顾一切的开始,探索 JavaScript 的函数究竟发生了什么?
lambda 表达式
20世纪30年代,Church 首次公开发表了 lambda 演算
lambda 演算实际上非常复杂,但我们不去涉及那些有点困难的部分,接下来的介绍可以看作是 lambda 演算的一个子集
在 lambda 演算中,有三种项:
-
x
指变量 variable -
λx.t
指函数抽象 abstraction -
t1 t2
指函数应用 application
lambda 演算的运算规则,也即 application 之后我们进行的操作是:
(λx . t1) t2 → [x → t2] t1 // 指把 t1 中的 x 全部替换成 t2
lambda 演算的规则实际上很好理解,下面给出一个简单的案例。我们要实现一个加法函数,它对参数中的 x
加一,用 C 语言表达,它可能是这样:
int add(int x) { return x + 1 }
那么用 lambda 演算去写,我们可以这么写:
λx.(x+1)
那如果我要把这个 add
应用在数字 2
上呢?
int ans = add(2);
用 lambda 演算去写,即
λx.(x+1) 2
根据 lambda 演算自己的推导规则,容易求出最后的结果是 3。
(本文主要关注函数式编程的思想而非细节,所以我们暂时不考虑 Church Number 或者 Type System 等等等等)
在 lambda 演算中,函数是一等公民(first-class functions),意味着函数与其他数据类型(如数字、字符串等)具有相同的地位和待遇。为了方便理解,我们再举一个这样的例子:
定义函数 apply
,它接收一个函数和一个整数作为参数,并将函数应用到整数上,返回一个整数结果,用类似 C 语言的语法可以写作 int apply(function f, int x)
,用 lambda 演算则写作:
λf.λx.(f x)
在定义一个函数 square
,它接收一个整数作为参数,将这个整数平方,用类似 C 语言的语法可以写作 int square(int x)
,用 lambda 演算则写作
λy.(y*y)
那么我们可以写出这样的式子,式子的意义类似于 apply(square, 3)
,按上面的定义,这个答案显然是 9,接下来我们通过 lambda 演算的过程去执行它:
λf.λx.(f x) λy.(y*y) 3
做归约,有
λx.(λy.(y*y) x) 3
λy.(y*y) 3
9
发生了什么?我们把函数传给了另一个函数,就像我们在 C 语言课程上把值传给了一个函数一样,并且它正确地工作了~通过这个例子,我们就可以浅显的理解什么叫做 “函数是一等公民”。
lambda 演算中另一个重要的概念是作用域 scope。在这样的 lambda 表达式 λx.t
中,对于 x
,当 x
出现在 λx . t
的 t
中时,我们认为 x
是被约束 bound
的,否则是自由的;我们称 λx
是一个绑定器 binder
,t
是这个绑定器的作用域 scope
;一个不含自由变量的项成为封闭项(组合子 combinators
),最简单的组合子,称为恒等函数,记作 id = λx . x
lambda 演算是函数式编程的基石。下面我们会介绍函数式编程是如何吸收和应用 lambda 演算中的概念的。
函数式编程与 OCaml
Q:为什么我们需要函数式编程?
A:设想我们在高中时解数学题的过程:对于一个数学量 x
,它可能代表某地到某地的距离,我们会列出一个函数 y = f(x)
来描述 y
和 x
之间的关系用来解题,y
可能是地铁的票价,或者火车的票价等等等等……这种解题方式既符合我们接受的数学教育,也很难出错。
而在命令式编程中,看看我们会写出来什么东西: x = x + 1;
显然这在数学上是不可接受的。x
不再能被称为一个变量,而更像是一个内存空间的指代,而为了得出我们想要的结果(这里指把 x
递增之后的值 x_new
),我们直接在内存空间上进行修改,抛弃了我们原来的值 x_old
。这是一种状态机的思维,x
从 x_old
转移到了 x_new
,但是这导致代码在数学上丧失了严谨性。
而且,在上文的 lambda 演算中我们提到 “函数是一等公民”,这意味着复杂的运算可以通过函数之间的组合而完成:假设我们还有一个变量 z
表示花费票价和我剩余的资金之间的关系,有这样的函数 z = g(y)
,那我知道了 x
要怎么求解 z
呢?
我相信,大部分人会把 y = f(x)
代入进 z = g(y)
得到 z = g(f(x))
,然后再代入不同的 x
得出不同的 z
;这比一个个计算中间结果 y
才能求解出 z
要方便得多。
还有一个例子可以很好的解释 “封闭” 的概念,假设我们有这样的一串代码:
int y = 0;
int f(int x) {
y = y + 1;
return x + 1;
}
cout << f(2);
我们的函数 f
在计算递增结果的过程中产生了一个副作用 side effect:y
的值被暗中修改了,而且这个十分危险的过程甚至只对程序的开发者可见。这是我们在大型软件开发中不可避免会引入的一种危险的性质,毕竟编译器在编译时并不会给你抛出任何异常。
但是在上面的数学题的案例中,显然我们在计算 y
的过程中不可能修改 x
本身,这是一个无副作用的式子。
回到问题,为什么我们需要函数式编程?
-
从数学角度容易被解释和证明
-
更加灵活和简洁的代码
-
可预测性和无副作用:函数式编程强调无副作用的函数,这意味着函数的输出仅依赖于其输入参数,而不依赖于外部状态。这使得函数的行为更加可预测,也更容易推理和测试。
OCaml 意为 Objective Caml,是 Caml 语族的一种方言,全称为 Objective Categorical Abstract Machine Language(面向对象的范畴理论抽象的机器语言)。OCaml 是一个静态类型的函数式编程语言,可以很好的体现我们上面提到的优点:
以整个数组求和为例,我们可以编写如下的函数:
let sum = List.fold_left ( + ) 0;;
List.fold_left
是一个高阶函数,它会递归地应用在数组 list 的每一项上,List.fold_left <function> <initial> <list>
可以等价于 <function> <initial> (...(<function> <initial> (<function> <initial> <list[0]>))...) <list[len]>
( + )
可以把加法 +
转换为函数调用的形式:x + y
等价于 ( + ) x y
再来观察函数 sum
,容易发现:
-
函数是一等公民:我们把
( + )
作为参数传递给了fold_left
-
封闭:函数只使用传入的参数进行计算,不修改参数的值,更对外界无副作用
哎?如果 sum 是一个函数,为什么我没有写一个 list 参数呢?它不应该是 let sum lst = List.fold_left ( + ) 0 lst;;
吗?
这个事还要回到上面的 lambda 演算:
在计算 λf.λx.(f x)
,我们自然而然地先代换了 f
,再代换了 x
,这符合给出的 lambda 简单定义。再仔细思考这背后的数学含义,这是否意味着:λf.λx.(f x)
等价于 λf.(λx.(f x))
?λf.(λx.(f x))
是一个单一参数的函数,返回值同样是一个单一参数的函数。这就是柯里化 Currying 函数————当一个单一参数的函数,它的返回值又是一个带单个参数的函数,这样的函数称之为柯里化函数。
函数式编程与 JavaScript
讲到这里,事情的答案已经水落石出了。由于函数式编程的优秀特性,C 语系和 JavaScript 在发展过程中都或多或少吸收了函数式编程思想:
-
高阶函数:把函数作为参数,在 C 语系中体现为函数指针和回调函数,在 JavaScript 中体现为回调函数
-
封闭性:体现为闭包
-
柯里化:在 JavaScript 中,柯里化函数允许我们创建可以接受多个参数的函数,并且可以一次处理一个参数,这在处理异步代码时非常有用。
-
纯函数:JavaScript 鼓励使用纯函数,即不依赖外部状态且不产生副作用的函数。
更加深入?
-
神书《Types and Programming Language》TaPL
-
Cornell cs 3110
-
范畴论
-
另一本神书 SICP