[核心概念] 一文说透JS中的函数柯里化(Currying)

1,202 阅读10分钟

函数柯里化 (Currying)

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 什么是柯里化
  • 柯里化的应用场景有哪些
  • 你了解函数式编程吗
  • 实现一个curring

这是干什么的?

先看看其他官方的各种定义引用:

Mostly adequate guide: Currying ——只传递给函数一部分参数来调用它,让它返回一个函数处理剩下的参数的函数式编程方式

Kristina Brainwave: 柯里化是一个把具有较多 arity(参数数量) 的函数转换成具有较少 arity 函数的过程。

举个例子: 它是指将一个函数从可调用的 f(a, b, c) 转换为可以这样调用 f(a)(b)(c)。柯里化不会调用函数,它只是对函数进行转换

我们先做个简单总结

  • 柯里化是一种函数式编程的技术
  • 只传递给函数一部分参数来调用它,并返回一个函数去处理剩下的参数。
  • 它不仅被用于 JavaScript,还被用于其他编程语言。

分析理解

上面我们下了个定义,但是我猜你还是对这个玩意不理解,有什么用,看分析你就明白了。

javascript中函数调用一般长这样:

let add = function(a, b) { 
  return a + b 
}
add(1, 2)                 //= 3

一个函数接受一定数量的参数,然后执行后返回一个 value。当我们传入少的,或者多的参数(反正跟定义的不同数量),会造成结果不合预期(传少)或者多出来的参数被忽略的结果。

add(1)                    //= NaN
add(1, 2, 'ignore args')  //= 3

下面我们使用curry的方式来把多参数加法变成一个个单参数调用的函数

var curry = require('curry')  // 假设这个curry方法是外部引入的
// 我们把这个函数柯里化, 就是把这个函数传到 curry() 中并返回出了一个被curry的函数 sum3
var sum3 = curry(function(a, b, c) { 
  return a + b + c
})
// 那么接下来我们可以这样调用他们
sum3(1, 2, 3)      //= 6     原来的方式当然ok
sum3(1)(2)(3)      //= 6     可一个个参数调用
sum3(1)(2, 3)      //= 6     
sum3(1, 2)(3)      //= 6     也可传入部分,再传入剩余的

sum3(1)(2, 3) 称为以偏函数partial的方式调用 或者称为 Partial Application (偏函数应用)

这是指使用一个函数并将其应用一个或多个参数但不是全部参数,在这个过程中创建一个新函数。

这样有啥好处,哪些场景可以用

我们先空泛地说下好处,再解释

一句话:令函数有更好的可读性灵活性复用性

其他潜在好处:

  • 可以让你生成一个小型的,易于配置的函数库,而且这些函数的行为始终如一。(没有副作用的纯函数[相关概念])
  • 可以让你养成良好的函数命名习惯

用些例子解释下:

1. 参数复用,形成一些偏函数,灵活应用

例如: 我们有一个用于格式化和输出信息的日志的函数 log(level, message)。假设长这样

  • level 设置日志警告等级 'warn', 'error', 'info'
  • message 日志内容信息
function log(level, message) {
  console.log(`[${level}] ${message}`);
}

非常简单的函数, 想想平时都是这样调用是不是

if (exp) {
  log('warn', 'sth... warn')
}

log('error', '...message')
...

现在柯里化看看能有啥变化

import _ from 'loadsh'
var log = _.curry(log)
// 柯里化之后,log 仍正常运行:
log("warn", "some warn");  // "[warn] some warn"
// 但是也可以以柯里化形式运行:
log("warn")("some warn");  // "[warn] some warn"

这样我们能创建更多『便捷函数』或者说偏函数

let warnLogger = log('warn');

// 使用它
warnLogger("message");      // [warn] message

warnLogger 是带有固定第一个参数的日志的偏函数,这个函数是参数固定的原来函数的部分函数。

那么我们现在调用方式

