函数式编程之柯里化

发布于 2022-05-28 13:22:05 字数 11189 浏览 1055 评论 0

概念

柯里化(英语:Currying),又称为部分求值,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回一个新的函数的技术,新函数接受余下参数并返回运算结果。

核心思想

你可以使用比它期望的更少的参数调用一个函数。它会返回一个新函数用于接收剩余的参数。

换句话说,一个柯里函数随着时间的推移会接收并提供额外的参数。它通过返回一个全新的函数来实现这个功能。

有什么用?

从函数式编程的角度而言,它可以使你的程序变得更“纯粹”。

纯粹函数有助于你和其他团队成员更好的理解你的程序。

下面举一个例子来说明:

假设我们要操作一个包含动物信息的列表:

const animals = [
  {
    id: 1,
    name: "Tails",
    type: "Cat",
    adoptable: false
  },
  {
    id: 2,
    name: "Soul",
    type: "Cat",
    adoptable: true
  },
  {
    id: 3,
    name: "Fred",
    type: "Dog",
    adoptable: true
  },
  {
    id: 4,
    name: "Fury",
    type: "Lion",
    adoptable: true
  }
];

我们需要根据种类过滤这些动物。我们可能会像下边这样写:

animals.filter(animal =>
   animal.type === "Cat"
);

该代码的主要缺陷在于动物的种类和过滤的行为绑定的太紧密了。影响了可复用性并且培养了我们最讨厌的敌人的状态。

const isAnimal =
   type =>
     animal =>
       animal.type === type

animals.filter(isAnimal("Cat"));

isAnimal() 接受一个参数 type ; isAnimal()返回一个全新的函数; 返回的新函数作为 .filter() 的回调函数。闭包允许这个新函数访问最初传入的type变量,因此我们可以进行基于种类的比对。

随着应用不断变大,想要了解每个函数的功能以及它们来自代码库哪个部分变得越来越困难。像改进的例子中一样尽量使用纯粹函数可以避免维护程序状态并且由于将程序划分成了更多的单一职责的小块,程序也变得更易于测试了。

这里是从编写代码的角度看的,下面从使用上来看:

再看个例子:

function ajax(type, url, data) {
    var xhr = new XMLHttpRequest();
    xhr.open(type, url, true);
    xhr.send(data);
}

// 虽然 ajax 这个函数非常通用,但在重复调用的时候参数冗余
ajax('POST', 'www.test.com', "name=kevin")
ajax('POST', 'www.test2.com', "name=kevin")
ajax('POST', 'www.test3.com', "name=kevin")

// 利用 curry
var ajaxCurry = curry(ajax);

// 以 POST 类型请求数据
var post = ajaxCurry('POST');
post('www.test.com', "name=kevin");

// 以 POST 类型请求来自于 www.test.com 的数据
var postFromTest = post('www.test.com');
postFromTest("name=kevin");

curry 的这种用途可以理解为:参数复用。本质上是降低通用性,提高适用性。

var curry = require('lodash').curry;

var match = curry(function(what, str) {
  return str.match(what);
});

var replace = curry(function(what, replacement, str) {
  return str.replace(what, replacement);
});

var filter = curry(function(f, ary) {
  return ary.filter(f);
});

var map = curry(function(f, ary) {
  return ary.map(f);
});

这里的代码中遵循的是一种简单,同时也非常重要的模式。即策略性地把要操作的数据(String, Array)放到最后一个参数里。到使用它们的时候你就明白这样做的原因是什么了。

match(/\s+/g, "hello world"); // [ ' ' ]
match(/\s+/g)("hello world"); // [ ' ' ]

var hasSpaces = match(/\s+/g); // function(x) { return x.match(/\s+/g) }
hasSpaces("hello world"); // [ ' ' ]
hasSpaces("spaceless"); // null

filter(hasSpaces, ["tori_spelling", "tori amos"]); // ["tori amos"]
var findSpaces = filter(hasSpaces); // function(xs) { return xs.filter(function(x) { return x.match(/\s+/g) }) }

findSpaces(["tori_spelling", "tori amos"]); // ["tori amos"]
var noVowels = replace(/[aeiou]/ig); // function(replacement, x) { return x.replace(/[aeiou]/ig, replacement) }

var censored = noVowels("*"); // function(x) { return x.replace(/[aeiou]/ig, "*") }
censored("Chocolate Rain"); // 'Ch*c*l*t* R**n'

这里表明的是一种“预加载”函数的能力,通过传递一到两个参数调用函数,就能得到一个记住了这些参数的新函数。

实现 Array.map() 方法

方法: 使用 for 循环。

我们在使用for循环的时候,一个循环过程确实很好封装,但是我们在for循环里面要对每一项做的事情却很难用一个固定的东西去把它封装起来。因为每一个场景,for循环里对数据的处理肯定都是不一样的。

我们将这些不一样的操作单独用一个函数来处理,具体这个回调函数中会是什么样的操作,则由我们自己在使用时决定。因此,根据这个思路的封装实现如下。

