Skip to content

一文搞懂函数柯里化+手写及类数组对象-数组的转化

手写函数柯里化原理

柯里化,英语:Currying(果然是满满的英译中的既视感),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

函数柯里化的核心在于:函数里面返回函数,从而做到参数复用的目的。

1.最简单的柯里化例子

add 函数

jsx
// 普通的add函数
function add(x, y) {
  return x + y;
}

// Currying后
function curryingAdd(x) {
  return function (y) {
    return x + y;
  };
}

add(1, 2); // 3
curryingAdd(1)(2); // 3

看了例子,主要概念就是让函数返回一个 function 可以接受第二个(或者更多)括号里的参数并输出期望值。——> 所以说我们要返回一个函数

2.手写题:限制参数个数的写法

限制为 3 个参数:题干为输出 sum(1,2)(3)的值,也就是说三个参数的情况

针对这个题目,需要做的是:

1、利用闭包创建一个数组保存参数 2、返回一个方法,用于接收下一个括号里的参数 3、全部接收后,返回所有参数的和

如果仅仅针对题目,我们得到下面的代码

js
// 固定数量参数
function constSum() {
  // 因为就三个参数,直接用计数器,定义数组添加参数,判断数组长度也可以
  (this.counter = 0), (this.sumNum = 0);
  //逗号运算符:这段代码使用逗号运算符在同一行内初始化了两个变量 this.counter 和 this.sumNum。逗号运算符允许在一个表达式中依次执行多个子表达式,并返回最后一个子表达式的结果。(所以说可以用变量去接收值)
  //相当于:
  // this.counter = 0;
  // this.sumNum = 0;
  let that = this; //必须要先把this变量定义出来,否则里面箭头函数取不到这个this

  // ...rest是ES6中参数解构的写法,函数中的rest为数组
  return function innerSum(...rest) {
    rest.forEach((item) => {
      that.counter++; //记录进来了几个参数
      that.sumNum += item;
    });
    // 判断
    if (that.counter === 3) {
      //如果已经有3个数字了,直接返回
      return that.sumNum;
    } else {
      return innerSum; //如果还没有3个数字,那么返回一个函数继续接收参数
    }
  };
}

let sum = new constSum();
var result = sum(1, 2)(3);
var result2 = sum(1, 2, 3);
console.log(result); // 6
console.log(resul2); // 6

这样可以实现题目中的效果,但是并不好,因为限定死 3 个参数,如果我们希望拥有更灵活通用的方法呢?

比如可以支持 sum(1,2,3)(4)(5)......

3.手写题:不限制参数个数的写法

不限定参数数量的高级写法:题干为 sum(1,2)(3) sum(1)(2)(3) sum(1,2,3)

写法一:基本常用写法

针对这个题目,需要做的是:

1、利用闭包创建一个数组保存参数 2、返回一个方法,用于接收下一个括号里的参数 3、全部接收后,返回所有参数的和

有几个括号,就会进行几次递归,并在递归时执行 args.push(...arguments)操作

注意:arguments 就是函数的所有参数内容,可以用 … 进行展开

最后的输出结果是一个函数,控制台输出的函数是被转换为 string 类型的字符串了的,转换为 string 是因为调用了 toString 方法,只要我们改写 toString 方法的内容,让他输出 sum 的结果,而不是简单转义,就可以达到我们的目的了!

js
function sum() {
  // 第一次执行时,定义一个数组专门用来存储所有的参数
  // let args = arguments; //用于获取第一个括号里的参数
  // 因为arguments本身是一个类数组结构(key值为数字的,具有length属性),因此上述代码还需要进行改进,下面这行才是正确的
  let _args = Array.prototype.slice.call(arguments);
  // Array.prototype.slice.call(arguments)能将类数组结构转成数组。
  // 注意:所有的数组实例都继承于 Array.prototype,所有的数组方法都定义在 Array.prototype 身上

  // 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值(_args会长久的存在)
  let _adder = function () {
    _args.push(...arguments); // arguments默认就为函数的参数,即使我们没有列出形参
    return _adder; //第二次即以后都是走的这里了!
  };

  // 利用toString隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
  _adder.toString = function () {
    return _args.reduce((prev, cur) => {
      return prev + cur;
    });
  };

  return _adder; //只有第一次的()会走到这里,相当于高阶函数(返回了一个函数)
}
console.log("" + sum(1)(2)(3)); // 6
console.log("" + sum(1, 2, 3)(4)); // 10
console.log("" + sum(1)(2)(3)(4)(5)); // 15

