深入理解函数式编程

疑惑一号疑惑一号 in 前端 2022-11-04 21:09:21

实际上Array是一种Monad(后面讲解)。上面这个例子里面的Num,实际上就是一个最简单的Monad,而fmap是属于Functor(函子)的概念。首先,fmap的目的是把数据从一个类型映射到另一个类型,而JavaScript里面的map函数实际上就是这个功能。

前言

本文分为上下两篇,上篇讲述函数式编程的基础概念和特性,下篇讲述函数式编程的进阶概念、应用及优缺点。函数式编程既不是简单的堆砌函数,也不是语言范式的终极之道。我们将深入浅出地讨论它的特性,以期在日常工作中能在对应场景中进行灵活应用。

先览:代码组合和复用

在前端代码中,我们现有一些可行的模块复用方式,比如:
深入理解函数式编程
除了上面提到的组件和功能级别的代码复用,我们也可以在软件架构层面上,通过选择一些合理的架构设计来减少重复开发的工作量,比如说很多公司在中后台场景中大量使用的低代码平台。

可以说,在大部分软件项目中,我们都要去探索代码组合和复用

函数式编程,曾经有过一段黄金时代,后来又因面向对象范式的崛起而逐步变为小众范式。但是,函数式编程目前又开始在不同的语言中流行起来了,像 Java 8、JS、Rust 等语言都有对函数式编程的支持。

今天我们就来探讨 JavaScript 的函数,并进一步探讨JavaScript 中的函数式编程(关于函数式编程风格软件的组织、组合和复用)。

什么是函数式编程?

定义

函数式编程是一种风格范式,没有一个标准的教条式定义。我们来看一下维基百科的定义:

函数式编程是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算是该语言最重要的基础。而且λ演算的函数可以接受函数作为输入的参数和输出的返回值。

我们可以直接读出以下信息:

  • 避免状态变更
  • 函数作为输入输出
  • λ演算有关

那什么是λ演算呢?

函数式编程起源:λ演算

λ演算(读作 lambda 演算)由数学家阿隆佐·邱奇在 20 世纪 30 年代首次发表,它从数理逻辑(Mathematical logic)中发展而来,使用变量绑定(binding)和代换规则(substitution)来研究函数如何抽象化定义(define)、函数如何被应用(apply)以及递归(recursion)的形式系统。

λ演算和图灵机等价(图灵完备,作为一种研究语言又很方便)。

通常用这个定义形式来表示一个λ演算。
深入理解函数式编程
所以λ演算式就三个要点:

  1. 绑定关系。变量任意性,x、y 和 z 都行,它仅仅是具体数据的代称。
  2. 递归定义。λ项递归定义,M 可以是一个λ项。
  3. 替换归约。λ项可应用,空格分隔表示对 M 应用 N,N 可以是一个λ项。

比如这样的演算式:
深入理解函数式编程
通过变量代换(substitution)归约(reduction),我们可以像化简方程一样处理我们的演算。
λ演算有很多方式进行,数学家们也总结了许多和它相关的规律和定律(可查看维基百科)。
举个例子,小时候我们学习整数就是学会几个数字,然后用加法/减法来推演其他数字。在函数式编程中,我们可以用函数来定义自然数,有很多定义方式,这里我们讲一种实现方式:
深入理解函数式编程
上面的演算式表示有一个函数f和一个参数x。令0x1f x2f f x…
什么意思呢?这是不是很像我们数学中的幂:a^x(a 的 x 次幂表示 a 对自身乘 x 次)。相应的,我们理解上面的演算式就是数字 n 就是 f 对 x 作用的次数。有了这个数字的定义之后,我们就可以在这个基础上定义运算。
深入理解函数式编程
其中 SUCC 表示后继函数(+1 操作),PLUS 表示加法。现在我们来推导这个正确性。
深入理解函数式编程
这样,进行λ演算就像是方程的代换和化简,在一个已知前提(公理,比如 0/1,加法)下,进行规则推演。