Array.prototype.myMap = function(fn, objCtx) {
    if(typeof fn !== 'function') return;
    let result = [];
    for(let i = 0, len = this.length; i < len; i++) {
        result.push(fn.call(objCtx, this[i], i, this));
    }
    return result;
}

注意:当 map() 不传第二个参数时,回调函数中的 this 是未定义的(即非严格模式下是 window)。

[1,2,3].map(function(item, index) {
	console.log('--',this)
}) // 打印三个 window

[1,2,3].map(function(item, index) {
	console.log('--',this)
},[233]) // 打印三个 [233]

所以,当使用代码规范如 eslint 时,会发现使用 map() 其中回调函数不写返回值会报错。

实现一个 add() 方法

实现一个add方法,使计算结果能够满足如下预期:

add(1)(2)(3) = 6
add(1, 2, 3)(4) = 10
add(1)(2)(3)(4)(5) = 15

即,计算结果是所有参数的和,add方法没执行一次,肯定返回了一个同样的函数,继续计算剩下的参数。

当参数为两个或者三个,我们可以这样写:

function add(a) {
    return function (b) {
        return a + b;
    }
}
// 调用
add(1)(2);

function add() {
    return function (a, b) {
        return function (c) {
            return a + b + c;
        }
    }
}
// 调用
add()(1,2)(3)

可以发现,虽然可以实现,但是参数的使用被限制得很死。

我们需要一种通用的封装:利用闭包的特性,将所有的参数,集中到最后返回的函数里进行计算并返回结果。

function add() {
    let args = [...arguments].slice(0); // 存储第一次执行时的参数

    // 在内部声明一个函数,利用闭包的特性保存 args 并收集接下来执行传入的参数值
    function adder() {
        function _adder() {
            args = args.concat(...arguments);
            return _adder;
        }
        _adder.toString = function() {
            return args.reduce(function (a, b) {
                return a + b;
            });
        }
        return _adder;
    }
    return adder.apply(null, args);
}

add(1,2,3)(1); // 7
add(1,2,3)(1)(1,2); // 10
add(1,2,3)(1)(1,2)()(); // 10

写法二: 思路一毛一样,只是写法略有不同

function add() {
    let args = [...arguments];
    function _add() {
        args = args.concat([...arguments]);
        return add.apply(null, args);
    }
    _add.toString = function() {
        return args.reduce(function (a, b) {
            return a + b;
        });
    }
    return _add;
}

这里的实现,没有对调用 add() 方法的次数和传参的个数做限制,既可以调用任意次,也可以传入任意个参数的个数。

实现方法:内部函数 _adder 返回了自身函数,并同时重写了 该函数的toString() 方法,是为了可以无限制的调用 add() 方法的同时可以将之前所有的参数做一个处理。

这里涉及到函数的 toString() 方法。

  • Function.toString()
function fn() {
    return 20;
}

console.log(fn + 10); 
// "function fn() {return 20}20"
function fn() {
    return 20;
}

fn.toString = function() {
    return 10;
}

console.log(fn + 10);
// 20
function fn() {
    return 20;
}

fn.toString = function() {
    return 10;
}

fn.valueOf = function() {
    return 5;
}

console.log(fn + 10);
// 15

涉及到隐式转换。当我们没有重新定义 toString() 与 valueOf() 时,函数的隐式转换会调用默认的 toString() 方法,它会将函数的定义内容作为字符串返回

而当我们主动定义了 toString / vauleOf 方法时,那么隐式转换的返回结果则由我们自己控制了。其中 valueOf() 的优先级会 toString() 高一点

但实际上,会发现如果不停地调用 add() 方法而不传参数的话,是没有什么意义的,程序白白执行。通常情况下,我们会表明函数的形参的个数,而分几次调用则由调用者决定。

这里只是对加法操作进行柯里化,可不可以对所有带有参数的函数都进行柯里化。

对函数 add(a, b, c) 进行柯里化;
对函数 add(a, b, c, d) 进行柯里化;

这里就涉及到封装一个通用的柯里化函数,对任何带有参数的函数都使用。即能将任何有参数的函数进行柯里化。

实现一个对任何带有参数的函数都适用的柯里化函数

  1. 实现一个将参数仅分为两次调用的柯里化函数
let curry = function (fn) {
    let args = [...arguments].slice(1);
    return function () {
        args = args.concat(...arguments);
        return fn.apply(this, args);
    }
}
function add(a, b, c) {
	return a + b + c
}

let curryAdd1 = curry(add, 1);
curryAdd(3, 4);

let curryAdd2 = curry(add);
curryAdd(1, 3, 4);

let curryAdd2 = curry(add, 1, 2);
curryAdd(4);

let curryAdd2 = curry(add, 1, 2, 3);
curryAdd();

可以发现函数的 bind()函数就是这样的存在。可以查看 bind()的实现第二次调用才执行

