美文网首页
柯里化 / (call, apply, bind)

柯里化 / (call, apply, bind)

作者: 火锅伯南克 | 来源:发表于2020-06-18 19:01 被阅读0次

柯里化是啥?
柯里化是一种技术, 柯里是个人 。
什么人?
一个搞数学和逻辑学的人。
他发明的这个?
不是, 这个概念是Moses Schönfinkel发明, 由Gottlob Frege引入的。
那为什么以柯里命名呢?
可能和人大多知钱学森,却不知郭永怀一样吧。

柯里化概念

在数学和计算机科学中,局部套用是一种技术,它将接受多个参数(或元组参数)的函数求值转换为对一系列函数求值,每个函数都有一个参数。

用人话来说,就是利用闭包去缓存参数,然后返回一个拥有缓存参数的函数,当累计参数数量与原函数参数数量相等时,函数执行。

案例
如果想要做一个专门检查数据类型的库,一般实现方法如下:

代码片段编号:1
const tools = {
  isString(arg){
    return Object.prototype.toString.call(arg) === '[object String]'
  },
  isNumber(arg){
    return Object.prototype.toString.call(arg) === '[object Number]'
  },
  isObject(arg){
    return Object.prototype.toString.call(arg) === '[object Object]'
  },
  isArray(arg){
    return Object.prototype.toString.call(arg) === '[object Array]'
  },
  ...等等
}
console.log(tools.isString('')) //true

但是给人一种很low的感觉,因为重复代码太多了,改造如下。

代码片段编号:2
function typeFn(type){
  return function (value){
    return Object.prototype.toString.call(value) === `[object ${type}]`
  }
}
const tools = {};
const Arr = ['String', 'Number', 'Object', 'Array'];
Arr.forEach(item => {
  tools[`is${item}`] = typeFn(item)
})
console.log(tools.isString('')) //true

虽然实现了需要的功能,但这只是闭包应用,不是柯里化。
我们再换一个片场,比如现在你接手了一个既古老又庞大项目,项目中有如下函数:

代码片段编号:3
function typeFn(type, value){
  return Object.prototype.toString.call(value) === `[object ${type}]`
}

很明显,这个函数就是来判断数据类型的。现在由于新的需求,你需要这个老函数,因为它写的并不错。但是你又要让他变得具体一点,比如像代码片段1那样分门别类,因为传两个参数让你感觉到啰嗦,但是你又不能像代码片段2一样修改他,因为冒然的修改历史代码后果很严重。这时候就需要借助柯里化来实现了。

代码片段编号:4
const currying = function(fn,...arr) {
  let len = fn.length
  return function(...arg) {
    arr = [...arr, ...arg]
    if(arr.length < len){
      return currying(fn, ...arr)
    }
    return fn.call(this, ...arr)
  };
};
const tools = {};
const Arr = ['String', 'Number', 'Object', 'Array'];
Arr.forEach(item => {
  tools[`is${item}`] = currying(typeFn, item)
})
console.log(tools.isString('')) //true

在这个写法中typeFn,通过currying的柯里化包装,得到一个新的函数,包装过程中缓存了typeFn的第一个参数,之后再次调用,只需要传另一个参数就可以了,是不是方便多了。

这么写会不会造成内存泄漏?

柯里化还有一种应用场景,就是延迟执行。
好了,换片场了,现在有一个狠狠奇葩的情况,你要写一个商品展示的页面,这个页面初始化前,你需要先向后台请求一个接口A,这个接口返回给你的信息如下:

{
  listApi: [
    'https://api.taobao.com/goodslist',
    'https://api.jingdong.com/goodslist',
    'https://api.pinduoduo.com/goodslist',
  ],
  key: 'price',
}

listApi字段表示,接下来你要请求的所有接口,length是固定的3个,但是接口名可能会变,这些接口会返回真正的商品列表数据,且数据格式一致,如下:

