随着 React Hooks 的推出和 Vue Composition API 的出现,函数式编程(Functional Programming,简称 FP)在前端领域中获得了更多关注,使得 FP 这一古老的编程范式又重新焕发活力。相较于命令式编程(Imperative Programming)或者面向对象式编程(Object-oriented Programming, 简称 OOP)有其独特的魅力。本文主要讲解 FP 在 JS 领域中的一些概念以及应用。
一个简单的例子
假设有这么一个需求:有一个存储学生信息的数组,里面包含了学生的 name
,score
等等信息,我们只需要从这个数组中筛选出学生的 name
信息并返回。按照传统的命令式编程,大致代码如下: 如果是 FP 的形式,那么代码差不多是: 命令式的方式完全是面向过程的,它关注的是逻辑的执行过程(即"how"),它会详细的描述每一步该怎么做。与之相对的,函数式的方式则是属于声明式,关注的是做什么(即"what"),将重点放在了数据处理的逻辑本身上,至于内部是怎么进行循环的,是使用 while
还是 for
,我们无需关心。
在现在流行的框架中,React, Vue 就是典型的声明式框架,而 jQuery 就是典型的命令式框架。相对来说,声明式的编程方式可读性更强,使我们能够以更简洁、清晰的方式表达程序的行为和意图("what"),将怎么做 ("how")的隐藏到框架内部中。
函数式编程要点
函数是"一等公民"
作为"一等公民",函数具有与其他数据类型(如 number
、string
等)同等的地位,可以像任何其他值一样被赋予变量、作为另一个函数的参数传递、作为函数的返回值,甚至可以存储在数据结构中。
当一个函数使用另一个函数作为入参或者返回值时,该函数就被成为高阶函数(Higer-Order Function),比如 higher1
,higher2
。
既然是"一等公民",函数可以减少不必要地包裹,减少胶水代码。
闭包
既然函数是"一等公民",那么当一个函数内部存在另一个函数的作用域时,对当前函数进行操作,内部函数访问外部函数作用域的变量,这被称作闭包。
闭包使得函数不仅仅可以访问局部变量,还能记住和访问它被创建时的环境,使得函数具有了"记忆性"。闭包允许函数在其声明的词法作用域之外被调用时,仍然能访问到它的词法作用域内的变量。
借助高阶函数和闭包的特性,FP 能实现非常多复杂的特性,闭包可以说是 FP 的基石。
闭包是一个比较复杂的概念,它涵盖了作用域、函数和状态管理等多个方面。由于闭包并非本文的重点,对于希望更深入了解闭包的读者,建议参考其他的闭包文章,或者参考笔者之前写的作用域和闭包。
纯函数
纯函数定义了一种函数行为准则:
- 保证相同的输入 将始终产生相同的输出
- 在过程中不会引发任何副作用
副作用是指除了函数的主要任务外的其他行为,可能影响外部状态的任何操作。这可能包括但不限于以下几点:
- 改变外部数据
- DOM 查询/修改
- I/O
在数学层面,不管是 <math xmlns="http://www.w3.org/1998/Math/MathML"> f ( x ) = x + 1 f(x)=x+1 </math>f(x)=x+1 还是 <math xmlns="http://www.w3.org/1998/Math/MathML"> f ( x ) = x 2 f(x)=x^2 </math>f(x)=x2... 所有的函数都是纯函数,就是单纯的数值之间的映射,不存在任何副作用,是一个纯粹的世界。而在程序中,副作用却非常常见,有副作用的函数不仅可读性更低,还会给程序带来更多的不确定性。
foo
依赖于外部变量 y
,这意味着即便 x
保持不变,只要 y
发生变化,foo
的输出也可能随之不同。因此,由于这种对外部状态的依赖,我们无法将 foo
视为一个纯函数。 纯函数意味着函数的输出只依赖于其输入参数,不受程序中其他状态的影响。这不并意味着函数不能使用外部变量,如果外部状态或数据是固定的,不是影响函数输出的侧因,我们依旧可以将函数看待为纯函数:
不可变性
js 内置方法如 splice
和 reverse
是不纯的函数,它们会直接修改原始数据结构。这种就地 (in-place)修改原数组的行为,破坏了数据的不可变性,从而可能导致难以追踪的问题和 bug。
我们倾向于使用不会改变原始数据的方法,从而实现更可靠和可预测的代码行为。 避免副作用的关键在于采用不可变性的原则来处理数据。这意味着在需要改变程序状态时,不直接修改已有的数据结构,而是生成一份新的数据副本,并对这个副本进行操作。
由于 js 中的对象是通过引用传递的,即便是使用 const
关键字声明的变量,也只能保证变量的引用不变,而无法阻止变量所指向的对象或数组本身被修改。这意味着要实现真正的数据不变性是具有挑战性的。
如果希望 address
属性在复制时也保持独立,防止互相影响,我们需要对对象进行深拷贝,确保所有的嵌套对象都被复制。过程会变得相对复杂。比较好的方式是借助 Immutable.js 这样的开源库来简化这一过程。
不可变性并不是说数据绝对不能变化,而是强调以不可变的眼光看待数据这样的思维方式。
引用透明
引用透明性表示在程序中,函数调用可以无损地被其返回值替换,而不会影响整个程序的行为。
纯函数显然具有引用透明性,引用透明也是判断函数是否是纯函数的方式之一。
强调纯的意义
我们如此强调使用纯函数,纯函数的好处到底是什么? 笔者认为最重点的是以下三点:
- 原子化 :纯函数意味不存在指向不明的 this,不存在对外部变量的引用修改,不存在对参数的修改 ,不依赖于系统的其他部分。函数内部即为一切,无论是阅读,调试,重构,迁移都和系统的其他部分解耦
- 便于单元测试:纯函数由于没有副作用和外部依赖,因此非常容易测试。不需要模拟外部环境或者状态,只需检查给定输入是否产生预期的输出
- 缓存/记忆化 :由于纯函数对相同的输入总是产生相同的输出,所以它们的结果可以被缓存起来。如果函数被重复调用,并且输入参数相同,可以直接返回缓存的结果,避免重复的计算,比如 Lodash 中的 memoize 函数
箭头函数
ES6
规范引入了箭头函数:
- 没有this ,也没有arguments
- 箭头函数的变量引用仍然有name 和length属性
相对于 function
声明的函数,箭头函数书写形式上更简洁,更像是 FP 所对应的λ演算式。
Length
length
属性在下文还有应用,这里再展开讲讲。length
属性返回的是没有指定默认值的参数个数。也就是说,指定了默认值或者采用了 rest 参数后,length
属性将失真。
FP 实践
在 FP 中,柯里化(Currying)和函数组合(Compose)可谓是不可或缺的两大支柱。接下来重点讲讲这两部分。
柯里化
偏函数(Partially-applied Function)
试想一个场景,我们需要使用 axios
编写多个请求的工具函数,而这些函数的 URL 往往是已知的,只有请求参数待确定:
除了 URL 不同外,两个函数几乎一样。这个时候需要考虑如何将其转换为可复用的逻辑。
我们定义了一个 partial
的偏函数,它接收一个工具函数和预设的参数,并返回一个新的函数,这个新的函数用于接收工具函数的剩余参数,当再次调用 getPerson(params)
时,才会真正的执行 axios.get
去请求数据。
偏函数严格来讲是一个减少函数参数个数的过程,可以将函数的一次性传参调用转为两次。
其实内置的 bind
方法也能实现类型的效果,该函数有两个功能:预设 this
关键字,以及偏应用实参。
但是 this
绑定为 null
看起来这个使用方法看起来非常糟糕。我们并不想在 FP 中涉足 this
。
柯里化的概念很简单,与偏函数类似:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
但是它会想对于偏函数更进一步,可以将实参的应用拆分成 N 多次。
curry
函数的实现如下:
Data-last
在以 Lodash 为代表的框架中,常常采用 Data-first 的形式,它将数据作为函数的第一个参数,然后再是其他参数:
而在 FP 中,Data-last 是一种常见的函数参数排序惯例,它指的是将数据作为函数的最后一个参数传递。这使得它更容易支持柯里化和偏函数应用,因为一旦函数中涉及到 data 之前的参数被固定下来,返回的新函数就可以便捷地用于不同的数据上。 比如 Ramda 就是一个采用 Data-last 的函数式工具库。
Placeholder
不管是 Data-last 还是 Data-first。函数的形参顺序并不总是柯里化想要的顺序,比如 getRequest
有时是 url
先确定,有时是 data
先确定,甚至是 cb
先确定。无论我们怎么改变 getRequest
的参数顺序,都无法满足各种场景,而且并不是所有的工具函数都可以直接改变,尤其是从三方库中引入的函数。
为了提供更高的灵活性,通常函数工具库都会提供一个特殊性的占位符(比如 Ramda 中提供 R.__
表示占位符)。它允许暂时留出参数的位置,可以稍后提供这些参数。通过使用占位符,可以自由地选择哪些参数先固定,哪些参数后续再提供。
函数组合
函数组合说起来很简单,就是将多个函数组合成一个函数。
假设有这么一个需求:
- 将给定的字符串文本拆分成单词
- 将拆分的单词进行去重
在命令式编程中,实现逻辑可能会像下面这样:
重点在于 tool
函数,它本质上就是在内部依次调用两个拆分的工具函数,并把上一个工具函数的结果作为后一个的入参,再简化一下就是 unique(getWords(str))
。 如果我们将工具函数拆分的足够细,那么这种模式非常的常见。而函数组合就是为这种模式提供一个通用的组合工具,完成 tool
工具函数的构建: 这个 compose
函数会接收一系列的工具函数,并返回一个新的工具函数,这个新的工具函数内部就是依次执行之前传入的函数,注意是从右向左 依次执行。
但是为什么这么做?我认为最简单的解释(但不一定符合真实的历史)就是我们在以手动执行的书写顺序来列出它们时,或是与我们从左往右阅读这个列表时看到它们的顺序相符合。 即
compose (unique, getWords) -> unique (getWords (x))
-- 《组合函数》
之前的 compose
方式有一个问题是:第一个工具函数只能接收 1 个参数,为了修正这个问题,可以这样处理:
相对于前一版,这一版通过懒加载实现,reduceRight
的每一步不再是直接执行传入的工具函数,而是返回一个包裹工具函数的新函数:
Pipe
compose
是从右到左的顺序来执行,而 pipe
则刚好相反,是从左到右 执行,除此之外和 compose
的使用方式完全一致。在实现上,只需要将 reduceRight
换成 reduce
即可,这里不再赘述。
笔者更倾向于使用
pipe
来做组合。
结合柯里化和函数组合,可以常方便地组合不同的业务需求场景。
还是以前面的拆分句子为例,getWords
依旧需要,但是拆分后的单词可能有不同的处理,比如:
- 过滤出长度大于 5 的单词
- 过滤出长度小于 3 的单词
- 单词转大写
- ...
Point-free
Pointfree 是一种编程风格,它指的是函数定义时不显式提及将要操作的数据。换句话说,就是不直接使用参数来定义函数。
这种风格通过组合已有的函数来构造新函数,避免了参数的使用,强调了函数的组合而非数据的操作。这使得代码更加简洁和表达性强。
前面的例子笔者没有可以使用 Point-free 的风格来编写,存在诸多形参,比如 map
, filter
中的回调函数。使用 Ramda 来改写为 Point-free 风格: Point-Free 模式虽然能够减少不必要的命名,让代码保持简洁和通用。但也是一把双刃剑,并非所有的函数式代码是和 Point-Free 的,尤其不要为了 Point-Free 而 Point-Free。可以使用它的时候就使用,不能使用的时候就用普通函数。
生态
虽然本文给出了几个 FP 工具函数,但在实践中无需重新造轮子。现已存在诸多成熟的开源函数式编程库供使用。
Lodash/fp
Lodash 几乎是目前必装的工具库之一,如果你的项目已经安装了它,可以直接使用 lodash/fp ,开启你的 FP 旅程。
Ramda
如果想深入的体验 FP, 笔者更推荐使用本文已经推荐过的 Ramda 。它提供了非常多的工具函数,并且所有的函数自动地被柯里化,且完全遵循 Data-last 的原则。
在不熟悉它所提供的工具函数时,可以借助 Learn ramda, the interactive way 交互式的查询符合需求的函数:
Ramda 的另一个优势是提供了完善的类型提示,在使用 TS 时,开发体验更友好。其内部采用了大量的类型体操 -- Learn Advanced TypeScript Types。
Hindley-Milner 类型声明
FP 并非 JS 独有的概念,而是一种跨语言的编程范式。为了明确地描述函数的类型签名,FP 通常采用了 Hindley-Milner 类型系统。
比如查看 Ramda 文档时,会发现它的类型声明长这样: 这就是 Hindley-Milner 类型声明,如果有使用 TS 的经验,可以很容易掌握这种类型定义方式。
TS 要求显式地注明函数的参数名和类型。相比之下,Hindley-Milner 类型系统专注于表达函数的参数和返回值的类型,而不涉及具体的参数名称。
->
相当于 TS 中的函数声明 =>
, Number -> Number -> Number
表示:
add
函数在接收一个Number
后, 然后返回Number -> Number
这样的函数- 返回的函数在接收一个
Number
后, 然后返回Number
结果
还记得我们说过 Ramda 所有的函数都是自动柯里化吗?这个签名就反映出了柯里化的特性,连续箭头看起来可能很头疼,稍微用括号组合一下会更易读一点:Number -> ( Number -> Number)
。类比到 TS 相当于:
Hindley-Milner 系统更强调参数类型的种类,不一定是具体的某种类型,比如: identity
的类型是 a->a
,它强调的是函数返回值和参数是一样类型,至于这个 a
具体是什么,无所谓,可以类比为 TS 的泛型:
如果需要对类型进行限制,比如需要是数组类型,或者必须继承特定的接口,形如:
You Don't Need FP ?
软件开发不存在所谓的"银弹",FP 并没有比 OOP 或者命令式编程更高级。虽然本文介绍了 FP 的诸多优点,但同样存在很多不足。
性能
FP 的性能相对来说是一个短板。FP 强调不可变数据、函数的无状态和无副作用、以及函数的组合。虽然这些特点带来了许多编程上的好处,但这些好处有时候会以牺牲性能为代价。
- 不可变数据:使用不可变数据结构,这意味着不会直接修改数据,而是创建数据的修改版本。这个过程需要额外的内存分配和复制操作
- 函数调用开销:FP 大量使用高阶函数和函数组合,这会导致增加的函数调用开销,在 JS 中,函数式的方式必然会比直接写语句指令慢
- 递归:FP 倾向于使用递归来代替循环,在某些编程语言中,尤其是那些不支持尾递归优化(Tail Call Optimization)的语言中,深层次的递归可能导致栈溢出或性能问题
- 垃圾收集:FP 倾向于频繁地创建新对象而不是修改现有对象,可能增加垃圾收集器的工作负载
尽管如此,React 和 Vue 还是选择了采用函数式组件的方案。实际上,很多时候导致性能瓶颈的真正原因是业务逻辑代码。
目前开源的函数式框架大都进行了性能优化。虽然相较于原生代码可能存在一定的性能差距,但性能通常仍然是可观的,能满足大多数应用的需求。 Benchmark: Ramda vs. Lodash vs. VanillaJS - MeasureThat.net
可读性
虽然 FP 一直在强调可读性更优异,初学者在尝试采用函数式编程时,可能会发现自己写出的代码并不如使用熟悉的命令式编程那样直观易懂,需要大量的练习。 当然,一味追求函数式编程的范式,如强调 Point-free 风格,有时反而可能损害代码的可读性,比如下面的代码,笔者很难认同它的价值: 万不可为了炫技写代码。
难以 Debug
前面才说了 FP 容易写单测,怎么到这里又变成难以 Debug 了呢?在实际的业务开发中,除了通过单测来保证代码的正确性外,很多时候是面向 Debug 编程,毕竟很多业务场景并没有单测覆盖(正经人谁写单测呢),得通过打印 log 或者断点调试。
命令式编程不管是 log 还是断点都比较简单:
在 FP 函数组合中,要打印上一个函数的结果就相对麻烦一点,需要提供 trace
工具函数用于打印:
Ramda 也提供
tap
函数实现类似的效果
再到断点调试,由于使用函数组合和高阶函数,一个函数包一个函数。采用单步调试时,你会发现断点会在不同的工具函数之前跳来跳去,并且还需要去忽略掉工具库本身的代码,相对于命令式来说体验会差很多:
当然,FP 并不是不能进行有效的调试,而是需要采取不同的策略和工具来适应其特点。
总结
FP 主张将功能细分为小而专一的函数,而不是创建大而全的函数。开发者再通过诸如 Ramda 等工具提供的工具函数作为连接组件,将这些细粒度的函数像搭乐高一样组合使用,从而构建复杂的功能。
本文只触及了函数式编程的基础概念。即便是基础,已经足以体现 FP 的强大能力。如果希望更深入了解 FP,可以继续了解诸如函子(Functors)、半幺群(Semigroups)、Monad 等高级概念。笔者在实践中对此也使用不多,不再班门弄斧。
学习 FP 的意义并非在于一定要把程序改造成 FP 范式,而是掌握更多解决问题的思路,比如:纯函数,以不可变的眼光对待数据,Point-free 等等思想。即使在 OOP 中也能发挥巨大的益处。我们可以在系统的不同部分灵活地结合多种编程范式的优势,从而构建更强大、更可靠的应用程序。
至此就是本文的全部内容,希望能对你有所帮助,如有错误欢迎指正。