You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

384 lines
29 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 39 | 综合实现(二):如何实现函数式编程?
你好,我是宫文学。
近些年函数式编程正在复兴。除了一些纯函数式编程语言比如Lisp、Clojure、Erlang等众多的主流编程语言如Python、JavaScript、Go甚至Java它们都有对函数式编程的支持。
你应该会发现,现在人们对于函数式编程的讨论有很多,比如争论函数式编程和面向对象编程到底哪个更强,在语言里提供混合的编程模式到底对不对等等。
这些论战一时半会儿很难停息。不过我们的这一讲,不会涉及这些有争议的话题,而是试图从编译技术的角度,来探讨如何支持函数式编程,包括如何让函数作为一等公民、如何针对函数式编程的特点做优化、如何处理不变性,等等。通过函数式编程这个综合的主题,我们也再一次看看,如何在实现一门语言时综合运用编译原理的各种知识点,同时在这个探究的过程中,也会加深你对函数式编程语言的理解。
好,我们先来简单了解一下函数式编程的特点。
## 函数式编程的特点
我想,你心里可能多多少少都会有一点疑问,为什么函数式编程开始变得流行了呢?为什么我在开篇的时候,说函数式编程正在“复兴”,而没有说正在兴起?为什么围绕函数式编程会有那么多的争论?
要回答这几个问题,我会建议你先去了解一点历史。
我们都知道,计算机发展历史上有一个重要的人物是阿兰 · 图灵Alan Turing。他在1936年提出了一种叫做**图灵机**的抽象模型,用来表达所有的计算。图灵机有一个无限长的纸带,还有一个读写头,能够读写数据并根据规则左右移动。这种计算过程跟我们在现代的计算机中,用一条条指令驱动计算机运行的方式很相似。
不过,计算模型其实不仅仅可以用图灵机来表达。早在图灵机出现之前,阿隆佐 · 邱奇Alonzo Church就提出了一套Lambda演算的模型。并且计算机科学领域中的很多人其实都认为用Lambda演算来分析可计算性、计算复杂性以及用来编程会比采用图灵机模型更加简洁。而**Lambda演算就是函数式编程的数学基础**。
补充:实际上,邱奇是图灵的导师。当年图灵发表他的论文的时候,编辑看不懂,所以找邱奇帮忙,并推荐图灵成为他的学生,图灵机这个词也是邱奇起的。所以师生二人,对计算机科学的发展都做出了很大的贡献。
因为有Lambda演算的数学背景所以函数式编程范式的历史很早。上世纪50年代出现的Lisp语言就是函数式编程语言。Lisp的发明人约翰 · 麦卡锡John McCarthy博士是一位数学博士。所以你用Lisp语言和其他函数式编程语言的时候都会感觉到有一种数学思维的味道。
也正因如此与函数式编程有关的理论和术语其实是有点抽象的比如函子Functor、单子Monad、柯里化Currying等。当然对它们的深入研究不是我们这门课的任务。这里我想带你先绕过这些理论和术语从我们日常的编程经验出发来回顾一下函数式编程的特点反倒更容易一些。
我前面也说过目前流行的很多语言虽然不是纯粹的函数式编程语言但多多少少都提供了对函数式编程的一些支持比如JavaScript、Python和Go等。就连Java语言也在Java8中加入了对函数式编程的支持很多同学可能已经尝试过了。
我们使用函数式编程最多的场景恐怕是对集合的处理了。举个例子假设你有一个JavaScript的数组a你想基于这个数组计算出另一个数组b其中b的每个元素是a中对应元素的平方。如果用普通的方式写程序你可能会用一个循环语句遍历数组a然后针对每个数组元素做处理
```
var b = [];
for (var i = 0; i< a.length; i++){ //遍历数组a
b.push(a[i]*a[i]); //把计算结果加到数组b中
}
```
不过你也可以采用更简单的实现方法。
这次我们使用了map方法并给它传了一个回调函数。map方法会针对数组的每个元素执行这个回调函数并把计算结果组合成一个新的数组。
```
function sq(item){ //计算平方值的函数
return item*item;
}
var b = a.map(sq); //把函数作为参数传递
```
它还可以写成一种更简化的方式也就是Lambda表达式的格式
```
var b = a.map(item=>item*item);
```
通过这个简单的例子,我们可以体会出函数式编程的几个特点:
### 1.函数作为一等公民
也就是说,函数可以像一个数值一样,被赋给变量,也可以作为函数参数。如果一个函数能够接受其他函数作为参数,或者能够把一个函数作为返回值,那么它就是**高阶函数**。像示例程序中的map就是高阶函数。
那函数式编程语言的优势来自于哪里呢?就在于它可以像数学那样使用函数和变量,这会让软件的结构变得特别简单、清晰,运行结果可预测,不容易出错。
根据这个特点,我们先来看看函数式编程语言中的函数,跟其他编程语言中的函数有什么不同。
### 2.纯函数Pure Function
在函数式编程里面,有一个概念叫做纯函数。纯函数是这样一种函数,即**相同的输入,永远会得到相同的输出**。
其实你对纯函数应该并不陌生。你在中学时学到的函数就是纯函数。比如对于f(x)=ax+b对于同样的x所得到的函数值肯定是一样的。所以说纯函数不应该算是个新概念而是可以回归到你在学习计算机语言之前的那个旧概念。
在C语言、Java等语言当中由于函数或方法里面可以引用外面的变量比如全局变量、对象的成员变量使得其返回值与这些变量有关。因此如果有其他软件模块修改了这些变量的值那么该函数或方法的返回值也会受到影响。这就会让多个模块之间基于共享的变量耦合在一起这种耦合也使得软件模块的依赖关系变得复杂、隐秘容易出错牵一发而动全身。这也是像面向对象语言这些命令式编程语言最令人诟病的一点。
而对于纯函数来说,它不依赖外部的变量,这个叫做**引用透明Reference Transparency**。纯函数的这种“靠谱”、可预测的特征,就给我们的编程工作带来了很多的好处。
举个例子。既然函数的值只依赖输入那么就跟调用时间无关了。假设有一个函数式g(f(x))如果按照传统的求值习惯我们应该先把f(x)的值求出来再传递给g()。但如果f(x)是纯函数,那么早求值和晚求值其实是无所谓的,所以我们可以**延迟求值Lazy Evaluation**。
延迟求值有很大的好处。比如在下面的伪代码中unless是一个函数f(x)是传给它的一个参数。在函数式编程语言中只有当condition为真时才去实际对f(x)求值。这实际上就降低了工作量。
```
//在满足条件时执行f(x)
unless(condition, f(x));
//伪代码
int unless(bool condition, f(x)){
if (condition)
return f(x);
}
```
再回到纯函数。我说纯函数的输出仅依赖输入,有一点需要说明,就是函数只有返回值这一种输出,没有其他的输出。换句话说,**纯函数没有副作用Side Effect**。
什么是副作用呢?简单地说,就是函数在运行过程中影响了外界环境。比如,修改了一个全局变量或者是对象的属性、往文件里写入内容、往屏幕上打印一行字、往数据库插入一条记录、做了一次网络请求,等等。也就是说,纯函数要求程序除了计算,其他的事情都不要做。
如果函数有副作用的话那么我们前面说的时间无关性就被破坏了。比如说原来a函数是在屏幕上打印“欢迎b函数是屏幕输出你的名字最后形成“欢迎XXX”。那么a和b的前后顺序就不能颠倒。
你可能会说,一个有用的程序,哪能没有副作用呀。你说得对。在函数式编程里,程序会尽量把产生副作用的函数放在调用的外层,而完成内部功能的大部分函数,都保持是纯函数。比如,最外层的函数接受网络请求,并对客户端返回结果,它是有副作用的。而程序所使用的其他函数,都没有副作用。
纯函数的功能是如此地简单纯粹以至于它还能继续带来一些好处。比如说像Erlang这样的语言可以在运行时给某些函数升级而不用重启整个系统。为什么呢因为这些升级后的函数针对相同的输入程序得到的结果是一样的那么对这个函数的使用者来说就没有任何影响。这也是用Erlang写的系统会具有很高的可靠性的原因之一。
不过函数式编程语言里使用的也不全都是纯函数比如有的函数要做一些IO操作。另外闭包是函数引用了词法作用域中的自由变量而引起的所以也不是纯函数。
总结起来,在函数式编程中,会希望函数像数学中的函数那样纯粹,即**不依赖外部(引用透明),也不改变外部(无副作用),从而带来计算时间、运行时替换等灵活性的优势**。
好,说完了函数的不同,我们再来看看函数式编程语言里使用变量跟其他语言的不同。
### 3.不变性Immutability
我们都知道在数学里面当我们用到x和y这样的变量的时候它所代表的值在计算过程中是不变的。
没错,这也是函数式编程的一个重要原则,**不变性**。它的意思是,程序会根据需要来创建对象并使用它们,但不会去修改对象的状态。如果有需要修改对象状态的情况,那么去创建一个新对象就好了。
在前面的示例程序中map函数返回了一个新的数组而原来的数组保持不变。这就体现了不变性的特点。
不变性也会带来巨大的好处。比如说,由于函数不会修改对象的状态,所以就不存在并发程序中的竞争情况,进而也就不需要采用锁的机制。所以说,**函数式编程更适合编写并发程序**。这个优势,也是导致这几年函数式编程复兴的重要原因。
好,那么最后,我们再来注意一下函数式编程语言在编程风格上的不同。
### 4.声明式Declarative的编程风格
在计算机语言中,实现编程的方式主要有几种。
第一种实现方式我们会一步步告诉计算机该去怎么做计算循环访问a的元素计算元素的平方值并加到b中。这种编程风格叫做**命令式Imperative编程**,即命令计算机按照你要求的步骤去做。命令式编程风格植根于现代计算机的结构,因为机器指令本质上就是命令式的。这也是图灵机模型的特点。
而第二种实现方式叫做**声明式Declarative编程**。这种编程风格会要求计算机给出你想要的结果而不关心过程。比如在前面的示例程序中你关心的是对数组中的每个元素计算出平方值。至于具体的处理步骤是对数组a的元素顺序计算还是倒序计算你并不关心。
声明式编程风格的另一个体现,是递归函数的大量使用。这是因为我们描述一个计算逻辑的时候,用递归的方式表达通常会更简洁。
举个例子。你可能知道斐波纳契Fibonacci数列中的每个数是前两个数字的和。这个表达方式就是递归式的。写成公式就是Fibonacci(n)=Fibonacci(n-1)+Fibonacci(n-2)。这个公式与我们用自然语言的表达完全同构,也更容易理解。
我把计算斐波纳契数列的程序用Erlang这种函数式语言来写一下你可以进一步体会到声明式编程的那种简洁和直观的特点
```
%% 计算斐波那契的第N个元素
fibo(1) -> 1; %%第一个元素是1
fibo(2) -> 1; %%第二个元素也是1
fibo(N) -> fibo(N-1) + fibo(N-2). %%递归
```
好了,现在我们已经了解了函数式编程的一些关键特征。它的总体思想呢,就是像数学那样去使用函数和值,使可变动部分最小化,让软件的结构变得简单、可预测,从而获得支持并发、更简洁的表达等优势。那么下面,我们就一起来看看如何结合编译原理的相关知识点,来实现函数式编程的这些特征。
## 函数式编程语言的编译和实现
为了实现函数式语言,我们在编译期和运行时都要做很多工作。比如,要在编译器前端做分析和各种语义的检查; 要以合适的方式在程序内部表示一个函数;要针对函数式编程的特点做特别的优化,等等。接下来我们就从编译器的前端工作开始学起。
### 编译器前端的工作
函数式编程语言,在编译器的前端也一样要做很多的语法分析和语义分析工作。
你应该知道,语言的设计者,需要设计出**如何声明一个函数**。像是JavaScript语言会用function关键字Go语言用func关键字Rust语言用的是fn关键字而C语言根本不需要一个关键字来标识一个函数的定义另外如何声明函数的参数和返回值也会使用不同的语法。编译器都要能够正确地识别出来。
语义分析的工作则更多,包括:
1. **符号表和引用消解**:当声明一个函数时,要把它加入到符号表。而当程序中用到某个函数的时候,要找到该函数的声明。
2. **类型检查和推导**既然函数可以被当做一个值使用那么它一定也是有类型的也要进行类型检查和推导。比如在程序的某个地方只能接受返回值为int有一个参数为String的函数那么就需要被使用的函数是否满足这个要求。关于函数的类型一会儿我还会展开讲解。
3. **语法糖处理**在函数式编程中经常会使用一些语法糖。最常见的语法糖就是Lambda表达式Lambda表达式可以简化匿名函数的书写。比如前面JavaScript的示例代码中对数组元素求平方的函数可以写成一个Lambda表达式从而把原来的代码简化成了一行
```
var d = a.map(item=>item*item); //括号中是一个lambda表达式
```
在这个示例程序中,=>左边的是匿名函数的参数右边的是一个表达式这个表达式的计算结果就是匿名函数的返回值。你看通过一个Lambda表达式代替了传统的函数声明代码也变得更简洁了。
OK因为在编译器前端还要对函数做类型分析所以我们再来探究一下函数的类型是怎么一回事。
### 把函数纳入类型系统
这里我要先提一个问题,就是在函数式编程语言里,既然它能够把函数当做一个值一样去看待,那么也应该有相应的类型吧?这就要求语言的类型系统能够把函数包含进来。因此函数式编程语言在编译的时候,也要进行**类型检查和类型推断**。
不过,我们在谈论类型时,比较熟悉的是值类型(如整型、浮点型、字符型),以及用户自定义的类型(如结构、类这些),如果函数也是一种类型,那跟它们是什么关系呢?如果由你来设计,那么你会怎么设计这个类型体系呢?
在不同的语言里设计者们是以不同的方式来解决这个问题的。拿Python来说Python中一切都是对象函数也不例外。函数对象的ob\_type字段也被设置了合适的类型对象。这里你可以再次回味一下[Python的类型系统](https://time.geekbang.org/column/article/261063)设计得是如何精巧。
我们再看看Scala的类型系统。上一讲我提出过Scala实现了一个很漂亮的类型系统把值类型和引用类型也就是自定义类做了统一。它们都有一个共同的根就是Any。由于Scala是基于JVM的所以这些类型最后都是以Java的类来实现的。
那么函数也不例外。因为Scala的函数最多支持22个参数所以Scala里有内置的Function1、Function2…Function22这些类作为函数的类型它们也都是Any的子类型。每个Scala函数实际上是这些类的实例。
另外Swift语言的文档对类型的定义也比较清楚。它以产生式的方式列出了type的语法定义。根据该语法类型可以是函数类型、数组类型、字典类型、元组类型等等这些都是类型。
![](https://static001.geekbang.org/resource/image/59/2a/59422fb82f57e8a877d66c6947cab22a.jpg "Swift语言中对类型的语法定义")
并且它还把所有类型分成了两个大类别命名类型Named Type和复合类型Compound Type
* **命名类型**包括类、结构体、枚举等,它们都有一个名称,比如自定义类的类名就是类型名称。
* **复合类型**则没有名称,它是由多个其他类型组合而成的。函数和元组都属于复合类型。函数的类型是由参数的类型和返回值的类型组合而成的,它们都是编译器对函数类型进行计算的依据。
举例来说假设一个函数有两个参数分别是类型A和B而返回值的类型是C那么这个函数的类型可以计为(A, B)->C。这就是对函数的类型的形式化的表达。
那么进一步,我们**如何在编译期里针对函数的类型做类型分析呢**?它跟非复合的类型还真不太一样,因为编译器需要检查复合类型中的多个元素。
举个例子。在一个高阶函数g()里能够接收一个函数类型的参数f(A,B),要求其类型是(A, B)->C而实际提供的函数f2的类型是(A1, B1)->C1那么你在编译器里如何判断函数的类型是否合法呢这里的算法要做多步的检查
* 第一f2也必须有两个参数这点是符合的。
* 第二检查参数的类型。A1和B1必须是跟A和B相同的类型或者是它们的父类型这样f1才能正确地给f2传递参数。
* 第三检查返回值的类型。C1则必须是C的子类型这样f1才能接收f2的返回值。
好,说完了编译器的前端工作,我们再来看看函数在语言内部的实现。
### 函数的内部实现
在函数式编程里,所有一切都围绕着函数。但是在编译完毕以后,函数在运行时中是怎么表示的呢?
就像不同的面向对象的语言,在运行时是以不同的方式表示一个对象的,不同的函数式编程语言,在运行时中去实现一个函数的机制也是不太一样的。
* 在Python中一切都是对象所以函数也是一种对象它是实现了Callable协议的对象能够在后面加上一对括号去调用它。
* 在Scala和Java这种基于JVM的语言中函数在JVM这个层次没有获得原生支持因此函数被编译完毕以后其实会变成JVM中的类。
* 在Julia、Swift、Go、Rust这样编译成机器码的语言中函数基本上就是内存中代码段或文本段的一个地址。这个地址在编译后做链接的时候会变成一个确定的地址值。在运行时跳转到这个地址就可以执行函数的功能。
补充:再具体一点的话,**编译成机器码的函数有什么特点呢?**我们再来回顾一下。
首先,函数的调用者要根据调用约定,通过栈或者寄存器设置函数的参数,保护好自己负责保护的寄存器以及返回地址,然后调用函数。
在被调用者的函数体内,通常会分为三个部分。头尾两个部分叫做**序曲prelude**和**尾声epilogue**,分别做一些初始化工作和收尾工作。在序曲里会保存原来的栈指针,以及把自己应该保护的寄存器存到栈里、设置新的栈指针等,接着执行函数的主体逻辑。最后,到尾声部分,要根据调用约定把返回值设置到寄存器或栈,恢复所保护的寄存器的值和栈顶指针,接着跳转到返回地址。
返回到调用者以后,会有一些代码恢复被保护起来的寄存器,获取返回值,然后继续执行后面的代码。
这样,把上述整个过程的细节弄清楚了,你就知道如何为函数生成代码了。
最后,我们必须提到一种特殊情况,就是**闭包**。闭包是纯函数的对立面,它引用了上级作用域中的一些自由变量。闭包在运行时不仅是代码段中的一个函数地址,还必须保存自由变量的值。为了实现闭包的运行时功能,编译器需要生成相应的代码,以便在生成闭包的时候,可以在堆里申请内存来保存自由变量的值。而当该闭包不再被引用了,那么就会像不再被引用的对象一样,成为了内存垃圾,要被垃圾回收机制回收。
好了,到这里你可能会觉得,看上去函数的实现似乎跟命令式语言也没有什么不同。不过,接下来你就会看到不同点了,这就是延迟求值的实现。
### 延迟求值Lazy Evaluation
在命令式语言里我们对表达式求值是严格按照顺序对AST求值。但对于纯函数来说由于在任何时候求值结果都是一样的因此可以进行一定的优化比如延迟求值Lazy Evaluation从而有可能减少计算工作量或者实现像unless()那样的特别的控制结构。
那么针对这种情况,编译器需要做什么处理呢?
我举个例子,对于下面的示例程序(伪代码):
```
g(condition, x){
if (condition)
return x;
else return 0;
}
```
如果我们调用的时候在x参数的位置传入的是另一个函数调用f(y)也就是g(condition, f(y))那么编译器就会把g()的函数体内用到x的地方都转换成对f(y)的调用:
```
if (condition)
return f(y);
else return 0;
```
这种把对参数的引用替换成对函数调用的技术,叫做**换名调用**。
不过换名调用有一个缺点就是f(y)有可能会被多次调用,而且每次调用的结果都是一样的。这就产生了浪费。那么这时,编译器就要更聪明一点。
怎么办呢?那就是在第一次调用的时候,记录下它的值。如果下次再调用,则使用第一次调用的结果。这种方式叫做**按需调用**。
总而言之,纯函数的特征就导致了延迟求值在编译上的不同。而函数式编程另一个重要的特征,不变性,也会对编译和运行过程造成影响。
### 不变性对编译和运行时的影响
在遵守不变性原则的情况下,对程序的编译也会有很大的不同。
第一由于函数不会修改对象的状态所以就不存在并发程序中的竞争情况进而也就不需要采用锁的机制编译器也不需要生成与锁有关的代码。Java、JavaScript等语言中关于参数逃逸的分析也变得不必要了因为反正别的线程获得了某个对象或结构体也不会去修改它的状态。
第二,不变性就意味着,只可能是新的对象引用老的对象,老的对象不可能引用新的对象。这对于垃圾收集算法的意义很大。在分代收集的算法中,如果老对象被新对象引用,那必须等到新对象回收之后老对象才可能被回收,所以函数式编程的程序现在可以更容易做出决定,把老对象放到老一代的区域,从而节省垃圾收集算法的计算量;另外,由于对象不会被改变,因此更容易实现增量收集和并行收集;由于不可能存在循环引用,因此如果采用的是引用计数法的话,就没有必要进行循环引用的检测了。
第三,不变性还意味着,在程序运行过程中可能要产生更多的新对象。在命令式语言中,程序需要对原来的对象修改状态。而函数式编程,只能每次创建一个新对象。所以,垃圾收集算法需要能够尽快地收集掉新对象。
OK了解了不变性我们再来看看针对函数式编程语言的优化算法。其中最重要的就是对递归函数的优化。
### 对递归函数的优化
虽然命令式的编程语言也会用到递归函数但函数式编程里对递归函数的使用更加普遍比如通常会用递归来代替循环。如果要对一个整型数组求和命令式编程语言会做一个循环而函数式编程语言则更习惯于用递归的方式表达sum(a, i) = a\[i\] + sum(a, i-1)。
按照传统的函数调用的运行方式,对于每一次函数调用,程序都要增加一个栈桢。递归调用一千次,就要增加一千个栈桢。这样的话,程序的栈空间很快就会被耗尽。并且,函数调用的时候,每次都要有一些额外的开销,比如保护寄存器的值、保存返回地址、传递参数等等。
我在[第7讲](https://time.geekbang.org/column/article/248770)的优化算法里,提到过**尾调用优化**也就是执行完递归函数后马上用return语句返回的情况。
```
f(x){
....
return g(...); //尾调用
}
```
在尾调用的场景下,上一级函数的栈桢已经没什么用了,程序可以直接复用。函数调用的过程,可以被优化成指令的跳转,不需要那些函数调用的开销。
不过对于递归调用的情况往往还需要对递归函数返回值做进一步的计算。比如在下面的求阶乘的函数示例中返回值是x\*fact(x-1)。
```
//fact.c 求阶乘
int fact(int x){
if (x == 1)
return 1;
else
return x*fact(x-1); //对递归值要做进一步的计算
}
```
对于编译器来说它可以经过分析把这种情况转换成一个单纯的尾调用。具体地说就是它相当于引入了一个临时的递归函数fact2()并且用第一个参数acc来记录累计值
```
int fact(x){
if (x == 1)
return 1;
else
return fact2(x, x-1); //调用一个临时的递归函数
}
int fact2(int acc, int x){ //参数acc用来保存累计值
if (x == 1){
return acc;
}
else{
return fact2(acc * x, x-1); //一个单纯的尾调用
}
}
```
如果我们调用fact(5)其实际执行过程就会在acc参数中连续地做乘法从而实现阶乘
```
->fact(5)
->fact2(5,4)
->fact2(5*4,3)
->fact2(5*4*3,2)
->fact2(5*4*3*2,1)
->5*4*3*2
```
你可以观察一下编译器实际生成的汇编程序看看优化后的成果。如果用“clang -O1 -S -o fact.s fact.c”来编译fact函数就会得到一个汇编代码文件。我对这段代码做了注释你可以理解下它的逻辑。你可以发现优化后的函数没有做任何一次递归调用。
```
_fact: ## @fact
pushq %rbp # 保存栈底指针
movq %rsp, %rbp # 把原来的栈顶,设置为新栈桢的栈底
movl $1, %eax # %eax是保存返回值的。这里先设置为1
cmpl $1, %edi # %edi是fact函数的第一个参数相当于if(x==1)
je LBB0_3 # 如果相等跳转到LBB0_3就会直接返回1
movl $1, %eax # 设置%eax为1这里%eax会保存累计值
LBB0_2:
imull %edi, %eax # 把参数乘到%eax来
decl %edi # x = x-1
cmpl $1, %edi # x是否等于1
jne LBB0_2 # 如果不等跳到LBB0_2做连乘
LBB0_3:
popq %rbp # 回复原来的栈底指针
retq # 返回
```
要想完成这种转换就要求编译器能够基于IR分析出其中的递归结构然后进行代码的变换。
## 课程小结
这一讲,我们一起讨论了实现函数式编程特性的一些要点。我希望你能记住这些关键知识点:
第一函数式编程的理论根基可以追溯到比图灵机更早的Lambda演算。要理解函数式编程的特点你可以回想一下中学时代数学课中的内容。在函数式编程中函数是一等公民。它通过强调纯函数和不变性大大降低了程序的复杂度使软件不容易出错并且能够更好地支持并发编程。并且由于采用声明式的编程风格往往程序可以更简洁表达性更好。
第二,不同的语言实现函数的机制是不同的。对于编译成机器码的语言来说,函数就是一个指向代码的指针。对于闭包,还需要像面向对象的语言那样,管理它在内存中的生存周期。
第三,函数仍然要纳入类型体系中,编译器要支持类型的检查和推断。
第四,针对函数式编程的特点,编译器可以做一些特别的优化,比如延迟求值、消除与锁有关的分析、对递归的优化等等。
同样,我把这一讲的知识点梳理成了思维导图,供你参考:
![](https://static001.geekbang.org/resource/image/01/5c/018732d4dc9c1ddf9ef5e3860f9e465c.jpg)
## 一课一思
这节课中我提到,在很多情况下,用函数式编程表达一个计算逻辑会更简洁。那么,你能不能找到这样的一些例子?欢迎分享你的经验。
如果你身边也有对函数式编程感兴趣的朋友,那么也非常欢迎你把这节课分享给 TA。感谢你的阅读下一讲我们会一起解析华为的方舟编译器到时候再见