Javascript 中的函数式编程

Posted by Yinode on Friday, November 24, 2017

TOC


函数式编程

作为一种越来越重要的编程泛型,我在学习完SICP之后也见识到了他极为强大的抽象能力,但是如何把函数式编程中的技巧合理合适的运用到JS开之中呢,所以我就记录一下一些比较常用的函数式编程手段。

我认为使用函数式编程是一件需要慎重考虑的事情,不能为了炫技而随意的滥用FP。

偏函数

一种减少参数的手段,通过闭包来记忆函数,获得更强大的表达力。


    function partial(fn,...presetArgs) {
        return function partiallyApplied(...laterArgs){
            return fn( ...presetArgs, ...laterArgs );
        } ;
    }

    function reverseArgs(fn) {
        return function argsReversed(...args){
           return fn( ...args.reverse() );
        }; //将一个函数的参数顺序反转
    }

    function partialRight( fn, ...presetArgs ) {
      return reverseArgs(
          partial( reverseArgs( fn ), ...presetArgs.reverse() )
      );
    }

柯理化

可以认为是一种让函数持续被调用,等到某个时机成熟就一下取出只有的所有参数统一进行计算。


   function curry(fn,arity = fn.length) {
        return (function nextCurried(prevArgs){
            return function curried(nextArg){
                var args = prevArgs.concat( [nextArg] ); // 如果要修改成任意个数参数都能成功柯理化,可以修改nextArg=>...nextArg concat=> concat(nextArg)

                if (args.length >= arity) {
                    return fn( ...args );
                }
                else {
                    return nextCurried( args );
                }
            };
        })( [] );
    }

利用prevArgs记住之前的所遇参数,等接受的长度达到目标就运行原函数,否则运行nextcurried继续保存保存参数。

这种柯理化是严格柯理化,也就是每次只能接受一个参数,另外一种叫松散柯理化,一次可以接受任意参数的调用。

两者适用于不同的地方,柯理化更适合每次需求一个参数的情况,而偏应用比较适合一次参入多个参数的情况。两者从原理上都是相同的,就是通过闭包保存数据。

反柯理化

反柯理化也是一种常见的需求,他能把一个柯理化的函数重新变成一个正常一次调的函数,他的原理其实就是多次运行一个柯理化函数。


    function uncurry(fn) {
        return function uncurried(...args){
            var ret = fn;

            for (let i = 0; i < args.length; i++) {
                ret = ret( args[i] );
            }

            return ret
        };
    }

单一参数


function unary(fn) {
    return function onlyOneArg(arg){ // 只接受一个参数
        return fn( arg );
    };
}

var adder = looseCurry( sum, 2 );

// 出问题了:
[1,2,3,4,5].map( adder( 3 ) );
// ["41,2,3,4,5", "61,2,3,4,5", "81,2,3,4,5", "101, ...

// 用 `unary(..)` 修复后:
[1,2,3,4,5].map( unary( adder( 3 ) ) );
// [4,5,6,7,8]

恒定参数


function constant(v) {
    return function value(){
        return v;
    };
}

扩展在参数中的妙用


function spreadArgs(fn) {
    return function spreadFn(argsArr) {
        return fn( ...argsArr );
    };
}

function gatherArgs(fn) {
    return function gatheredFn(...argsArr) {
        return fn( argsArr );
    };
} // 相反的操作。让一个支持数组的支持单个参数

有时候某个函数他只支持单个参数传入,如果在不修改原函数的情况下让他支持数组传参呢,就是通过这个辅助函数,他可以让一个函数支持组合传参。

无形参风格


function double(x) {
    return x * 2;
}

[1,2,3,4,5].map( function mapper(v){
    return double( v );
} );

省去不必要的对应关系。

偏应用是用来减少函数的参数数量 —— 一个函数期望接收的实参数量 —— 的技术,它减少参数数量的方式是创建一个预设了部分实参的新函数。 柯里化是偏应用的一种特殊形式,其参数数量降低为 1,这种形式包含一串连续的链式函数调用,每个调用接收一个实参。当这些链式调用指定了所有实参时,原函数就会拿到收集好的实参并执行。你同样可以将柯里化还原。 其它类似 unary(..)、identity(..) 以及 constant(..) 的重要函数操作,是函数式编程基础工具库的一部分。 无形参是一种书写代码的风格,这种风格移除了非必需的形参映射实参逻辑,其目的在于提高代码的可读性和可理解性。

组合函数

其实我们需要组合一些函数的时候,其实很多时候都是相似的,从 originvalue > fn_a > fn_b > value

也就是说一个函数的输出将是另外一个函数的输入


function compose2(fn2,fn1) {
    return function composed(origValue){
        return fn2( fn1( origValue ) );
    };
} // 注意顺序,先运行fn1,再运行fn2

通用组合函数


function compose(...fns) {
    return function composed(result){
        // 拷贝一份保存函数的数组
        var list = fns.slice();

        while (list.length > 0) {
            // 将最后一个函数从列表尾部拿出
            // 并执行它
            result = list.pop()( result );
        }

        return result;
    };
}// 可以接受任意数量的函数可以组合

结合偏右函数


var filterWords = partialRight( compose, unique, words );

var biggerWords = filterWords( skipShortWords );
var shorterWords = filterWords( skipLongWords );

通过偏右函数我们获得了更高一级的组合能力,先定义先处理的机器,再定义最后处理的机器(使用偏右的原因是我们的通用组合函数的顺序是从右到左的)

重排序组合


function pipe(...fns) {
    return function piped(result){
        var list = fns.slice();

        while (list.length > 0) {
            // 从列表中取第一个函数并执行
            result = list.shift()( result );
        }

        return result;
    };
}

这种名为pipe的组合方式源自于linuix/unix系统其实就是之前的组合方式的反转版本,参数的传入可以按照正常的阅读顺序,从左往右查看。

抽象

抽象经常被定义为对两个或多个任务公共部分的剥离。通用部分只定义一次,从而避免重复。为了展现每个任务的特殊部分,通用部分需要被参数化。

如果我们在多处重复通用的行为,我们将会面临改了几处但忘了改别处的维护风险。在做这类抽象时,有一个原则是,通常被称作 DRY(don’t repeat yourself)。 DRY 力求能在程序的任何任务中有唯一的定义。代码不够 DRY 的另一个托辞就是程序员们太懒,不想做非必要的工作。

… 抽象是一个过程,程序员将一个名字与潜在的复杂程序片段关联起来,这样该名字就能够被认为代表函数的目的,而不>是代表函数如何实现的。通过隐藏无关的细节,抽象降低了概念复杂度,让程序员在任意时间都可以集中注意力在程序内容>中的可维护子集上。 《程序设计语言》, 迈克尔 L 斯科特

声明式关注点在是什么上 – 这 3 个函数传递的数据从一个字符串到一系列更短的单词 – 并且将怎么做留在了 compose(..)的内部。 在一个更大的层面上看,shorterWords = compose(..) 行解释了怎么做来定义一个 shorterWords(..) 实用函数,这样在代码的别处使用时,只需关注下面这行声明式的代码输出是什么。

shorterWords( text );

相较于在我们的代码里详细列出每个调用,函数组合使用 compose(..) 实用函数来提取出实现细节,让代码变得更可读,让我们更关注组合完成的是什么,而不是它具体做什么。 组合 ———— 声明式数据流 ———— 是支撑函数式编程其他特性的最重要的工具之一。

纯函数

纯函数就是那些没有副作用的函数。纯函数的好处在于你可以看他的名字就遇见他所有能触发的变化,如果他不是纯的,那么他会变得很难把控,你不得不把注意放到代码中反复检查。一般来说,JS中的特性导致很难再工程中大量使用纯函数,但是提高一个函数的纯度,依旧很有必要。减少副作用的目的并不是他们在程序中不能被观察到,而是设计一个程序,让副作用尽可能的少,因为这使代码更容易理解。一个没有观察到的发生的副作用的程序在这个目标上并不像一个不能观察它们的程序那么有效。

接下来介绍一些降低副作用的方法

限制

如果不能提高单纯的提高函数的纯度,我们可以通过限制他的副作用深度来降低后果。也就是再副作用函数的外围防止副作用对象。比如说避免对全局作用域产生副作用,而是存放在某个函数作用域中

隔离

隔离也是一种降低副作用的办法

在我们程序的其余部分使用此通用程序时隔离副作用的方法时创建一个接口函数,执行以下步骤:

  1. 捕获受影响的当前状态
  2. 设置初始输入状态
  3. 运行不纯的函数
  4. 捕获副作用状态
  5. 恢复原来的状态
  6. 返回捕获的副作用状态

回避

如果你产生副作用的对象是一个数据或者是对象,可以对这个对象进行一个深度的拷贝,进行操作的时候只对那个拷贝的对象进行操作。

控制this

this是函数式编程中一个不稳定的因素,你可以控制他,强制传入一个指定的上下文对象。

值的不可变性

值的不可变性是指当需要改变程序中的状态时,我们不能改变已存在的数据,而是必须创建和跟踪一个新的数据。这有助于我们创建可读的函数式编程环境。

其实再JS之中,有很多的值属于是不可变的。也就是说这个值具有不可变性,比如说数值,字符串。

从值到值

我们再JS中使用的const语法对于对于对象或者数组其实是没有什么用处的,反而可能会带来一些额外的误导。因为他表面上看起来不可变,但实质上内部的值是可变的。const只对于简单的数值有用,比如一些常用的常量。const max_size = 123

相比较而言。如果我们希望冻结一个对象类型的值,那么Object.freeze()可能是更好的选择。但他任然只有一层冻结。

Object.freeze() 方法可以冻结一个对象,冻结指的是不能向这个对象添加新的属性,不能修改其已有属性的值,不能删除已有属性,以及不能修改该对象已有属性的可枚举性、可配置性、可写性。也就是说,这个对象永远是不可变的。该方法返回被冻结的对象。

性能

回顾我们之前对于值的不可变性的定义,如果我们操作一个1000个值的数组,我们还要重新生成一个复制并且做出修改并返回,那么这里面一定会有性能因素。

为此我们可以引入diff树的概念,即只保留原始副本,每当数据更改就增加一个diff以及修改的数据。这有点象vue的虚拟DOM树和git的版本控制。本质上都是一种头指针的更改。这可以让我们跟踪我们的数据更改并且保持高效的性能。

但是手写这些库的难度是非常高的,可以使用一些第三方的库 比如 Immutable.js

以不可变的眼光看待数据

我们在建造一个函数的时候,在不确定传入的对象是否可变的情况下,我们要用永远不可变的眼光看待数据,永远小心这些副作用所带来的负面效果。


function updateLastLogin(user) {
    var newUserRecord = Object.assign( {}, user );
    newUserRecord.lastLogin = Date.now();
    return newUserRecord;
} //永远都创建副本来小心对待

就好比是JS中的很多原生方法一样,例如 concat(..) 和 slice(..) 等。

区域总结

普通变量用const,对象用freeze,性能问题用库。对待所有传入的对象都使用不可变的眼光看待。

闭包与对象

闭包和对象都可以做到相似的事情,并且他们大体上都能互相转化。这两者本质上属于同构关系。具有多对多的性质对应。这两者没有绝对的优劣之分。两者具有不同的特点。从不可变性上来说,闭包通常在被返回之后内部的值是是不可变的。从私有的角度上说,闭包具有更强的私有性,一个对象的属性往往是公开的。从拷贝的角度上说,对象跟容易被拷贝。从枚举的角度上说,对象更容易被枚举。

综合来讲,两者都各具优势。

到底使用那种实现你的业务逻辑,我认为是不确定的。

列表处理

js中的遍历方法

假设我们有一个数组,每个元素是一个人。你面前站了一排人。 foreach 就是你按顺序一个一个跟他们做点什么,具体做什么,随便: people.forEach(function (dude) { dude.pickUpSoap(); });

map 就是你手里拿一个盒子(一个新的数组),一个一个叫他们把钱包扔进去。结束的时候你获得了一个新的数组,里面是大家的钱包,钱包的顺序和人的顺序一一对应。 var wallets = people.map(function (dude) { return dude.wallet; });

reduce 就是你拿着钱包,一个一个数过去看里面有多少钱啊?每检查一个,你就和前面的总和加一起来。这样结束的时候你就知道大家总共有多少钱了。 var totalMoney = wallets.reduce(function (countedMoney, wallet) { return countedMoney + wallet.money; }, 0);

补充一个 filter 的: 你一个个钱包数过去的时候,里面钱少于 100 块的不要(留在原来的盒子里),多于 100 块的丢到一个新的盒子里。这样结束的时候你又有了一个新的数组,里面是所有钱多于 100 块的钱包: var fatWallets = wallets.filter(function (wallet) { return wallet.money > 100; });

摘自知乎vue作者的回答

映射

映射的作用就是将一个值转换为另一个值。

我们把列表作映射看成是对每一个子元素进行独立的映射计算。独立的子元素之间应该不具有任何关系。

在js中已经提供了map方法,要注意使用他的时候要注意不要产生任何副作用,把他单纯的看作是映射方法。

过滤器

js中filter的最大问题就在于他是具有歧义的,我们可以使用过滤器过滤我们不要的部分,也可以使用过滤器保留我们需要的部分。

这样的表达是含糊不清的,我们可以引入两种过滤器的变种来达到清晰语义的目的。


var filterIn = filter;

function filterOut(predicateFn,arr) {
    return filterIn( not( predicateFn ), arr );
}

这样就非常容易表达我们的目的了,in用于留住我们检查通过的部分 out用于剔除那些检查不通过的部分。

reduce

与前两者不同,他的作用是接受一个缩减器和一个初始值,并对列表中的值进行整合,它具有次序,并且是从左到右的。但是js提供的相反方向的版本,使用rightReduce可以更好的实现组合函数


function compose(...fns) {
    return function composed(result){
        return fns.reduceRight( function reducer(result,fn){
            return fn( result );
        }, result );
    };
} //通过reduce来实现一个函数的结果迭代到另一个函数之中。

reduce可以实现map与filter,因为缩减器总是接受一个可以被持续传递的值,如果他一开始就是个数组,就能不断的被填充。

高级列表操作

去重


var unique =
    arr =>
        arr.filter(
            (v,idx) =>
                arr.indexOf( v ) == idx
        );

扁平化

即把一个嵌套数组展开


var flatten =
    arr =>
        arr.reduce(
            (list,v) =>
                list.concat( Array.isArray( v ) ? flatten( v ) : v )
        , [] );

Zip

将两个数组交替合并成一个数组就叫zip


function zip(arr1,arr2) {
    var zipped = [];
    arr1 = arr1.slice();
    arr2 = arr2.slice();

    while (arr1.length > 0 && arr2.length > 0) {
        zipped.push( [ arr1.shift(), arr2.shift() ] );
    }

    return zipped;
}


合并列表


function mergeLists(arr1,arr2) {
    var merged = [];
    arr1 = arr1.slice();
    arr2 = arr2.slice();

    while (arr1.length > 0 || arr2.length > 0) {
        if (arr1.length > 0) {
            merged.push( arr1.shift() );
        }
        if (arr2.length > 0) {
            merged.push( arr2.shift() );
        }
    }

    return merged;
}