时隔多月,才重拾写文章的心情。藉此,我问了问我的朋友能够写些什么 “说 FP (Functional Programming) 吧“ 他说。我笑了,我到现在还想不出一个完整,让各方信服的函数式编程的定义啊!不过我倒是在学 Haskell, Rust 和 Idris 的期间倒是对函数有了不一样的见解,就作为笔记的方式来写吧。
函数是绑定了变量名的闭包
其实函数在电脑科学还有数学上的分别还是很大的。至少在命名上,电脑科学管他叫 子程序 (Subroutine/procedure),数学当然是叫 函数 (Function)了。
在数学里,一个函数的值仅决定于函数参数的值,不依赖其他状态。举个例子:
f(x) = x + 2
那么我们的 x
如果一样,那么返回的结果都是一样的,没有例外。
而在电脑科学里面,子程序(俗称函数)则没有上述的概念,它指的是一组的语句块而已,靠传入的参数,执行语句块,然后返回一个值。我们用 Rust
语言做个例子:
fn add2 (x: i8) -> i8 { x + 2 }
// add2(3) => 5
那么闭包是什么呢?闭包常常和 lambda
一起互相倒换使用,因为闭包和 lambda
的定义完全一样。
我先放个 Rust
语言程序的例子给你们看看:
let add2 = |x : i8| -> i8 { x + 2 }
// add2(3) => 5
闭包 (closure) 的意思来自英文 close, 有封闭,关闭的意思。但是为啥我们要用 close 的意思来命名这个语言特性呢?
原因在于闭包有封闭性。怎么说呢?这个就要谈到函数在范畴论的正确定义了,函数其实就是两个集合的元素一一对应的意思。那么我们的函数 add2
其实就是把 整数 (8 bytes
大小的)
集合的
x 映射到整数 (8 bytes 大小的) 集合。而这个函数的定义就是 加上一个整数 2。而分别在于,我们弄的这个函数没有个名字。
而第二个例子我们把我们的闭包绑定到一个变量叫 add2
。这下 add2
的语义就是这个闭包了。我们可以使用 add2
这个变量来调用这个闭包。
let add2 = |x : i8| -> i8 { x + 2 }
add2(3) // => 5
这个在某些宣称是函数第一类型的编程语言系统里面也能够实现。比如说 Javascript
的例子更为明显。
let add2 = (x) => x + 2
add2(3) // => 5
或者是直接调用闭包。
((x) => x + 2)(3) // => 5
闭包对于很多程序员来说都不算陌生,尤其是受到近年来的编程语言的薰陶和影响的新程序员。
语义绑定
在我解释什么是语义绑定之前,先讨论看看变量。
let a = 3, b = 5, c = 7
我们把 a
绑定了 3; b
绑定了 5,以此类推。
这样我们下来的语句还是函数都会把 a
当成 3。我们说我们把 3 这个值赋予了一个名字,也就是打上 a
的标签,并不会修改 3 这个值本身。
这样就成立了一个对应关系, 准确点就是 一对一的映射关系。
如果你不明白这个,你尝试去理解它就想我们的科学的常数那样。当我们说到光速的时候我们尝试用
c
表示,而这个c
表示 2.998 x 10^8。速度用v
表示,π
表示 3.1416。
但是语义的绑定一定离不开一个 语境(context)。就在我们这个文章中,a
之所以会表示
3,是受限于我们的系统中的。而脱离了我们这个文章,a
其实不过是一个字母集合的其中一个元素。
举个例子,就是我物理的
g
在电磁学里面是个单位 (G, 磁感应通量),而在经典力学里面G
是个重力加速度常数。两者不同科目却刚巧用了一样的字母来表示。
G
之所以是表示重力加速度是因为我们给它选定的 “论域” 是经典力学,而不是电磁学。
能够让我们绑定 a
的范围就是语境。语境和绑定是密不可分的,那么我们要怎样一般式表示语境呢?最好的方法当然是命名一个语境,为他取名字是人类应付一个新概念最好的方法!而这个名字的方式叫
词汇环境 (lexical environment)。
语境,域和抽象
对于词汇环境这个抽象的概念,我们可以把它看作是一个百科全书的条目,给内部的内容一个明确的定义或是指引。而脱离了这个词汇环境,这个内容就变成了一个具有歧义的内容。
再形而上一点的解释就是,我们的词汇定义让我们能够在空间上搜寻,然后得到该内容的严格语义。
在 SICP 这本书的定义就把词汇环境说成了抽象。而我们身边的东西都是给物件起名字,给个动作一个名字,描述一个物件,到叙述一个事件,表达一个想法,统统都是抽象。在电脑中,我们的指针指向的都是数据还有代码;一个物件,一个函数,一个过程都能够用一种统一的方式看待。
当科学人员需要表达一个想法,而在已有的词汇堆中找不到适合的定义的时候,会倾向建立一组新的词汇,也多了一词汇环境。这也是抽象的一环。
而词汇环境有两个维度,一就是 时间;二就是 空间。我们不说时间,反而会深入在空间上。
为了区分这两个词汇环境,我们把空间词汇环境说成是 辖域(scope);而时间词汇环境叫 延伸(extent)。
在空间上一定有个边界,一个内容一旦过了这个边界就变得无效。这时候我们就把划分辖域的边界的过程叫 划界(scoping)。
域的传入和计算
让我们回到现实计算机上面,我们把之前我们讨论的概念联系到函数上,用 Javascript
举个例子,看看函数的辖域。
function (a, b) {
// This is where scope begins
let answer = a * a + b * b
return answer
// This is where scope ends
}
以上是一个匿名函数(anonymous function, 即没有绑定变量的函数,通常和 lambda, closure 合为一谈)。
仔细地想,函数其实会创建一个辖域 (之后我们都会倾向于简约成 域)。这个域, 同时也是函数的语句块里面所绑定的变量离开了这个域都会失效,失去绑定的意义。而且我们在调用这个函数的时候,
我们也在这个域里面为传入的值绑定了 a
, b
两变量。而这个就是我们的函数的 参数(argument)。
函数会返回值,通过这条件,我们就能够将结果绑定到某个变量。
let result = (function (a, b) {
let answer = a * a + b * b
return answer
})(2, 3) // 13
匿名函数近年来由于函数式编程越来越火热,各方编程语言都逐渐加入了这项特性。原因就是他能够很方便地在定义,然后调用,可以和其他函数组合,让组合的方式来构造程序。
Javascript 也就是因为有如此的特性 (函数第一类型) 才被人说他是披着 C 外衣的 Lisp。
函数的一一对应和柯理化 (currying)
函数一一对应最直观的表达方式就是, 接受一个值,返回一个值 的形式。而我们把这个值的可能性限定在一个集合,他返回的可能性也会是一个集合 (即使这个函数也只会返回一个固定的值,也能够归类在一个值里面)。
对此我们如果约束函数的入口只有一个参数,返回一个值。那么多参数的函数应该怎么实现呢?
我们靠的就是 柯理化 (currying),把函数变成一个链那样,比如说一个接受 3 个参数的函数,我们在 Haskell 是这样定义的。
three :: a -> b -> c -> d
这样的定义很巧妙。为什么呢?因为这样写就是把函数看成是个链那样,接受三个参数函数在接受了一个参数之后会返回一个 已经确定了一个参数的偏函数应用(partial application)
,也就是个半成品的概念。直到三个参数都被确定了,就会返回一个值,这就是上面文中的 d
。
我们也能够在那些有函数第一公民的编程语言上面如此实现:
let three = (a) => (b) => (c) => { ... }
展开来其实是
let three = function (a) {
function (b) {
function (c) {
...
}
}
}
这样函数嵌套,都会创建一个域,传入参数进入这个域。返回一个函数…… 以此类推,直到嵌套的函数都完了,才执行里面的语句块。在 Javascript
里面我们这样使用:
three(100)(200)(300)
函数的柯理化有什么好处呢?
最显而易见的就是在工程上我们方便拆分和组建代码
let sum = (a) => (b) => (c) => a + b + c
let sum100 = sum(100)(0)
let sum_two_number = sum(0)
sum100(3) // 103
sum_two_number(1)(3) // 4
还有的是更方便我们分析,还有编译器/人为的优化。