Overview
函数式编程在理论层面上有诸多概念,纯函数式的编程对于日常的业务开发来说也是一种『乌托邦』式的存在,但越来越多的高级语言开始向函数式方向发展,将函数式中的重要理念引入自身语法中。
前端领域也有很多特性、库或者框架来支持和应用函数式编程:
- 前端麻烦的异步问题,可以由函数式编程中的异步计算来解决
- 声明式编程基本被业界证明是前端 UI 编程的一种最佳实践方式
学习函数式编程,最重要的是思考如何利用它的设计理念帮助我们写出更好的代码,总结如何将这些理念应用到日常开发中,帮助我们去提升代码的可读性、可维护性等。
函数式编程的收益
函数式编程通过以下设计,可以实现简单、清晰、易维护、可复用的代码
- 状态不可变、纯函数
- 更高层次的抽象,丰富的集合操作
- 强调组合、提高复用性
- 避免引入状态,Pointfree
纯函数
函数式编程中的『函数』并非我们日常开发中所说的函数(方法),而是数学意义上的函数—映射。
我们知道,数学上的函数对相同的输入必定有相同的输出(映射关系是一一对应的)。
因此,函数式编程中纯函数也要满足同样的特征:
- 相同的输入,必定得到相同的输出
- 函数调用没有任何副作用
相同的输入,相同的输出
相同的输入,相同的输出,意味着函数不能依赖除入参以外的任何外部状态
相信大家在平常开发中,也能有这样的感受: 在理解、维护一个函数时,若其依赖了大量的外部状态,必定会造成不小的认知压力。 除了要理解函数本身的逻辑外,还要去关心其引用的外部状态信息。 有时不得不跳出函数本身去查看这些依赖的外部信息,阅读流程也因此被打断。
面向对象中类的成员函数隐式地包含this
指针,通过它可以很方便地在成员函数中引用成员变量,这就是纯函数的典型反面教材。
实现了函数级的解耦,除了入参没有复杂的依赖关系,这样的函数可读性、可维护性就变得很高
无副作用
无副作用,意味着除了期望的函数输出值以外。没有任何其他的产出
常见的副作用包括,但不限于:
- 改变外部数据(如类的成员变量、全局变量)
- 获取用户交互信息(用户输入)
- 发送网络请求
- 读写文件、执行DB操作
- 读取系统状态信息
- ...
纯函数的收益
总之,纯函数就是不能与外部有任何的耦合关系,既包括对外界的依赖也包括对外界的影响
很明显,纯函数的收益主要有:
- 可维护性更高
- 可测性更强
- 可复用性更好
- 高并发更容易,没有多线程问题
- 可缓存,由于相同的输入,必定有相同的输出,因此对于高频、昂贵的操作可以缓存结果,避免重复计算
在实际开发中,虽然无法做到所有函数都是纯函数,但纯函数意识应该要深植我们脑海中,尽可能地写更多的纯函数。
高阶函数 Higher-order function
函数式编程还有一个重要理念:函数是值,即一等函数(first-class),或者说函数是一等公民身份。 这意味着任何可以使用值的地方都可以使用函数,如参数、返回值等。
所谓高阶函数,就是其参数或返回值至少有一个是函数类型。
- 高阶函数使得复用粒度降到了函数级别,在面向对象中复用粒度一般在类级别
- 从另一个角度讲,高阶函数也实现了更高层级的抽象,因为实现细节可以通过参数的形式传入,即在函数级别上实现了依赖注入机制
- 闭包是高阶函数得以实现的底层支撑能力
柯里化 Currying
简单讲,柯里化就是将『多参数函数』转换成『一系列单参数函数』的过程。
function add(x, y) {
return x + y
}
var addCurrying = function(x) {
return function(y) {
return x + y
}
}
add
是进行加法运算的函数,其接收2个参数,如add(1, 2)
addCurrying
是经过柯里化处理过的,本质上addCurrying
是单参数函数,其返回值也是一个单参数函数。add(1, 2)
,等价于addCurrying(1)(2)
柯里化有什么作用?
- 在函数式集合操作上,如:
filter
、map
、reduce
、expand
等只接收单参数函数,如果现有的函数是多参数,可通过柯里化转换成单参数 - 当某个函数需要多次调用,且部分参数相同时,通过柯里化可以减少重复参数样板代码
如,有多次调用加法运算的需求,且每次都是加10
时,用普通add
函数实现:
add(10, 1)
add(10, 2)
add(10, 3)
// 柯里化之后
var addTen = addCurrying(10)
addTen(1)
addTen(2)
addTen(3)
著名的第三方库lodash
提供了curry
封装函数,使得柯里化更加方便,如上面的addCurrying
用lodash#curry
函数实现:
var curry = lodash.curry
var addCurrying = curry(function(a, b) {
return a + b
})
对函数式编程来说,柯里化是一项不可或缺的技能。 对我们而言,即使不写函数式的代码,在解决重复参数等问题上柯里化也提供了一种全新的思路。
集合操作三板斧
函数式编程语言和面向对象语言对待代码重用的方式不一样:
- 面向对象语言喜欢大量地建立有很多操作的各种数据结构
- 函数式语言也有很多的操作,但对应的数据结构却很少
- 面向对象语言鼓励我们建立专门针对某个类的方法,我们从类的关系中发现重复出现的模式并加以重用
- 函数式语言的重用表现在函数的通用性上,它们鼓励在数据结构上使用各种共通的变换,并通过高阶函数来调整操作以满足具体事项的要求
在面向对象的命令式编程语言里面,重用的单元是类和用作类间通信的消息,通常可以表述成一幅类图(class diagram)。例如这个领域的开拓性著作《设计模式:可复用面向对象软件的基础》就给每一个模式都至少绘制了一幅类图。
在 OOP 的世界里,开发者被鼓励针对具体的问题建立专门的数据结构,并以方法的形式,将专门的操作关联在数据结构上。函数式编程语言选择了另一种重用思路。它们用很少的一组关键数据结构(如 list 、set 、map )来搭配专为这些数据结构深度优化过的操作。我们在这些关键数据结构和操作组成的一套运转机构上面,按需要“插入”另外的数据结构和高阶函数来调整机器,以适应具体的问题。例如我们已经在几种语言中操练过的 filter 函数,传给它的代码块就是这么一个“插入”的部件,筛选的条件由传入的高阶函数确定,而运转机构则负责高效率地实施筛选,并返回筛选后的列表。 —摘录来自 “《函数式编程思维》福特(Neal Ford)”
正如上述摘录,函数式编程的又一重要理念:在有限的集合(Collection)上提供丰富的操作。
现在,很多高级语言都提供了大量对集合操作的支持,通过这些高度抽象的操作,可以写出非常简洁、易读的代码。
映射 map
map 是日常开发中使用频率最高的操作之一,map 就是将集合中的每个元素进行一次转换,得到一个新的值,其类型可以相同也可以不同。
map(function callback(currentValue[, index[, array]])
过滤 filter
过滤就是将列表中不满足指定条件的元素过滤掉,满足条件的元素以新列表的形式返回。
filter(callback(element[, index[, array]])[, thisArg])
本质就是为filter
注入一个回调,用于判断其中的元素是否满足指定条件。
// 将年龄未满18的过滤掉:
const ages = [19, 2, 8, 30, 11, 18]
const result = ages.filter(age => age >= 18)
console.log(result) // 19, 30, 18
化约、折叠 reduce
简单讲就是将指定操作依次作用于集合每个元素上,操作结果按操作规则依次叠加,并最终返回该叠加结果。
结果类型一般是一个具体的值,而不是 Iterable
,因此经常出现在链式调用的末端。
reduce(
callback(accumulator, currentValue[, index[, array]])
[, initialValue])
// 有的语言(JS)还提供了从右往左折叠的版本
reduceRight(
callback(accumulator, currentValue[, index[, array]])
[, initialValue])

const array = [1, 3, 5, 7, 9]
const sum = array.reduce((value, element) => value + element, 0)
console.log(sum) // 输出:25
不可变性 immutable
集合上的操作还有一个重要特性:不可变性(immutable),即这些操作不会改变它们正在作用的集合,而是生成新集合来提供操作结果。
不可变性很好地避免了中间状态、状态不同步等问题。 同时,不变性语义使得代码可读性、维护推理性变得更好。因为,通过filter
、map
、reduce
等操作,而不是for
、while
循环语句操作集合,可以清楚地表达将会生成一个新集合,而不是修改现有集合的意图,代码更加简洁明了。
有很多框架都有类似的思想,如:flux、redux 等,它们都强调(强制)任何操作都不能直接修改现有数据,而是在现有数据的基础上生成新数据,最终整体替换掉老数据。
另外,由于集合上的这些操作的返回值类型大都是集合,因此,当有多个操作作用于集合时,就可以以链式调用的方式实现。这也进一步简化了代码。
// 将从后台获取的用户头像url转换成头像widget显示在界面上(最多显示4个,同时过滤掉无效url)
// 函数式
memberIconURLs.filter(_isValidURL)
.slice(0, 4)
.map(_memberWidgetBuilder)
.reduce(_addMemberWidget2Stack, stack);
// for 循环(命令式)
let count = 0
for (let i = 0; i < memberIconURLs.length; i++) {
const url = memberIconURLs[i];
if (_isValidURL(url)) {
const memberWidget = _memberWidgetBuilder(url);
_addMemberWidget2Stack(stack, memberWidget);
count++;
}
if (count >= 4) {
break;
}
}
再看个例子,进一步感受一下两者的差异:
// 获取成绩 >=90 分学生的 email
// 函数式
const smart_student_emails_func = students =>
students
.filter(_ => _.score >= 90)
.map(_ => _.email)
// 命令式
const smart_student_emails_command = function(students) {
const emails = []
students.forEach(function(item, index, array) {
if (item.score >= 90) {
emails.push(item.email)
}
})
return emails
}
很明显,函数式实现的代码简洁、易读、逻辑清晰、不易出错。 for
循环版本需要很小心地维护实现上的细节问题,还引入了不必要的中间状态:count
、url
、memberWidget
、emails
等,这些都是滋生 bug 的温床!
说到减少中间状态就不得不提 Pointfree。
Pointfree
Pointfree 最直接的定义就是没有中间状态,没有参数,数据直接在组合的函数间流动
从本质上说,Pointfree 就是通过一系列『通用函数的组合』来完成更复杂的任务
其设计理念:
- 鼓励写高内聚、可复用的『小』函数
- 强调『组合』,而非『耦合』,复杂任务通过小任务组合完成,而不是将所有操作耦合在一个『大』函数里
组合后的函数就像是用管道连接的一样,数据在其中自由流动,无须外界干预:
分析上面获取成绩>=90分学生 email 的函数式版本,发现整个过程其实可以分为 2 个独立的步骤:
- 过滤出成绩>=90分的学生
- 取学生的 email
将这两个步骤独立成2个小函数:
const smartStudents = (students) => students.filter(_ => _.score >= 90)
const emails = (students) => students.map(_ => _.email)
这时smartStudentEmails
就可以写成下面这样:
// 嵌套版 smart_student_emails_nest
const smart_student_emails_nest= students => emails(smartStudents(students))
这种嵌套调用的写法好像看不出有什么优势,但有一点可以明确:
一个函数smartStudents
的输出,直接成为另一个函数emails
的输入。
const compose = (f, g) => x => f(g(x))
// 其入参为两个单参数函数 (f、g)
// 输出还是一个单参数函数 x => f(g(x))
我们通过引入compose
函数来实现一个组合版本的smartStudentEmails
// 组合版
const smart_student_emails_compose = compose(emails, smartStudents)
相比嵌套调用版本 smart_student_emails_nest
,组合版本 smart_student_emails_compose
具有以下两点优势:
- 可读性更好,从右往左而不是由内而外的阅读顺序更符合我们的思维习惯
- 自始至终从未提及要操作的数据,减少了中间状态信息(状态越多越容易出错)
注意:
对于日常开发,实现 smartStudentEmails
来说,excellentStudentEmails_Func
版本是更好的写法,smart_student_emails_compose
只是用于解说 Pointfree 的概念。
总结
开发中不要奢求,也没办法做到纯函数式的编程。
但函数式编程中很多优秀的设计理念都值得我们去学习和借鉴:
- 纯函数
- 做好抽象,屏蔽细节
- 状态不可变,避免过多的中间状态
- 高内聚的小函数
- 多用组合
- ...