//https://api.taobao.com/goodslist,返回的数据
{ 
  list: [
    { name: '电动趿拉板', price: 100, moodes: 325}
  ]
}
//https://api.jingdong.com/goodslist,返回的数据
{ 
  list: [
    { name: '太阳能手电', price: 150, moodes: 210}
  ]
}
//https://api.pinduoduo.com/goodslist,返回的数据
{ 
  list: [
    { name: '手摇洗衣机', price: 90, moodes: 550}
  ]
}

所有商品根据和A接口key字段的值一致的字段进行排序,但愿我说明白了。。
在此要先声明,Promise.all 和发布订阅模式也能实现,但是这里只用柯里化,也就是代码片段4的currying函数。

function tableList(key, List1, List2, List3 ){
  let arr = [...List1, ...List2, ...List3];
  arr.sort((o1, o2) => o1[key] - o2[key])
  console.log(arr)
}

let fn = currying(tableList)
//A接口请求
setTimeout(()=>{
  let Adata = {
    listApi: [
      'https://api.taobao.com/goodslist',
      'https://api.jingdong.com/goodslist',
      'https://api.pinduoduo.com/goodslist',
    ],
    key: 'price',
  }
  fn = fn(Adata.key)  // 在这里缓存第一个参数
  //淘宝接口请求
  setTimeout(()=>{
    let taobaoList =[
      { name: '电动趿拉板', price: 100, moodes: 325}
    ]
    fn = fn(taobaoList)
  },2000)
  //京东接口请求
  setTimeout(()=>{
    let jingdongList = [
      { name: '太阳能手电', price: 150, moodes: 210}
    ]
    fn = fn(jingdongList)
  },1000)
  //拼多多接口请求
  setTimeout(()=>{
    let pinduoduoList = [
      { name: '手摇洗衣机', price: 90, moodes: 550}
    ]
    fn = fn(pinduoduoList)
  },3000)
},1000)

4秒后输出

[
  { name: '手摇洗衣机', price: 90, moodes: 550},
  { name: '电动趿拉板', price: 100, moodes: 325},
  { name: '太阳能手电', price: 150, moodes: 210}
]

柯里化的弊端也很明显,就是一定要事先确定函数的参数个数,实现此类场景还是使用Promise.all 和发布订阅模式比较合适,在此只为展示。

函数的bind方法就是柯里化

function.bind(thisArg[, arg1[, arg2[, ...]]])
bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

//最新MDN对bind的Polyfill写法
Function.prototype.bind = function (content, ...arg) {
  let _this = this
  if (typeof _this !== 'function') {
    throw new TypeError('Function.prototype.bind - ' +
           'what is trying to be bound is not callable');
  }
  return function (...argument) {
    return _this.apply(content, [...arg, ...argument])
  }
}
function sum(a, b, c){
  let d = this.d
  return a + b + c + d
}
//注意返回的是新函数
//新函数中的this为 {d: 4}
let a = sum.bind({d: 4}, 1)

console.log(a(2, 3)) // 10

原来MDN的写法比这个复杂得多,因为考虑到了原函数的原型对象,以及函数可能用作构造函数使用,也就是使用new,类似下面这样。

//这是很久以前MDN对bind的Polyfill写法
Function.prototype.bind = function (context, ...args) {
  if (typeof this !== "function") {
    throw new TypeError("Bind must be called on a function");
  }
  let _this = this,
    fn = new Function,
    Bound = function (...arguments) {
      return _this.apply(
        this instanceof fn && context ? this : context,
        [...args, ...arguments]
      );
    };
  fn.prototype = this.prototype
  Bound.prototype = new fn;
  return Bound;
};
//下面是 new 修改 this 的相关代码:
this instanceof fn && context ? this : context  
// ... 以及:
fn.prototype = this.prototype;  
Bound.prototype = new fn();

我们并不会详细解释这段代码做了什么(这非常复杂并且不在我们的讨论范围之内),不过简单来说,这段代码会判断硬绑定函数是否是被 new 调用,如果是的话就会使用新创建
的 this 替换硬绑定的 this。
——《你不知道的JavaScript(上卷)》94页

从MDN修改bind的写法和相关书籍的解释可以看出,解释器内部的bind方法执行过程还是比较复杂的,无论你用代码怎么模拟都有欠缺,所以还是尽量使用原生bind方法。如果要支持低版本浏览器,请到 这里寻找解决方案。

