1.2 函数式编程
1.2.1 什么是函数式编程
顾名思义,函数式编程就是非常强调使用函数来解决问题的一种编程方式。
你可能会问,使用函数不是任何一种语言、任何一种编程方式都有的方式吗?这根本不算是什么特点啊。的确,几乎任何一种编程语言都支持函数,但是函数式编程对函数的使用有一些特殊的要求,这些要求包括以下几点:
❑ 声明式(Declarative)
❑ 纯函数(Pure Function)
❑ 数据不可变性(Immutability)
在深入介绍这三个特点之前,先提个问题:JavaScript算不算函数式编程语言?
有的语言是纯粹意义上的函数式编程语言,比如Haskell、LISP,这些编程语言本身就强制要求代码遵从以上三个要求,不过,和很多其他语言一样,JavaScript语言并没有强制要求数据不可变性,用JavaScript写的函数也并不能保证没有副作用。
那么,JavaScript到底算不算函数式编程语言呢?
从语言角度讲,JavaScript当然不算一个纯粹意义上的函数式编程语言,但是,JavaScript中的函数有第一公民的身份,因为函数本身就是一个对象,可以被赋值给一个变量,可以作为参数传递,由此可以很方便地应用函数式编程的许多思想。
我们把函数式编程看作一种编程思想,即使语言本身不支持一些特性,我们依然可以应用这样的编程思想,用于提高代码的质量。所以,JavaScript并不是纯粹的函数式编程语言,但是,通过应用一些编程规范,再借助一点工具的帮助,我们完全可以用JavaScript写出函数式的代码,RxJS就是辅助我们写出函数式代码的一种工具。
接下来,我们分别介绍JavaScript如何满足函数式编程的特性需要。
1.声明式
和声明式相对应的编程方式叫做命令式编程(Imperative Programming),命令式编程也是最常见的一种编程方式。
先来看一个命令式编程的例子,我们要实现一个功能,将一个数组中每个元素的数值乘以2。这样一个功能可以实现为一个叫double的函数,代码如下:
function double(arr) { const results = [] for (let i = 0; i < arr.length; i++){ results.push(arr[i] * 2) } return results }
代码1-1 double函数根据参数数组产生一个新的数组,每个元素乘以2
上面的double实现是典型的命令式编程风格,我们的代码把计算逻辑完整地描述了一遍:
步骤一,创造一个名为results的数组,顾名思义,这个results数组将会是最终结果,不过这个数组一开始是空的,在计算过程中会不断填充内容;
步骤二,创建一个循环,循环次数就是输入参数数组arr的长度;在循环体中,每次把数组arr中的一个元素乘以2,把结果push到数组results;
步骤三,循环结束的时候,results就是我们想要的结果,返回这个结果,结束。
这个double函数的实现没有任何毛病,但是,我们又来了一个需求:实现一个函数,能够把一个数组的每个元素加一。
好的,一样的套路,我们可以再实现一个addOne函数,代码如下:
function addOne(arr) { const results = [] for (let i = 0; i < arr.length; i++){ results.push(arr[i] + 1) } return results }
如果比较一下double和addOne这两个函数的代码,就会发现,除了函数名和push的参数之外,这两个函数如出一辙,简直就是一个模子里倒出来的。实际上,我在写addOne的时候,就是把double的代码拷贝过来,把函数名改为addOne,然后把push的参数改为arr[i]+1。
是不是嗅到点不大好的味道?没错,不大好的味道来自于重复代码,因为,“重复代码可能是所有软件中邪恶的根源。在double和addOne函数中出现了大量的重复代码,这就是让人感觉很不舒服的地方,我们应该想办法改进。
我们可以看到命令式编程的一个大问题,因为我们通过代码让电脑按照我们的指示来解决问题,但是这世界上很多问题都有相似的模式,比如上面的double和addOne函数,都是循环一个数组,对每个元素做一个操作,然后把操作结果塞给另一个数组。很自然我们会想到把这种模式抽象出来,这样就不用重复地编写这样的代码。
我们利用JavaScript的map函数,来重新实现double和addOne函数,代码如下:
function double(arr) { return arr.map(function(item) {return item * 2}); } function addOne(arr) { return arr.map(function(item) {return item + 1}); }
代码1-2 double和addOne的map实现方式
可以看到代码简洁了很多,因为省略了重复代码。我们看不到重复的for循环,也看不到往一个数组里做push动作的指令,这一切都被封装在数组的map函数中,map的主要贡献就是提供了一个函数类型的参数,这个函数参数可以定制对每一个数组元素如何处理。
我们来看double中这个定制函数,代码如下:
function(item) {return item * 2}
这个函数实现了这样一个功能:不管传入什么数据,都会返回这个数据乘以2的结果。
这就是声明式编程,因为在double函数中,代码实际上是这样一种解读:把一个数组映射(map)为另一个数组,每个对应的元素都乘以2。
相比之下,代码1-2中的声明式代码,要比代码1-1中的命令式编程代码更容易维护。
利用JavaScript的语法特性,我们可以把上面例子中的函数简写为lambda表达式,double和addOne函数的实现可以进一步简化,代码如下:
const double = arr => arr.map(item => item * 2); const addOne = arr => arr.map(item => item + 1);
当代码简洁到这个地步,可以看到,在命令式编程中的for循环,都被封装在了map函数的实现中,这样一来,对我们开发者来说,就不需要再去写循环的重复代码,直接使用map就可以写出更简洁、更有表现力的代码。实际上,当你熟悉RxJS编程之后,你会发现自己几乎再也不会写循环语句了。
在JavaScript中,因为函数具有第一公民的地位,一个函数可以作为参数传递给另一个函数,所以才让map这种功能实现成为可能。数组函数map的使用只是最简单的声明式编程的样例,本书的其他章节将进一步介绍声明式编程。
心细的读者可以注意到,上面我们实现的double和addOne函数,文字描述是“将一个数组的每个元素乘以2(或者加1)”,但是,实际上并没有真的去修改作为参数的数组,而是产生了一个新的数组,这就涉及函数式编程的另一个重要特性:纯函数。
2.纯函数
还是以上面提到的double函数为例子,不过,这一次我们从使用者角度来看double,代码如下:
const oneArray = [1, 2, 3]; const anotherArray = double(oneArray); // anotherArray的内容为[ 2, 4, 6 ] // oneArray的内容依然是[ 1, 2, 3 ]
我们先声明一个oneArray,然后将oneArray作为参数传递给double函数,返回的结果赋值给anotherArray,因为double的实现遵从了保持数据不可改的原则,所以oneArray数组依然是以前的值,而anotherArray是一个全新的数组,这两个数组中的数据相互独立,互不干扰。所以,我们说double就是一个“纯函数”。
所谓纯函数,指的是满足下面两个条件的函数。
❑ 函数的执行过程完全由输入参数决定,不会受除参数之外的任何数据影响。
❑ 函数不会修改任何外部状态,比如修改全局变量或传入的参数对象。
表面上看起来,纯函数的要求,是限制了我们编写函数的方式,似乎让我们没法写出“更强大”的函数,实际上,这种限制带来的好处远远大于所谓“更强大”的好处,因为,纯函数让我们的代码更加简单,从而更加容易维护,更加不容易产生bug。
我们还是通过一段代码例子来说明,代码如下:
const originalArray = [1, 2, 3]; const pushedArray = arrayPush(originalArray, 4); const doubledPushedArray = double(pushedArray); // pushedArray值应该是[ 1, 2, 3, 4 ] // doubledPushedArray值应该是 [ 2, 4, 6, 8 ]
只看这段代码中几个函数的命名,就能够猜出每个值的计算结果,但是,当上面的语句执行完毕之后,originalArray的值是什么呢?
先需要问,希望originalArray的值是什么,从代码维护性角度来说,当然希望originalArray保持初始值,因为这样很清晰,也可以继续使用originalArray做其他的计算,于是,我们增加下面的代码。
const doubleOriginalArray = double(originalArray);
然后,运行结果,我们一看实际上doubleOriginalArray的值是:
[ 2, 4, 6, 8 ]
不对呀,我们的originalArray应该只有三个元素,double之后产生的doubleOriginal-Array应该也只有三个元素,预期的结果是这样。
[ 2, 4, 6 ]
问题出在哪里呢?
问题出在arrayPush这个函数,我们刚才一直没有展示arrayPush函数的实现,现在我们来看一看arrayPush的代码,如下所示:
function arrayPush (arr, newValue) { arr.push(newValue); return arr; }
可以看到,arrayPush这个函数直接调用了传入参数arr的push函数,在JavaScript中,数组的push函数会修改数组的值,在数组尾部添加新的元素,所以,arrayPush直接修改了输入参数,这违背了纯函数的第二条规则。
如果arrayPush是一个纯函数,则这段程序就会显得可靠得多。
我们尝试写一个arrayPush的纯函数实现,代码如下:
function arrayPush (arr, newValue) { return [...arr, newValue]; }
在上面的代码中,我们使用了ES6的扩展操作符(Spread Operator),将输入参数arr的所有元素扩展开,然后在后面加上newValue,产生一个全新的数组,输入参数arr没有发生改变,这是一个纯函数。
和纯函数相反的就是“不纯函数”(Impure Function),一个函数之所以不纯,可能做了下面这些事情:
❑ 改变全局变量的值。
❑ 改变输入参数引用的对象,就像上面不是纯函数的arrayPush实现。
❑ 读取用户输入,比如调用了alert或者confirm函数。
❑ 抛出一个异常。
❑ 网络输入/输出操作,比如通过AJAX调用一个服务器的API。
❑ 操作浏览器的DOM。
上面还只是不纯函数的一部分表现,其实,有一个很简单的判断函数纯不纯的方法,就是假设将一个函数调用替换为一个预期返回的常数,程序运行结果是否一样。
还是以上面的代码为例,我们看arrayPush是不是一个纯函数,就看使用它的地方。
const pushedArray = arrayPush(originalArray, 4); // 预期得到[ 1, 2, 3, 4]
我们预期这个调用返回4个元素的数组,那么,就把对arrayPush的函数调用替换成预期的返回结果。
const pushedArray = [ 1, 2, 3, 4];
对于使用数组push函数的实现,这样的替换当然产生不同的效果,所以它并不是纯函数。
满足纯函数的特性也叫做引用透明度(Referential Transparency),这是更加正式的说法。怎么称呼不重要,重要的是开发者要理解,所谓的纯函数,做的事情就是输入参数到返回结果的一个映射,不要产生副作用(Side Effect)。
如果你有写单元测试代码的经历,可能会很快意识到,用纯函数是非常容易写单元测试的。单元测试是对每一小段代码单元的测试,被测对象往往就是一个函数,如果一个函数和太多外部的资源有牵连,比如访问AJAX请求、依赖某个全局变量,那光是mock这些外部的资源就让写单元测试的过程痛苦不堪,当这些外部的东西设计发生变化的时候,又需要修改对应的单元测试。
“测试驱动开发”(Test-Driven Development)提出了这么多年,在业界被追捧得很热,但却总是得不到全面实施,一个很大的原因就是很多软件项目因为不遵守函数式编程的规范,所以造成单元测试困难。如果被测函数都是纯函数,单元测试可以轻松达到100%的代码覆盖率。
3.数据不可变
数据不可变(Immutable)是函数式编程非常重要的一个概念,对于刚刚接触这个概念的朋友,可能会觉得莫名其妙,因为众所周知程序就是用代码指令在操作数据,如果数据不能变化,那一个程序又能够干什么有用的事情?
程序要好发挥作用当然是要产生变化的数据,但是并不意味着必须要去修改现有数据,替换方法是通过产生新的数据,来实现这种“变化”,也就是说,当我们需要数据状态发生改变时,保持原有数据不变,产生一个新的数据来体现这种变化。
不可改变的数据就是Immutable数据,它一旦产生,我们就可以肯定它的值永远不会变,这非常有利于代码的理解。
其实,你可能已经体会到了Immutable数据类型的好处,在JavaScript中,字符串类型、数字类型就是不可改变的数据,使用这两种类型的数据给你带来的麻烦比较少。相反,JavaScript中大部分对象都是可变的,比如JavaScript自带的原生数组类型,数组的push、pop、sort函数都会改变一个数组的内容,由此引发的bug可不少。这些不纯的函数导致JavaScript天生不是一个纯粹意义上的函数式编程语言。
在JavaScript社区已经出现一些辅助工具来实现Immutable特性,比如Immutable.js这样的库,不过,即使没有这些工具的帮助,程序员也只需要一点纪律感,就能保持代码中的数据不可变。
注意
JavaScript中的const关键字虽然有常数(constant)的意思,但其实只是规定一个变量引用的对象不能改变,却没有规定这个const变量引用的对象自身不能发生改变,所以,这个“常量”依然是变量。
1.2.2 为什么函数式编程最近才崛起
函数式编程并不是新概念,实际上,在计算机科学出现之初,函数式编程就已经崭露头角,教科书式的函数式编程语言LISP在1958年就诞生了,但是,为什么一直都是命令式编程和面向对象编程大行其道呢?
1.函数式编程历史
说来话长,想当年,阿兰·图灵和冯·诺依曼祖师爷开天辟地,创立了计算机这门学科,因为这行前无古人,所以最早的一批学者都有其他专业的背景,有的是电子电气方面的专家,有来自物理学科,还有的本来是数学家。不同的背景,也就带来了对计算机发展方向的不同观点。
当时,数学家们提出的编程语言模型自然具有纯数学的气质,也是最优雅、最易于管理的解决方法。这其中的代表人物就是阿隆佐·邱奇(Alonzo Church),邱奇在计算机诞生之前就提出Lambda演算(Lambda Calculus)的概念,也就是用纯函数的组合来描述计算过程。根据Lambda演算,如果一个问题能够用一套函数组合的算法来描述,那就说明这个问题是可计算的,很自然,也就可以用编程语言的函数组合的方式来实现这样的计算过程。
可是,数学家们的理念,并没有在计算机发展初期被大范围应用,为什么呢?因为当时的硬件制造技术还很不发达,电子元件远没有当今这样的水平,那时候每一个电子元件制造成本高,而且体积大,无法在一小片芯片上放置很多元件,无论是运算元件还是存储元件,都是又慢又贵。
既然物理硬件昂贵,那么只好省着点用了,这种情况下,和硬件靠得最近的物理学家和电子电气工程师们掌握了编程语言的主流方向,命令式编程就是这样发展起来的。
早期的编程工作中,程序员必须考虑硬件的架构,如何使用CPU计算资源,如何巧妙利用有限的那么几个寄存器(register),如果不考虑的话,性能肯定无法过关。在这样的硬件条件下,函数式编程的想法要实现,只能通过一层软件模拟来复现数学家设想的模型,这多出来的一层无疑要耗费性能,所以光是性能这一个因素,就让函数式编程的实践难以推广。
还好,电子技术在飞速发展,计算机的运算能力和存储能力不断提高。1965年,电子芯片公司Intel的创始人戈登·摩尔根据观察,做了这样的断言:“当价格不变时,集成电路上可容纳的元器件的数目,约每隔18~24个月便会增加一倍,性能也将提升一倍。”这也就是著名的“摩尔定律”,根据这个定律,计算机的计算能力是以指数趋势增长,从那之后很长一段时间,软件行业也一直在享受计算能力增长带来的红利。
但是,进入21世纪之后,大家发现“摩尔定律”渐渐不管用了,集成电路上的元器件数目不能增长得这么快,因为,电子部件的密度快要达到物理极限了,一个集成电路上没法聚集更多的器件,虽然工程师们还在进一步提高CPU的性能,但是,普遍认同的观点是,单核的运算能力不可能保持摩尔定律的增长速度。
这时候,芯片的发展方向转为多核,软件架构也向分布式方向发展。这种转化很合理,既然一个核一秒钟只能做N次运算,那么我用8个核,一秒就能进行8*N次运算;同样,一个CPU中核的数量虽然是有限的,但是可以把计算量分布在不同的计算机上,假如一台计算机一秒钟的运算能力是N,那么1000台计算机,一秒钟的计算能力就是1000*N。
既然硬件的解决方案只能如此,剩下的唯一问题就是,如何把运算分布到不同的核或者不同的计算机上去呢?如果是用命令式编程,真的很难,因为编写协调多核或者分布式的任务处理程序非常困难,让每个开发者都做这样的工作,那真是非常不现实;然而,函数式编程却能够让大部分开发者不需要操心任务处理,所以非常适合分布式计算的场景。
声明式的函数,让开发者只需要表达“想要做什么”,而不需要表达“怎么去做”,这样就极大地简化了开发者的工作。至于具体“怎么去做”,让专门的任务协调框架去实现,这个框架可以灵活地分配工作给不同的核、不同的计算机,而开发者不必关心框架背后发生了什么。
与此同时,计算机业界也发现,随着CPU性能和存储设备性能的提高,当初导致函数式编程性能问题的障碍,现在都不是问题了,这也给函数式编程崛起增加了助推力。
可能读者会问,基于现在的计算机硬件架构,用函数式编程写出的程序,肯定性能会比命令式编程写出来的要低一些吧?其实未必。首先,当今软件已经是一个很复杂的系统,性能并不完全由是否更直接翻译为机器语言决定。其次,在性能相当的情况下,软件的开发速度要比运行速度重要得多。打个比方,用命令式编程,开发一个网络服务花费6个月,每个请求处理时间是1毫秒;用函数式编程,开发同样的网络服务花费3个月,每个请求处理时间是10毫秒,是否值得花3个月去获得这9毫秒的性能增长呢?
要知道,从客户端感知到的反应速度,不光包含服务器端的计算处理时间,还包含网络传输时间,比如平均网络传输时间是200毫秒,200毫秒是一个比较正常的网络延迟,那么访问命令式编程服务的反应时间是201毫秒,访问函数式编程服务的反应时间是210毫秒。201毫秒和210毫秒,用户感知的性能没有那么大的区别,这时候,我们当然更愿意选择能够提高开发速度的方法。
2.语言演进
除了硬件性能和软件开发需求的推动,语言的演进也是推动函数式编程被接受的一大动因。
曾几何时,只有Haskell和LISP这样的纯函数式编程语言才高举这面大旗,但是后来,一些本来属于命令式阵营或者面向对象阵营的编程语言也开始添加函数式的特性,这样,更多的开发者能够接触到函数式这种编程思想。增加了函数式特性的这些语言,当然包括JavaScript。
如果要学习最正统的函数式编程,比如Haskell,可能需要极大的耐心,因为学习过程中要涉及很多数学概念和推演,在开始实践函数式编程之前,就要面对这么庞大的背景知识,很有可能学着学着就睡着了。在这本书中,不会灌输给读者繁复的数学理论,而是尽量用浅显易懂的语言和代码示例来介绍函数式编程,空有理论没有实践是无意义的。
当然,这并不是说学习正统的函数式编程语言没有意义,如果时间和精力允许,读者当然应该学习Haskell或者LISP这样纯粹的函数式编程语言,但这不是本书的范围。
1.2.3 函数式编程和面向对象编程的比较
要介绍函数式编程(Functional Programming)就不得不拿另一个编程范式面向对象编程(Object Oriented Programming)作对比,因为面向对象编程曾经统治了业界很长一段时间,而函数式编程正在逐渐挑战面向对象的地位。
这两种编程方式都可以让代码更容易理解,不过方式不同。简单说来,面向对象的方法把状态的改变封装起来,以此达到让代码清晰的目的;而函数式编程则是尽量减少变化的部分,以此让代码逻辑更加清晰。
面向对象的思想是把数据封装在类的实例对象中,把数据藏起来,让外部不能直接操作这些对象,只能通过类提供的实例方法来读取和修改这些数据,这样就限制了对数据的访问方式。对于毫无节制任意修改数据的编程方式,面向对象无疑是巨大的进步,因为通过定义类的方法,可以控制对数据的操作。
但是,面向对象隐藏数据的特点,带来了一个先天的缺陷,就是数据的修改历史完全被隐藏了。有人说,面向对象编程提供了一种持续编写烂代码的方式,它让你通过一系列补丁来拼凑程序。这话有点过激,但是也道出了面向对象编程的缺点。
当我们在代码中看到一个对象实例的时候,即使知道了对象的当前状态,也没法知道这个对象是如何一步一步走到这个状态的,这种不确定性导致代码可维护性下降。
函数式编程中,倾向于数据就是数据,函数就是函数,函数可以处理数据,也是并不像面向对象的类概念一样把数据和函数封在一起,而是让每个函数都不要去修改原有数据(不可变性),而且通过产生新的数据来作为运算结果(纯函数)。
在本书后续章节中,我们会继续体会函数式编程的这些好处。