Java的Stream流是个非常棒的,能够体现函数式编程魅力的工具。但是也是被误解很深的一个工具。我记得我初次使用的时候,觉得这东西非常难以理解。如果我要处理一个List,我要这么干:
scss
list.stream().map().collect();
我完全不能理解,这种东西为什么能循环处理List。也不理解,我为什么要抛弃正常的for循环,使用这一串难以理解的东西。和这个系列一样,我会从另外一个角度来看待Stream流(一个不大常见的视角)。
请注意:和之前的代码风格类似,我只是想说明思路,代码几乎不能在编译器中运行,我也没有进行过编译校验。
for(T t:list)
首先,我们从增强for开始。
增强for首先是个语法糖,Java会在编译期,将这个语法糖根据是数组还是集合,将它编译成两种不同类型的循环:
ini
// 遍历数组时 → 翻译成基于索引的普通 for 循环
int[] arr;
for (int i = 0; i < arr.length; i++)
// 遍历集合时 → 翻译成使用 Iterator 的循环
List list;
for (Iterator iterator = list.iterator(); iterator.hasNext(); )
这里很明显,优点是你无法写出错误选择遍历方式而产生的性能问题,比如通过索引遍历 LinkedList:
ini
LinkedList list;
for (int i = 0; i < list.length; i++ ){
list.get(i);
}
你做不到,你做不到的原因是你放弃了如何对一个集合/数组进行循环,也放弃了如何取值。 for(T t:list)的意思就是,我不关心如何循环,也不关心要处理的每个元素如何从集合中取出。
同样缺点也是如此。比如你做不到:
css
for (int i = 0; i < list.length-1; i++ )
实际单从语法上,你甚至不能确保你处理的数据,是按顺序处理的。只不过它的实现是按顺序处理的,List本身也是顺序的保障。
注意了,这个分层是很重要的,要学会从不同的角度看这个语法糖。 语法糖通常隐藏了细节,隐藏了细节意味着你控制不了细节。
有些细节,你想当然的认为合理,但它的实现并不是你想象的,如果你不想深究,那就要往最坏的情况去考虑你的代码行为,否则就会出现好多技术文章中说的坑。
forEach(Consumer<? super T> action)
接下来是forEach,单从函数签名上看。
forEach是说我要循环
action是说我要如何处理List中的元素
而List则是从上下文中中获取。那么看待以下代码:
arduino
list.forEach(System.out::println);
就是list->循环->对其中的原型进行打印操作。
那么循环是如何进行的呢?点击forEach跳转到实现,你会看到以下代码:
javascript
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
所以和前面一样,最后还是for (Iterator iterator = list.iterator(); iterator.hasNext(); )
但是它有两处明显不同,第一处是集合来自上文的传递,第二处是,如果处理元素,你可以定义在别处了,而这是函数式编程带来的一个特性:抽象行为,我们在第一篇(Java的函数式编程(一))的时候,就说过这个特点了,如果你没有看过前面的,或者你还不了解函数式编程,那么你可以回去看看。
那么这种抽象带来了什么呢?以list.forEach(System.out::println);为例子:我们可以这样操作:
go
// 伪代码:给打印行为起名,省略 Java 要求的目标接口类型
var println = System.out::println;
list1.forEach(println);
list2.forEach(println);
其实也还有:
scss
Optional.ofNullable(list).ifPresent(println);
// 关于Optional,下一篇我们会讲,这里演示下,抽象行为是什么意思。
那么传统的写法呢,也可以抽象,它是这样的:
ini
public void println(T t){
System.out.println(t);
}
for (int i = 0; i < list1.length-1; i++ ){
println(list1.get(i));
}
for (int i = 0; i < list2.length-1; i++ ){
println(list2.get(i));
}
你依然可以认为forEach还没有跳出语法糖的范畴。但是实际上forEach已经跳出了单纯的语法糖,它把集合嵌入到了运行的上下文,把如何循环,如何取值交给了增强for,而增强for实际上又把如何循环,如何取值交给编译器。forEach只把如何处理元素交给了你,通过Consumer<? super T> action。
回过头来,我们就很容易理解为何在forEach执行return操作,和你设想的不同了。就像你无论在 public void println(T t)中如何进行return,它处理的只能是它范围的事。实际上Consumer<? super T> action和println都无法得知它们外部是不是循环。
目前看来,似乎传统写法能实现一样的能力。但是在表现力上差了一截。理解下表现力,理解函数式编程的人和不理解函数式编程的人,会在这里产生一个分歧。以forEach为例,如果你知道forEach的运行原理,你会知道这套编程范式超强的表现力源自,在代码行为上,函数式编程把各种可以抽象的行为,分离到了不同的地方,对通用的行为做了统一的抽象,所以可以用少量的代码写出更多的含义。而不理解函数式编程的人,则会直接打上函数式编程晦涩难懂的标签。
Stream
接下来,我们看一段代码:
css
list.forEach(i->{ i = i + 1; });
假设list中的元素是14。那么当这个循环执行完后list就变成了25。思考下,这有什么不妥?
list发生了变化,但是函数式编程强调不可变性,而可变意味着副作用,可以看这篇回忆Java的函数式编程(三)。而Consumer似乎也很容易和副作用挂钩,可以看这篇Java的函数式编程(四)。
那么如果我们想避开值发生变化的副作用应该怎么办?你不可能避开事物是发生变化的这个特性,对吧。我昨天想吃云吞,今天想吃馄炖。对吧。
这里我要暂时叉开一个小分支,因为如果深究的话,就是深拷贝和浅拷贝的问题,而这背后则是一门完全的函数式语言和Java在工程实践上的取舍,这个取舍甚至在Java8引入函数式编程这个特性之前就存在了。这个讨论超出了这篇文章应该讨论的范围。
好,让我来解释纯函数式代码在这个问题上如何处理值要发生变化这个问题,观察一下代码(代码非可运行代码,用于理解):
ini
// 伪代码约定:concat 将几个列表拼成一个新列表,
// 不修改输入列表,产生的新列表后续也不再修改。
public Pair<List, Integer> addItem(Pair<List, Integer> pair) {
List list = pair.getLeft();
Integer index = pair.getRight();
// 只读取当前位置前后的元素,不修改它们
List before = list.subList(0, index);
List after = list.subList(index + 1, list.size());
// 根据旧值计算新值
Integer value = list.get(index) + 1;
// 构造新列表,不修改旧列表
List newList = concat(
before,
Collections.singletonList(value),
after
);
// 新列表、新索引,组成新的状态
return Pair.of(newList, index - 1);
}
public List addList(Pair<List, Integer> pair) {
if (pair.getRight() < 0) {
return pair.getLeft();
}
var newPair = addItem(pair);
return addList(newPair);
}
// 从前文的 list 开始,从最后一个元素向前处理
List result = addList(Pair.of(list, list.size() - 1));
对应的状态变化:
css
([1, 4], 1)
([1, 5], 0)
([2, 5], -1)
这里使用了递归的方式,摆脱了副作用,有多少次循环,就会产生多少的对象。这里会有聪明的人反应过来,这难道不会消耗大量的内存吗?答对了,会。但是有两个点,第一,无副作用也代表无任何IO,外部存储,即使是内存也是,所以纯逻辑上,函数式编程只管如何表达运算,不管其它。但是副作用在函数式编程中就像反向的皇帝新装,大家都当它不存在,但是现实是总是存在的。第二,工程上,还是要处理的,所以现实的函数式编程总是需要搭配垃圾回收(栈溢出有尾递归优化,有意思的是尾递归总能转为循环,循环也总能转为尾递归),有的回收手动,有的回收自动,有的回收在编写时标明,编译后手动。请各位各自对号入座。所以副作用的实际是无法完全隔离的,只能是在某些场景下(看不到怎么IO的,或者其它反正它有办法只要不是我要管的)当它不存在。
所以一般原生的函数式编程语言中,循环不是天然支持的。
代码比较难理解,我的锅,因为前面list.forEach(i->i+1)的例子如果要强行剥离的话,就是这样。如果要更好的理解递归如何剥离副作用,我建议尝试如何使用递归改写讲list中所有的元素相加(如果哪天缘分到了,可以单独对递归讲一次,但是Java语言不适合做这个事)。你可以尝试改写以下代码,解决可变量的副作用:
ini
Intarget a = 0;
for (int i = 0; i < list.length-1; i++ ){
a+=list.get(0);
}
前面关于递归的例子我写的太糟糕了,因为这部分原不在我的计划中,但是写到的时候,我想表达,如何用不可变实现可变,也没有想太清楚,我的建议是你尝试下我说的那个循环改为递归,观察下是不是涉及的变量都可以转换为不可变(即赋值后,就不再变化)。你也可以跳过这段糟糕的例子,直接得到结论。
回到forEach的副作用来看,表层上来看似乎是Consumer带来的副作用。而实际上forEach的副作用根源在没有一个收集元素并返回的机制,这让它在更高的一层变成了类似一个有入参而没出参的函数。forEach的入参来自它的上文list,而这个东西,它没有返回结果。还记得我们在Java的函数式编程(四)中提到的函数的特点吗?一个函数必须是有一个入参,有一个出参。如果有进无出,有出无进,或者无进无出。总是容易和副作用挂钩(其实有时候有进有出也不保证和副作用无关,但是和前面一样,有时候你要提醒皇帝没穿,有时候你又要认为他穿着)。
所以,我们继续把收集这部分工作也抽出来或者说添加进去。对的,Stream增加了对元素收集的抽象,接下来,我们要开始分析collect()。
总结
原本是想一口气写完Stream的,但是码了太多字,超出了我的预想。这个系列目前最长的一篇,那个剥离副作用的伪代码,伤了太多脑细胞,我已经写了接近2个小时。所以这一篇暂时到此为止。
总结下,我们从for(T t:list)->forEach(注意stream的forEach又是另外一套东西)->stream开头。之所以是这个顺序是,是我想展示,函数式编程是如何将不同的行为分离到不同的地方。stream只是开了个头,还没有解释具体的实现,但是已经大致说明了stream目前会把如何循环,取元素(stream())和如何处理元素(map())和如何收集(collect()),它的循环方式和前面两个也完全不同(思想上),拆分成三块。常见的关于函数式编程难以理解,难以调试,很大程度上来源于于这里,逻辑被拆的七零八落,常见的编程范式经验在全新的范式下不再适用。
而当你看大部分函数编程教程都在告诉你,这是这个行为,那是那么用,更有的教程在测试它们之间的性能差异的时候,自然而然的感到怪异,进而排斥。我几乎花了快一万字,才勉强摸到了Stream,函数式编程在实践中的魅力总算可以有一定呈现。
待续。。。