其中argumentsArray.prototype.slice.calltoString隐式转换其实都是 js 基础知识点

先明白这里 toString 的功能:

在返回最后一次执行结束后,return 了一个_adder方法,在输出时(不是 console.log 的时候,是在进行各种符号运算的时候),正常会隐式调用Function.toString()的方法,以字符方式输出方法。因为上面改写了_adder 的 toString 方法,所以最后没有以字符方式输出方法,而是隐式调用了我们改写的输出了所有参数的和的 toString 方法。

使用“”+,可以隐式触发 toString 方法,可以输出6这样的数字,但如果输出一下这个结果,或者这个结果的类型比如:

js
// ...上略
console.log(sum(1)(2)(3)); //[Function: _adder] { toString: [Function (anonymous)] }
console.log(typeof sum(1)(2)(3)); // function

我们会得到这个结果其实就是一个函数。所以“”+的这个方法其实是很勉强的实现了题目的效果,但得到的数据不能直接作为 Number 来使用。

还是需要显示调用并 Number.parseInt 强转才能得到 number 类型的结果:

js
// ...上略
let sumToNumber = Number.parseInt(sum(1)(2)(3));
console.log(sumToNumber); // 6
console.log(typeof sumToNumber); // number

注:什么是 toString 隐式转换?

在 JavaScript 中,隐式转换是指在表达式中涉及不同数据类型的操作时,JavaScript 引擎自动进行的数据类型转换,以便完成操作。其中一个常见的隐式转换是通过 toString 方法实现的。

toString 是 JavaScript 中的一个方法,它存在于所有对象类型中,包括基本数据类型(如数字、字符串等)。当你在一个对象上使用某些操作符(==也会触发)或将其与其他数据类型进行运算时,JavaScript 引擎可能会自动调用对象的 toString 方法,默认将其转换为字符串,以便进行操作。

例如:

js
const num = 42;
const str = "Hello";

const result = num + str; // num 隐式转换为字符串,然后进行字符串拼接
console.log(result); // 输出 "42Hello"

在这个例子中,num(一个数字)和 str(一个字符串)进行了相加。由于操作符是字符串拼接操作,JavaScript 引擎将自动调用 numtoString 方法将其转换为字符串,然后进行拼接操作。

同样,toString 隐式转换也可以在其他上下文中发生,例如将对象放入模板字符串中,或者将对象与其他数据类型一起传递给函数等。

需要注意的是,不同的对象类型可能会有不同的 toString 行为。例如,数组的 toString 方法将返回以逗号分隔的元素列表(输出"1,2,3,4,5"),对象的 toString 方法通常会返回一个标识该对象的字符串(输出 "[object Object]")。

总之,toString 隐式转换是 JavaScript 在处理不同数据类型之间的操作时自动发生的一种数据类型转换。

我们可以给对象或者函数去自定义一个 toString 方法,改变默认的调用:

js
//函数:
let _adder = function () {
  _args.push(...arguments);
  return _adder;
};

_adder.toString = function () {
  return _args.reduce((prev, cur) => {
    return prev + cur;
  });
};
js
//对象:
let obj = {
  name: "zhangsan",
  toString: function () {
    return `${this.name}, ${this.age} years old, works as ${this.job}`;
  },
};

注:arguments 是个什么东西?

arguments

这里我们可以看到 arguments 对象将我传入的五个参数以数组的形式保存在里面,还有保存了我传入函

数的实参的个数(length)。

而且我们可以看到 arguments 对象的 __proto__ 是指向 object 的,这也说明了他是个类数组对

象(本质上是一个对象,但是具有 length 属性,很特殊),而不是一个数组。

arguments 是可以在函数中直接访问的,不需要 this.也不需要定义!

怎么把 arguments 转化为数组呢?可以用 Array.prototype.slice.call()方法,也可以用[...arguments]展开 arguments 放到数组里面!

所以说使用对象的扩展运算符也可以展开对象(ES7 新特性),…arguments就可以把它展开放入数组里面了!

