从 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 . tt 中时,我们认为 x 是被约束 bound 的,否则是自由的;我们称 λx 是一个绑定器 bindert 是这个绑定器的作用域 scope;一个不含自由变量的项成为封闭项(组合子 combinators),最简单的组合子,称为恒等函数,记作 id = λx . x

lambda 演算是函数式编程的基石。下面我们会介绍函数式编程是如何吸收和应用 lambda 演算中的概念的。

函数式编程与 OCaml

Q:为什么我们需要函数式编程?

A:设想我们在高中时解数学题的过程:对于一个数学量 x,它可能代表某地到某地的距离,我们会列出一个函数 y = f(x) 来描述 yx 之间的关系用来解题,y 可能是地铁的票价,或者火车的票价等等等等……这种解题方式既符合我们接受的数学教育,也很难出错。

而在命令式编程中,看看我们会写出来什么东西: x = x + 1; 显然这在数学上是不可接受的。x 不再能被称为一个变量,而更像是一个内存空间的指代,而为了得出我们想要的结果(这里指把 x 递增之后的值 x_new ),我们直接在内存空间上进行修改,抛弃了我们原来的值 x_old。这是一种状态机的思维,xx_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

posted @ 2024-10-03 21:43  sysss  阅读(36)  评论(0)    收藏  举报