【译】成为一名函数式码农系列之四

原文链接:So You Want to be a Functional Programmer (Part 4)

作者:Charles Scalfani

翻译:野草

本文首发于前端早读课【第882期】

往期回顾:

成为一名函数式码农系列之一

成为一名函数式码农系列之二

成为一名函数式码农系列之三

柯里化(Currying)

还记得上一篇&version=12020010&nettype=WIFI&fontScale=100&pass_ticket=JFf28rl3lRGLTbD%2BeYD6h8wkUGWvoH%2F01Zq%2BK7gOhD5pHe4AAQLoPJz9TyOx7Y%2Fm)我们讲的吗?当mult5接受1个参数,而add接受2个参数时,mult5add 的函数复合遇到了麻烦。

其实解决这个问题很简单,我们只需限定所有的函数只能接受1个函数。

听起来有点难以接受?相信我,并没有你想得那么糟糕。

我们只需将接受2个参数的add函数写成每次只接受1个参数(下简称单参)的函数。那要怎么做呢?正巧,函数柯里化就是用来干这个的。

柯里化函数是每次只接受单参的函数。

我们先赋值add的第1个参数,然后再组合上mult5,得到mult5AfterAdd10函数。当mult5AfterAdd10函数被调用的时候,add得到了它的第2个参数。

我们用JavaScript重写add函数:

var add = x => y => x + y

此时的add函数先后分两次得到第1个和第2个参数。具体地说,add函数接受单参x,返回一个也接受单参y的函数,这个函数最终返回x+y的结果。

现在我们可以利用这个add函数来写出一个可行的mult5AfterAdd10函数。

var compose = (f, g) => x => f(g(x));
var mult5AfterAdd10 = compose(mult5, add(10));

该复合函数接受2个函数,fg,然后返回一个接受单参x的函数,当这个函数被调用时,f复合上g的映射就会应用在x上。

总结一下,我们到底做了什么?我们就是将简单常见的add函数转化成了柯里化函数,这样add函数就变得更加自由灵活了。我们先将第1个参数10输入,而当mult5AfterAdd10函数被调用的时候,最后1个参数才有了确定的值。

至此,你可能在思考怎么用Elm语言重写add函数。根本无需思考。在Elm这类的函数式语言中,所有的函数都默认是柯里化处理的。

所以,柯里化版本的add函数还是这样写(指跟上一篇提到的写法一致):

add x y = 
    x + y

而在上一篇文章中,我们已经知道了mult5AfterAdd10函数的写法。

mult5AfterAdd10 = 
    (mult5 << add 10)

从语法上来说,比起命令式语言(如JavaScript),Elm更胜一筹。因为它在函数式处理上进行了优化,比如柯里化,函数复合上。

柯里化与重构(Curring and Refactoring)

当你创建了一个接受很多参数的普适函数,然后想限制部分参数生成接受更少参数的一些特殊函数,此时柯里化大展拳脚,可以为你独当一面。

例如,我们有以下两个函数,它们分别将输入字符串用单花括号和双花括号包裹起来:

bracket str =
    "{" ++ str ++ "}"
doubleBracket str =
    "{{" ++ str ++ "}}"

调用方式如下:

bracketedJoe =
    bracket "Joe"
doubleBracketedJoe =
    doubleBracket "Joe"

我们可以将bracketdoubleBracket推广为更普适的函数:

generalBracket prefix str suffix =
    prefix ++ str ++ suffix

但每次我们调用generalBracket函数的时候,都得这么传参:

bracketedJoe =
    generalBracket "{" "Joe" "}"
doubleBracketedJoe =
    generalBracket "{{" "Joe" "}}"

之前参数只需要输入1个,但定义了2个独立的函数;现在函数统一了,每次却需要传入3个参数。能不能鱼与熊掌兼得呢?

如果重新调整一下generalBracket函数的参数顺序,由于函数是可以被柯里化的,我们可以利用generalBracket函数创建出bracket函数和doubleBracket函数:

generalBracket prefix suffix str =
    prefix ++ str ++ suffix
bracket =
    generalBracket "{" "}"
doubleBracket =
    generalBracket "{{" "}}"

只要将将最可能不变的参数即prefixsuffix放在最前面,把最可能变化的参数即 str放在最后面,我们很容易就能创建出generalBracket函数的特殊版本。

参数顺序是能否最大程度利用柯里化的关键所在。

同时注意一下,bracket函数和doubleBracket函数都是隐参标记式(point-free notation)写法,即参数 str是隐含的。它们都是单参数函数,等待着最后的参数输入。

现在我们可以像最开始那样调用了:

