函数式初识(3) - Point-Free

by Teobler on 22 / 08 / 2019

views

Point-free是什么?Point实际上指的是函数的参数,Point-free是一种编程风格,这是一种在函数里面实际上没有写任何东西的函数编写方式,这种方式可以将一个函数与其他函数一起生成一个新的函数,而实际上并没有定义这个新的函数的参数是什么。比如下面这个函数就是一个简单的Point-free风格的函数:

import * as R from "ramda";
 
const addOne = x => x + 1;
const square = x => x * x;
 
const addOneThenSquare = R.compose(square, addOne);
 
addOneThenSquare(1); // 4

由于JS不是一门严格的函数式编程语言,所以在这里我们引入了一个叫做“ramda”的第三方库来帮我们完成一些函数式编程的事情。首先我们有两个简单的函数,这两个函数的作用分别是对传入的数进行加一和平方的操作,然后我将两个函数compose(compose是函数式编程里面的一种方法,该函数会将接收到的方法“从右到左”串联起来依次执行,将上一个函数的返回值作为下一个函数的入参)起来得到一个新的函数,这个新函数的作用是讲传入的数先加一再平方。定义这个新函数的方法就是Point-free风格。

当我们的函数中有参数时,我们的编程风格更像是命令式的,其实我们是在引导一个变量从输入值转换成输出值。但是当我们使用Point-free风格编程时,我们的编程就更偏向声明式。实际上我们是将参数的转换过程隐藏了,其实当你去问别的程序员代码是显式的好还是隐式的好,可能大多数程序员都会告诉你显式的好,因为那可以让你看到代码里发生了什么。但是在有的情况下代码可以是隐式的,比如在函数式编程中,那些一个个的“函数组件”能够给我们足够的信心处理好我们的输入输出,我们只需要关注在最后的输出是不是我们想要的,不用去关注它是怎么去做转换的,因为这对于读你代码的人来说是一些不必要立马去了解的细节。

可读性

Point-free的定义很简单,看到这里说不定一些读者就会说到,我以前也这么玩过,只不过我不知道这个就叫做Point-free。是的Point-free的定义很简单,但是要用好它就没那么简单了。设想一下,上面那个简单的例子,如果把他们的函数名改成xyz,你还能知道最后一个函数是在干嘛吗,当然这个例子比较极端,但是不妨试想下你在项目上看到过的同事起的各种奇葩的命名,然后将这一堆看起来就不知道干嘛的函数全部compose在一起,那这对于维护代码的人来说将是灾难性的。这就与函数式编程能够提高代码可读性的结论背道而驰了。所以要用好Point-free的第一件事情就是为你的函数“组件”起一个通俗易懂的好名字,让阅读者能够看着名字就知道函数在干嘛而不用去深入函数细节,还原函数式编程的初衷。

所以不要滥用Ponit-free,当项目中出现了过多的Point-free风格但又没有严格符合编程规范的话,很可能会让你的项目难以维护。

但是其实compose只是Point-free中的一种应用,还有许许多多别的应用,只要你使用得当,你的代码依旧可以清晰可读,比如下面的这个例子:

const not = (fn) => {
    return function negated(...args) {
        return !fn(...args);
    }
}
 
const isOdd(v) = {
    return v % 2 === 1;
}
 
const isEven = not(isOdd);
 
isEven(4);

在这个例子中,我们当然可以将isEven的定义写成return v % 2 === 0,但是其实这样的定义是给代码的阅读者增加了额外的理解负担的,你当然可以说例子里面的代码很简单,你可以一眼就看出来这个函数要做的事情,但是如果这是你公司项目中的一个复杂的判断函数呢,那是不是你就需要读两次几乎一样的代码来判断,最后发现这两个函数其实只是在做相反的事情。

这样的定义方式并不是为了我们在写代码的时候可以少写几个字母,更重要的是让我们项目里面的各个”函数组件“之间有了联系,让读代码的人一眼就能看出来,这两个函数是相反的关系,让他能够更容易的理解代码,说到底是为了提升代码的可读性。

函数的“形状”

