函数式语言里的列表
在第一篇文章中我曾说过,对一个符合命令式编程的数据结构(这里以列表为例),我们会将它看作一个带索引的集合。例如对于下面这个列表:带索引的列表
我们可以通过索引来访问它的每一个元素。但在函数式语言里,它们眼中的列表形象有些不一样,它们看到的不是带索引的格子,而是看成由列表的第一个元素(叫作头部)和列表的其余元素(叫作尾部)这两部分组合而成。列表分为head和tail
List = head + tail
所幸,我们可以通过Java来实现这一结构。
当我们在Java中迭代列表时,我们可能要做的事有:假设这里有一个List strList;
for(String str : strList){
……//要做的事情
}
要注意,循环和条件分支语句里的执行体都是惰性的。因为循环一旦被中断,其余的元素就不会被处理,而条件语句里,如果判断条件为假,将不会执行相应分支。但条件语句里的条件是严格的,或者说是及早求值的。
折叠列表:
折叠操作将列表通过某一操作转换为一个单值,就如同上图中的计算所有字符串长度一样。单值的结果可以是任何类型。
折叠可以分为两种情况,左折叠和右折叠,即从左到右开始计算或从右到左开始计算。
如果操作符合交换律,则两种折叠方式等价,否则就是完全不同的折叠操作。
折叠操作需要一个初始的累加器,如果没有提供累加器,则默认最左边或最右边的元素作为累加器。当折叠操作完成后,累加器的最终值就是我们要求的单值。
例如:这里有一个实数列表(1,2,3,4,5,6,7);
对于求和操作,令累加器acc的初始值为0,
则使用左折叠为:
(((((((0+1)+2)+3)+4)+5)+6)+7)注意从最里面的括号计算。
右折叠为:
(1+(2+(3+(4+(5+(+(6+(7+0)))))))
因为实数的加法操作符合交换律,所以左折叠和右折叠的结果是一样的。
对于上面的例子,用Java语言表示(这里使用Java类库util包下的List),可写为:
public static Integer foldLeft(List iList,Integer acc,FunctionDec> f) {
int result =acc;
for(Integer i:iList) {
result = f.apply(result).apply(i);
}
return result;
}
第一个参数表示要折叠的列表,第二个为累加器,第三个是要进行的折叠操作。
实际调用时可写成
int result = foldLeft(iList,0,x->y->x+y);
我们可以定义一个更通用的版本:
public static U foldLeft(List eList,U acc,FunctionDec> f){
U result = acc;
for(T t : eList) {
result = f.apply(result).apply(t);
}
return result;
}
同理可写出右折叠的通用版本:
public static U foldRight(List eList,U acc,FunctionDec> f) {
U result =acc;
for(int i=eList.size();i>0;i--) {
result = f.apply(eList.get(i-1)).apply(result);
}
return result;
}
这是折叠操作的两个命令式版本, 可以看出这两个函数在参数上的区别在于,左折叠为U->T->U,右折叠为T->U->U;这是因为对于左折叠来说,累加器U在元素T的左侧,例如(0+1),而对右折叠来说,累加器U在元素的右侧,例如(7+0),它们最终都会返回一个U。这点区别也可以在for循环中看出来。
在前文我曾说过,函数式编程一个特点就是把控制权委托给语言,让它在运行时自己处理。而上面的代码我们都要手动管理迭代转换过程,这不是函数式编程所推崇的。不过还有一种我们常用的操作可以让我们更加函数式的处理上述问题——递归。它也是我们向运行时托付操作细节的一个例子,也更加符合对列表进行各种操作的习惯,毕竟我们的列表也是递归定义的(head和tail)。下一篇我将详细介绍Java中的递归,尾调用,尾递归及其优化技术,并尝试把递归的每一步保存在堆上来确保栈的安全。