码字辛苦,个人原创,转载请注明作者及出处。谢谢合作!
本文描述了 JavaScript 函数式编程的若干重要特征,以及一些好的实践建议。意在引导以前是非函数式编程的同学,能快速切入到函数式编程的理念中来;而对于正在“函数式”的同学,也可巩固认识,同时也希望提出意见交流。
另外,本文略长,只消了解 ES6
,就无阅读困难,请读者耐心阅读。
背景介绍
关于函数式编程的起源,有一段这样“不接地气”的历史。
在众多光芒万丈的一群人之中,有一位叫阿隆佐。他设计了一个名为 lambda 演算的形式系统。这个系统实质上是为其中一个超级机器设计的编程语言。在这种语言里面,函数的参数是函数,返回值也是函数。
除了阿隆佐·邱奇,艾伦·图灵也在进行类似的研究。他设计了一种完全不同的系统(后来被称为 图灵机),并用这种系统得出了和阿隆佐相似的答案。到了后来人们证明了图灵机和 lambda 演算的能力是一样的。
由于二战的推动,1949 年,现实世界中率先诞生了第一台图灵机,相比之下,运行阿隆佐的 lambda 演算硬件(Lisp 机)到了1973 年才得以实现,这还得归功于 MIT。
引用这段历史说明说明什么呢?说明函数式编程很有来头。
一个简单易懂的模型
我们的程序本质上都可以描述为:输入数据 => 运算处理 => 输出数据
I => {... f ...} => O
- 输入数据并不复杂,只要给它一定的结构就好了
- 输出数据也不复杂,因为复杂的数据并不是我们想要的
所以两端简单,中间很复杂。所有这些复杂的过程都交给函数 f
。如果一个函数 f
太过膨胀,或者无法胜任,那就用 n
个函数来分担解决。
I => {... f1 => f2 => f3 => ... => fn ...} => O
对于上述模型,请读者只消专注于函数及函数彼此的关系,并将数据屏蔽在视线之外。
其他编程风格
函数式编程和语言无关,它只不过是一种编程风格(再直白点就是一种思维方式,一种代码组织习惯)而已。只要有函数的语言,几乎都能进行函数式编程。只不过有些语言,天生就能做到更纯粹而已。
作为编程风格,我们常见的还有以下这些。
1、面向对象编程(OOP)
面向对象强调数据与行为绑定。
2、命令式编程(CP)
数据与行为深度耦合。
3、声明式编程(DP?)
我们常见的 SQL 数据库操作语言,便是声明式编程的典范。这篇 文章 已经讲的足够清楚了。
4、函数式编程(FP)
数据和行为是解耦的。函数式编程属于声明式编程。
5、图形化编程(GP?)
MIT 的 Scratch 是一款典型的图形化编程语言。
到此,我们仍然无需理会上面提到的种种概念。等 JSer 们刷完新闻,冲上了一杯咖啡,才开始言归正传。
函数式编程的关键特征
首先,函数式编程是不是 “烧脑” 编程?对我们普罗大众来说,或许还轮不到 “烧脑”,要烧也是那些可敬的布道师们帮我们顶替了。
也就是说函数式编程似难也不难,那该如何学习函数式编程呢?
在笔者看来,仍然可以采用 “黑盒子” 学习方法,我们先从它的一些关键特征入手,而有意的屏蔽一些底层而复杂的知识。
纯函数
纯函数是函数式编程的第一重要特征。它有两条原则:
- 对于相同的输入,一定有相同的输出;
- 对输入的东西,不要改变它,对引用的东西,也不要改变它。
第一条好说,第二条就是所谓的无 “副作用”。
我们常常所写的不纯的函数,基本上都是副作用满天飞。比如下面的 “副作用” 的例子。
let arr = [1, 2, 3, 4, 5, 6];
// slice 是一个纯函数
arr.slice(0, 3);
// =>[1, 2, 3]
arr.slice(0, 3);
// =>[1, 2, 3]
// splice 是一个不纯的函数
arr.splice(0, 3);
// => [1, 2, 3]
arr.splice(0, 3);
// => [4, 5, 6]
上述示例,slice
函数只要输入是 (0, 3)
无论执行多少次,返回值恒为 [1, 2, 3]
;
但是 splice
函数相同的输入,执行 2 遍,返回的值就不同了。原因是 splice
每次执行,额外的改变了(破坏了)数组 arr
。这就是副作用。
再看一个副作用的例子:
let temperature = 35;
function check(t) {
// 副作用1
return t > temperature;
}
function monitor(day) {
// 副作用2
if(check(day.temperature)){
console.warn('High temperature warning!');
}
}
短短的几行代码,就有 2 处副作用。
副作用 1 因为依赖了外部的系统变量 temperature
, 一旦别处导致这个系统变量变化(这是难以说清的事),那么这个 check
函数就不满足相同输入恒有相同输出了。
副作用 2 尽管 monitor
满足相同输入恒输出 undefined
, 但它仍然依赖了外部变量 check
函数,仍然可能有未知事情发生。
副作用带给我们的麻烦是很多的,除了每次得小心翼翼,更为麻烦的事是,一旦系统变量改变,因为跨度太大,问题将很难定位。
如何消除 “副作用”,其实非常容易:
const TEMPERATURE = 35;
function check(t) {
// 最好的做法是将变量 TEMPERATURE 收入函数体保护起来
return t > TEMPERATURE;
}
function monitor(check, day) {
if(check(day.temperature)){
console.warn('High temperature warning!');
}
}
减少副作用,其实不仅是函数式编程的要求,在我们日常编程中也应该培养这样的代码习惯。很多优秀的技术框架也在遵循着这一原则。
Redux 技术思想就提倡无副作用的纯函数,这点从 reducer
的设计就体现出来了。当然,React 本身也包含很多函数式编程思想,在此就不去展开了。
一些 I/O
是天生自带副作用的,正如上文所提到的,这部分我们有一些特殊的处理办法。JavaScript 天然存在而且还相当隐晦的副作用就是 this
,下文会介绍到它。除此之外,JavaScript 很多的副作用都是可以避免的,关键是培养好避免副作用的习惯。
柯里化 curry
柯里化的主要思路:
“函数接收多个参数,一次调用" 转变成 "函数每次只接收一个参数,分多次调用”。
简言之,就是将多维变成一维。
curry:: f(x1, x2, ...xn) = f(x1)(x2)(...xn)
用具体函数举例就很容易理解了。
// 柯里化之前
let distance = function(x, y, z){
return Math.sqrt(x*x + y*y + z*z);
}
distance(1, 4, 8);
// => 9
// 柯里化之后
let distance_curried = function(x){
return function(y){
return function(z){
return Math.sqrt(x*x + y*y + z*z);
}
}
}
// 分多次调用
var xDistance = distance_curried(1);
var xyDistance = xDistance(4);
var myDistance = xyDistance(8);
// => 9
// 简写为
distance_curried(1)(4)(8)
// => 9
柯里化一个函数的结果,就是新生成的函数,每次传一个参数,执行后返回的仍是一个函数,直至返回最后结果。
换言之,函数每次只接收一个参数,执行后,就返回一个新函数处理剩余的参数。
至于柯里化算法怎么实现的,这里不去追究。正如前文介绍的,函数式编程是一种声明式编程,只管做什么,不管怎么做。因此,只需知道柯里化做的是分多次调用,但不管它是怎么做到的。
约定:函数在前,数据在后
这是一条重要约定。约定了作为参数时,函数们在前,数据在最后。
首先,它强调了函数的地位,准确的说是我们编程习惯中的地位——函数应该站在前排。
其次,数据是我们最后考虑的东西,我们始终关注 “映射逻辑” 本身的建设。
再次,约定这样的参数顺序,某些函数经柯里化之后,不至于会搞不清楚本次调用是该传函数还是该传数据。
从下面的示例,来看看我们如何去遵循这条重要约定。
// 1、将数组 filter 方法封装一下
let arrFilter = function(f, arr) {
return arr.filter(f);
}
// 2、柯里化
let filter = curry(arrFilter);
//结束,就这么简单
// 第一次调用
let filterSpaces = filter(hasSpaces);
//插一个问题:请问 hasSpaces 是个啥?
// 对,回答它是个函数,一定是没错的
// 因为函数式编程的世界全是函数嘛~
let hasSpaces = (val) => /\s+/g.test(val);
// 第二次调用
filterSpaces(['jeremy', 'jere my'])
// => ['jere my']
函数式编程的世界遍地都是函数,尤其是一个函数柯里化后,几乎绝大部分函数的执行结果,仍然是一个函数。
这仍然可以寻迹阿隆佐当时提出的 “在这种语言里面,函数的参数是函数,返回值也是函数”。
所以,忘掉烦恼吧,忘掉与副作用纠缠打斗的记忆吧,现在满地都是白花花、金灿灿的函数。
组合
两个函数组合之后返回了一个新函数。就这么简单!
var fnC = compose(fnA, fnB);
组合 (compose
) 是函数式编程的一个重要概念,有了它,就可以任意 “摆布” 函数了。
var first = (x) => x[0];
var reverse = reduce((acc, x) => [x].concat(acc), []);
// 组合后生成一个新函数
var last = compose(first, reverse);
// 新函数开始吃进数据
last(['jeremy', 'hello', 'world']);
// => 'world'
// 要是反过来组合
var reverse_one = compose(reverse, first);
// 新函数开始吃进相同数据
reverse_one(['jeremy', 'hello', 'world']);
// => Uncaught TypeError: reverse is not a function
可见,组合内的参数顺序不能随意置换和颠倒。
组合满足结合律
组合中处理的全是函数,且 compose
中作为参数的函数,是从右往左依次调用,即最靠后的函数被优先执行(先进后出)。
compose(f, compose(g, h))
依次从右向左调用,即 h() -> g() -> f()
由此组合的结合律是:
compose(f, compose(g, h)) == compose(compose(f , g), h)
组合的结合律是相邻参数两两组合,并没有颠倒参数顺序。
注意,Ramda.js 的 R.pipe
则是从左往右执行函数组合(先进先出),但这是另外一码事。
组合也有好的实践
让组合可重用度高就是好的组合实践。
结合律的一大好处是任何一个函数分组都可以被拆开来,然后再以它们自己的组合方式两两组合在一起。
compose(addSymbol, toUpperCase, first, reverse)
拆解 & 组合 1:
var last = compose(first, reverse);
var symboledUpperLast = compose(addSymbol, toUpperCase, last);
拆解 & 组合 2:
var last = compose(first, reverse);
var upperLast = compose(toUpperCase, last);
var symboledUpperLast = compose(addSymbol, upperLast);
拆解 & 组合 3:
var last = compose(first, reverse);
var symboledUpper = compose(addSymbol, toUpperCase);
var symboledUpperLast = compose(symboledUpper, last);
谁的可重用性高,感觉是第 3 种,也说不准,得有更多的实际需求,才能判断这事。
范畴学
范畴学 是组合的理论依据。它和集合论,函数理论都有很多相关概念。概念本身也不难理解,此处不赘叙。
其它特征 - pointfree
函数无须提及将要操作的数据是什么样的。
阮大大的文章讲解得非常细致。其实 pointfree 也不是什么复杂的概念,运用一等公民函数、柯里化(curry)以及组合这些武器,就很容易实现这个目标。
敲黑板强调 - 全部都是函数
如果每一个函数都是一个兵,那全城皆兵。草木仍然是草木,草木...呃,是数据。
无论柯里化(curry
),还是组合(compose
),都是面向于函数,最后生成一个函数,任何时候,你见到的几乎都是函数,函数时刻待命。
let stylity = compose(map(addSymbol), reverse);
其实本条不算是特征,到算作一条反复洗脑的 “碎碎念”。addSymbol
是一个函数,map(addSymbol)
运算后是一个函数,最后的结果 stylity
仍然是一个函数。
函数式编程的一些好的实践
这些好的实践,并不是函数式编程所专有的,但是有助于加深对函数式编程风格的理解。同时,它们应该贯穿在我们设计、代码之中。实践得多了,我们也就更容易过渡到函数式编程。
等价替换
var hi = function(name){ return "Hi " + name; };
var greeting = function(name) {
return hi(name);
};
// 等价
greeting = hi;
因为函数是纯的,不会有副作用。那么接收相同的输入,返回相同的输出,两个函数就是等价的。
既然等价,为啥还要多一层裹脚布?所以直接赋值相等即可。
但在布满地雷的非函数式编程中,不纯的函数,等价替换往往需要很慎重。
“包裹它”不如“暴露它”
包裹一个函数,不如直接把它暴露成参数。因为这符合强调函数地位的要求。
$.get('/path/fp', function(json){
return renderGet(json);
});
以上是一个常用 ajax
的运用。更为常见的要求是,如果有报错,那得增加一个 error
参数,我们继续参考 nodejs
将错误参数放在第一个参数位置的约定,做出以下调整:
$.get('/path/fp', function(error, json){
return renderGet(error, json);
});
这是自然想到的修改方案,但是也面临着还得修改 renderGet
函数的麻烦,如果有多处这样使用,那得多处修改。
如果,仅仅遵循一条原则(养成思维习惯就好了)——突出函数的地位,增加函数的曝光度,那就会有这样的修改思路:
$.get('/path/fp', renderGet);
这样的好处是,无论要求 renderGet
函数修改改成什么样的参数形式,都只限制在这个函数本身了。
顺便提一下的是,一些 API 设计中,在设计传参数时,指明传递一般参数,不如指明传递一个函数。
解耦函数,函数名称请通用化
写业务逻辑时,有些中间函数或者辅助会被提取出来,此时的命名一般会和业务耦合。等到相关代码都写完后,或者你在做 codeview 时,你会发现它和业务其实是可以解耦的。那么当时的那种基于业务上下文思考的函数命名,就完全可以改成一般化的命名,让它从名字上看就显得是通用的。
在命名的时候,我们特别容易把自己限定在特定的数据上。这种现象很常见,也是重复造轮子的一大原因。
函数式编程更多的专注在函数身上,它有着比较彻底的函数与数据解耦,所以压根不会有这么强的数据耦合。但这一条实践,也值得我们一般式编程借鉴。
避免 this 的副作用
let Sound = {
_sound: 'miao',
play() {
console.log(this._sound);
}
}
上面是一个非常常见的示例,如果遵循了函数是一等公民、包裹它不如暴露它 等等这些理念或建议,那么在需要的时候, play
方法就应该被当作另一个函数的参数。比如:
$.ajaxSuccess(Sound.paly);
因为 Sound.paly
函数中使用了 this
,而它指向了函数外部即调用上下文。从纯函数定义的角度看,this
就是一块最大的 “副作用"。
解决的办法大家都知道,就是将 this
锁在笼子里,如同将权力之手锁在笼子里一样。
$.ajaxSuccess(Sound.play.bind(this));
而事实上,但在函数式编程中根本用不到它。
结语
说了这么多,关于函数式编程,以上最重要的两点就是:
- 函数是一等公民,要时刻把函数放在参数位置
- 每一个函数尽量是无副作用的纯函数
至于那些底层的、高级的、数学的逻辑,就把它们统统先关在 “黑盒子” 里吧。