谈一下Lambda表达式是什么-深入理解Lambda表达式背后的计算机哲学

446 阅读10分钟

今天来认真谈一下Lambda表达式是什么。可能你在很多场合都听到这个词,但是它从何而来,为什么要叫Lambda表达式,为什么函数式编程语言有那么多优势,但是一直不温不火?Java的Lambda到底是什么?

好,接下来我们就来好好谈一谈。

编程范式

如果要真正的了解Lambda表达式,要有一个前置的问题,我们需要了解:什么是编程范式?

正如软件工程中不同的群体会提倡不同的“方法学”一样,不同的编程语言也会提倡不同的“编程范式”。一些语言是专门为某个特定的范型设计的,如 Smalltalk 和 Java 支持面向对象编程,而 Haskell 和 Scala 则支持函数式编程,同时还有另一些语言支持多种范型,如 Ruby 、 Common Lisp 、 Python 、 Rust ,当然还有像SQL这样的编程语言,他们也遵循某种编程范式。

目前编程语言主要的编程范式有三种:命令式编程,声明式编程和函数式编程。

指令式编程

指令式编程的主要思想是关注计算机执行的步骤,即一步一步告诉计算机先做什么再做什么。

比如:如果你想在一个数字集合 collection(变量名) 中筛选大于 100 的数字,你需要这样告诉计算机:

第一步,创建一个存储结果的集合变量 results; 第二步,遍历这个数字集合 collection; 第三步:一个一个地判断每个数字是不是大于 100,如果是就将这个数字添加到结果集合变量 results 中。 伪代码实现如下:

List<int> results = new List<int>();
foreach(var num in collection)
{
    if (num > 100)
          results.Add(num);
}

很明显,这个样子的代码是很常见的一种,不管 C, C++还是C#, Java, Javascript, BASIC, Python, Ruby等等,都是指令式编程。

声明式编程

声明式编程是以数据结构的形式来表达程序执行的逻辑。它的主要思想是告诉计算机应该做什么,但不指定具体要怎么做。 SQL 语句就是最明显的一种声明式编程的例子,例如:

SELECT * FROM collection WHERE num > 100

除了SQL,网页编程中用到的HTML 和CSS 也都属于声明式编程。 通过观察声明式编程的代码我们可以发现它有一个特点是它不需要创建变量用来存储数据。 另一个特点是它不包含循环控制的代码如forwhile等控制语句。

函数式编程

函数式编程(Functional Programming)是一种编程范式,它主要关注函数的定义、组合和应用,强调将计算过程看作是函数之间的转换和组合,并避免使用共享状态和可变数据。

在函数式编程中,函数被视为数学映射,它们接受输入并产生输出,没有副作用,也就是说不会改变任何环境或状态。这使得函数具有引用透明性,即相同输入始终生成相同输出,这样可以更容易地推理和测试代码,也方便代码的重用和组合。

函数式编程还使用高阶函数(Higher-Order Functions)和Lambda表达式(Lambda Expressions)来实现抽象和组合,同时使用常见的函数式工具如列表解析、模式匹配和递归等,来处理复杂的算法和数据结构。

在函数式编程中,函数是头等对象头等函数 ,这意味着一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。比起 指令式编程 ,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。因于此于同样功能的程序,极端情况下,像Lisp这样的函数式语言而言,同一个逻辑代码的长度可能是C代码的二十分之一。

我们以Fibonacci举例, Fibonaccii数列的数学定义式为: F(n) = F(n-1) + F(n-2),要使用C语言实现一个斐波那契数列:

int fibonacci_nth(int nth)
{
    int n, n_1, n_2;
 
    for(int I = 0, n = n_1 = n_2 = 1; I < nth; I ++){
        n = I > 1 ? n_1 + n_2 : n;
        n_1 = n;
        n_2 = n_1;
    }
 
    return n;
}

而在函数式语言Lisp中:

defun Fibonacci (n)                   
    (if (< n 2)                      
        1                              
        (+ (Fibonacci (- n 1))   
           (Fibonacci (- n 2)))) 
)

