美文网首页Web前端之路让前端飞程序员
如何开始JavaScript函数式编程

如何开始JavaScript函数式编程

作者: Jeremy_young | 来源:发表于2017-08-22 22:12 被阅读262次

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

    本文描述了 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));
    

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

    结语

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

    • 函数是一等公民,要时刻把函数放在参数位置
    • 每一个函数尽量是无副作用的纯函数

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

    相关文章

      网友评论

        本文标题:如何开始JavaScript函数式编程

        本文链接:https://www.haomeiwen.com/subject/dujzrxtx.html