js
let _args = Array.prototype.slice.call(arguments);
let _args = [...arguments]; //二者等价

注:什么是类数组对象?

类数组对象是指具有类似数组的结构,但不具有数组的方法和属性的对象。这些对象通常具有数字索引(从 0 开始),并且具有 length 属性来表示其长度,但它们不具备数组的方法(如 pushpopforEach 等)。

常见的类数组对象包括:

  1. DOM 节点列表: 通过 document.querySelectorAll() 或类似方法获取的 DOM 元素列表,例如 NodeListHTMLCollection
  2. 函数的 arguments 对象: 函数内部的 arguments 对象,它是一个类数组对象,包含函数调用时传递的参数。
  3. 字符串:字符串可以通过索引访问字符,具有类似数组的特性(可以理解为类数组对象,但是它不是对象)。
  4. TypedArray 对象: TypedArray 是一类类数组对象,用于处理二进制数据,如 Int8ArrayFloat32Array 等。
  5. 自定义对象: 您也可以创建自己的类数组对象,只需满足索引从 0 开始、具有 length 属性的条件。

尽管类数组对象与数组有一些相似之处,但由于它们缺少数组的方法和属性,因此无法直接使用数组的操作。为了使用数组的方法,您通常需要将类数组对象转换为真正的数组,例如使用 Array.from()Array.prototype.slice.call() 方法。

注:Array.prototype.slice.call()方法详解

Array.prototype.slice.call() 方法实际上是将 JavaScript 中数组的 slice 方法应用于其他对象(类数组对象或数组),以创建一个新的数组。这种方法通常用于将类数组对象转换为真正的数组,以便可以使用数组的方法和属性。

Array.prototype.slice.call() 是将 slice 方法与 call 方法结合使用的技巧,因为 slice 本身就是一个函数,那么它的原型上肯定有 call 方法,所以我们利用了 call 方法来在 slice 方法上设置特定的上下文,以便我们可以在类数组对象上调用 slice 方法。因为 slice 方法实际上是 Array 原型上的方法,但我们想要将它用于类数组对象上。

让我们再次强调一下这个结合的过程:

  1. Array.prototype.slice 是数组的原型方法,用于创建数组的浅拷贝。它通常这样使用:array.slice(start, end),其中 array 是一个真正的数组,startend 是起始和结束索引。
  2. call 方法是 JavaScript 函数的方法,它允许你指定函数的执行上下文(this 值)以及传递参数列表。通常这样使用:function.call(thisValue, arg1, arg2, ...)
  3. 当我们使用 Array.prototype.slice.call(arrayLike) 时,我们将 call 方法应用在 slice 方法上,并将 arrayLike(类数组对象)作为参数传递给 call 方法。这会在 slice 方法内部使用 arrayLike 作为执行上下文,实际上就是在类数组对象上调用了 slice 方法,从而将其转换为真正的数组。
js
function convertToArray(arrayLike) {
  return Array.prototype.slice.call(arrayLike);
}

const args = convertToArray(arguments);
console.log(args);

那么 Array.prototype.slice.call()和[...arguments]两者具体有什么区别呢?

注意:Array.from() 方法 其实和 Array.prototype.slice.call() 方法也是等价的,效果一样,都是转化类数组对象为数组用的!

还有一个重要区别:Array.from()可以把Set集合转化为数组,而Array.prototype.slice.call()方法不可以!

Array.prototype.slice.call(arguments)[...arguments] 都可以用来将类数组对象(如 arguments 对象)转换为真正的数组,但它们在语法和用法上有一些区别。

  1. 语法差异:
    • Array.prototype.slice.call(arguments) 使用了 call 方法,将 slice 方法应用在 arguments 对象上。slice 方法接受参数用于指定截取的起始位置和结束位置。
    • [...arguments] 使用了扩展操作符 ...,它在数组字面量内使用,将类数组对象展开成一个新的数组。
  2. 用法:
    • Array.prototype.slice.call(arguments) 的语法稍微冗长,因为它需要在调用 slice 方法时传递起始位置和结束位置。如果你不需要截取部分数组,可以省略这些参数。
    • [...arguments] 更简洁,它会将整个 arguments 对象转换为一个新的数组。

示例比较:

js
function example() {
  console.log(Array.prototype.slice.call(arguments)); // 使用 slice 方法
  console.log([...arguments]); // 使用扩展操作符
}