if (exp) {
  warnLogger('sth... warn')
}
errLogger('...err message')

可读性是不是大大增加了,而且更灵活,因为你的这些小函数是可以互相组合的。 这里的例子因为简单,你还看不出好处有多大。尝试在复杂项目中使用,你会发现这种编程习惯会让你思路更清晰。

2. 将操作原子化,方便单元测试

简单来说,就是把各种小操作给工具函数化,增加了可读和复用性。

还又很重要的一点是,这些函数可以非常方便地进行单元测试[关联概念]。

很多人说前端单元测试没用,那是因为你处理的逻辑真的太简单!当你的逻辑复杂到一定程度,关联非常强,分支众多,你一星期之后发现有bug,或者让你马上加个新功能,你敢随便改(加)吗,你确定改了之后不会影响之前的功能 ?都丢给测试合适吗,如果逻辑划分清楚,函数的职责清晰,输入输出一定,首先你的,方便(可扩展,灵活性强),其次,你只要跑一遍自己的单元测试,心理不慌,改起来也非常放心,至少不会影响之前的逻辑。

举个例子:仅仅是个例子,请举一反三

var objects = [{ id: 1 }, { id: 2 }, { id: 3 }]
let idList = objects.map(function(item) { 
  return item.id 
})

其实我们要做的操作就是:遍历这个对象数组并取出他们的id

那么现在我们可以用柯里化处理一波

import _ from 'loadsh'

var get = _.curry(function(prop, object) { 
  return object[prop] 
})

// map接受一个function 是用来获取每个对象的'prop'的 这里的prop是'id'
objects.map(get('id'))    //= [1, 2, 3]

我们在get函数中 真正创建的的可以部分配置的函数。 再看我们平时是不是使用这种方式多点,来创建一个方法用于获取对象数组中的id

let getIDs = function(objects) {
    return objects.map(get('id'))
}
getIDs(objects)        //= [1, 2, 3]

我们甚至可以进一步把 map也进行curry处理

let curriedMap = curry(function(fn, value) { 
  return value.map(fn) 
})
var getIDs = curriedMap(get('id'))

getIDs(objects)        //= [1, 2, 3]

这样的代码读上去更清楚不是吗,当你在平时工作中积累了很多原子操作处理,就像一块块积木,顺手拿来,解决问题的速度会让你惊讶的。

平时的代码积累是有复利的,别小看一点点的积累,可以从函数,方法工具类开始,打造你自己的工具集。再进一阶,平时积累你自己的系统,服务(可以是别人开源的),当真正遇到现实问题,你的口袋里会拿出别人惊讶的东西,你的开发速度会比别人快n倍,不是因为你很聪明,而是因为你之前做过,并积累下来了。写代码的高手,可能是组装他积累东西的高手,解决现实问题的高手,是非常务实的。

实际怎么写

如果你在现实项目中想用curry,建议先了解函数式编程,自然而然地使用。单用curry也建议直接用lodash (省的自己写,而且别人的也处理了this等其他需要特殊处理的部分, placeholders也是很好用的)

_.curry

当然你也可以选择自己实现一个,根据下面原理,简单实现。

根据现实情况考虑问题选择最合适地方案,是一个程序员的基本素质。

原理是什么?

我们深刻理解了这个概念之后,可以探究下它的实现(面试也经常问到这方面源码),可能有人觉得没啥用,我觉得它的用处是拓展出其他相关联的【必知】概念,也可以看看你的硬编码能力,再不济看看你的记忆力如何也是好的。(^-^)

了解原理实现,最好先了解 闭包【关联概念(强)】的概念。这是理解下面原理实现的前提。

function myCurry(func) {
  // 我们myCurry调用应该返回一个包装器 curried,令这个函数curry化
  return function curried(...args) {
    // curry 的使用主要看参数数量
    return args.length >= func.length ?
      // 如果传入的 args 长度与原始函数所定义的(func.length)相同或者更长,
      // 那么只需要将调用传递给它即可。直接现在就调用,返回函数结果
      func.call(this, ...args) :
      // 否则的话,返回另一个包装器方法,递归地调用curried,将之前传入的参数与新的参数拼接后一起传入。
      // 然后,在一个新的调用中,再次,我们将获得一个新的偏函数(如果参数不足的话),或者最终的结果。
      (...rest) => {
        return curried.call(this, ...args, ...rest);
      };
  };
}

注意每次调用参数不足返回包装器函数时,会将上一轮参数保存在词法环境中,利用闭包的特性,进行下一轮判断。

我觉得看注释应该不用多解释了,不理解评论区留言吧。

写完可以用这个例子测试下

function sum(a, b, c) {
  return a + b + c;
}

let curriedSum = myCurry(sum);

console.log( curriedSum(1, 2, 3) );  // 6,仍然可以被正常调用
console.log( curriedSum(1)(2,3) );   // 6,对第一个参数的柯里化
console.log( curriedSum(1)(2)(3) );  // 6,全柯里化

其他

只允许确定参数长度的函数

柯里化要求函数具有固定数量的参数

注意使用 rest 参数的函数,例如 f(...args)不能以这种方式进行柯里化

为何使用函数式编程风格

Pointfree 就是如何使用函数式编程的答案

  • 这就叫做 Pointfree:不使用所要处理的值,只合成运算过程。中文可以译作"无值"风格。
  • Pointfree 的本质就是使用一些通用的函数组合出各种复杂运算。上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。

例子

下面是一个字符串,请问其中最长的单词有多少个字符

var str = 'Lorem ipsum dolor sit amet consectetur adipiscing elit';

我们先定义一些基本运算。

// 以空格分割单词
var splitBySpace = s => s.split(' ');

// 每个单词的长度
var getLength = w => w.length;

// 词的数组转换成长度的数组
var getLengthArr = arr => R.map(getLength, arr); 

// 返回较大的数字
var getBiggerNumber = (a, b) => a > b ? a : b;

// 返回最大的一个数字
var findBiggestNumber = 
  arr => R.reduce(getBiggerNumber, 0, arr);

然后,把基本运算合成为一个函数

var getLongestWordLength = R.pipe(
  splitBySpace,
  getLengthArr,
  findBiggestNumber
);

getLongestWordLength(str) // 11

可以看到,整个运算由三个步骤构成,每个步骤都有语义化的名称,非常的清晰。这就是 Pointfree 风格的优势。

memoization

在计算机领域,记忆(memoization)是主要用于加速程序计算的一种优化技术,它使得函数避免重复演算之前已被处理过的输入,而返回已缓存的结果。 -- wikipedia

function memoizeFunction(func) {
    var cache = {};
    return function() {
        var key = arguments[0];
        if (cache[key]) {
            return cache[key];
        } else {
            var val = func.apply(this, arguments);
            cache[key] = val;
            return val;
        }
    };
}

var fibonacci = memoizeFunction(function(n) {
    return (n === 0 || n === 1) ? n : fibonacci(n - 1) + fibonacci(n - 2);
});

console.time('start1');
fibonacci(100)
console.timeEnd('start1')
// 第二次有缓存
console.time('start2');
fibonacci(100)
console.timeEnd('start2')

缓存对计算速度提升效果明显


继续下去,你总会有收获。 上面这句话给你们,同样也给我自己前进的动力。

我是摩尔,数学专业,做过互联网研发,测试,产品
致力用技术改变别人的生活,用梦想改变自己的生活
关注我,找到自己的互联网思路,踏实地打牢固自己的技术体系
点赞、关注、评论、谢谢
有问题求助可私信 Q 1602111431@qq.com / (微信) 我会尽可能帮助你,但是注意提问方式,建议先看这篇文章:提问的智慧

参考