演算:变量的含义

在λ演算中我们的表达式只有一个参数,那它怎么实现两个数字的二元操作呢?比如加法 a + b,需要两个参数。
这时,我们要把函数本身也视为值,可以通过把一个变量绑定到上下文,然后返回一个新的函数,来实现数据(或者说是状态)的保存和传递,被绑定的变量可以在需要实际使用的时候从上下文中引用到。
比如下面这个简单的演算式:
深入理解函数式编程
第一次函数调用传入 m=5,返回一个新函数,这个新函数接收一个参数 n,并返回 m + n 的结果。像这种情况产生的上下文,就是 Closure(闭包,函数式编程常用的状态保存和引用手段),我们称变量 m 是被绑定(binding)到了第二个函数的上下文。
除了绑定的变量,λ演算也支持自由的变量,比如下面这个 y:
深入理解函数式编程
这里的 y 是一个没有绑定到参数位置的变量,被称为一个自由变量。
绑定变量和自由变量是函数的两种状态来源,一个可以被代换,另一个不能。实际程序中,通常把绑定变量实现为局部变量或者参数,自由变量实现为全局变量或者环境变量。

演算:代换和归约

演算分为 Alpha 代换和 Beta 归约。 前面章节我们实际上已经涉及这两个概念,下面来介绍一下它们。
Alpha 代换指的是变量的名称是不重要的,你可以写λm.λn.m + n,也可以写λx.λy.x + y,在演算过程中它们表示同一个函数。也就是说我们只关心计算的形式,而不关心细节用什么变量去实现。这方便我们不改变运算结果的前提下去修改变量命名,以方便在函数比较复杂的情况下进行化简操作。实际上,连整个 lambda 演算式的名字也是不重要的,我们只需要这种形式的计算,而不在乎这个形式的命名。
Beta 归约指的是如果你有一个函数应用(函数调用),那么你可以对这个函数体中与标识符对应的部分做代换(substitution),方式为使用参数(可能是另一个演算式)去替换标识符。听起来有点绕口,但是它实际上就是函数调用的参数替换。比如:
深入理解函数式编程
可以使用 1 替换 m,3 替换 n,那么整个表达式可以化简为 4。这也是函数式编程里面的引用透明性的由来。需要注意的是,这里的 1 和 3 表示表达式运算值,可以替换为其他表达式。比如把 1 替换为(λm.λn.m + n 1 3),这里就需要做两次归约来得到下面的最终结果:
深入理解函数式编程

JavaScript 中的λ表达式:箭头函数

ECMAScript 2015 规范引入了箭头函数,它没有 this,没有 arguments。只能作为一个表达式(expression)而不能作为一个声明式(statement),表达式产生一个箭头函数引用,该箭头函数引用仍然有 name 和 length 属性,分别表示箭头函数的名字、形参(parameters)长度。一个箭头函数就是一个单纯的运算式,箭头函数我们也可以称为 lambda 函数,它在书写形式上就像是一个λ演算式。
深入理解函数式编程
可以利用箭头函数做一些简单的运算,下例比较了四种箭头函数的使用方式:
深入理解函数式编程
这是直接针对数字(基本数据类型)的情况,如果是针对函数做运算(引用数据类型),事情就变得有趣起来了。我们看一下下面的示例:
深入理解函数式编程
fn_x 类型,表明我们可以利用函数内的函数,当函数被当作数据传递的时候,就可以对函数进行应用(apply),生成更高阶的操作。 并且 x => y => x(y)可以有两种理解,一种是 x => y 函数传入 X => x(y),另一种是 x 传入 y => x(y)。
add_x 类型表明,一个运算式可以有很多不同的路径来实现。

上面的 add_1/add_2/add_3 我们用到了 JavaScript 的立即运算表达式 IIFE。

