浅谈函数

关于函数的一点点笔记

时隔多月,才重拾写文章的心情。藉此,我问了问我的朋友能够写些什么 “说 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
    

还有的是更方便我们分析,还有编译器/人为的优化。