上面的递推关系式在Lisp中得到了体现但是在C代码中被隐去了。C程序员自己做了抽象,告诉程序应该怎样做而不是让计算机自行去得到结论。或许你会反驳,C照样可以写出递归的代码,看起来和Lisp几乎一模一样,但是到底还是有不同的,根本原因在于数据与代码的一致性。

那为什么说函数式编程没有副作用呢?

函数式编程之所以被认为是没有副作用的编程范式,是因为它强调函数的引用透明性和不可变性。在函数式编程中,一个函数的输出只取决于其输入,而不受任何外部状态或环境的影响,这意味着函数不会对外部状态进行修改,也不会产生任何副作用。

具体来说,函数式编程禁止使用共享状态、可变数据和全局变量等,因为这些机制使得函数的行为变得不可预测,可能导致副作用。而在函数式编程中,所有的数据都是不可变的,一旦创建就无法被修改,这样可以保证函数执行的结果始终一致,并避免了许多潜在的错误和问题。

虽然在实际编程中,完全避免副作用是很难的,但函数式编程依然提供了一种有效的方式来减少和控制副作用,使得程序更加可靠和易于维护。

当然,编程范式本身对语言设计来说并没有严格的区分,一个语言可以有多种编程范式。但是这里面存在了一个我们可能出来的问题。为什么会有这么多范式?

这个就要从计算机的发展史说起。

计算机发展的背景

1920 年,当时的数学界领袖希尔伯特(David Hilbert, 德国数学家)提出了一个”元数学“研究项目。他是这样认为的:

  • 1.所有数学都来自正确选择的有限公理系统;
  • 2.某些这样的公理系统可通过某些方法(例如希尔伯特自己发明的epsilon 演算) 证明是一致的。

这个问题转换成我们可以理解的就是:如果将证明过程纯机械化,这样机器就可以通过已有的公理推理出大量定理(是不是有点像人工智能,机器自己把定理枚举了)。

当然,后来一个和他共事的叫做哥德尔的数学家最终证明了希尔伯特上述猜想基本上不可能实现,但是正是因为这个所谓的”元数学“研究项目,促进了可计算理论和数理逻辑的发展。其中就包括当时正在研究这一领域的阿隆佐邱奇(Alonzo Church)以及他的徒弟阿兰图灵(Alan Turing,丘奇是图灵的博士生导师)。

在这要解决一个公理系统或者问题能够被自动推导或者解决之前,首先要搞清楚一个问题:

就是怎么去判定到底一个未解的问题是否真的有解?也就是所谓的可计算问题。这个研究思路是这样的,为这个计算建立一个数学模型,用这个模型来模拟这个计算,这个模型当然被称为计算模型,然后我们来证明凡是这个计算模型能够完成的任务都是能够计算的任务,凡是这个计算模型不能够完成的任务那么都是不可计算的任务。阿隆佐邱奇的Lambda演算和图灵的图灵机就这样被发明出来了,他们就是在两个不同的方向上解决了”可计算问题“,成为了计算机理论的基础。

可计算问题是一个非常复杂的问题,不光是数学问题,更是是个哲学问题。有兴趣的可以阅读本文章最后的参考书籍,提前说一句,别陷太深😁。

很显然,从上面描述可以知道,Lambda演算是(图灵完备的)的,因为他们解决的就是同一个问题。

不过,因为图灵机的工作模式(如下图所示)明显是可以被物理实现的,所以基于图灵机的计算机被实现了,而Lambda演算这种纯数学方式在很长时间并不受到重视。

04d9499838244648bb61895a111d9219~noop.image.png

看到了吗?图灵机的核心在于”可变状态“。

可以简单地说,图灵机的几种表示,促成了几种类型语言的产生。图灵本人对于可计算问题的解,促成了C风格的指令式的语言的产生;而Church的表述,则促成了Lisp风格的函数式语言的产生。

Lambda演算(Lambda Calculus)是什么?

Lambda演算简单的说,就是任何可以被计算的问题,都可以转换成一个个简单的函数来处理(包括构造 lambda项并对它们执行归约运算 。所谓归约计算,在可计算性理论与计算复杂性理论中,所谓的归约是将某个计算问题转换为另一个问题的过程)