λ演算是一种抽象的数学表达方式,我们不关心真实的运算情况,我们只关心这种运算形式。因此上一节的演算可以用 JavaScript 来模拟。下面我们来实现λ演算的 JavaScript 表示。
深入理解函数式编程
我们把λ演算中的 f 和 x 分别取为 countTime 和 x,代入运算就得到了我们的自然数。
这也说明了不管你使用符号系统还是 JavaScript 语言,你想要表达的自然数是等价的。这也侧面说明λ演算是一种形式上的抽象(和具体语言表述无关的抽象表达)。

函数式编程基础:函数的元、柯里化和 Point-Free

回到 JavaScript 本身,我们要探究函数本身能不能带给我们更多的东西?我们在 JavaScript 中有很多创建函数的方式:
深入理解函数式编程
虽然函数有这么多定义,但 function 关键字声明的函数带有 arguments 和 this 关键字,这让他们看起来更像是对象方法(method),而不是函数(function) 。
况且 function 定义的函数大多数还能被构造(比如 new Array)。
接下来我们将只研究箭头函数,因为它更像是数学意义上的函数(仅执行计算过程)。

  • 没有 arguments 和 this。
  • 不可以被构造 new。

函数的元:完全调用和不完全调用

不论使用何种方式去构造一个函数,这个函数都有两个固定的信息可以获取:

  • name 表示当前标识符指向的函数的名字。
  • length 表示当前标识符指向的函数定义时的参数列表长度。

在数学上,我们定义 f(x) = x 是一个一元函数,而 f(x, y) = x + y 是一个二元函数。在 JavaScript 中我们可以使用函数定义时的 length 来定义它的元。
深入理解函数式编程
定义函数的元的意义在于,我们可以对函数进行归类,并且可以明确一个函数需要的确切参数个数。函数的元在编译期(类型检查、重载)和运行时(异常处理、动态生成代码)都有重要作用。
如果我给你一个二元函数,你就知道需要传递两个参数。比如+就可以看成是一个二元函数,它的左边接受一个参数,右边接受一个参数,返回它们的和(或字符串连接)。
在一些其他语言中,+确实也是由抽象类来定义实现的,比如 Rust 语言的 trait Add。
但我们上面看到的λ演算,每个函数都只有一个元。为什么呢?
只有一个元的函数方便我们进行代数运算。λ演算的参数列表使用λx.λy.λz 的格式进行分割,返回值一般都是函数,如果一个二元函数,调用时只使用了一个参数,则返回一个「不完全调用函数」。这里用三个例子解释“不完全调用”。
第一个,不完全调用,代换参数后产生了一个不完全调用函数 λz.3 + z。
深入理解函数式编程
第二个,Haskell 代码,调用一个函数 add(类型为 a -> a -> a),得到另一个函数 add 1(类型为 a -> a)。
深入理解函数式编程
第三个,上一个例子的 JavaScript 版本。
深入理解函数式编程
不完全调用”在 JavaScript 中也成立。实际上它就是 JavaScript 中闭包(Closure,上面我们已经提到过)产生的原因,一个函数还没有被销毁(调用没有完全结束),你可以在子环境内使用父环境的变量。
注意,上面我们使用到的是一元函数,如果使用三元函数来表示 addThree,那么函数一次性就调用完毕了,不会产生不完全调用。
函数的元还有一个著名的例子(面试题):
深入理解函数式编程
造成上述结果的原因就是,Number 是一元的,接受 map 第一个参数就转换得到返回值;而 parseInt 是二元的,第二个参数拿到进制为 1(map 函数为三元函数,第二个参数返回元素索引),无法输出正确的转换,只能返回 NaN。这个例子里面涉及到了一元、二元、三元函数,多一个元,函数体就多一个状态。如果世界上只有一元函数就好了!我们可以全通过一元函数和不完全调用来生成新的函数处理新的问题。
认识到函数是有元的,这是函数式编程的重要一步,多一个元就多一种复杂度。