bracketedJoe =
    bracket "Joe"
doubleBracketedJoe =
    doubleBracket "Joe"

但,这次我们调用的是更普适的柯里化函数,generalBracket

常见的函数式函数(Common Functional Function)

我们来看下函数式语言中3个常见的函数:Map,Filter,Reduce

在这之前,先看如下JavaScript代码:

for (var i = 0; i < something.length; ++i) {
     // do stuff
}

这段代码存在一个很大的问题,但不是bug。问题在于它有很多重复代码(boilerplate code)。

如果你用命令式语言来编程,比如Java,C#,JavaScript,PHP,Python等等,你会发现这样的代码你写地最多。

这就是问题所在。

现在让我们来一起解决这个问题。将代码重写成一个(或者几个)绝对不需要for循环的函数。额,我是说,几乎不需要,至少在我们投入函数式语言之后。

先用名为things的数组来修改上述代码:

var things = [1, 2, 3, 4];
for (var i = 0; i < things.length; ++i) {
    things[i] = things[i] * 10; // 警告:值被改变 !!!!
}
console.log(things); // [10, 20, 30, 40]

什么?!数值被改变了!

我们再试一次,这次不会再修改things

var things = [1, 2, 3, 4];
var newThings = [];
for (var i = 0; i < things.length; ++i) {
    newThings[i] = things[i] * 10;
}
console.log(newThings); // [10, 20, 30, 40]

我们没有修改things数值,实质上却修改了newThings。暂时先不管这个,毕竟我们现在用的是JavaScript。一旦使用函数式语言,任何东西都是不可变的。

到此,你应该明白这些函数是如何运行以及如何减少可变性带来的混乱。

现在我们将代码封装成一个函数,这就是我们要说的第一个常见的函数式函数。我们将其命名为map,因为这个函数的功能就是将一个数组的每个值映射(map)到新数组的一个新值。

var map = (f, array) => {
    var newArray = [];
    for (var i = 0; i < array.length; ++i) {
        newArray[i] = f(array[i]);
    }
    return newArray;
};

函数f作为参数传入,那么函数map可以对array数组的每项进行任意的操作。

现在我们可以使用map重写之前的代码:

var things = [1, 2, 3, 4];
var newThings = map(v => v * 10, things);

瞧,没有for循环!而且代码更具可读性,也更易分析。

好吧,对技术上来说,函数map中还是存在for循环。至少现在不需要写一堆重复代码了。

我们再看另外一个常见的数组过滤(filter)函数是怎么写的:

var filter = (pred, array) => {
    var newArray = [];
    for (var i = 0; i < array.length; ++i) {
        if (pred(array[i]))
            newArray[newArray.length] = array[i];
    }
    return newArray;
}

当某些项需要被保留的时候,断言函数pred返回TRUE,否则返回FALSE。

来看下实例,怎么用filter函数过滤奇数。

var isOdd = x => x % 2 !== 0;
var numbers = [1, 2, 3, 4, 5];
var oddNumbers = filter(isOdd, numbers);
console.log(oddNumbers); // [1, 3, 5]

比起用for循环的手动编程(hand code),filter函数简单多了。

最后一个常见函数叫reduce。通常这个函数用来将一个数列归约(reduce)成一个数值,但事实上它能做很多事情。

在函数式语言中,这个函数称为fold

var reduce = (f, start, array) => {
    var acc = start;
    for (var i = 0; i < array.length; ++i)
        acc = f(array[i], acc); // f() 接受2个参数
    return acc;
});

reduce函数接受一个归约函数f,一个初始值start,以及一个数组array

归约函数f接受2个参数,array 的当前项,累加器acc。它在每次迭代中利用这些参数又产生新的累加器,直到迭代完成返回最后一次的累加器。

下面这个例子可以帮助我们理解具体的过程:

var add = (x, y) => x + y;
var values = [1, 2, 3, 4, 5];
var sumOfValues = reduce(add, 0, values);
console.log(sumOfValues); // 15

add函数接受2个参数,并将它们加起来。我们的归约函数正好也接受2个参数,所以add可以作为归约函数传入。

我们从start为0开始,传入待累加的数组valuesreduce函数遍历数组values,每次迭代值都被累加,然后返回最后的累加值,并赋值给sumOfValues

这三个函数,map,filter,reduce能让我们绕过for循环这种重复的方式,对数组做一些常见的操作。但在函数式语言中只有递归没有循环,这三个函数就更有用了。附带提一句,在函数式语言中,递归函数不仅非常有用,还必不可少。

预告:下一篇,我将讲述引用完整性,执行顺序,类型等等。