【fixed point】λ演算

原文地址: 神奇的λ演算

现在时兴讲函数式编程,弄得如果不会写两句λ表达式你都不好意思跟人说自己是敲代码的。所以我也就趁着这阵风头,琢磨琢磨了这个函数式编程。怎么算来,也有个三年两载了,出师还不敢说,将将入门估计还算凑合。朋友说让给写篇文章或者翻译翻译哪个大佬的文章,盛情难却,就把刚学的这点水倒出来出出糗好了。

说函数式编程这个东西,根源来自于λ演算,虽然后面加了很多特性,但总归要从这里出发。关于λ演算这个东西,也是很有历史的,有兴趣了解相关历史的,可以参阅相关的wikipedia,这里就不多说了。咱们直奔主题。

引子

说到 λ 演算,自然要从 λ 表达式说起。到底什么是 λ 表达式呢?其实很简单,就是一种特殊的语法所书写的匿名函数。

所谓匿名函数,就是说我们不需要给这个函数起个什么名字。这一点很重要,如果按照非匿名函数的观点,但凡是函数都需要有个名字,那么就很难和“函数是第一类对象”这样的观点兼容。毕竟,动态生成的无穷多种可能性的函数,我们总不能预先都给他们准备好名字是吧。更何况,就算有这个心思,也没这个可能,光一阶的函数的数量就已经高于整个实数的数量了,比实数少的自然数我们都列不完呢(虽说叫可列集,不过谁都没列完过),更何况是它了。

我们来看看一般目前的语言怎么去写匿名函数。其实这里有点倒因为果,毕竟是先有了λ演算,才有了现代语言中的这些匿名函数。不过便于理解起见,也就不管那么多了。先来看看JavaScript、Matlab和C#几种语言的具体实现方式

    // javascript
    var f = function (x) { return x+1; }
    % Matlab
    f = (x) x + 1;
    // C#
    Function<int, int> f = x => x + 1;

看,写起来都还算简单,不过数学家们有更偷懒的办法,他们把上面的函数用更加简化的方式来表示,只用一个关键字 'λ' 来表示对函数的抽象。还是上面这个函数,用不太规范的方式来表示下 λ 演算中对上述函数表达方式,大体上是这样

    λ x. x+1

所谓的不规范的地方,主要就是这个加法运算,具体怎么不规范了,请看下节。

基本概念

学界有个公认的观点,叫做“奥卡姆剃刀”原则,说如果有多种理论去解释相同的现象,假设最少的那个往往是最受欢迎的。

学程序设计的同好们都知道,学一门编程语言,我们总是要去了解这门语言中诸如数据类型、变量、常量、运算符、表达式这些原生定义的东西。这种东西就相当于上帝之手,设定了最基本的规则和假定,然后我们在它画的这个圈圈里面玩。

哲学上一个常识,内涵越小外延越大。我们搞了那么多的假定,往往就给自己做了很大的束缚。数学家不喜欢这么玩,所以构建这个λ演算的时候,更倾向于用尽可能少的原生性规则来进行限定。