柯里化函数:函数元降维技术

柯里化(curry)函数是一种把函数的元降维的技术,这个名词是为了纪念我们上文提到的数学家阿隆佐·邱奇。
首先,函数的几种写法是等价的(最终计算结果一致)。
深入理解函数式编程
这里列一个简单的把普通函数变为柯里化函数的方式(柯里化函数 Curry)。
深入理解函数式编程
柯里化函数帮助我们把一个多元函数变成一个不完全调用,利用 Closure 的魔法,把函数调用变成延迟的偏函数(不完全调用函数)调用。这在函数组合、复用等场景非常有用。比如:
深入理解函数式编程
虽然你可以用其他闭包方式来实现函数的延迟调用,但 Curry 函数绝对是其中形式最美的几种方式之一。

Point-Free|无参风格:函数的高阶组合

函数式编程中有一种 Point-Free 风格,中文语境大概可以把 point 认为是参数点,对应λ演算中的函数应用(Function Apply),或者 JavaScript 中的函数调用(Function Call),所以可以理解 Point-Free 就指的是无参调用。
来看一个日常的例子,把二进制数据转换为八进制数据:
深入理解函数式编程
这段代码运行起来没有问题,但我们为了处理这个转换,需要了解 parseInt(x, 2) 和 toString(8) 这两个函数(为什么有魔法数字 2 和魔法数字 8),并且要关心数据(函数类型 a -> b)在每个节点的形态(关心数据的流向)。有没有一种方式,可以让我们只关心入参和出参,不关心数据流动过程呢?
深入理解函数式编程
上面的方法假设我们已经有了一些基础函数 toBinary(语义化,消除魔法数字 2)和 toStringOx(语义化,消除魔法数字 8),并且有一种方式(pipe)把基础函数组合(Composition)起来。如果用 Typescript 表示我们的数据流动就是:
深入理解函数式编程
用 Haskell 表示更简洁,后面都用 Haskell 类型表示方式,作为我们的符号语言。
深入理解函数式编程
值得注意的是,这里的 x-> [int] ->y 我们不用关心,因为 pipe(..)函数帮我们处理了它们。pipe 就像一个盒子。
深入理解函数式编程
BOX 内容不需要我们理解。而为了达成这个目的,我们需要做这些事:

  • utils 一些特定的工具函数。
  • curry 柯里化并使得函数可以被复用。
  • composition 一个组合函数,像胶水一样粘合所有的操作。
  • name 给每个函数定义一个见名知意的名字。

综上,Point-Free 风格是粘合一些基础函数,最终让我们的数据操作不再关心中间态的方式。这是函数式编程,或者说函数式编程语言中我们会一直遇到的风格,表明我们的基础函数是值得信赖的,我们仅关心数据转换这种形式,而不是过程。JavaScript 中有很多实现这种基础函数工具的库,最出名的是 Lodash。
可以说函数式编程范式就是在不停地组合函数。

函数式编程特性

说了这么久,都是在讲函数,那么究竟什么是函数式编程呢?在网上你可以看到很多定义,但大都离不开这些特性。

  • First Class 函数:函数可以被应用,也可以被当作数据。
  • Pure 纯函数,无副作用:任意时刻以相同参数调用函数任意次数得到的结果都一样。
  • Referential Transparency 引用透明:可以被表达式替代。
  • Expression 基于表达式:表达式可以被计算,促进数据流动,状态声明就像是一个暂停,好像数据到这里就会停滞了一下。
  • Immutable 不可变性:参数不可被修改、变量不可被修改—宁可牺牲性能,也要产生新的数据(Rust 内存模型例外)。
  • High Order Function 大量使用高阶函数:变量存储、闭包应用、函数高度可组合
  • Curry 柯里化:对函数进行降维,方便进行组合。
  • Composition 函数组合:将多个单函数进行组合,像流水线一样工作。

另外还有一些特性,有的会提到,有的一笔带过,但实际也是一个特性(以 Haskell 为例)。

  • Type Inference 类型推导:如果无法确定数据的类型,那函数怎么去组合?(常见,但非必需)
  • Lazy Evaluation 惰性求值:函数天然就是一个执行环境,惰性求值是很自然的选择。
  • Side Effect IO:一种类型,用于处理副作用。一个不能执行打印文字、修改文件等操作的程序,是没有意义的,总要有位置处理副作用。(边缘)

数学上,我们定义函数为集合 A 到集合 B 的映射。在函数式编程中,我们也是这么认为的。函数就是把数据从某种形态映射到另一种形态。注意理解“映射”,后面我们还会讲到。
深入理解函数式编程

First Class:函数也是一种数据

函数本身也是数据的一种,可以是参数,也可以是返回值。
深入理解函数式编程
通过这种方式,我们可以让函数作为一种可以保存状态的值进行流动,也可以充分利用不完全调用函数来进行函数组合。把函数作为数据,实际上就让我们有能力获取函数内部的状态,这样也产生了闭包。但函数式编程不强调状态,大部分情况下,我们的“状态”就是一个函数的元(我们从元获取外部状态)。

纯函数:无状态的世界

通常我们定义输入输出(IO)是不纯的,因为 IO 操作不仅操作了数据,还操作了这个数据范畴外部的世界,比如打印、播放声音、修改变量状态、网络请求等。这些操作并不是说对程序造成了破坏,相反,一个完整的程序一定是需要它们的,不然我们的所有计算都将毫无意义。
但纯函数是可预测的,引用透明的,我们希望代码中更多地出现纯函数式的代码,这样的代码可以被预测,可以被表达式替换,而更多地把 IO 操作放到一个统一的位置做处理。
深入理解函数式编程
这个 add 函数是不纯的,但我们把副作用都放到它里面了。任意使用这个 add 函数的位置,都将变成不纯的(就像是 async/await 的传递性一样)。需要说明的是抛开实际应用来谈论函数的纯粹性是毫无意义的,我们的程序需要和终端、网络等设备进行交互,不然一个计算的运行结果将毫无意义。但对于函数的元来说,这种纯粹性就很有意义,比如:
深入理解函数式编程
当函数的元像上面那样是一个引用值,如果一个函数的调用不去控制自己的纯粹性,对别人来说,可能会造成毁灭性打击。因此我们需要对引用值特别小心。
深入理解函数式编程
上面这种解构赋值的方式仅解决了第一层的引用值,很多其他情况下,我们要处理一个引用树、或者返回一个引用树,这需要更深层次的解引用操作。请小心对待你的引用。
函数的纯粹性要求我们时刻提醒自己降低状态数量,把变化留在函数外部。

引用透明:代换

通过表达式替代(也就是λ演算中讲的归约),可以得到最终数据形态。
深入理解函数式编程
也就是说,调用一个函数的位置,我们可以使用函数的调用结果来替代此函数调用,产生的结果不变。
一个引用透明的函数调用链永远是可以被合并式代换的。

不可变性:把简单留给自己

一个函数不应该去改变原有的引用值,避免对运算的其他部分造成影响。
深入理解函数式编程
一个充满变化的世界是混沌的,在函数式编程世界,我们需要强调参数和值的不可变性,甚至在很多时候我们可以为了不改变原来的引用值,牺牲性能以产生新的对象来进行运算。牺牲一部分性能来保证我们程序的每个部分都是可预测的,任意一个对象从创建到消失,它的值应该是固定的。
一个元如果是引用值,请使用一个副本(克隆、复制、替代等方式)来得到状态变更。

高阶:函数抽象和组合