总之, 就是在实参传入的个数大于等于形参的个数时,必须执行两次。嗯...有限制。

我们需要的是,在实参传入的个数大于等于形参的个数时,可以执行任意次;只要每次执行都传入参数,且参数的个数至少是形参的个数。

看个知识点:

  • Function.length

Function.length 形参的个数。形参的数量不包括剩余参数个数,仅包括第一个具有默认值之前的参数个数。
与之对比的是, arguments.length 是函数被调用时实际传参的个数(实参的个数)。

Function 构造器本身也是个Function。他的 length 属性值为 1 。

Function 原型对象的 length 属性值为 0 。

console.log(Function.length); /* 1 */

console.log((function()        {}).length); /* 0 */
console.log((function(a)       {}).length); /* 1 */
console.log((function(a, b)    {}).length); /* 2 etc. */

console.log((function(...args) {}).length); 
// 0, rest parameter is not counted


console.log((function(a, b = 1, c) {}).length); // 1
console.log((function(a = 1, b, c) {}).length) // 0
console.log((function(b, a = 1, c) {}).length) // 1
console.log((function(b, c, a = 1) {}).length) // 2
  1. curry() 函数来了...

会发现由于形参数量的限定,使得curry() 函数的实现要比 add() 方法的实现要简单。

var curry = function(fn, args = []) {
    let length = fn.length;
    return function () {
        let newArgs = args.concat(...arguments); 
        // args = args.concat(...arguments); wrong !!!
        // 注意 这里需要使用新变量来保存之前的参数,否则因为闭包的原因,第二次使用 curry()后的函数时,这里 args 便不是 空值[] 了,会保存上次结尾的 args

        if(newArgs.length < length) {
            return curry(fn, newArgs);
        } else {
            return fn.apply(null, newArgs);
        }
    }
}

测试1: 对 add1() 进行柯里化

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

var addCurry1 = curry(add1);

addCurry1(1)(2)(3); // 6
addCurry1(1)(2,3); // 6
addCurry1(1,2,3); // 6
addCurry1(1,2)(3); // 6

测试2:对 add2() 进行柯里化

function add2(a, b, c, d){
	return a * b * c * d;
}
var addCurry2 = curry(add2);

addCurry2(1)(2)(3)(4); // 24
addCurry2(1)(2, 3, 4); // 24
addCurry2(1, 2, 3, 4); // 24
addCurry2(1, 2)(3)(4); // 24

柯里化应用

[11, 11, 11, 11].map(parseInt); // [11, NaN, 3, 4]

我们知道 parseInt(string, radix) 的转换规则是:点这里

总的来说就是:当radix在2~36之间时(0,看做是十进制下的数,除外,直接输出NaN),将string看做radix进制下的数,并将这个数转换为十进制输出:从第一个字符开始解析,如果不为radix进制下的数直接输出NaN; 否则解析到不属于radix进制下的字符时,该忽略该字符及其后的所有字符。

举例说明:

parseInt(2, 0); // 基数为 0 把 string 当做十进制
parseInt(2, 1); // NaN
parseInt(11, 2); // 3
parseInt(113, 2); // 3 因为 113中的3不是二进制下的字符,忽略
parseInt(11, 3); // 4 将三进制的 11 转换为 十进制 ;任意进制转换为十进制有个幂乘公式

知识点巩固完了,要说的是,按照一等函数的方式使用 parseInt会带来一些副作用。相同的输入数字 11 ,结果各不同。

如何改变这种情况呐,可以使用 curry(), 强制 parseInt() 在每次调用时只接收一个参数。

但是,这里curry() 函数,是向左柯里化的。即先执行最右边的参数。

[11, 11, 11, 11].map(curry(parseInt)(10));

偏函数

固定了函数的某一个或几个参数,这时才返回一个新的函数,接收剩下的参数, 参数个数可能是1个,也可能是2个,甚至更多。

function Add(x, y ,z) {}
AddBySeven = Partial(Add, 7);
AddBySeven(5, 10); // returns 22;

反柯里化(uncurrying)

可能遇到这种情况:拿到一个柯里化后的函数,却想要它柯里化之前的版本,这本质上就是想将类似 f(1)(2)(3) 的函数变回类似 g(1,2,3) 的函数。

总结

可以发现,如果一个函数剩余的参数应用都是其返回函数实现的,典型的柯里化。

即 curry() 返回一个新的函数,允许我们用一个或多个参数来调用它,然后它将部分应用;直到它收到最后一个参数(基于原始函数的参数数量),此时它将返回使用所有参数调用原始函数的计算值。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

JSmiles

生命进入颠沛而奔忙的本质状态,并将以不断告别和相遇的陈旧方式继续下去。

0 文章
0 评论
84961 人气
更多

推荐作者

已经忘了多久

文章 0 评论 0

15867725375

文章 0 评论 0

LonelySnow

文章 0 评论 0

走过海棠暮

文章 0 评论 0

轻许诺言

文章 0 评论 0

信馬由缰

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文