npm install function-bind
Function.prototype.bind = require("function-bind")

反柯里化

再说反柯里化之前,先探讨一个问题,你是不是曾经和我有如下相同的疑问

Object.prototype.toString.call('a') //'[object String]'
//疑问:这是一个Object的原型方法,为什么执行对象可以是任何类型呢?

let a = {0:0,1:1,2:2,length:3}
Array.prototype.slice.call(a) //[0, 1, 2]
Array.prototype.push.call(a, 3)
consoel.log(a) //{0:0,1:1,2:2,3:3,length:4}
....
Array.prototype.slice.call('abc') //['a', 'b', 'c']
//疑问:这是一些Array的原型方法,为什么执行对象可以伪数组和String呢?

let a = ['ABc','Err','BlUe']
String.prototype.toLowerCase.call(a).split(',')
// ["abc", "err", "blue"]
//疑问:这是一个String的原型方法,为什么执行对象可以是Array呢?

解除疑惑第一步,首先看看call和apply干了什么

Function.prototype.call = function (context, ...args) {
  //这段代码有必要:如 a = {a:Function.prototype.call}
  //a.a() 没有意义
  if (typeof this !== "function") {
    throw new TypeError(`caller is not a function`);
  }
  //为这两个值时,指针会指向global,严格模式为undefined
  //例子var obj = {a(){console.log(this)}}
  //使用内置的call,obj.a.call(null) // window
  if(context === undefined || context === null){
    context = window
  }
  //其他所有值要转换为object
  //例子var obj = {a(){console.log(this)}}
  //使用内置的call,obj.a.call('') // String {""}
  context = Object(context)
  //在context的原型上添加方法,使函数调用时指向context
  //使用Symbol确保添加的属性不会覆盖原有属性,且不可枚举
  //总之原则就是,添加一个对原对象影响尽可能小的属性
  let key = Symbol(),
      pro = Object.getPrototypeOf(context);
  Object.defineProperty(pro, key ,{
    value: this,
    configurable: true
  })
  let result = context[key](...args)
  delete pro[key]
  return result;
}

Function.prototype.apply = function (context, arr = []) {
  if (typeof this !== "function") {
    throw new TypeError(`caller is not a function`);
  }
  if(context === undefined || context === null){
    context = window
  }
  context = Object(context)
  let key = Symbol(),
      pro = Object.getPrototypeOf(context);
  Object.defineProperty(pro, key ,{
    value: this,
    configurable: true
  })
  let result = context[key](...arr)
  delete pro[key]
  return result;
}

在模拟代码中,call 和apply在执行时,先把执行函数定义在了context的原型对象上,然后用context去调用这个函数,这个this可不就是指向context了吗。

执行方式是知道了,但是还有一个疑问,执行过程是什么呢?这个关乎内置构造器的原型方法的写法,比如Array.prototype.slice,如果context能够为伪数组,就可以推断这个方法并没有对context的数据类型做校验,而只是对传入的数据检验了有没有length属性,有没有类似数组的索引属性。

//模仿内部实现
Array.prototype.slice = function (begin, end) {
  var i, cloned = [],
    size, len = this.length;
  // 为“begin”处理负值
  var start = parseInt(begin) || 0;
  start = (start >= 0) ? start : Math.max(0, start + len);
  // 为“end”处理负值
  var over = end === undefined ? len : parseInt(end);
  over = over == over ? Math.min(over, len) : 0;
  if (over < 0) {
    over = len + over;
  }
  // 切片的实际期望大小
  size = over - start;
  if (size > 0) {
    cloned = new Array(size);
    if (this.charAt) {
      for (i = 0; i < size; i++) {
        cloned[i] = this.charAt(start + i);
      }
    } else {
      for (i = 0; i < size; i++) {
        cloned[i] = this[start + i];
      }
    }
  }
  return cloned;
}

类似Array.prototype.slice这种不去校验调用者的本质类型,而是只要调用者具备特定的属性或方法,能通过执行测试,就能使用的行为。称作鸭子类型测试。

鸭子类型