JS 中用的最多的就是 Array 相关的高阶函数。实际上 Array 是一种 Monad(后面讲解)。
深入理解函数式编程
通过高阶函数传递和修改变量:
深入理解函数式编程
高阶函数实际上为我们提供了注入环境变量(或者说绑定环境变量)提供了更多可能。React 的高阶组件就从这个思想上借用而来。一个高阶函数就是使用或者产生另一个函数的函数。高阶函数是函数组合(Composition)的一种方式。
高阶函数让我们可以更好地组合业务。常见的高阶函数有:

  • map
  • compose
  • fold
  • pipe
  • curry
  • ….

惰性计算:降低运行时开销

惰性计算的含义就是在真正调用到的时候才执行,中间步骤不真实执行程序。这样可以让我们在运行时创建很多基础函数,但并不影响实际业务运行速度,唯有业务代码真实调用时才产生开销。
深入理解函数式编程
map(addOne)并不会真实执行+1,只有真实调用 exec 才执行。当然这个只是一个简单的例子,强大的惰性计算在函数式编程语言中还有很多其他例子。比如:
深入理解函数式编程
“无穷”只是一个字面定义,我们知道计算机是无法定义无穷的数据的,因此数据在 take 的时候才真实产生。
惰性计算让我们可以无限使用函数组合,在写这些函数组合的过程中并不产生调用。但这种形式,可能会有一个严重的问题,那就是产生一个非常长的调用栈,而虚拟机或者解释器的函数调用栈一般都是有上限的,比如 2000 个,超过这个限制,函数调用就会栈溢出。虽然函数调用栈过长会引起这个严重的问题,但这个问题其实不是函数式语言设计的逻辑问题,因为调用栈溢出的问题在任何设计不良的程序中都有可能出现,惰性计算只是利用了函数调用栈的特性,而不是一种缺陷。
记住,任何时候我们都可以重构代码,通过良好的设计来解决栈溢出的问题。

类型推导

当前的 JS 有 TypeScript 的加持,也可以算是有类型推导了。
深入理解函数式编程
没有类型推导的函数式编程,在使用的时候会很不方便,所有的工具函数都要查表查示例,开发中效率会比较低下,也容易造成错误。
但并不是说一门函数式语言必须要类型推导,这不是强制的。像 Lisp 就没有强制类型推导,JavaScript 也没有强制的类型推导,这不影响他们的成功。只是说,有了类型推导,我们的编译器可以在编译器期间提前捕获错误,甚至在编译之前,写代码的时候就可以发现错误。类型推导是一些语言强调的优秀特性,它确实可以帮助我们提前发现更多的代码问题。像 Rust、Haskell 等。

其他

你现在也可以总结一些其他的风格或者定义。比如强调函数的组合、强调 Point-Free 的风格等等。
深入理解函数式编程

小结

函数式有很多基础的特性,熟练地使用这些特性,并加以巧妙地组合,就形成了我们的“函数式编程范式”。这些基础特性让我们对待一个 function,更多地看作函数,而不是一个方法。在许多场景下,使用这种范式进行编程,就像是在做数学推导(或者说是类型推导),它让我们像学习数学一样,把一个个复杂的问题简单化,再进行累加/累积,从而得到结果。
同时,函数式编程还有一块大的领域需要进入,即副作用处理。不处理副作用的程序是毫无意义的(仅在内存中进行计算)。

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

上面说的,都是最基础的 JavaScript 概念+函数式编程概念。但我们还留了一个“坑”。
如何去处理 IO 操作?
我们的代码经常在和副作用打交道,如果要满足纯函数的要求,几乎连一个需求都完成不了。不用急,我们来看一下 React Hooks。React Hooks 的设计是很巧妙的,以 useEffect 为例:
深入理解函数式编程
在函数组件中,useState 用来产生状态,在使用 useEffect 的时候,我们需要挂载这个 state 到第二个参数,而第一个参数给到的运行函数在 state 变更的时候被调用,被调用时得到最新的 state。
这里面有一个状态转换:
深入理解函数式编程
React Hooks 给我们的启发是,副作用都被放到一个状态节点里面去被动触发,行程一个单向的数据流动。而实际上,函数式编程语言确实也是这么做的,把副作用包裹到一个特殊的函数里面。
如果一个函数既包含了我们的值,又封装了值的统一操作,使得我们可以在它限定的范围内进行任意运算,那么,我们称这种函数类型为 Monad。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 把 Array< T >类型映射到 Array< K >类型,操作仍然在数组范畴,数组的值被映射为新的值。 如果用 TypeScript 来表示,会不会更清晰一点?
深入理解函数式编程
看起来 Monad 只是一个实现了 fmap 的对象(Functor 类型,mappable 接口)而已。但 Monad 类型不仅是一个 Functor,它还有很多其他的工具函数,比如:

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

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

