在上篇中,我们分析了函数式编程的起源和基本特性,并通过每一个特性的示例来演示这种特性的实际效果。首先,函数式编程起源于数理逻辑,起源于λ演算,这是一种演算法,它定义一些基础的数据结构,然后通过归约和代换来实现更复杂的数据结构,而函数本身也是它的一种数据。其次,我们探讨了很多函数式编程的特性,比如:
但我们也指出了一个实际问题:不能处理副作用的程序是毫无意义的。我们的计算机程序随时都在产生副作用。我们程序里面有大量的网络请求、多媒体输入输出、内部状态、全局状态等,甚至在提倡“碳中和”的今天,电脑的发热量也是一个不容小觑的副作用。那么我们应该如何处理这些问题呢?
本文通过深入函数式编程的副作用处理及实际应用场景,提供一个学习和使用函数式编程的视角给读者。一方面,这种副作用管理方式是一种高级的抽象形式,不易理解;另一方面,我们在学习和使用函数式编程的过程中,几乎都会遇到类似的副作用问题需要解决,能否解决这个问题也决定了一门函数式编程语言最终是否能走上成功。
本文主要分为三个部分:
上面说的,都是最基础的JavaScript概念+函数式编程概念。但我们还留了一个“坑”。
如何去处理IO操作?
我们的代码经常在和副作用打交道,如果要满足纯函数的要求,几乎连一个需求都完成不了。不用急,我们来看一下React Hooks。React Hooks的设计是很巧妙的,以useEffect为例:
在函数组件中,useState用来产生状态,在使用useEffect的时候,我们需要挂载这个state到第二个参数,而第一个参数给到的运行函数在state变更的时候被调用,被调用时得到最新的state。
这里面有一个状态转换:
React Hooks给我们的启发是,副作用都被放到一个状态节点里面去被动触发,行程一个单向的数据流动。而实际上,函数式编程语言确实也是这么做的,把副作用包裹到一个特殊的函数里面。
如果一个函数既包含了我们的值,又封装了值的统一操作,使得我们可以在它限定的范围内进行任意运算,那么,我们称这种函数类型为Monad。Monad是一种高级别的思维抽象。
先思考一个问题,下面两个定义有什么区别?
num1是数字类型,而num2是对象类型,这是一个直观的区别。
不过,不仅仅如此。利用类型,我们可以做更多的事。因为作为数字的num1是支持加减乘除运算的,而num2却不行,必须要把它视为一个对象{val: 2},并通过属性访问符num2.val才能进行计算num2.val + 2。但我们知道,函数式编程是不能改变状态的,现在为了计算num2.val被改变了,这不是我们期望的,并且我们使用属性操作符去读数据,更像是在操作对象,而不是操作函数,这与我们的初衷有所背离。
现在我们把num2当作一个独立的数据,并假设存在一个方法fmap可以操作这个数据,可能是这样的。
得到的还是对象,但操作通过一个纯函数addOne去实现了。
上面这个例子里面的Num,实际上就是一个最简单的Monad,而fmap是属于Functor(函子)的概念。我们说函数就是从一个数据到另一个数据的映射,这里的fmap就是一个映射函数,在范畴论里面叫做态射(后面讲解)。
由于有一个包裹的过程,很多人会把Monad看作是一个盒子类型。但Monad不仅是一个盒子的概念,它还需要满足一些特定的运算规律(后面涉及)。
但是我们直接使用数字的加减乘除不行吗?为什么一定要Monad类型?
首先,fmap的目的是把数据从一个类型映射到另一个类型,而JavaScript里面的map函数实际上就是这个功能。
我们可以认为Array就是一个Monad实现,map
看起来Monad只是一个实现了fmap的对象(Functor类型,mappable接口)而已。但Monad类型不仅是一个Functor,它还有很多其他的工具函数,比如:
这些概念在学习Haskell时可以遇到,本文不作过多提及。这些额外的函数可以帮助我们操作被封装起来的值。
范畴论是一种研究抽象数学形式的科学,它把我们的数学世界抽象为两个概念:
为什么说这是一种形式上的抽象呢?因为很多数学的概念都可以被这种形式所描述,比如集合,对集合范畴来说,一个集合就是一个范畴对象,从集合A到集合B的映射就是集合的态射,再细化一点,整数集合到整数集合的加减乘操作构成了整数集合的态射(除法会产生整数集合无法表示的数字,因此这里排除了除法)。又比如,三角形可以被代数表示,也可以用几何表示、向量表示,从代数表示到几何表示的运算就可以视为三角形范畴的一种态射。
总之,对象描述了一个范畴中的元素,而态射描述了针对这些元素的操作。范畴论不仅可以应用到数学科学里面,在其他科学里面也有一些应用,实际上,范畴论就是我们描述客观世界的一种方式(抽象形式)。
相对应的,函子就是描述一个范畴对象和另一个范畴对象间关系的态射,具体到编程语言中,函子是一个帮助我们映射一个范畴元素(比如Monad)到另一个范畴元素的函数。
群论(Group)研究的是群这种代数结构,怎么去理解群呢?比如一个三角形有三个顶点A/B/C,那么我们可以表示一个三角形为ABC或者ACB,三角形还是这个三角形,但是从ABC到ACB一定是经过了某种变换。这就像范畴论,三角形的表示是范畴对象,而一个三角形的表示变换到另一个形式,就是范畴的态射。而我们说这些三角形表示方式的集合为一个群。群论主要是研究变换关系,群又可以分为很多种类,也有很多规律特性,这不在本文研究范围之内,读者可以自行学习相关内容。
科学解释一个Monad为自函子范畴上的幺半群。如果没有学习群论和范畴论的话,我们是很难理解这个解释的。
简单来说先固定一个正方形abcd,它和它的几何变换方式(旋转/逆时针旋转/对称/中心对称等)形成的其他正方形一起构成一个群。从这个角度来说,群研究的事物是同一类,只是性质稍有不一样(态射后)。
另外一个理解群的概念就是自然数(构成一个群)和加法(群的二元运算,且满足结合律,半群)。
到此,我们可以理解Monad为:
很多函数式编程里面都会实现一个Identity函数,实际就是一个幺元素。比如JavaScript中对Just满足二元结合律可以这么操作:
我们要在一个更大的空间上讨论这个范畴对象(Monad)。就像Number封装了数字类型,Monad也封装了一些类型。
Monad需要满足一些定律:
一旦定义了Monad为一类对象,fmap为针对这种对象的操作,那么定律我们可以很容易证明:
我们可以通过Monad Just上挂载的操作来对数据进行计算,这些运算是限定在了Just上的,也就是说你只能得到Just(..)类型。要获取原始数据,可以基于这个定义一个fold方法。
fold(折叠,对应能力我们称为foldable)的意义在于你可以将数据从一个特定范畴映射到你的常用范畴,比如面向对象语言的toString方法,就是把数据从对象域转换到字符串域。
JavaScript中的Array.prototype.reduce其实就是一个fold函数,它把数据从Array范畴映射到其他范畴。
一旦数据类型被我们锁定在了Monad空间(范畴),那我们就可以在这个范畴内连续调用fmap(或者其他这个空间的函数)来进行值操作,这样我们就可以链式处理我们的数据。
有了Just的概念,我们再来学习一些新的Monad概念。比如Nothing。
Nothing表示在Monad范畴上没有的值。和Just一起正好描述了所有的数据情况,合称为Maybe,我们的Maybe Monad要么是Just,要么是Nothing。这有什么意义呢?
其实这就是模拟了其他范畴内的“有”和“无”的概念,方便我们模拟其他编程范式的空值操作。比如:
这种情况下我们需要去判断x和y是否为空。在Monad空间中,这种情况就很好表示:
我们在Monad空间中消除了烦人的!== null判断,甚至消除了三元运算符。一切都只有函数。实际使用中一个Maybe要么是Just要么是Nothing。因此,这里用Maybe(..)构造可能让我们难以理解。
如果非要理解的话,可以理解Maybe为Nothing和Just的抽象类,Just和Nothing构成这个抽象类的两个实现。实际在函数式编程语言实现中,Maybe确实只是一个类型(称为代数类型),具体的一个值有具体类型Just或Nothing,就像数字可以分为有理数和无理数一样。
除了这种值存在与否的判断,我们的程序还有一些分支结构的方式,因此我们来看一下在Monad空间中,分支情况怎么去模拟?
假设我们有一个代数类型Either,Left和Right分别表示当数据为错误和数据为正确情况下的逻辑。
这样,我们就可以使用“函数”来替代分支了。这里的Either实现比较粗糙,因为Either类型应该只在Monad空间。这里加入了布尔常量的判断,目的是好理解一些。其他的编程语言特性,在函数式编程中也能找到对应的影子,比如循环结构,我们往往使用函数递归来实现。
终于到IO了,如果不能处理好IO,我们的程序是不健全的。到目前为止,我们的Monad都是针对数据的。这句话对也不对,因为函数也是一种数据(函数是第一公民)。我们先让Monad Just能存储函数。
你可以想象为Just增加了一个抽象类实现,这个抽象类为:
这个抽象类我们称为“应用函子”,它可以保存一个函数作为内部值,并且使用apply方法可以把这个函数作用到另一个Monad上。到这里,我们完全可以把Monad之间的各种操作(接口,比如fmap和apply)视为契约,也就是数学上的态射。
现在,如果我们有一个单子叫IO,并且它有如下表现:
我们把这种类型的Monad称为IO,我们在IO中处理打印(副作用)。你可以把之前我们学习到的类型合并一下,得到一个示例:
通常一个程序会有一个主入口函数main,这个main函数返回值类型是一个IO,我们的副作用现在全在IO这个范畴下运行,而其他操作,都可以保持纯净(类型运算)。
IO类型让我们可以在Monad空间处理那些烦人的副作用,这个Monad类型和Promise(限定副作用到Promise域处理,可链式调用,可用then折叠和映射)很像。
除了上面我们提到的一些示例,函数式编程可以应用到更广的业务代码开发中,用来替代我们的一些基础业务代码。这里举几个例子。
用这种方式构建的模块,组合和复用性很强,你也可以利用lodash的其他库对req做一个其他改造。我们调用业务代码的时候只管传递params,分支校验和错误检查就教给validate.js里面的高阶函数就好了。
这个例子也是来源于前端常见的场景。我们使用函数式编程的思想,把多个看似不相关的函数进行组合,得到了业务需要的subscribe函数,但同时,上面的任意一个函数都可以被用于其他功能组合。比如callback函数可以直接给dom回调,listenInput可以用于任意一个dom。
这种通过高阶组件不停组合得到最终结果的方式,我们可以认为就是函数式的。(尽管它没有像上一个例子一样引入IO/Monad等概念)
这个也是常见的前端场景,当文本长度大于X时,显示省略号,这个实现使用Ramdajs。这个过程中你就像是在搭积木,很容易就把业务给“搭建”完成了。
函数式编程的库可以学习:
你可以结合起来使用。下面是Ramda.js示例:
而纯函数式语言,有很多:
函数式编程并不是什么“黑科技”,它已经存在的时间甚至比面向对象编程更久远。希望本文能帮助大家理解什么是函数式编程。
现在我们来回顾先览,实际上,函数式编程也是程序实现方式的一种,它和面向对象是殊途同归的。在函数式语言中,我们要构建一个个小的基础函数,并通过一些通用的流程把他们粘合起来。举个例子,面向对象里面的继承,我在函数式编程中可以使用组合compose或者高阶函数hoc来实现。
尽管在实现上是等价的,但和面向对象的编程范式对比,函数式编程有很多优点值得大家去尝试。
除了上面提到的风格和特性之外,函数式编程相对其他编程范式,有很多优点:
当然,函数式编程也存在一些不足之处:
日常业务开发中,往往我们需要取长补短,在适合的领域用适合的方法/范式。大家只要要记住,软件开发并没有“银弹”。
Q:你觉得Promise是不是一种Monad IO模型?
A:我认为是的。纯函数是没有异步概念的,Promise用了一种很棒的方式把异步和IO转化为了.then函数。你仍然可以在.then函数中写纯粹的函数,也可以在.then函数中调用其他的Promise,这就和IO Monad
的行为非常像。
Q:你愿意在生产中使用Haskell/Lisp/Clojure等纯函数式语言吗?
A:不论是否愿意使用,现在很多语言都开始引入函数式编程语法了。并不是说函数式编程一定是优秀的,但它至少没有那么恐怖。有一点可以肯定的是,学习函数式编程可以扩展我们的思维,增加我们看问题的角度。
Q:有没有一些可以预见的好处?
A:有的。比如强制你写代码的时候去关注状态量(多少、是否引用值、是否变更等),这或多或少可以帮助你写代码的时候减少状态量的使用,也慢慢地能复合一些状态量,写出更简洁的代码。
Q:函数式编程能给业务带来什么好处?
A:业务拆分的时候,函数式的思维是单向的,我们会通过实现,想到它对应需要的基础组件,并递归地思考下去,功能实现从最小粒度开始,上层逐步通过函数组合来实现。相比于面向对象,这种方式在组合上更方便简洁,更容易把复杂度降低,比如面向对象中可能对象之间的相互引用和调用是没有限制的,这种模式带来的是思考逻辑的时候思维会发散。
这种对比在业务复杂的情况下更加明显,面向对象必须要优秀的设计模式来实现控制代码复杂度增长不那么快,而函数式编程大多数情况下都是单向数据流+基础工具库就减少了大量的复杂度,而且产生的代码更简洁。
俊杰,美团到家研发平台/医药技术部前端工程师。