一只会鸭子叫的鸡混在队伍中,为喜欢听鸭子叫的国王演唱“好日子”

美国印第安纳诗人詹姆斯·惠特科姆·莱利(1849年-1916年)可能是这个说法的创造者。他写道:“当我看到一只鸟,它走路像鸭子,游泳像鸭子,叫声像鸭子,我就称其为鸭子。”

套用主流观点:“当我看到一个人,他穿个格子衫,背个双肩包,头上没啥毛,我就称其为程序员”

(怎么把鸭子和程序员扯上关系了。。还好我不是程序员)

这种归纳推理方法有点像马克思主义哲学中描述的“形而上学”:用孤立、静止、片面的方式看待问题。

JavaScript与鸭子类型

JavaScript是动态语言,编译器将数据的类型检查安排在运行时而不是编译时,并在编译器的输出中包含用于运行时类型检查的代码。这些附加的内容使这门语言享受鸭子类型的大多数益处,仅有的缺点是需要在编译时识别和指定这些动态数据。

为了很好地解释,我使用函数执行过程,因为它体现了这门语言的鸭子特性且更具体。

function test(a, b, c){
  console.log(a, b, c)
}

我们知道JavaScript的函数参数是按值传递,而不是引用,相当于给参数做了一个备份,所以上面函数相当于这个。

function test(a, b, c){
  var a = a;
  var b = b;
  var c = c;
  console.log(a, b, c)
}

编译器生成了检测数据类型的代码,解释器执行时根据这些代码去判断数据类型并存入内存。


如果是静态类型的语言,例如Java,函数参数也是按值传递,但是参数在定义时就已经确定了类型,编译时无需生成用于类型检测的代码。

public void test(int a, char b, Object c){
  System.out.print(""+a+b+c);
}

弊端:这样的方式要求JavaScript程序员在任何时候都必须很好地理解自己正在操作的代码,哪怕这并不是自己创造的。
微软出的TypeScript就是为了弥补JavaScript这一弊端而产生的。

扯得太远了,不过个人认为了解反柯里化之前先了解这些是有必要的。因为反柯里化本身并不难,而且用处也不多,反而不如前面提到的那些衍生问题重要。

反柯里化的三种写法

Function.prototype.unCurring = unCurring
function unCurring() {
  if (typeof this !== "function") {
    throw new TypeError(`caller is not a function`);
  }
  return (context, ...arg) => this.apply(context, arg)
}

function unCurring() {
  if (typeof this !== "function") {
    throw new TypeError(`caller is not a function`);
  }
  return (...arg) => this.call(...arg)
  //或者这样
  //return (arg) => Function.prototype.call.apply(this, arg);
}

function unCurring() {
  if (typeof this !== "function") {
    throw new TypeError(`caller is not a function`);
  }
  return Function.prototype.call.bind(this)
}

(...arg) => this.call(...arg)(arg) => Function.prototype.call.apply(this, arg) 为什么等价?

设:fn为一个任意自定义函数

fn.call( context, 1, 2, 3 )
与
Function.prototype.call.apply( fn, [context, 1, 2, 3] );

为啥他俩是等价的?

设:Function.prototype.call = A

那么
Function.prototype.call.apply( fn, [context, 1, 2, 3] ) 等于 
A.apply( fn, [context, 1, 2, 3] )

根据apply的执行原理,可知 
A.apply( fn, [context, 1, 2, 3] ) 等于 
fn.A( context, 1, 2, 3 )

那么在换算回来
伪代码

fn.(Function.prototype.call)( context, 1, 2, 3 )

最后得:fn.call( context, 1, 2, 3 )

只需要记住其中一个就可以了,看看他能干什么。

var push = Array.prototype.push.unCurring();
var a = {}
push(a, 1, 2, 3)
console.log(a) // {0: 1, 1: 2, 2: 3, length: 3}

柯里化是参数越来越少,而反柯里化正好是越来越多,通过上面这个例子,使用反柯里化生成了一个伪数组的push方法,但说实话这个玩意真不知道有什么作用。反柯里化的应用还需要探索。

相关文章

网友评论

      本文标题:柯里化 / (call, apply, bind)

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