为什么要函数式编程?
用函数编程 != 函数式编程
几乎所有编程语言中我们都会用到函数,但大部分情况都不是函数编程。函数编程的重要特点之一是使用函数的声明式推导
来代替结构关键字的命令式过程
。比如
const obj = {...}
const list = [...]
//命令式
for(let v of obj){...}
for(let k in obj){...}
for(let i=list.length;i--;){...}
while(...){...}
//声明式
each(obj, (v,k)=>...)
each(list, (v,i)=>...)
这个例子很好的诠释了两种编程模式的区别 —— 声明式只关注输入/输出,不在乎循环是for...of
还是for...in
又或者for还是while,相反,命令式则关注执行细节,因为不同的语法决定了循环子是value还是key。
那么,如果在编码中用了each就是函数编程了?某种意义上是的,但函数编程不仅仅是用each函数代替for关键字,它包含很多概念 —— 纯函数、高阶函数、可推导性、柯里化、惰性计算等等,它是一种编程范式,是一种编码习惯,更是一种思维模式。关于什么是函数编程不是本文重点,这里推荐一本很好的入门书 —— Luis Atencio的《JavaScript函数式编程指南》。
为什么要用函数式编程?
难道命令式不能实现功能吗?函数隐藏了细节,错了怎么办?用了那么多函数还不叫函数式编程?在具体举例描述函数式编程的优点前,我们先来回顾一下软件的一些常见定义 —— 可读性、健壮性、可维护性、运行效率、封装度。
下面通过一些编码中常见的例子来对比函数式与命令式的区别:
场景1 —— 可读性
- 当我们要判断一个集合是否为空
//命令式
if(list.length < 1){
...
}
//声明式
if(isEmpty(list)){
...
}
显然从可读性来看声明式更好,况且命令式为了确保代码运行正常还需要验证list变量本身必须是Array类型,否则获取.length属性将收到一个错误,它看起来就像这样
//命令式
if(list != null && list.length < 1){
...
}
- 输入框字段不能为空
//命令式
if(name != null && name != undefined && name != ''){
...
}
//声明式
if(!isBlank(name)){
...
}
例子中的函数不仅在可读性上更胜一筹,同时也解决了代码容错问题,我们不关心入参是否符合函数内部逻辑的逻辑,是否是一个有效值,也不用担心无效值会导致程序异常,这是函数式编程中的一个概念 —— 透明引用 —— 如果 f(x)=y 那么 f(!x)=!y。
场景2 —— 健壮性
健壮性是函数式编程最重要的优势,可以减少大量的潜在错误,包括无效引用、类型异常、范围溢出等等。
- 容错处理
//不健壮
if(a.b == null){...}
list.forEach(...)
ary.splice(...)
obj['key']()
let x = list[5]
//健壮
if(isNull(get(a,'b'))){...}
each(list,...)
splice(ary,...)
get(obj,'key',()=>{})()
通过函数组合及默认值设置可以避免这些潜在错误。
- 错误调试Debug
恭喜,函数式编程中针对函数本身不存在调试问题,也就是说函数库提供的函数无需调试。函数式编程所依赖的函数库中的函数在使用时可以看作宿主系统提供的原生函数,例如c语言中的printf
,js中的console.log
,java中的Math.random
,python中的print
等等。原因是函数式编程中提供的函数大部分都是纯函数,纯函数的特征就是确定的输入一定有确定的输出,具有明确的可推导性。
下面这个函数isNumber
当参数类型已知时,返回值可推导而不会产生程序异常或任何非boolean类型的结果。
//返回值一定是boolean类型
const rs = isNumber(null) //true | false
当然,在函数链模式下数据流本身是需要跟踪调试的,比如
_([1,2,3,4])
.map(v=>v*3)
.tap(v=>console.log(v))//调试数据流
.join('-')
.value()
场景3 —— 可维护性
当我们开始维护代码逻辑时,最重要的当然是能看得通,理得顺。除了可读性要高之外,逻辑间的可推导性也非常重要。比如,每个函数的返回值就是看上去应该返回的那个值。
- 字符串首字母大写
//自定义
function cap(str){
//获取str首字母及后续字符串
//把首字母变大写
//返回大写后的首字母 + 后续字符串
}
const str = cap('abc') //Abc | Error | ...
//使用函数库
const str = capitalize('abc') //Abc
这是个很简单的逻辑实现,但区别是capitalize
函数的返回值一定是字符串,而cap
函数的返回值有以下几种可能
- 正确字符串
- 异常字符串
- 错误中断
比如,如果参数是null
,就无法获取首字母及变大写,这些操作最终会导致Error而中断,这就是不可推导性。整个逻辑会因为某些函数的不可预测而产生不稳定性,但对于capitalize
函数,它的返回值永远可预测。
- 获取集合大小
//命令式
Object.keys(obj)
ary.length
set.size
//函数式
size(obj)
size(ary)
size(set)
统一的API有助于快速理解代码意图,良好的函数封装可以屏蔽不同原生数据集合间的差异,获取size则是集合最常用的操作之一。
场景4 —— 封装、重用
封装是函数的基本特性,我们在编码时创建自定义函数也是为了进行过程封装及重用。函数式编程通常都会依赖于一个强大且丰富的函数库(函数语言内置函数库,而js可以通过使用外置函数库)且在不同的方面提供了多种封装(比如本文开篇介绍的each
函数)。
- 获取集合最后一个元素
//未封装
const lastOne = list[list.length-1]
//已封装
const lastOne = last(list)
- 集合映射 1 ⇒ 1
//未封装
const newList = [];
list.forEach(v=>newList.push(v+1))
//已封装
const newList = map(list,v=>v+1)
- 集合映射 m ⇒ n
//未封装
const newList = [];
list.forEach(v=>if(n%2)newList.push(v+1))
//已封装
const newList = flatMap(list,n=>n%2?n:[])
- 数据分组
const users = [
{name:'zhangsan',sex:'m',age:33},
{name:'lisi',sex:'f',age:21},
{name:'wangwu',sex:'m',age:25},
{name:'zhaoliu',sex:'m',age:44},
]
//未封装
const grouped = {}
each(users, u=>{
if(!grouped[u.sex])grouped[u.sex] = []
grouped[u.sex].push(u)
})
//已封装
const grouped = groupBy(users, u=>u.sex)
更多封装的js函数库可以查看这里;
场景5 —— 性能
针对js来说,如果做web开发数据量一大就会导致UI卡顿而影响使用体验,尤其是大数据量的内容处理比如嵌套循环或者大量数据进行分组/映射时可能会出现性能瓶颈。在函数式编程中可通过惰性计算及列表推导来解决大数据量集合处理。
关于惰性计算及列表推导可以查看这里;关于集合操作性能优化可以查看这里
结语
函数式编程已经越来越多的影响到各种语言,即使是OOP大佬的JAVA也引入了stream API来处理集合数据,而对家C#也是更早引入了lambda。函数式编程并不是要放弃OOP,他们的关注点不同(但明显js的函数性更纯一些)。
学习并习惯函数式编程是一个渐进的过程,不妨从上面列举的几个场景开始,逐步了解掌握函数式编程。