函数式编程是很早就出现的一种编程范式,就像面向过程编程、面向对象编程一样,函数式编程作为一种编程范式,有自己的关注重点和编程理念。
函数式编程(英语:functional programming)或称函数程序设计、泛函编程,是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算(lambda calculus)为该语言最重要的基础。而且,λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。
本文将结合js语言
梳理函数式编程的理念、函数的一些特点、函数式编程的基本概念以及函子的应用,如:高阶函数、闭包、纯函数、柯里化、函数组合、常用的一些函子等。
编程范式
编程范式也就是一种编程风格,编程范型提供了(同时决定了)程序员对程序执行的看法。例如,在面向对象编程中,程序员认为程序是一系列相互作用的对象,而在函数式编程中一个程序会被看作是一个无状态的函数计算的序列。
比如:面向过程编程的关注重点在于事件完成的方法、步骤,因为计算机的执行就是一条一条执行执行的,也很符合计算机的运算逻辑,但这种编程方式不适合我们用代码去“表达”和理解这个世界,而面向对象编程则以“万物皆为对象”为准则,某件事的实现只是某些对象的行为组合而已,比如我们要实现一个五子棋的游戏,从面向过程的逻辑来说,我们一般需要进过这些步骤:
- 游戏开始
- 玩家A落子
- 判断游戏是否结束,是则执行步骤7
- 未结束则玩家B落子
- 判断游戏是否结束,是则执行步骤7
- 未结束则回到步骤2
- 游戏结束
程序的执行逻辑确实是首位的,但我们可以从不同的角度的理解来实现,对于面向对象来说,玩家A和玩家B都属于同一类对象,我们可以称为玩家,玩家都有落子这一行为,本程序中还有游戏本身我们也看作一个对象,游戏有开始、结束的行为、记录棋谱的行为以及判断游戏是否结束的逻辑,当然你还可以把棋子看作一个对象,这取决于你希望封装这个对象来干嘛。我们封装一个对象除了能更好地理解程序逻辑之外,还能更好地使用继承和实现来复用代码。所以上面的五子棋游戏用面向对象的描述就是:游戏开始后,两个玩家轮流执行落子这一行为,每次落子都会触发游戏判断本轮游戏是否结束的行为,未结束则继续落子,直到游戏结束。
而本文要讲的函数式编程重点在于把某个操作步骤抽象为一个只关心输入和输出的函数(映射),而与程序本身无关,这有点像数学中函数的概念:f(x)
,我们输入某个参数,一定会得到某个输出,且不管在什么环境下都不会发生变化,即与状态无关,这就是输入输出之间的映射关系。
函数式编程可以让我们最大程度的复用代码,由于函数式编程注重无状态以及无副作用,在使用这种函是我们可以直接调用,而无需关心代码执行状态和代码之间数据的联系。
如果说万物皆对象,那么万物也都有一些不变的公理和定理,面向对象编程具象了某个实例,而函数式编程抽象了对象中的某种客观规律,就比如从宏观角度来说,万物皆有万有引力定律一般,这些定律在一定条件下是固定不变的,我们就可以抽象为一个函数。
不管是什么样的编程范式,都是不冲突的,都只是我们对程序的理解角度不同而已,我们应当各取所长,综合不同的场景使用,而不是追求和极致的使用某一种编程风格:
- 面向过程编程是解决问题的关键,可以让我们专注于算法的优化
- 面向对象编程让我们更好的梳理程序之间的逻辑关系,实现更好的扩展思路。
- 函数式编程的无状态特点是其最大的优点,理论上,可以在任意场景下使用,类似
Math.sin
这种函数,我们无需关心数据之间千丝万缕的联系,我们只想得到数据对应的映射关系,本质上也就提高了代码的复用性和降低了代码之间耦合性(可以说直接切断了耦合)。
函数式编程的特点
函数是一等公民
一等公民很好理解,就是作为程序设计语言世界的一员,拥有最多的权利(特权)。函数是一定公民的意思也就是函数在该程序语言中拥有一等的使用特权:别的成员有的特性,函数也有,别的成员没有的特性,函数也可能有。
函数作为一等公民,一般有以下特点:
- 函数可以作为编程存储在变量中
- 函数可以作为参数传递
- 函数可以作为返回值
实际上,在JavaScript
中,函数就是一个普通的对象,所以我们可以通过new Function
的方式创建一个该函数对象的实例。
1 | // 函数作为变量存储 |
函数作为一等公民的函数又称为头等函数。
高阶函数
在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:
- 接受一个或多个函数作为输入
- 输出一个函数
一句话就是高阶函数允许接收至少一个函数作为参数或者返回一个函数作为输出。
高阶函数可以让我们组合多个普通函数,也就是让函数更抽象,屏蔽了执行细节,我们只需关注输入和输出。
现在我们来手动实现数组中的几个高阶函数来具体感受一下:map
、every
、some
:
1 | const arr = [1,2,3,8,9]; |
闭包(Closure)
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。
闭包在定义上有很多的专业术语,描述也很抽象,我们需要在大量使用中感受闭包的概念。我们先理解一些基本的概念:
头等函数:也就是上文所讲的“函数是一等公民”:函数作为变量存储,可以作为函数的参数或返回值来引用。
词法作用域:也称为静态作用域,静态作用域在编译时就已经确定了该词法变量的作用域范围(也就是词法和实体引用的生效范围)。比如下面的代码:
1
2
3
4
5
6function foo(){
const a = 2;
function bar(){
const b = 3;
}
}函数
foo
拥有两个变量a
和bar
,其中bar
是一个函数,我们就说a
和bar
的作用域在foo
函数内生效,也就是js中所说的函数作用域。而bar
中也有自己的变量b
,同样的,b
变量只在bar
函数作用域中生效,也就是foo
无法访问变量b
。执行环境:执行环境就是调用该语句时所处的作用域。
我们再次来观察闭包的定义:闭包则实际上是一个函数的实例,也就是说它是存在于内存里的某个结构体,函数就是一种结构体,但是不是所有函数我们都称为闭包,它还需要满足最关键的一点:具有包含环境成分和控制成分的实体。(彼得·兰丁(Peter Landin)1964所定义)。
用不严谨但是更具体的话来总结就是:闭包是对其周围状态(词法环境)和引用(执行环境)具有绑定关系的函数实例。
我们以下面的代码为例:
1 | function foo(){ |
- 变量
c
是函数foo
的实例,foo
返回了内部函数bar
的引用,所以c
指向函数bar
。 bar
的词法环境,也就是内部变量a
和b
的作用域分别是foo
和本身bar
。- 而函数
bar
在调用时执行环境变成了全局作用域,因为引用c
的词法作用域是全局作用域。 - 综上可知:
bar
函数的执行环境为全局作用域,词法环境包括foo
函数作用域和bar
函数本身的作用域。 - 理论上,全局作用域是不能访问函数作用域的,但是
bar
的执行环境就是全局作用域,并且访问到了它的词法作用域,也就是函数作用域,这样,我们就说函数bar
的执行环境和词法环境有了绑定关系,也就符合了闭包的定义,所以被引用的bar
函数体就是一个闭包。
即,闭包可以建立起执行环境和词法环境之间的绑定关系,也就让本不能访问函数作用域的全局作用域能进行访问。
在Javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收。如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。所以上面的例子中
foo
执行后并不会被回收,就是因为有变量c
引用了bar
,而bar
又引用了foo
,当变量c
被回收后,foo
才会被回收。因此,如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
更多细节可参考:深入理解Javascript闭包(closure) - Felix Woo、闭包 - MDN。
函数式编程中的一些基本概念
纯函数
在提到函数式编程时,我们一再强调函数式编程关注的无状态和无副作用两个重点,而纯函数就是这样一种函数。
在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:
- 相同的输入,一定会有相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
- 无副作用。该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
第一条对应无状态特性,执行状态不会影响输出结果,即不管执行环境是什么,相同的输入总会有相同的输出,就相当于求和函数:输入1,2
,输出一定是3
。第二条无副作用一般指,整个函数的执行过程不会造成其他外部数据的变化,即不会改变外部执行环境。
其实两个要求总结来说就两个关键词:外部执行环境和输入输出,外部执行环境不会影响输出值,计算输出值的过程同样不会改变外部执行环境,因而称为纯函数。
比如说js中的数据原型函数:slice
就是纯函数,但splice
就是不纯的函数,因为splice
在截取数组之后会改变原数组,也就是执行环境发生了变化,函数产生了副作用。
柯里化
基本概念
柯里化一个以人名命名的函数技术,要了解背后的原理,我们先观察一段代码:
1 | // 求b的a次方 |
power
函数是计算b的a次方
的一个函数,有时候我们会多次使用到求平方这样的函数,也就是参数a
为2
时的函数。这样我们可以把参数a
固定为2,并返回一个函数,这个函数就变得更实用:
1 | // 求b的a次方 |
这样,我们就称power
到power2
的过程为柯里化。
在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的,尽管它是Moses Schönfinkel和戈特洛布·弗雷格发明的。
在直觉上,柯里化声称“如果你固定某些参数,你将得到接受余下参数的一个函数”。所以对于有两个变量的函数,如果固定了,则得到有一个变量的函数。
柯里化是一种处理函数中附有多个参数的方法,并在只允许单一参数的框架中使用这些函数。例如,一些分析技术只能用于具有单一参数的函数。现实中的函数往往有更多的参数。弗雷格表明,为单一参数情况提供解决方案已经足够了,因为可以将具有多个参数的函数转换为一个单参数的函数链。这种转变是现在被称为“柯里化”的过程。
简言之,我们把处理多参数函数转换为更少参数函数的方法称为柯里化。
上面的实例中,我们来封装一个更适用的柯里化函数,通过这个函数,我们就能得到power2
、power3
等等类型的单一参数函数。
1 | // 柯里化后的power函数 |
柯里化函数的实现
柯里化函数,也就是某个函数fn
,当我们传入部分参数,返回值就是只具有剩余参数的fn
子函数(注:这里的子函数只是一个称谓,比如我们可以说2的b次方
是a的b次方
的子类,也就是次方关系中的一种情况)。现在我们就来实现把普通函数变为柯里化函数的工具函数:
- 语法:
curryFun = curry(func)
- 参数:普通函数
func
- 返回值:柯里化函数
curryFun
- 功能:把普通函数
func
封装为柯里化函数curryFun
,返回的curryFun
可以接受至少一个参数,如果接受的参数已经满足func
,直接返回func()
的执行结果,如果还有剩余参数,则返回以剩余参数作为参数的func
函数的子函数。
1 | function curry(func) { |
注:在JavaScript工具库lodash中已经实现了类似的柯里化工具函数:
curry
。
现在,我们来简单测试一下:
1 | // 普通求和函数 |
值得注意的是,当函数参数小于2以及参数不确定时是无法柯里化的。
函数组合
我们使用纯函数和柯里化函数后很容易写出“洋葱代码”,类似f(g(h(x)))
这种层次包裹的函数,这其实是不利于代码阅读的,这种层层包裹的函数就像是一条数据管道:h(x)
→ g(y) → f(z)
。数据经过层层传递,最终才变成了最后的输出结果。这条由多个函数组合而成的管道,我们就可以统称为一个函数w(x)
,那么w(x)
就与f(g(h(x)))
无异,但更能便于我们阅读和修改组合体,这种由多个函数依次处理并传递数据的形成的新函数就称为组合函数。
现在我们来实现一个接收多个函数,返回一个组合函数的工具函数:
1 | // 版本一 |
有几个值得关注的点:
- 在洋葱函数中,执行顺序为从内而外,即从右往左依次执行,比如
f(g(h(x)))
,所以我们在实现组合函数时参数按照从左到右的书写顺序,但执行顺序却是从右往左- 组合函数中的每一个函数的参数都为
1
位,这点尤其需要注意,多参数的情况下我们需要柯里化为一个参数的函数。lodash
工具库中已实现了类似的组合函数,名为flowRight
。
简单的测试:
1 | const _ = require('lodash') |
组合函数相当于几个有固定的执行顺序的函数组合,所以满足结合律的特性,比如compose(a,b,c)
和compose(a,compose(b,c))
是无差异的。组合函数虽然方便阅读,但也屏蔽了内部细节,在执行结果出错时,我们一般很难凭肉眼找到问题,我们就可以在组合函数中间插入调试函数,来展示或者检测是否符合预期,比如:
1 | // 调试函数 |
函数组合的过程是一种无数据的合成运算,即我们不需要关心处理的数据的(输入和输出),只关心需要合成的函数以及执行顺序,这种编程风格我们又称为“pointfree”,即无值风格。
函子
基本概念
还是那句话,函数式编程的关注点在于追求无状态和无副作用两个特性,但很多时候,数据来源或者输出都取决于用户IO操作,或者某些未知的异步操作等等。这种时候函数就很可能变得不纯,我们要做的只是尽可能的让数据变得可控,编程中的函子设计理念
能让我们更好地让数据在有效范围得到控制。函子(Functor
)是范畴学中的一个概念:
在范畴论中,函子是范畴间的一类映射。
说到映射,自然而然就想到了函数,实际上,函子就是一种特别的函数。函子的定义十分抽象,且有各种专业术语,这里我们通过代码上的使用来感受函子的设计理念即可,感兴趣的可自行参考函子 - wiki。
在代码设计中,函子通常表示为一个具有以下功能的容器:(这个容易你可以立即为对象,也可以理解为特殊的函数)
- 包含一个不对外暴露的值
value
。 - 实现一个
map
接口(契约),该接口接收一个自定义函数Fn
用于处理value
值,并返回一个Fn
处理后的值作为value
的新函子容器。即需要实现这样的map
接口:- 语法:
map(fn)
- 参数
fn
:用于处理函子容器的value
值,得到new_value
。 - 返回值:一个以
new_value
作为容器值的新函子容器。
- 语法:
函子在范畴学中定义为一类映射,上面的fn
就是相当于其中一个映射,当我们再次使用新函子进行map
调用时,又会得到一个新的fn
,这些fn
组合起来就是所谓的一类映射:(有点类似上文说的函数组合,所以函子也是符合结合律的)
函子的实现
更具上面的定义,我们用一个类来实现函子(你可以使用普通Object,也可以使用函数来实现,都无差别):
1 | // 一个简单的函子容器 |
注:由于
value
是私有变量 ,不对外暴露(当然,js目前并不支持私有变量,你也可以使用symbol
来实现一个私有属性),在使用时,我们常常为了优雅,不直接使用new
来创建函子对象,而是提供一个of
的静态变量进行创建。
我们现在来测试一下:
1 | function join(arr){ |
这里的测试代码其实就是上文我们测试函数组合
一模一样的代码,可以发现,函子和函数组合都是链式执行函数的一种结构。不同在于,函子容器的链式调用可以让我们更方便组合调用函数直接的关系,以及可以修改函子容器,让其具有一些特定的功能,以此来更好地进行全局控制数据异常,避免调用过程中的数据副作用。
比如上面的案例中,由于传入的数据可能并不可靠,就会导致数据变得不纯,比如传入的数组是空对象或者空参数:
1 | const c = Container.of(undefined) |
那么执行过程就会发生错误而变得不纯:TypeError: Cannot read property 'join' of undefined
。
针对不同的应用场景,我们可以对普通的函子进行改造,也可以称为限定范畴,以此来控制一定范畴数据变得可控:比如常见的MayBe函子
、Monad函子
,下面会一一说明这些具有特定功能的函子。
MayBe函子
MayBe
函子就是对空值数据的进行处理的一种函子。
1 | class MayBe { |
可以发现,我们对普通函子进行了简单的改造,新增了一个isNull
方法,当数据判断为空值时,就直接返回null
作为新容器的值,而无需执行函数调用,也就避免了错误的发生。
IO函子
除了数据回导致函数变得不纯,某些异步操作异常也会让函数变得不纯,比如一些异步IO
操作,这里所谓的IO
函子就是把这一系列异步操作都组合到_value
中,也就是把_value
变成一个组合函数,这样,我们需要手动调用一下_value
函数才会开始执行,也就相当于把可能不纯的函数延迟执行,让调用者手动调用时才触发,一定程度上让执行变得可控:
1 | class IO { |
IO
函子其实就是函子和组合函数的结合使用案例,函子的_value
为序列map
参数的组合函数。由于
_value
是一个函数,为了获取返回值,我们提供一个run
方法来获取执行结果。(run
执行是调用者手动触发的,可以更好地控制异常发生的位置)。
比如我们在Node
环境下获取当前进程的绝对执行路径:
1 | const c = IO.of(process).map(p => p.execPath) |
Task函子
一种专门处理异步函数的函子。在folktale这个库中已经实现了Task
函子,我们安装并体验一下:
1 | const fs = require('fs') |
task
函数有点类似promise
对象,具体使用细节我们可以参考官方文档:task。
这样,我们就可以在onResolved
中拿到异步函数的返回值,如果我们在这个函数中处理返回值,那就不是所谓的函数式编程了(我们应该使用函数来处理,而非直接对外暴露细节),也没有体现函子的使用特性。实际上,task
函子化函数可以接收系列的map
来处理异步函数接受到的数据,最后才传递给listen
,就像这样:
1 | // 测试 |
这样,最终接收到的数据就是map
处理后的数据。
Pointed函子
就是一个简单的概念:实现了of
静态接口的函子,也就是我们上面一直在使用的方法。
Monad函子
我们先观察一个现象:我们定义了两个reverse
和upper
函数,这两个函数都是函子函数,
1 | // 最简单的pointed函子 |
现象就是,当我们使用函子作为map
的参数时,就会发生函子嵌套现象,这时我们如果需要获取数据,就需要层层调用run()
方法,这样的写法是极不友好的。
类似洋葱函数的不友好一样,我们使用组合函数解决了函数层层包裹的问题,同样的,面对函子层层嵌套的问题,我们可以使用Monad函子
进行函子扁平化处理。
其实原理很简单,就是在map
中传递函子时,我们手动调用一下run
方法,以此来直接获取value
作为传递值,为了和map
做区分,我们可以使用新的函数名flatMap
,Monad
函子看起来就会像这样:
1 | class Container { |
其实就是在
map
的fn
后面自动执行了run()
作为返回值。
总结
函数式编程作为一种编程范式,追求一种无状态和无副作用的函数编程。涉及到的概念主要分为:
- 支持函数式编程的基本特点:
- 函数是一等公民
- 支持高阶函数
- 可闭包
- 函数式编程的理念:
- 纯函数
- 柯里化
- 管道与函数组合
- 函数式编程的更好的数据控制:使用各种类型的函子。