于是,他们规定了一下最基本,最核心的三个基本假定,我们不妨理解为最基本的语法规则。

  • 第一个就是所谓函数为第一类对象的原则,用文字陈述有点绕口,这么说的:“ 全体λ表达式构成Λ空间,λ表达式为Λ空间到Λ空间的函数 ”。
    我曾经尝试过用C#来表达这句话,居然通过编译了,贡献出来

      // C#
      delegate L L(L x);

    这说的是,定义了一个委托类型L,这种委托类型以一个类型为L的参数作为输入,返回一个L类型的返回值。所有L类型的实例,就构成了上述的Λ空间。

  • 第二个是函数的抽象原则,即对于一个λ表达式,不妨称为P,我们可以用一个字母代表哑元,不妨称为 x,使用抽象规则,我们可以构造出另一个λ表达式来,像这样

      λ x. P

    它表示我们构造了一个函数,这个函数以一个λ表达式输入,返回另一个λ表达式,这个返回值等于将P中所有非哑元的字母 x 用刚刚输入的那个表达式替换掉。
    例如我们有几个表达式,P分别为

      x y z
      y (λ x. x)

    如果应用上述抽象规则,并用 x 标记新抽象的哑元,可以得到

      λ x. x y z
      λ x. y (λ x. x)

    如果应用某个不等于也不包含 x, y, z 字母的表达式 a 作为输入的话,上述两个λ表达式计算的结果应当分别为

      a y z
      y (λ x. x)

    为什么第二个里面的 x 仍然保留呢,就因为它是在 P 中的哑元,其实和P外的哑元是没关系的。可以用一个避免混淆的方式给它改改名字,比如说改成

      λ x. y (λ w.w)

    其实表达的是同一个意思,就好像这两个代码其实表达的运算是一个意思一样

      // javascript
      var f = function (x) { return x + 1; };
      var g = function (y) { return y + 1; };

    哑元的更换,不影响 λ 表达式的所表达的含义。

  • 第三个规则比较直观,就是应用函数的规则,对于一个 λ 表达式 P 如果希望将另一个 λ 表达式 q 作为它的输入进行求值,我们简单的将他们左右并列书写即可

      P q

    这里不使用括号写法P(q),因为我们后面可以看到,如果用上括号,我们会后悔的。注意到一个 λ 表达式应用一个 λ 表达式作为输入后,返回的还是一个 λ 表达式,它即可以作为函数再接受一个新的输入,比如说 r,就可以写成这样

      P q r

    这里就默认了左结合优先的自然的规则。它也可以作为输入,传递到另一个 λ 表达式中,比如说 S,就可以写成这样

      S (P q)

    因为前面提到了左结合优先的原则,所以这里这个括号就不可避免了。

这三个规则,就构成了λ演算的全部基石,有的作者用更加形式化的方式将他们整理起来,有的还甚至提炼出更加具体的一些法则,其实我觉得,从本质上讲,这三者其实是核心,外围的那些规则都是一些自然的辅助性衍生。如果希望看比较规范严谨的定义的,我推荐H. Simmons和A. Schalk写的这本书,他们写的非常严谨,相当的棒。如果希望看稍微通俗一点,又不像我这样太通俗的,建议看看R. Rojas写的这本,只有13页。如果还嫌长,看A. Jung的这篇10页的小文也好。其实我都是从wikipedia上转摘来的,这几篇我看过觉得不错,更完整的书单还是建议去Lambda Calculus的页面看,毕竟萝卜青菜各有所爱嘛。

Bool运算

如果前面那些算看起来比较枯燥,让人看了有然并卵的感觉的话,那现在我们就算正式踏上了 λ 演算的神奇之旅了。我们先来看看最简单的情况,用上述定义的基本规则,我们来构造Bool运算。

首先是True和False,简写作 T 和 F,我们采用Church的方案(此大咖就是这整个神奇 的 λ 演算的发明人,我没看他的原文,就不厚着脸皮转引他的文章了)

    T = λ x y. x
    F = λ x y. y

这里,使用了著名的柯里化技巧(Currying),用一元函数构造多元函数,展开来写 T 的定义应当是

    T = λ x. (λ y. x)

表示的含义是 T 是这样一个函数,它接收任何一个输入 x,并会返回一个函数 λ y. x,这个被返回的函数接收另外一个输入 y,而不论这个输入 y 是什么,它返回第一个输入的参数 x .

同样,F 是这样一个函数,它的展开式写作

    F = λ x. (λ y. y)

它接收 x 并返回 λ y. y,而后者接收什么就原模原样的返回什么。

这里 T 和 F 接受第一个参数后所返回的两个函数也很重要,可以分别称为常函数 λ y. x 和恒等函数 I = λ y. y。其实更常见的恒等函数写作 λ x. x,上一节我们提到过,变换哑元不改变函数含义。

