关于本文:
本文发表于前端早读课【第882期】
往期回顾:
柯里化(Currying)
还记得上一篇我们讲的吗?当mult5
接受1个参数,而add
接受2个参数时,mult5
和add
的函数复合遇到了麻烦。
其实解决这个问题很简单,我们只需限定所有的函数只能接受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个函数,f
和g
,然后返回一个接受单参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"
我们可以将bracket
和doubleBracket
推广为更普适的函数:
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 "{{" "}}"
只要将将最可能不变的参数即prefix
和suffix
放在最前面,把最可能变化的参数即 str
放在最后面,我们很容易就能创建出generalBracket
函数的特殊版本。
参数顺序是能否最大程度利用柯里化的关键所在。
同时注意一下,bracket
函数和doubleBracket
函数都是隐参标记式(point-free notation)写法,即参数 str
是隐含的。它们都是单参数函数,等待着最后的参数输入。
现在我们可以像最开始那样调用了:
bracketedJoe =
bracket "Joe"
doubleBracketedJoe =
doubleBracket "Joe"
但,这次我们调用的是更普适的柯里化函数,generalBracket
。
常见的函数式函数(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开始,传入待累加的数组values
。reduce
函数遍历数组values
,每次迭代值都被累加,然后返回最后的累加值,并赋值给sumOfValues
。
这三个函数,map
,filter
,reduce
能让我们绕过for循环这种重复的方式,对数组做一些常见的操作。但在函数式语言中只有递归没有循环,这三个函数就更有用了。附带提一句,在函数式语言中,递归函数不仅非常有用,还必不可少。
预告:下一篇,我将讲述引用完整性,执行顺序,类型等等。