函数式初识(5) - 偏函数和柯里化

by Teobler on 31/08/2019

undefined views

从通用到特殊

在前面的文章中我们已经提到过在函数式编程中,一个函数“形状”的重要性,我们力求每一个函数都是一元函数,以便我们将各个函数组合在一起,但是我们总会遇到各种各样的情况迫使我们的函数是一元二元甚至是多元的,这时我们就需要借助柯里化来“改变函数的形状”,使其能够适应别的函数。同时这也是将一个函数从“一般化”引向“特殊化”的过程,这在函数式编程中极其重要。

const ajax = (url, data, cb) {/*..*/}

ajax(CUSTOMER_API, { id: 33 }, renderCustomer);

假如我们有一个函数ajax,然后我们通过传入一些参数调用它,它的作用是通过请求某个API拿到数据后在页面中渲染UI。一个很常见的函数,但是或许我们在调用的时候给了太多的信息量,这需要代码的阅读者思考这里发生了什么,我们其实可以让它传达的信息更简单些,能让读者一瞬间清晰的理解代码想要表达的内容,当然不是为了让代码单纯的更短,实际上可能会更长。

const getCustomer (data, cb) {
    return ajax(CUSTOMER_API, data, cb);
}

getCustomer({ id: 33 }, renderCustomer);

因为url其实是一个hard code,我们实际上只需要request data和callback就可以调用这个函数,那么我们将ajax封装用getCustomer起来,这个时候再去调用,就会少了一个变量,同时也少了一些信息量,能让读者一眼看出这个函数想干嘛。但是其实这个功劳是函数名带来的,这个名字要比ajax更加语义化,更能让人明白目的。

这样的变化其实并没有让代码变简单,但是因为多封装了一层函数,表面上看起来代码变复杂了,但是你可以将一个“一般化”的函数变成一个“特殊化”函数,并给它取了一个语义化的函数名,能让人一眼看出这个特殊的函数目的是什么,更容易理解。

按照这个思路,其实我们可以进一步将这个更加“特殊化”,向读者传递更加深层的信息。比如我们需要拿到当前特定登录用户的信息,那么我们可以有下面这样一个函数,可以更加详细的表达这些信息:

const getCurrentUser (cb) {
    // return ajax(CUSTOMER_API, { id: 33}, cb);
    return getCustomer({ id: 33}, cb);
}

getCurrentUser(renderCustomer);

值得注意的是我们在定义getCurrentUser这个函数的时候,我们其实可以用ajax这个函数去定义,不用是因为getCustomer能让我们更加清晰的去表达各个函数之间层层递进的关系。

参数顺序

当你想要让一个函数从“一般化”到“特殊化” — 也就是让一个多元函数逐渐变成一个一元、二元函数 — 的时候,我们需要重点考虑传入参数的顺序。参数的优先级应该也是从一般化到特殊化的,也就是说最闲传入的参数应该是一个一般化的参数,后面的参数越来越多具体。假如你一开始传入的就是一个很具体的参数,那么后面的参数将难以与其匹配,这个函数将直接成为一个单一作用的函数。

例如上面的ajax函数,如果我一开始传入的参数就是一个callback,那么我得到的新的柯里化函数只能用作render,别无它用,这也违背了函数式中函数组件复用的原则。所以在设计函数的时候,参数的顺序是一个很重要的点,这一点可以从各个函数式的三方库中(ramda.js, lodash/fp等)去吸取经验。

比如ramda库中的map函数,其所接受的第一个参数是callback回调函数,第二个参数时传入这个callbak的数组,第一个参数使这个map函数成为了一个特殊化的mapper,之后可以很轻易的复用它,它可以接受各种输入返回需要的值。假如第一个参数使数组,那么返回的新函数将是一个只用于该数组的map函数。

偏函数(partial)和柯里化(currying)

偏函数和柯里化是每一个函数式编程的库都会为你提供的两个工具函数,有了这两个函数,我们就可以不必手动去写如同上面例子的"函数特殊化",这一切可以交给工具函数帮我们实现。我们不关心其内部怎么实现的,我们在此只说怎么使用它们。

看到偏函数这个名字是不是有点熟悉,不错,它来源于数学中的偏导:

数学中,一个多变量的函数的偏导数(英语:partial derivative)是它关于其中一个变量的导数,而保持其他变量恒定(相对于全导数,在其中所有变量都允许变化)

那么这样就很好理解了,其实偏函数相当于提前设定好了某个函数需要的部分参数,并返回一个新的函数来等待接下来的参数传入:

const ajax(url, data, cb) { /*...*/ }

const getCustomer = partial(ajax, CUSTOMER_API);
// const getCurrentUser = partial(ajax, CUSTOMER_API, { id:33 });
const getCurrentUser = partial(getCustomer, { id: 33 });

getCustomer({ id: 33}, renderCustomer);
getCurrentUser(renderCustomer);

熟悉函数式的同学可能会更加喜欢柯里化,柯里化能将一个接受多个参数的函数转换成一次只接受一个参数的函数,没接收一个新的参数,将会返回一个新的函数等待接下来的一个参数,一直到所有参数接受完成后返回运算的结果:

// const ajax => url => data => cb => ajax(url, data, cb);
function ajax(url) {
    return function getData(data) {
        return function getCB(cb) {
            return ajax(url, data, cb);
        }
    }
}

ajax(CUSTOMER_API)({ id: 33})(renderCustomer);

const getCustomer = ajax(CUSTOMER_API);
const getCurrentUser = getCustomer({ id: 33});

当然这里我们是手动定义了一个天然柯里化的函数,但是如果每个函数都这样写那就太累了,所以我们用库来帮我们完成这件事:

const ajax = curry(3, function ajax(url, data, cb) {/*...*/});

const getCustomer = ajax(CUSTOMER_API);
const getCurrentUser = getCustomer({ id: 33});

curry函数要你告诉它你想要柯里化的函数有几个参数,然后再传入那个函数就ok了。

异同点

  • 两者都为了达成一个目的 — 将一个一般化的通用函数变成一个特殊化的函数。
  • 柯里化能做到”一次付费终生使用“,你只需要柯里化一次你的函数,那么这个函数就能被任意级别的特殊化,并且柯里化没有预先传入参数,所有参数需要你在接下来的使用中逐个传入。
  • 偏函数是"一次定型"的,每一次如果你想要继续改变函数的形状,你都需要再次调用工具函数,并且在最开始就传入了一部分初始参数,剩下的参数在调用时一次传入。