站在柯里化技巧的基础上,我们可以重新将 T 理解为一个选择函数,它接收两个参数,而返回第一个参数。而 F 则相反,它返回第二个参数。

    T a b = (λ x. (λ y. x)) a b = (λ y. a) b = a
    F a b = (λ x. (λ y. y)) a b = (λ y. y) b = b

在不影响理解的情况下,将重复书写的λ和不必要的括号都省略掉,就可以简略的书写柯里化技巧下的多元函数

    T = λ x. (λ y. x) = λ x. λ y. x = λ x y. x
    F = λ x. (λ y. y) = λ x. λ y. y = λ x y. y

就是这一节我们前面所提到的 T 和 F 的定义。

不动点

在数学上,我们经常会遇到不动点的概念。所谓不动点,即对某个函数 f(x) 存在这样的一个输入 x,使得函数的输出仍旧等于输入的 x 。形式化的表示即为

    f(x) = x

比如说刚刚学过循环小数的小朋友们就很喜欢纠结这个问题

    0.999... = 1

实际上这个可以用不动点的方式去理解,我们可以设 x = 0.999...,观察到 x 扩大到原来的10倍的时候再减去9,得到的仍旧是 x 本身,因此 x 是函数

    f(x) = 10x - 9

的不动点,满足方程

    x = f(x) = 10x - 9

简单的求解一下方程,就可以得到 x = 1的结论。

在前面的讨论中,我们曾接触过恒等算子 I = λ x. x ,对它而言任何一个 λ 表达式都是它的不动点。而对于一般性的 λ 表达式,寻找它对应的不动点,在后面的讨论中我们可以看到,是一个非常有意义的事情。

对于一般性的数学函数而言,不动点的存在性并不是一定的,比如说指数函数 ex,它就不存在不动点。而对于存在不动点的函数,寻找其不动点往往也是需要一定技巧的。但是那仅仅是对于一般意义的数学函数而言,在 λ 演算体系下,Λ空间中的每一个 λ 表达式都具有一个不动点。这来源于下面将要介绍的著名的不动点定理。

在介绍不动点定理之前,我们先来看一个比较有趣的 λ 表达式

    ω = λ x. x x

它表示接受一个输入 x,返回 x 作用到 x 自身的结果。比如说

    ω a = a a
    ω I = I I = I

现在要耍点赖皮了,我们让 ω 自己作用到自己身上,并按照应用函数的法则将其展开

    ω ω = (λ x. x x) ω = ω ω

展开后竟然又还原回来了,这似乎和我们所说的不动点的重现机制很近似。

而事实上,几个比较著名的不动点算子的构造过程都借助了这种技巧。下面我们来看最著名的不动点算子 Y 组合子

    Y = λ f. (λ x. f (x x)) (λ x. f (x x))

这里应用了两次自身作用自身的技巧,第一个是显式出现的 x x,而后者是整体上出现的

    a = λ x. f (x x)
    Y = λ f. a a

给出了 Y 组合子,不动点定理其实就已经通过构造法得到了证明。不动点定理如此说: 对于任意 λ 表达式 g,总存在不动点 x = Y g,使关系 g x = x 成立。简写作

    g (Y g) = Y g

证明过程很简单

    Y g = (λ f. (λ x. f (x x)) (λ x. f (x x))) g
        = (λ x. g (x x)) (λ x. g (x x))            # (a)
        = g ( (λ x. g (x x)) (λ x. g (x x)) )      # (b)
        = g Y g                                    # 将 (b) 中 (a) 重现的部分重新写作 Y g

除了 Y 组合子之外,其它比较著名的不动点算子还有

    X = λ f. (λ x. x x) (λ x. f (x x))
    Θ = (λ x y. y (x x y)) (λ x y. y (x x y))
posted @ 2017-05-31 12:09  FH1004322  阅读(337)  评论(0)    收藏  举报