总第540篇
2022年 第057篇
函数式编程是一种历史悠久的编程范式。作为演算法,它的历史可以追溯到现代计算机诞生之前的λ演算,本文希望带大家快速了解函数式编程的历史、基础技术、重要特性和实践法则。
1. 前文回顾
2. 本文简介
3. 副作用处理:单子Monad,一种不可避免的抽象
3.1 什么是Monad?
3.2 范畴、群、幺半群
3.3 Monad范畴:定律、折叠和链
3.4 Maybe和Either
3.5 IO的处理方式
4. 函数式编程的应用
4.1 设计一个请求模块
4.2 设计一个输入框
4.3 超长文本省略:Ramdajs为例
5. 函数式编程库、语言
6. 总结
6.1 优点
6.2 不足
7. FAQ
在上篇中,我们分析了函数式编程的起源和基本特性,并通过每一个特性的示例来演示这种特性的实际效果。首先,函数式编程起源于数理逻辑,起源于λ演算,这是一种演算法,它定义一些基础的数据结构,然后通过归约和代换来实现更复杂的数据结构,而函数本身也是它的一种数据。其次,我们探讨了很多函数式编程的特性,比如:
本文通过深入函数式编程的副作用处理及实际应用场景,提供一个学习和使用函数式编程的视角给读者。一方面,这种副作用管理方式是一种高级的抽象形式,不易理解;另一方面,我们在学习和使用函数式编程的过程中,几乎都会遇到类似的副作用问题需要解决,能否解决这个问题也决定了一门函数式编程语言最终是否能走上成功。
上面说的,都是最基础的JavaScript概念+函数式编程概念。但我们还留了一个“坑”。
如何去处理IO操作?
我们的代码经常在和副作用打交道,如果要满足纯函数的要求,几乎连一个需求都完成不了。不用急,我们来看一下React Hooks。React Hooks的设计是很巧妙的,以useEffect为例:
在函数组件中,useState用来产生状态,在使用useEffect的时候,我们需要挂载这个state到第二个参数,而第一个参数给到的运行函数在state变更的时候被调用,被调用时得到最新的state。
React Hooks给我们的启发是,副作用都被放到一个状态节点里面去被动触发,形成一个单向的数据流动。而实际上,函数式编程语言确实也是这么做的,把副作用包裹到一个特殊的函数里面。
先思考一个问题,下面两个定义有什么区别?
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<T, K>把Array<T>类型映射到Array<K>类型,操作仍然在数组范畴,数组的值被映射为新的值。如果用TypeScript来表示,会不会更清晰一点?
看起来Monad只是一个实现了fmap的对象(Functor类型,mappable接口)而已。但Monad类型不仅是一个Functor,它还有很多其他的工具函数,比如:
范畴论是一种研究抽象数学形式的科学,它把我们的数学世界抽象为两个概念:
为什么说这是一种形式上的抽象呢?因为很多数学的概念都可以被这种形式所描述,比如集合,对集合范畴来说,一个集合就是一个范畴对象,从集合A到集合B的映射就是集合的态射,再细化一点,整数集合到整数集合的加减乘操作构成了整数集合的态射(除法会产生整数集合无法表示的数字,因此这里排除了除法)。又比如,三角形可以被代数表示,也可以用几何表示、向量表示,从代数表示到几何表示的运算就可以视为三角形范畴的一种态射。
相对应的,函子就是描述一个范畴对象和另一个范畴对象间关系的态射,具体到编程语言中,函子是一个帮助我们映射一个范畴元素(比如Monad)到另一个范畴元素的函数。
群论(Group)研究的是群这种代数结构,怎么去理解群呢?比如一个三角形有三个顶点A/B/C,那么我们可以表示一个三角形为ABC或者ACB,三角形还是这个三角形,但是从ABC到ACB一定是经过了某种变换。这就像范畴论,三角形的表示是范畴对象,而一个三角形的表示变换到另一个形式,就是范畴的态射。而我们说这些三角形表示方式的集合为一个群。群论主要是研究变换关系,群又可以分为很多种类,也有很多规律特性,这不在本文研究范围之内,读者可以自行学习相关内容。
简单来说先固定一个正方形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分别表示当数据为错误和数据为正确情况下的逻辑。
终于到IO了,如果不能处理好IO,我们的程序是不健全的。到目前为止,我们的Monad都是针对数据的。这句话对也不对,因为函数也是一种数据(函数是第一公民)。我们先让Monad Just能存储函数。
你可以想象为Just增加了一个抽象类实现,这个抽象类为:
这个抽象类我们称为“应用函子”,它可以保存一个函数作为内部值,并且使用apply方法可以把这个函数作用到另一个Monad上。到这里,我们完全可以把Monad之间的各种操作(接口,比如fmap和apply)视为契约,也就是数学上的态射。
现在,如果我们有一个单子叫IO,并且它有如下表现:
我们把这种类型的Monad称为IO,我们在IO中处理打印(副作用)。你可以把之前我们学习到的类型合并一下,得到一个示例:
通常一个程序会有一个主入口函数main,这个main函数返回值类型是一个IO,我们的副作用现在全在IO这个范畴下运行,而其他操作,都可以保持纯净(类型运算)。
除了上面我们提到的一些示例,函数式编程可以应用到更广的业务代码开发中,用来替代我们的一些基础业务代码。这里举几个例子。
这个例子也是来源于前端常见的场景。我们使用函数式编程的思想,把多个看似不相关的函数进行组合,得到了业务需要的subscribe函数,但同时,上面的任意一个函数都可以被用于其他功能组合。比如callback函数可以直接给dom回调,listenInput可以用于任意一个dom。
函数式编程的库可以学习:
你可以结合起来使用。下面是Ramda.js示例:
而纯函数式语言,有很多:
函数式编程并不是什么“黑科技”,它已经存在的时间甚至比面向对象编程更久远。希望本文能帮助大家理解什么是函数式编程。
现在我们来回顾先览,实际上,函数式编程也是程序实现方式的一种,它和面向对象是殊途同归的。在函数式语言中,我们要构建一个个小的基础函数,并通过一些通用的流程把他们粘合起来。举个例子,面向对象里面的继承,我在函数式编程中可以使用组合compose或者高阶函数hoc来实现。
除了上面提到的风格和特性之外,函数式编程相对其他编程范式,有很多优点:
当然,函数式编程也存在一些不足之处:
Q:你觉得Promise是不是一种Monad IO模型?
IO Monad
的行为非常像。Q:你愿意在生产中使用Haskell/Lisp/Clojure等纯函数式语言吗?
Q:有没有一些可以预见的好处?
Q:函数式编程能给业务带来什么好处?
A:业务拆分的时候,函数式的思维是单向的,我们会通过实现,想到它对应需要的基础组件,并递归地思考下去,功能实现从最小粒度开始,上层逐步通过函数组合来实现。相比于面向对象,这种方式在组合上更方便简洁,更容易把复杂度降低,比如面向对象中可能对象之间的相互引用和调用是没有限制的,这种模式带来的是思考逻辑的时候思维会发散。
---------- END ----------
招聘信息
阅读更多