Web前端之路让前端飞程序员

如何开始JavaScript函数式编程

2017-08-22  本文已影响262人  Jeremy_young

码字辛苦,个人原创,转载请注明作者及出处。谢谢合作!

本文描述了 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']

函数式编程的世界遍地都是函数,尤其是一个函数柯里化后,几乎绝大部分函数的执行结果,仍然是一个函数。

这仍然可以寻迹阿隆佐当时提出的 “在这种语言里面,函数的参数是函数,返回值也是函数”

所以,忘掉烦恼吧,忘掉与副作用纠缠打斗的记忆吧,现在满地都是白花花、金灿灿的函数。

在函数的海洋遨游吧-侵删.jpg

组合

两个函数组合之后返回了一个新函数。就这么简单!

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));

而事实上,但在函数式编程中根本用不到它。

结语

说了这么多,关于函数式编程,以上最重要的两点就是:

至于那些底层的、高级的、数学的逻辑,就把它们统统先关在 “黑盒子” 里吧。

上一篇下一篇

猜你喜欢

热点阅读