example(1, 2, 3);

在此示例中,两种方式都会输出 [1, 2, 3],因为它们都将 arguments 对象转换为数组。

总的来说,虽然两种方法都可以用来实现类数组对象到数组的转换,但在大多数情况下,[...arguments] 更为简洁和常见。如果你需要截取数组的一部分,那么 Array.prototype.slice.call(arguments) 可能会更有用。

js
function example() {
  const slicedArguments = Array.prototype.slice.call(arguments, 1, 3);
  console.log(slicedArguments);
}

example(1, 2, 3, 4, 5);

注意:Array.isArray 是 JavaScript 中的内置方法,但是Array.toArray 并不是 JavaScript 中的内置方法!

如果您需要将类数组对象(如 DOM 节点集合,document.querySelectorAll('.my-elements'))或类似数组的对象(如字符串、arguments 对象等)转换为真正的数组,您可以使用一些现有的方法来实现:Array.prototype.slice.call 方法或者 Array.from 方法

类数组对象和对象,两者转化为数组的区别?

将类数组对象转换为数组通常需要使用 Array.from() 方法或 Array.prototype.slice.call() 方法。我们可以将它转换为真正的数组,里面就是每一项(不会带着索引的)。

而对象转换为数组通常需要使用 Object.entries() 方法: 获取属性名和属性值的键值对数组,最后组成数组,这样就会形成一个二维数组,而不是一个普通数组。

当然如果我们想要把对象转化获得一维数组,可以使用 Object.keys() 方法或者 Object.values() 方法,但是这样势必会丢掉 key 或者 value 中的一种!

写法二:用 ES6 特性优化,省略缓存数组的构建

使用ES6 的不定参数 rest的新特性,该特性可以让不定参数变成一个数组传入,不需要访问arguments,也省去了使用Array.prototype.slice.call(arguments);生成数组,更加优雅。

js
function sumWithES6(...rest) {
  // 这里使用的是ES6数组的解构
  var _args = rest;

  var _adder = function (...innerRest) {
    // 这里使用的是ES6数组的解构
    _args.push(...innerRest); // 直接放进缓存数组里面
    return _adder;
  };

  _adder.toString = function () {
    let sum = _args.reduce(function (a, b) {
      return a + b;
    });
    return sum;
  };
  return _adder;
}

console.log("" + sumWithES6(1)(2)(3)); // 6

ES6 的不定参数 rest