在Lambda演算中,一切都是函数。Lambda到底是如何演算的,作为搞工程研发的,我们并不需要太过关注其具体的演算过程。有兴趣的可以直接阅读相关专业书籍:《Lambda Calculus and Combinator, an introduction》或者其他Lambda Calculus的书籍。

Lambda演算非形式化的直觉描述

形式化验证是用数学方法去证明我们的系统是无Bug的,非形式化就是简单的直觉性质的描述。

Lambda演算的直觉描述如下:比如:

  • ”加2”函数f(x)= x + 2可以用lambda演算表示为λx.x + 2(或者λy.y + 2,参数的取名无关紧要。读作:对于参数x,返回x+2),而f(3)的值可以写作(λx.x + 2) 3。函数的应用是左结合(结合律) 的:fxy =(fx) y。

对于多参函数,函数f(x, y) = x - y写作λx.λy.x - y。这个过程是柯里化的过程。这里就不再细究。

不过,在计算机中,λ没必要存在,“. ”这个符合会被其他符号代替(与语言有关),以Java语言为例:

单参数:x+2:(x) ->{ return x+2; } 或者x->x+2

多参数: BinaryOperator add=(x, y) → x+y。

在Java中,Lambda表达式的主体不仅可以是一个表达式,而且也可以是一段代码块,使用大括号({})将代码块括起来。如果是只有一行代码的Lambda表达式也可使用大括号,用以明确Lambda表达式从何处开始、到哪里结束。

以Lambda演算为思想的语言,我们可以统称为函数式语言。请注意,基于Lambda演算函数式编程语言中的函数这个术语不是指计算机中的函数(实际上是Subroutine),而是指数学中的函数,即自变量的映射。在函数式语言中,如条件语句,循环语句也不是命令式编程语言中的控制语句,而是函数的语法糖,比如在Scala语言中,if else不是语句而是三元运算符,是有返回值的。

另外一个值得说明的是,基于Lambda演算的函数式编程语言要满足函数的数学特性

  • 输入不变,输出一定不变,不依赖其他状态。比如sqrt(x)函数计算x的平方根,只要x不变,不论什么时候调用,调用几次,值都是不变的。这个看起来有点无厘头,但是别忘了,在指令式语言中,可以通过二次赋值来改变结果;
  • 要满足函数的运算表示规则,比如说:f(x)=f(y)+f(z)或者f(f(x))=f(f(y)+f(z)),很显然,这种在指令式语言是无法实现的。

函数式语言的优势

函数式语言的优势就是Lambda演算相比较传统的指令式语言,至少有如下三个优势:

  • 一切皆函数:少了非常多的状态变量的声明与维护,赋值在函数式编程语言中是政治不正确的;

  • 易于并发编程:函数式编程不需要考虑死锁,因为它不修改变量,所以根本不存在”锁”线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程;

  • 代码更为简洁,可读性更强,一切都基于数学函数的表达,比如下面的列子:

public class IfExists {

    public static void traditional() {
        boolean flag = false;
        String[] citys = {"bj","sh","gz","sz"};
        for(String city:citys) {
            if(city.equals("bj")) {
                flag = true;
                break;
            }
        }
        System.out.println(flag);
    }

    public static void main(String[] args) {
        traditional();
    }
}

而在函数式语言scala中:

object IfExists{

  def main(args: Array[String]): Unit = {
    println(Array(“bj”,”sh”,”gz”,”sz”).contains(“bj”))
  }
}

现在,你理解了scala为什么更容易做并发编程了吧?

lambda表达式是什么?

说了半天,那么lambda是什么呢?基于上述说明,我们知道lambda表达式是什么了:

lambda表达式是指令式编程语言借鉴函数式编程语言中的lambda演算实现,将自身的匿名函数转换成符合Lambda演算规则的一种表达语法。之所以叫lambda表达式,这个是因为而Church当时把自己演算规则叫做lambda演算而已。

参考资料

下面是一些参考书籍,如果对这部分比较感兴趣的可以自己深入研究: