前文回顾

在上篇中,我们分析了函数式编程的起源和基本特性,并通过每一个特性的示例来演示这种特性的实际效果。首先,函数式编程起源于数理逻辑,起源于λ演算,这是一种演算法,它定义一些基础的数据结构,然后通过归约和代换来实现更复杂的数据结构,而函数本身也是它的一种数据。其次,我们探讨了很多函数式编程的特性,比如:

  • First Class
  • 纯函数
  • 引用透明
  • 表达式
  • 高阶函数
  • 柯里化
  • 函数组合
  • point-free

但我们也指出了一个实际问题:不能处理副作用的程序是毫无意义的。我们的计算机程序随时都在产生副作用。我们程序里面有大量的网络请求、多媒体输入输出、内部状态、全局状态等,甚至在提倡“碳中和”的今天,电脑的发热量也是一个不容小觑的副作用。那么我们应该如何处理这些问题呢?

本文简介

本文通过深入函数式编程的副作用处理及实际应用场景,提供一个学习和使用函数式编程的视角给读者。一方面,这种副作用管理方式是一种高级的抽象形式,不易理解;另一方面,我们在学习和使用函数式编程的过程中,几乎都会遇到类似的副作用问题需要解决,能否解决这个问题也决定了一门函数式编程语言最终是否能走上成功。

本文主要分为三个部分:

  • 副作用处理方式
  • 函数式编程的应用
  • 函数式编程的优缺点比较

副作用处理:单子 Monad,一种不可避免的抽象

上面说的,都是最基础的 JavaScript 概念+函数式编程概念。但我们还留了一个“坑”。

如何去处理 IO 操作?

我们的代码经常在和副作用打交道,如果要满足纯函数的要求,几乎连一个需求都完成不了。不用急,我们来看一下React HooksReact Hooks的设计是很巧妙的,以useEffect为例:

在函数组件中,useState用来产生状态,在使用useEffect的时候,我们需要挂载这个state到第二个参数,而第一个参数给到的运行函数在state变更的时候被调用,被调用时得到最新的state

这里面有一个状态转换:

React Hooks给我们的启发是,副作用都被放到一个状态节点里面去被动触发,行程一个单向的数据流动。而实际上,函数式编程语言确实也是这么做的,把副作用包裹到一个特殊的函数里面。

如果一个函数既包含了我们的值,又封装了值的统一操作,使得我们可以在它限定的范围内进行任意运算,那么,我们称这种函数类型为MonadMonad是一种高级别的思维抽象。

什么是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实现,mapArray< T >类型映射到Array< K >类型,操作仍然在数组范畴,数组的值被映射为新的值。 如果用 TypeScript 来表示,会不会更清晰一点?

看起来Monad只是一个实现了fmap的对象(Functor类型,mappable接口)而已。但Monad类型不仅是一个Functor,它还有很多其他的工具函数,比如:

  • bind函数
  • flatMap函数
  • liftM函数

这些概念在学习Haskell时可以遇到,本文不作过多提及。这些额外的函数可以帮助我们操作被封装起来的值。

范畴、群、幺半群

范畴论是一种研究抽象数学形式的科学,它把我们的数学世界抽象为两个概念:

  • 对象
  • 态射

为什么说这是一种形式上的抽象呢?因为很多数学的概念都可以被这种形式所描述,比如集合,对集合范畴来说,一个集合就是一个范畴对象,从集合A到集合B的映射就是集合的态射,再细化一点,整数集合到整数集合的加减乘操作构成了整数集合的态射(除法会产生整数集合无法表示的数字,因此这里排除了除法)。又比如,三角形可以被代数表示,也可以用几何表示、向量表示,从代数表示到几何表示的运算就可以视为三角形范畴的一种态射。

总之,对象描述了一个范畴中的元素,而态射描述了针对这些元素的操作。范畴论不仅可以应用到数学科学里面,在其他科学里面也有一些应用,实际上,范畴论就是我们描述客观世界的一种方式(抽象形式)。

相对应的,函子就是描述一个范畴对象和另一个范畴对象间关系的态射,具体到编程语言中,函子是一个帮助我们映射一个范畴元素(比如Monad)到另一个范畴元素的函数。

群论(Group)研究的是这种代数结构,怎么去理解群呢?比如一个三角形有三个顶点A/B/C,那么我们可以表示一个三角形为ABC或者ACB,三角形还是这个三角形,但是从ABCACB一定是经过了某种变换。这就像范畴论,三角形的表示是范畴对象,而一个三角形的表示变换到另一个形式,就是范畴的态射。而我们说这些三角形表示方式的集合为一个群论主要是研究变换关系,群又可以分为很多种类,也有很多规律特性,这不在本文研究范围之内,读者可以自行学习相关内容。

科学解释一个Monad为自函子范畴上的幺半群。如果没有学习群论和范畴论的话,我们是很难理解这个解释的。

简单来说先固定一个正方形abcd,它和它的几何变换方式(旋转/逆时针旋转/对称/中心对称等)形成的其他正方形一起构成一个群。从这个角度来说,群研究的事物是同一类,只是性质稍有不一样(态射后)。

另外一个理解群的概念就是自然数(构成一个群)和加法(群的二元运算,且满足结合律,半群)

到此,我们可以理解Monad为:

  • 满足自函子运算(从A范畴态射到A范畴,fmap是在自己空间做映射)。
  • 满足含幺半群的结合律。

很多函数式编程里面都会实现一个Identity函数,实际就是一个幺元素。比如 JavaScript 中对 Just 满足二元结合律可以这么操作:

Monad范畴:定律、折叠和链

我们要在一个更大的空间上讨论这个范畴对象(Monad)。就像Number封装了数字类型,Monad也封装了一些类型。

Monad需要满足一些定律:

  • 结合律:比如a · b · c = a · (b · c)。
  • 幺元:比如a · e = e · a = a。

一旦定义了Monad为一类对象,fmap为针对这种对象的操作,那么定律我们可以很容易证明:

我们可以通过Monad Just上挂载的操作来对数据进行计算,这些运算是限定在了Just上的,也就是说你只能得到Just(..)类型。要获取原始数据,可以基于这个定义一个fold方法。

fold(折叠,对应能力我们称为foldable)的意义在于你可以将数据从一个特定范畴映射到你的常用范畴,比如面向对象语言的 toString 方法,就是把数据从对象域转换到字符串域。

JavaScript 中的 Array.prototype.reduce 其实就是一个 fold 函数,它把数据从 Array 范畴映射到其他范畴。

一旦数据类型被我们锁定在了 Monad 空间(范畴),那我们就可以在这个范畴内连续调用 fmap(或者其他这个空间的函数)来进行值操作,这样我们就可以链式处理我们的数据。

Maybe和Either

有了Just的概念,我们再来学习一些新的Monad概念。比如Nothing

Nothing表示在Monad范畴上没有的值。和Just一起正好描述了所有的数据情况,合称为Maybe,我们的Maybe Monad要么是Just,要么是Nothing。这有什么意义呢?

其实这就是模拟了其他范畴内的“有”和“无”的概念,方便我们模拟其他编程范式的空值操作。比如:

这种情况下我们需要去判断xy是否为空。在Monad空间中,这种情况就很好表示:

我们在Monad空间中消除了烦人的!== null判断,甚至消除了三元运算符。一切都只有函数。实际使用中一个 Maybe要么是Just要么是Nothing。因此,这里用**Maybe(..)**构造可能让我们难以理解。

如果非要理解的话,可以理解MaybeNothingJust的抽象类,JustNothing构成这个抽象类的两个实现。实际在函数式编程语言实现中,Maybe确实只是一个类型(称为代数类型),具体的一个值有具体类型JustNothing,就像数字可以分为有理数和无理数一样。

除了这种值存在与否的判断,我们的程序还有一些分支结构的方式,因此我们来看一下在Monad空间中,分支情况怎么去模拟?

假设我们有一个代数类型EitherLeftRight分别表示当数据为错误和数据为正确情况下的逻辑。

这样,我们就可以使用“函数”来替代分支了。这里的Either实现比较粗糙,因为Either类型应该只在Monad空间。这里加入了布尔常量的判断,目的是好理解一些。其他的编程语言特性,在函数式编程中也能找到对应的影子,比如循环结构,我们往往使用函数递归来实现。

IO的处理方式

终于到IO了,如果不能处理好IO,我们的程序是不健全的。到目前为止,我们的Monad都是针对数据的。这句话对也不对,因为函数也是一种数据(函数是第一公民)。我们先让Monad Just能存储函数。

你可以想象为Just增加了一个抽象类实现,这个抽象类为:

这个抽象类我们称为“应用函子”,它可以保存一个函数作为内部值,并且使用apply方法可以把这个函数作用到另一个Monad上。到这里,我们完全可以把Monad之间的各种操作(接口,比如fmapapply)视为契约,也就是数学上的态射。

现在,如果我们有一个单子叫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等概念)

超长文本省略:Ramdajs为例

这个也是常见的前端场景,当文本长度大于X时,显示省略号,这个实现使用 Ramdajs 。这个过程中你就像是在搭积木,很容易就把业务给“搭建”完成了。

函数式编程库、语言

函数式编程的库可以学习:

  • Ramda.js:函数式编程库
  • lodash.js:函数工具
  • immutable.js:数据不可变
  • rx.js:响应式编程
  • partial.lenses:函数工具
  • monio.js:函数式编程工具库/IO库

你可以结合起来使用。下面是Ramda.js示例:

而纯函数式语言,有很多:

  • Lisp 代表软件 emacs…
  • Haskell 代表软件 pandoc…
  • Ocaml …

总结

函数式编程并不是什么“黑科技”,它已经存在的时间甚至比面向对象编程更久远。希望本文能帮助大家理解什么是函数式编程。

现在我们来回顾先览,实际上,函数式编程也是程序实现方式的一种,它和面向对象是殊途同归的。在函数式语言中,我们要构建一个个小的基础函数,并通过一些通用的流程把他们粘合起来。举个例子,面向对象里面的继承,我在函数式编程中可以使用组合compose或者高阶函数hoc来实现。

尽管在实现上是等价的,但和面向对象的编程范式对比,函数式编程有很多优点值得大家去尝试。

优点

除了上面提到的风格和特性之外,函数式编程相对其他编程范式,有很多优点:

  • 函数纯净 程序有更少的状态量,编码心智负担更小。随着状态量的增加,某些编程范式构建的软件库代码复杂度可能呈几何增长,而函数式编程的状态量都收敛了,对软件复杂度带来的影响更小。
  • 引用透明性 可以让你在不影响其他功能的前提下,升级某一个特定功能(一个对象的引用需要改动的话,可能牵一发而动全身)。
  • 高度可组合 函数之间复用方便(需要关注的状态量更少),函数的功能升级改造也更容易(高阶组件)。
  • 副作用隔离 所有的状态量被收敛到一个盒子(函数)里面处理,关注点更加集中。
  • 代码简洁/流程更清晰 通常函数式编程风格的程序,代码量比其他编程风格的少很多,这得益于函数的高度可组合性以及大量的完善的基础函数,简洁性也使得代码更容易维护。
  • 语义化 一个个小的函数分别完成一种小的功能,当你需要组合上层能力的时候,基本可以按照函数语义来进行快速组合。
  • 惰性计算 被组合的函数只会生成一个更高阶的函数,最后调用时数据才会在函数之间流动。
  • 跨语言统一性 不同的语言,似乎都遵从类似的函数式编程范式,比如Java 8的lambda表达式,Rust的collection、匿- 名函数;而面向对象的实现,不同语言可能千差万别,函数式编程的统一性让你可以舒服地跨语言开发。
  • 关键领域应用 因为函数式编程状态少、代码简洁等特点,使得它在交互复杂、安全性要求高的领域有重要的应用,像Lisp和Haskell就是因上一波人工智能热而火起来的,后来也在一些特殊的领域(银行、水利、航空航天等)得到了较大规模的应用。

不足

当然,函数式编程也存在一些不足之处:

  • 陡峭的学习曲线 面向对象和命令式编程范式都是贴近我们的日常习惯的方式,而函数式编程要更抽象一些,要想更好地管理副作用,你可能需要学习很多新的概念(响应式、Monad等),这些概念入门很难,而且是一个长期积累的过程。
  • 可能的调用栈溢出问题 惰性计算在一些电脑或特种程序架构上可能有函数调用栈错误(超长调用链、超长递归),另外许多函数式编程语言需要编译器支持尾递归优化(优化为循环迭代)以得到更好的性能。
  • 额外的抽象负担 当程序有大量可变状态、副作用时,用函数式编程可能造成额外的抽象负担,项目开发周期可能会延长,这时可能用其他抽象方式更好(比如OOP)。
  • 数据不变性的问题 为了数据不变,运行时可能会构建生成大量的数据副本,造成时间和空间消耗更大,拖慢性能;同时数据不可变性可能会造成构建一些基础数据结构的时候语法不简洁,性能也更差(比如LinkedList、HashMap等数据结构)。
  • 语义化的问题 往往为了开发一个功能,去造许多的基础函数,大量业务组件想要语义化的命名,也会带给开发人员很多负担;并且功能抽象能力因人而异,公共函数往往不够公用或者过度设计。
  • 生态问题 函数式编程在工业生产领域因其抽象性和性能带来的问题,被许多开发者拒之门外,一些特定功能的解决方案也更小众(相比其他编程范式),生态也一直比较小,这成为一些新的开发人员学习和使用函数式编程的又一个巨大障碍。

日常业务开发中,往往我们需要取长补短,在适合的领域用适合的方法/范式。大家只要要记住,软件开发并没有“银弹”。

FAQ

Q:你觉得Promise是不是一种Monad IO模型?

A:我认为是的。纯函数是没有异步概念的,Promise用了一种很棒的方式把异步和IO转化为了.then函数。你仍然可以在.then函数中写纯粹的函数,也可以在.then函数中调用其他的Promise,这就和IO Monad的行为非常像。

Q:你愿意在生产中使用Haskell/Lisp/Clojure等纯函数式语言吗?

A:不论是否愿意使用,现在很多语言都开始引入函数式编程语法了。并不是说函数式编程一定是优秀的,但它至少没有那么恐怖。有一点可以肯定的是,学习函数式编程可以扩展我们的思维,增加我们看问题的角度。

Q:有没有一些可以预见的好处?

A:有的。比如强制你写代码的时候去关注状态量(多少、是否引用值、是否变更等),这或多或少可以帮助你写代码的时候减少状态量的使用,也慢慢地能复合一些状态量,写出更简洁的代码。

Q:函数式编程能给业务带来什么好处?

A:业务拆分的时候,函数式的思维是单向的,我们会通过实现,想到它对应需要的基础组件,并递归地思考下去,功能实现从最小粒度开始,上层逐步通过函数组合来实现。相比于面向对象,这种方式在组合上更方便简洁,更容易把复杂度降低,比如面向对象中可能对象之间的相互引用和调用是没有限制的,这种模式带来的是思考逻辑的时候思维会发散。

这种对比在业务复杂的情况下更加明显,面向对象必须要优秀的设计模式来实现控制代码复杂度增长不那么快,而函数式编程大多数情况下都是单向数据流+基础工具库就减少了大量的复杂度,而且产生的代码更简洁。