ES6 引入 rest 参数(形式为...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

javascript
function add(...values) {
  let sum = 0;
  for (var val of values) {
    sum += val;
  }
  return sum;
}
add(2, 5, 3); // 10

上面代码的add函数是一个求和函数,利用 rest 参数,可以向该函数传入任意数目的参数。

写法三:优化,改为 total 变量记录和,并将求和函数抽离出来

1.利用闭包的写法:——> 更好,效率略高

js
const getSum = (args) => {
  // 参数求和方法,args一定是个数组
  return args.reduce((a, b) => {
    return a + b;
  });
};
const sum = (...args) => {
  let totalParamsSum = getSum(args); // 每次调用sum的时候都计算并作为总和total
  const _adder = (...arg2) => {
    const sum2 = getSum(arg2); // 将本次括号中的数字求和(这里不需要定义一个数组专门用来存储所有的参数,因为每次都是直接把数字加到total里面了)
    //写法一:
    totalParamsSum += sum2; //利用闭包直接改变total,不用每次都搞新的sum,而是把total利用闭包缓存起来
    return _adder; //后面从这里返回_adder,不需要每次重新初始化_adder
  };
  _adder.toString = () => {
    return totalParamsSum; //直接返回total即可
  };
  return _adder; //只有第一次调用sum从这里返回_adder
};
console.log(sum(1)(2)(3) == 6);

2.利用累加的写法:

js
const getSum = (args) => {
  // 参数求和方法,args一定是个数组
  return args.reduce((a, b) => {
    return a + b;
  });
};
const sum = (...args) => {
  const totalParamsSum = getSum(args); // 每次调用sum的时候都计算并作为总和total
  const _adder = (...arg2) => {
    const sum2 = getSum(arg2); // 将本次括号中的数字求和(这里不需要定义一个数组专门用来存储所有的参数,因为每次都是直接把数字加到total里面了)
    //写法二:
    return sum(totalParamsSum + sum2); // 把本次括号和与之前的和进行累加,然后重新构建一个sum,以便给total重新赋值,并返回_adder(这种理解起来较为困难,实际上就是每次都搞一个新的sum,但是这个新的sum会接收之前的所有和作为参数,保证total的累加性)
  };
  _adder.toString = () => {
    return totalParamsSum; //直接返回total即可
  };
  return _adder; // 每次都从这里返回_adder(每次都是一个重新初始化的_adder)
};
console.log(sum(1)(2)(3) == 6);

最终最简便的写法

js
//自己手写函数柯里化
const sum = (...rest) => {
  let _args = rest; //缓存数组 ——> 第一步
  let _adder = (...innerRest) => {
    //_adder函数,里面存储参数到缓存数组,并返回一个新的_adder函数 ——> 第二步
    _args.push(...innerRest);
    return _adder;
  };
  _adder.toString = () => {
    //重写toString方法 ——> 第三步
    return _args.reduce((a, b) => a + b);
  };
  return _adder;
};
console.log("" + sum(1)(2)(3));

注意:如果面试官让输出 sum(1)(2)(3).valueOf(),而不是直接 sum(1)(2)(3),那么就添加一个 valueOf()方法就可以了,就别重写 toString 方法了!

js
const sum = (...rest) => {
  let _args = rest;
  let _adder = (...innerRest) => {
    _args.push(...innerRest);
    return _adder;
  };
  _adder.valueOf = () => {
    return _args.reduce((a, b) => a + b); //默认a的初始值是null
  };
  return _adder;
};
console.log(sum(1)(2)(3).valueOf());

考法变形

1.参数接收一个累加函数并构造出来函数的形式,然后接收参数(固定个数或者非固定都可以)

使用 slice 和 apply

注意:这样的话,sum 并不直接是一个可以使用的柯里化函数,它的返回值才是一个柯里化函数

所以我们的 sum 这里是一个高阶函数!

js
function sum(fn) {
  return function () {
    let _args = Array.prototype.slice.call(arguments);

    let _adder = function () {
      _args.push(...arguments);
      return _adder;
    };

    _adder.toString = function () {
      return fn.apply(null, _args); //这里我们直接调用传入的累加函数
    };
    /*
            之前的写法:
            _adder.toString = function(){
                return _args.reduce((prev,cur) => {
                    return prev + cur;
                });
            }
        */
    return _adder;
  };
}

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

const sumAdd = sum(add); //构造出来一个柯里化函数

console.log("" + sumAdd(1)(2)(3)); // 输出 6
console.log("" + sumAdd(1, 2)(3)); // 输出 6

2.接收累加函数,并直接接收参数(固定个数或者非固定都可以)

js
function add(a, b, c) {
  // console.log(a, b, c);
  return a + b + c;
}
const curry = (fun, ...args) => {
  let arr = [];
  // arr.concat(args); //注意:concat是会返回新数组的,而不是修改原数组 ——> 大坑,所以尽量用push(...args)的方式!
  arr.push(...args); //push可以改变原数组,并且可以接收多个参数,可以使用push
  function sum(...args2) {
    // arr.concat(args2);
    arr.push(...args2);
    return sum;
  }
  sum.toString = () => {
    //return fun(arr[0], arr[1], arr[2]); //这种写法比较低级
    return fun.apply(null, arr); //高级写法:apply会自动将数组拆开为单个元素
  };
  return sum;
};
console.log(curry(add, 1)(2)(3) == 6);
console.log(curry(add)(1, 2)(3) == 6);
console.log(curry(add, 1, 2, 3) == 6);

涉及的知识点:

1.arr.push(...args)是往数组里面合并元素的最佳选择,因为 push 可以改变原数组,并且可以接收多个参数

而 concat 方法是会返回新数组的,而不是修改原数组,所以用 arr.concat(args)合并是不行的,需要写成 arr = arr.concat(args);

2.fn.apply(null, arr)是执行一个接收若干参数的方法的最佳选择,因为 apply 会自动将 arr 拆成单个元素传给 fn 方法

fun(arr[0], arr[1], arr[2])这种写法的普适应不高,并且写法不方便!