范畴、群、幺半群

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

  • 对象
  • 态射

为什么说这是一种形式上的抽象呢?因为很多数学的概念都可以被这种形式所描述,比如集合,对集合范畴来说,一个集合就是一个范畴对象,从集合 A 到集合 B 的映射就是集合的态射,再细化一点,整数集合到整数集合的加减乘操作构成了整数集合的态射(除法会产生整数集合无法表示的数字,因此这里排除了除法)。又比如,三角形可以被代数表示,也可以用几何表示、向量表示,从代数表示到几何表示的运算就可以视为三角形范畴的一种态射。
总之,对象描述了一个范畴中的元素,而态射描述了针对这些元素的操作。范畴论不仅可以应用到数学科学里面,在其他科学里面也有一些应用,实际上,范畴论就是我们描述客观世界的一种方式(抽象形式)。
深入理解函数式编程
相对应的,函子就是描述一个范畴对象和另一个范畴对象间关系的态射,具体到编程语言中,函子是一个帮助我们映射一个范畴元素(比如 Monad)到另一个范畴元素的函数。
群论(Group)研究的是群这种代数结构,怎么去理解群呢?比如一个三角形有三个顶点 A/B/C,那么我们可以表示一个三角形为 ABC 或者 ACB,三角形还是这个三角形,但是从 ABC 到 ACB 一定是经过了某种变换。这就像范畴论,三角形的表示是范畴对象,而一个三角形的表示变换到另一个形式,就是范畴的态射。而我们说这些三角形表示方式的集合为一个群。群论主要是研究变换关系,群又可以分为很多种类,也有很多规律特性,这不在本文研究范围之内,读者可以自行学习相关内容。
科学解释一个 Monad 为自函子范畴上的幺半群。如果没有学习群论和范畴论的话,我们是很难理解这个解释的。
深入理解函数式编程
简单来说先固定一个正方形 abcd,它和它的几何变换方式(旋转/逆时针旋转/对称/中心对称等)形成的其他正方形一起构成一个群。从这个角度来说,群研究的事物是同一类,只是性质稍有不一样(态射后)。
另外一个理解群的概念就是自然数(构成一个群)和加法(群的二元运算,且满足结合律,半群)。
深入理解函数式编程
到此,我们可以理解 Monad 为:

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

很多函数式编程里面都会实现一个 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。这有什么意义呢?
其实这就是模拟了其他范畴内的“有”和“无”的概念,方便我们模拟其他编程范式的空值操作。比如:
深入理解函数式编程
这种情况下我们需要去判断 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 了,如果不能处理好 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 等概念)

超长文本省略: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 等数据结构)。
  • 语义化的问题 往往为了开发一个功能,去造许多的基础函数,大量业务组件想要语义化的命名,也会带给开发人员很多负担;并且功能抽象能力因人而异,公共函数往往不够公用或者过度设计。
  • 生态问题 函数式编程在工业生产领域因其抽象性和性能带来的问题,被许多开发者拒之门外,一些特定功能的解决方案也更小众(相比其他编程范式),生态也一直比较小,这成为一些新的开发人员学习和使用函数式编程的又一个巨大障碍。

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

-- End --

相关推荐