函数式编程在理论层面上有诸多概念,纯函数式的编程对于日常的业务开发来说也是一种『乌托邦』式的存在,但越来越多的高级语言开始向函数式方向发展,将函数式中的重要理念引入自身语法中。
前端领域也有很多特性、库或者框架来支持和应用函数式编程:
学习函数式编程,最重要的是思考如何利用它的设计理念帮助我们写出更好的代码,总结如何将这些理念应用到日常开发中,帮助我们去提升代码的可读性、可维护性等。
函数式编程通过以下设计,可以实现简单、清晰、易维护、可复用的代码
函数式编程中的『函数』并非我们日常开发中所说的函数(方法),而是数学意义上的函数—映射。
我们知道,数学上的函数对相同的输入必定有相同的输出(映射关系是一一对应的)。
因此,函数式编程中纯函数也要满足同样的特征:
相同的输入,相同的输出,意味着函数不能依赖除入参以外的任何外部状态
相信大家在平常开发中,也能有这样的感受: 在理解、维护一个函数时,若其依赖了大量的外部状态,必定会造成不小的认知压力。 除了要理解函数本身的逻辑外,还要去关心其引用的外部状态信息。 有时不得不跳出函数本身去查看这些依赖的外部信息,阅读流程也因此被打断。
面向对象中类的成员函数隐式地包含this
指针,通过它可以很方便地在成员函数中引用成员变量,这就是纯函数的典型反面教材。
实现了函数级的解耦,除了入参没有复杂的依赖关系,这样的函数可读性、可维护性就变得很高
无副作用,意味着除了期望的函数输出值以外。没有任何其他的产出
常见的副作用包括,但不限于:
总之,纯函数就是不能与外部有任何的耦合关系,既包括对外界的依赖也包括对外界的影响
很明显,纯函数的收益主要有:
在实际开发中,虽然无法做到所有函数都是纯函数,但纯函数意识应该要深植我们脑海中,尽可能地写更多的纯函数。
函数式编程还有一个重要理念:函数是值,即一等函数(first-class),或者说函数是一等公民身份。 这意味着任何可以使用值的地方都可以使用函数,如参数、返回值等。
所谓高阶函数,就是其参数或返回值至少有一个是函数类型。
简单讲,柯里化就是将『多参数函数』转换成『一系列单参数函数』的过程。
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(function callback(currentValue[, index[, array]])
过滤就是将列表中不满足指定条件的元素过滤掉,满足条件的元素以新列表的形式返回。
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
简单讲就是将指定操作依次作用于集合每个元素上,操作结果按操作规则依次叠加,并最终返回该叠加结果。
结果类型一般是一个具体的值,而不是 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),即这些操作不会改变它们正在作用的集合,而是生成新集合来提供操作结果。
不可变性很好地避免了中间状态、状态不同步等问题。 同时,不变性语义使得代码可读性、维护推理性变得更好。因为,通过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 就是通过一系列『通用函数的组合』来完成更复杂的任务
其设计理念:
组合后的函数就像是用管道连接的一样,数据在其中自由流动,无须外界干预:
分析上面获取成绩>=90分学生 email 的函数式版本,发现整个过程其实可以分为 2 个独立的步骤:
将这两个步骤独立成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 的概念。
开发中不要奢求,也没办法做到纯函数式的编程。
但函数式编程中很多优秀的设计理念都值得我们去学习和借鉴: