函数式编程之柯里化与偏应用

6. 柯里化与偏应用

在本章中,我们将了解术语柯里化的含义,在了解了柯里化所做的事情及用途之后,我们将介绍另一个在函数式编程中称为偏应用的概念。

我们将研究一个简单的问题,并说明柯里化与偏应用这类函数式技术的运行机制

6.1 一些术语

先来了解一些术语

  • 一元函数:只接受一个参数的函数称为一元函数
  • 二元函数:接受两个参数的函数称为二元函数
  • 变参函数:变参函数是接受可变数量参数的函数,我们可以用 arguments 来捕捉参数

ES6 新增了扩展运算符,我们可以用它来模拟变参函数

function fn(a,...rest){
    console.log(a)
    console.log(rest)
}
fn(1,2,3,4,5)
// 1
// 2,3,4,5
复制代码

6.2 柯里化

相信这个概念你已经看过或者听过了,那么什么是柯里化呢?

柯里化是把一个多参数函数转换为一个嵌套的一元函数的过程

我们通过一个简单的例子来看一下

const add = (x, y) => x + y;
复制代码

这是一个简单的函数,我们将它柯里化

const addCurried = x => y => x + y;
复制代码

上面的 addCurried 函数式 add 的一个柯里化版本,如果我们用一个单一的参数调用 addCurried

addCurried(4)

它返回一个函数,在其中 x 值通过闭包被捕获,可能这样看得不太清楚,那我们改成 ES5 的语法

const addCurried = function(x){
    return function(y){
        return x + y; 
    }
}

// 使用方法
addCurried(4)(4)
// 8
复制代码

此处我们手动地把接受两个参数的 add 函数转换为含有嵌套的一元函数的 addCurried 函数。下面展示了如何把该处理过程转换为一个名为 curry 的方法

const curry = binaryFn => {
    return function(firstArg){
        return function(secondArg){
            return binaryFn(firstArg,secondArg);
        }
    }
}

// 使用方法
let autoCurriedAdd = curry(add)
addCurriedAdd(2)(2)
// 4
复制代码

现在我们回顾柯里化的定义

柯里化是把一个多参数函数转换为一个嵌套的一元函数的过程

那么问题就来了,我们要柯里化干什么?

6.2.1 柯里化用例

我们先从一个简单的例子开始。

假设我们要编写一个创建列表的函数。例如,我们需要创建 tableOf2、tableOf3、tableOf4 等

可以通过如下代码实现

const tableOf2 = y => 2 * y;
const tableOf3 = y => 3 * y;
const tableOf4 = y => 4 * y;

// 使用方法如下
tableOf2(4)
// 8
tableOf3(4)
// 12
tableOf4(4)
// 16
复制代码

可以把这些表格的概念概括为一个单独的函数

const genericTable = (x, y) => x * y

所以我们可以通过 curry 使用 genericTable 来构建表格

const tableOf2 = curry(genericTable)(2)
const tableOf3 = curry(genericTable)(3)
const tableOf4 = curry(genericTable)(4)
复制代码

6.2.2 日志函数——应用柯里化

上一节展示了柯里化能做什么。本节将使用一个复杂点的例子。比如开发者编写代码的时候会在应用的不同阶段编写很多日志。我们可以编写一个如下的日志函数

const loggerHelper = (mode,initialMessage,errorMessage,lineNo) => {
    if(mode === "DEBUG"){
        console.debug(initialMessage,errorMessage + "at line:" + lineNo)
    }else if(mode === "ERROR"){
        console.error(initialMessage,errorMessage + "at line:" + lineNo)
    }else if(mode === "WARN"){
        console.warn(initialMessage,errorMessage + "at line:" + lineNo)
    }else{
        throw "Wrong mode"
    }
}
复制代码

当团队中的任何开发者需要向控制台打印 Stats.js 文件中的错误时,可以用如下方式使用函数

loggerHelper("ERROR","Error At Stats.js","Invalid argument passed",23)
loggerHelper("ERROR","Error At Stats.js","undefined argument",223)
loggerHelper("ERROR","Error At Stats.js","curry function is not defined",3)
loggerHelper("ERROR","Error At Stats.js","slice is not defined",31)
复制代码

在这个函数里面,前两个参数都是一样的,那么能像刚刚一样使用 curry 函数吗?很可惜不能,因为上一节定义的函数只能接受两个参数,而这里是 4 个参数。

下面我们解决这个问题并实现 curry 函数的完整功能,让它能够处理多个参数

6.2.3 完整的 curry 函数

我们回到柯里化的定义,将多参数函数转换为嵌套的一元函数

我们先做一些判断,当用户输入不是函数时报错

let curry = fn =>{
    if(typeof fn !== 'function'){
        throw Error('No function provided')
    }
    return function curriedFn(...args){
        if(args.length < fn.length){
            return function(){
                return curriedFn.apply(null,args.concat([...arguments]));
            }
        }
        return fn.apply(null, args)
    }
}
复制代码

我们来逐步分析这段代码发生了什么

首先,我们判断如果用户传的不是函数就报错

然后返回一个函数,接受多个参数

判断传入参数的长度是否小于函数参数列表的长度。如果是就进入 if 代码块,如果不是,就调用传入的函数

现在来看 if 代码块里面

我们先判断 args 的长度和 fn 参数的长度是否一致,如果大于等于则直接调用函数。如果小于的话则递归调用 curriedFn。注意使用 concat 把参数连接起来,当参数大于或等于的时候调用原函数。直接看个例子吧

const multiply = (x, y, z) => x * y * z

let curriedMul1 = curry(multiply)(1,2,3)
// 6
// 这个 args 是三个,等于 multiply 的参数长度,所以直接返回结果

let curriedMul2 = curry(multiply)(2,3)
// 这个 args 是两个,小于参数长度,所以返回的还是一个函数,这个函数现在有两个参数,当再传入一个参数(也可以传入多个)时,就会执行该函数,例如
curriedMul2(4) // 24
curriedMul2(4,5,6) // 24

let curriedMul3 = curry(multiply)(2)
// 这个 args 是两个,小于参数长度,所以返回的还是一个函数,这个函数现在有一个参数,当再传入一个参数时,还是会返回一个函数,就变成第二种情况
curriedMul3(3) // 返回的还是一个函数,变成第二种情况
curriedMul3(3,4) // 24
curriedMul3(3)(4) // 24 
复制代码

现在 curry 函数可以把一个多参数函数转化为一个一元函数了

6.2.4 回顾日志函数

现在我们可以用 curry 函数重写这个函数了,下面通过 curry 解决重复使用前两个参数的问题

let errorLogger = curry(loggerHelper)("ERROR")("Error At Stats.js")
let debugLogger = curry(loggerHelper)("DEBUG")("Debug At Stats.js")
let warnLogger = curry(loggerHelper)("WARN")("Warn At Stats.js")
复制代码

现在我们能够轻松使用上面的柯里化函数并在各自的上下文中使用它们了

// 用于错误
errorLogger("Error message",21)
// Error At Stats.js Error message at line:21

// 用于调试
debugLogger("Debug message",223)
// Debug At Stats.js Debug message at line:223

// 用于警告
warnLogger("Warn message",34)
// Warn At Stats.js Warn message at line:223
复制代码

这太棒了,curry 函数有助于移除很多函数调用中的样板代码

6.3 柯里化实战

在上一节中我们看到了使用 curry 函数的简单示例,在本节这种,我们将看到柯里化技术在小巧而简洁的示例中的应用。本节中的示例将让你在日常工作中如何使用柯里化有更好的理解

假设我们要查找含有数字的数组内容,可以通过如下方式解决

let match = curry(function(expr,str){
    return str.match(expr)
})
复制代码

返回的 match 函数是一个柯里化函数。我们可以给第一个参数 expr 一个正则表达式 /\d+/,这将表明内容中是否含有数字

let hasNumber = match(/\d+/)

现在我们创建一个柯里化的 filter 函数

let filter = curry(function(f, ary){
    return ary.filter(f);
})
复制代码

通过 hasNumber 和 filter 我们就可以创建一个新的名为 findNumbersInArray 的函数

let findNumbersInArray = filter(hasNumber)

// 使用
finNumbersInArray(['js','number1'])
// ['number1']
复制代码

大功告成

6.4 偏应用

可能这一章有人就看不下去了,因为我也是,但是其实照做下来的话会发现没有那么难,也不是特别难理解,不要被吓倒!!!

什么是偏应用呢?我们先看这么一个功能

假设我们要 10ms 后做一组操作,可以通过 setTimeout 来实现

setTimeout(()=>console.log('do something'),10)
setTimeout(()=>console.log('do anotherthing'),10)
复制代码

这个函数可以柯里化吗?答案是否定的,因为 curry 的参数列表是从左往右的。一个变通的方案是这样

const setTimeoutWrapper = (time,fn)=>{
    setTimeout(fn,time)
}
const delayTenMs = curry(setTimeoutWrapper)(10)
delayTenMs(()=>console.log('do something'))
delayTenMs(()=>console.log('do anotherthing'))
复制代码

但是这样的话我们需要创建一个包裹函数,这也是一种开销,这里就可以使用偏应用技术

6.4.1 实现偏函数

为了全面理解偏应用技术的机制,我们将先创建一个偏(partial)函数。实现以后,我们将通过一个简单的例子学习如何使用偏函数(不要觉得难,自己跟着做一下,拿个例子试下就很清楚了)

const partial = function(fn,...partialArgs){
    let args = partialArgs
    return function(...fullArguments){
        let arg = 0;
        for(let i = 0; i < args.length && arg < fullArguments.length; i++){
            if(args[i] === undefined){
                args[i] = fullArguments[i];
            }
        }
        return fn.apply(null,args);
    }
}
复制代码

可能有些看不懂,来用个例子慢慢解释一下

const delayTenMs = partial(setTimeout,undefined,10)
复制代码

现在 args 就是 [undefined,10],然后返回的是一个函数,所以 delayTenMs 其实是如下形式

function delayTenMs(...fullArguments){
    let arg = 0;
    // 其中 args 是 [undefined,10]
    for(let i = 0; i < args.length && arg < fullArguments.length; i++){
        if(args[i] === undefined){
            args[i] = fullArguments[i];
        }
    }
    // fn 是 setTimeout
    return fn.apply(null,args);
}
复制代码

然后,我们来使用 delayTenMs

delayTenMs(()=>console.log('do something'))
// 10ms 后将打印出 do something,说明功能已经实现
复制代码

我们来看看为什么会这样

// 现在的 delayTenMs 函数是这样
function delayTenMs(...fullArguments){
    let arg = 0;
    // 其中 args 是 [undefined,10]
    for(let i = 0; i < args.length && arg < fullArguments.length; i++){
        if(args[i] === undefined){
            args[i] = fullArguments[arg++];
        }
    }
    // fn 是 setTimeout
    return fn.apply(null,args);
}

// 执行 
delayTenMs(()=>console.log('do something'))


复制代码

这时候 fullArguments 就变成了一个只有一个参数的数组,即 [()=>console.log('do something')],因为数组的每一项可以为任意类型,所以这里就是只有一个函数的数组

args.length 等于 2,fullArguments.length 等于 1,所以进入循环

args[0]===undefined 满足条件,所以把 args[0] 变为 fullArguments[0]args[0] = ()=>console.log('do something'),这时 args = [()=>console.log('do something'),10],最后执行 fn 即 setTimeout,并把 args 作为参数传进去,即得到我们想要的结果,怎么样,是不是没有想象的那么难?

好了,接下来我们再看一个例子,用 JSON.stringify 来格式化输出

let obj = {foo:'bar', bar:'foo'}
JSON.stringify(obj, null, 2)
// JSON.stringify 接收三个参数,第二个是一个函数或数组,感兴趣的可以自己查一下。第三个是缩进的字符数
复制代码

由于后两个参数是固定的,所以我们可以用 partial 来移除它们(最好自己想一下再看)

let prettyPrintJson = partial(JSON.stringify,undefined,null,2);
// 使用方法
prettyPrintJson(obj);
/* 
'{
   "foo":"bar",
   "bar":"foo"
}' 
*/
复制代码

这个程序其实有个 bug,如果你用一个不同的参数再次调用的时候还是给出第一次的结果,为什么呢?因为 args 是数组,传的是引用,而不是一个函数。所以这里第二次调用的时候 args[0] 已经不等于 undefined 了。所以不会修改这个值。

6.4.2 柯里化与偏应用

那么我们什么时候用柯里化,什么时候用偏应用呢?取决于 API 是如何定义的。如果 API 如 map、filter 一样定义,我们就可以轻松的使用 curry。而如果不是这样的话,可能选择偏应用更合适一些。

6.5 小结

今天主要学习了柯里化与偏应用这两个函数式编程中经常见到的词,也创建了对应的函数,并了解了它们的一般用途。柯里化和偏应用主要是对于参数进行一些操作,将多个参数转换为单一参数。如果参数不足的话它们就会处在一种中间状态,我们可以利用这种中间状态做任何事!!!

还记得刚开始我们说函数式编程组合的特点吗,这就是我们明天要学习的内容:组合一些小函数来构建一个新的函数。明天见

猜你喜欢

转载自juejin.im/post/5b9f62f66fb9a05ce273f6cd