可以注意到在使用Point-free这种编程风格时,组成最新那个函数的各个函数“组件”都是只接受一个参数并且只返回一个返回值,也就是说这里的函数都需要是数学中的一元函数。所以在函数式编程中,保持你的所有函数是一元函数是及其重要的,这可以保证它们如同乐高玩具一样拥有相同的接口,可以相互连接在一起。

当然并不是所有的函数都可以是一元函数,所以在函数式编程中有一个极其重要的东西是柯里化,当你的函数不是一元函数但是你却又想将其作为组件组合在一起时就需要通过柯里化的形式讲一个二元甚至是多元的函数转换成一个一元函数,然后再同其他函数组合在一起。也就是说在函数式编程中所有的函数都需要支持柯里化,这样才能保证各个函数之间能够正常工作。

Point-free的”高级“应用

其实Point-free的应用不仅仅是利用原本的函数来组合新的函数,更进一步的话,Point-free应该是使用一些函数的”通用组件“去组成你需要的”特殊组件“。我们还是以上面的两个函数为例:

const mod = (y) => {
    return function forX(x) {
        return x % y;
    }
}
 
const eq = (y) => {
    return function forX(x) {
        return x === y;
    }
}

加入我们有两个函数,其中一个函数先接受一个变量y,然后返回一个函数,这个新返回的函数接收另一个参数x,然后返回x对y取余的结果;第二个函数先接受一个变量y,然后返回一个函数,这个新返回的函数接收另一个参数x,然后返回x是否等于y的结果。请注意,这两个函数有两个很重要的特点:

  1. 它们接收的参数顺序是反直觉的,但是在函数式编程中却是必须应该这样的,原因我们可以继续往下看;
  2. 它们虽然都不是一元函数,但是都经过了柯里化处理,是由两个一元函数”嵌套“而成的,这一点在函数式编程中也极为重要,这决定了这些函数是否有相同的接口,是否能够组合在一起;

然后我们用这两个函数去定义我们之前例子中的两个函数:

const mod2 = mod(2);
const eq1 = eq(1);
 
const isOdd = (x) => eq1(mod2(x));

我们先给mod和eq两个函数传了一个参数,让它们从一个一般化的函数成为一个特殊化的函数,之后isOdd函数只接受一个参数,这个参数直接传给mod2,然后它的返回值再直接传给eq1。这样的定义其实已经从命令式慢慢过渡到声明式了,只不过还不能算是函数式的Point-free。不过这样直接传一个参数,计算后直接将返回值传给下一个函数作为入参,这在数学中有一个名字叫做Composition。是的没错,就是文章开头的那个composition,其实compose函数也很简单,他只做了一件事:

const compose = (fn2, fn1) => {
    return function composed(v) {
        return fn2(fn1(v));
    }
}

所以我们的定义又会变成:

const isOdd = compose(eq1, mod2);

但是其实在函数式编程中一般情况下大家都不会去特意构造一个特殊的函数(mod2, eq1),所以其实最后的Point-free定义就是:

const isOdd = compose(eq(1), mod(2));

其实乍一看上去,compose的参数也是反直觉的,人们通常习惯从左到右进行阅读,但其实这里的运行顺序是从右到左的。这当然是有原因的:

  1. 我们回到之前提到的eqmod函数中的第一个重要特点 — 参数顺序,我们假如他们的参数顺序是符合人类直觉的,那么他们就不可能像我们最终定义哪样进行定义;
  2. 当然你也可以说,我可以改变compose函数的定义来让他们可以组合在一起呀。这就是我第二个原因,在数学中函数复合写作f o g(相当于compose(f, g)),意思是一个函数接收一个参数x,并返回一个f(g(x))。

所以其实compose的定义是严格遵循数学中的composition的,当然如果你是在觉得这有点反直觉,其实也有一个叫做pipe的函数,作用跟compose完全一样,只是参数的顺序完全相反。

而且compose也是完全符合数学中的结合律的:

const associative = compose(f, compose(g, h)) === compose(compose(f , g), h); // true

这也就意味着我们可以有更高的灵活性去组合我们的函数。