Java8-函数式数据处理
# 函数式数据处理
# 引入流API的原因
日常开发中对集合的操作无法避免,且频率不低。为了应付更复杂的功能开发,特别是随着现代软件开发的需求变化和技术的发展,原有的集合处理方式逐渐显得不够高效和灵活。
传统的集合处理方式通常需要显式地使用循环结构(如for-each循环)来遍历集合并对每个元素执行操作。这种方式虽然有效,如果条件较少的情况下还好,但随着条件的增加。代码通常较为冗长,并且难以阅读和理解。为了解决这个问题,或者说让Java语言更受欢迎,开发者引入了流的API —— Stream.
Stream API提供了一种更加简洁、声明式的编程模型,使得代码更易于理解和维护。
# 传统方式
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
List<String> filteredNames = new ArrayList<>();
for (String name : names) {
if (name.startsWith("A")) {
filteredNames.add(name);
}
}
2
3
4
5
6
7
8
9
10
11
# 使用Stream API
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
2
3
4
5
相比传统的集合处理方式,stream流明显更加简洁,同时它的代码意图也更明显。此外,stream还支持多核并行处理,提高程序处理性能,下文会进一步说明。综上所属这就是引入流的原因。
- 代码更简洁,更易读
- 性能更好
# 关于流的简介
简短的定义就是“从支持数据处理操作的源生成的元素序列”。
元素序列——就像集合一样,流也提供了一个接口,可以访问特定元素类型的一组有序值。因为集合是数据结构,所以它的主要目的是以特定的时间/空间复杂度存储和访问元素(如ArrayList 与 LinkedList)。但流的目的在于表达计算,比如你前面见到的filter、sorted和map。集合讲的是数据,流讲的是计算。我们会在后面几节中详细解释这个思想。
源——流会使用一个提供数据的源,如集合、数组或输入/输出资源。 请注意,从有序集合生成流时会保留原有的顺序。由列表生成的流,其元素顺序与列表一致。
数据处理操作——流的数据处理功能支持类似于数据库的操作,以及函数式编程语言中的常用操作,如filter、map、reduce、find、match、sort等。流操作可以顺序执行,也可并行执行。
其实,流(数据流)就是,可以批量从一个地方转移到另一个地方的一系列数据,并且,我们在这个过程中可以对这些数据进行各种处理,比如:筛选、查找、匹配、映射等。
注意:单个数据的转移不能算流。转移到的另一个地方也可以是原地。
# 集合与流的区别
粗略地说,集合与流之间的差异就在于什么时候进行计算。集合是一个内存中的数据结构,它包含数据结构中目前所有的值——集合中的每个元素都得先算出来才能添加到集合中。(你可以往集合里加东西或者删东西,但是不管什么时候,集合中的每个元素都是放在内存里的,元素都得先算出来才能成为集合的一部分。)
相比之下,流则是在概念上固定的数据结构(你不能添加或删除元素),其元素则是按需计算的。 这对编程有很大的好处。在第6章中,我们将展示构建一个质数流(2, 3, 5, 7, 11, …)有多简单,尽管质数有无穷多个。这个思想就是用户仅仅从流中提取需要的值,而这些值——在用户看不见的地方——只会按需生成。这是一种生产者-消费者的关系。从另一个角度来说,流就像是一个延迟创建的集合:只有在消费者要求的时候才会计算值(用管理学的话说这就是需求驱动,甚至是实时制造)。 注:以上说明摘自《Java 8 实战》
# 只能被消费一次
和迭代器类似,流只能遍历一次。遍历完之后,我们就说这个流已经被消费掉了。你可以从原始数据源那里再获得一个新的流来重新遍历一遍,就像迭代器一样(这里假设它是集合之类的可重复的源,如果是I/O通道就没戏了)。例如,以下代码会抛出一个异常,说流已被消费掉了:
List<String> title = Arrays.asList("Java8", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println); // 打印标题中的每个单词
s.forEach(System.out::println); // java.lang.IllegalStateException:流已被操作或关闭”
2
3
4
所以,请千万记住,流只能被消费一次。如果想再次循环它,则需要重新获取流,比如:title.stream();.
# 相关操作
Stream接口定义了许多操作。它们大致上可以被分为两大类:
- 中间操作
- 终端操作
中间操作很简单,就是对流进行一些处理,比如筛选、查找等。它们操作完成后,会返回一个新流,可以继续处理或者终止该流。
什么叫返回新流呢?比如下面这个例子,filter之后,返回的新流,其中的元素就全是以“A”开头的了。
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
2
3
至于终端操作,是指会将流终止并生成结果的操作,且该结果是任何不是流的值,比如List,Integer等,甚至是void都可以。
比如上述代码中的collect(Collectors.toList())
,它就是一个终端操作,且返回值是一个 List
。具体来说,这个方法会将流中的所有元素收集到一个新的 ArrayList
中,并返回这个列表。
再来一个例子:
long count = apples.stream()
.filter(a -> a.getWeight() > 10) // 中间操作
.limit(3) // 中间操作
.count(); // 终端操作
2
3
4
其实仔细的朋友可能发现了,它的这种操作类似构建器模式
,需要什么操作在中间加,最后进行终止即可(构建器模式则调用build方法来表示完成)。
stream流的中间操作和终端操作都有很多,这里就展示几个常用的:
中间操作:filter、map、limit、sorted、distinct....
终端操作:
forEach:遍历流中的元素,返回类型为void
count:返回流中元素个数,返回类型为long
Collect:把流转成一个集合,比如List、Map等。
# 筛选和切片
# 通过谓词筛选
Streams接口支持filter方法。该操作会接受一个谓词(一个返回boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流。
// stream流中的 filter方法
public Stream<T> filter(Predicate<? super T> predicate);
//
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
2
3
4
5
6
7
下面就是一个使用filter()的例子:
List<Apple> apples = apps.stream()
.filter(Apple::isRed)
.collect(toList());
2
3
将红色苹果筛选出来。
# 筛选各异的元素
distinct的方法,它会返回一个元素各异(根据流所生成元素的hashCode和equals方法实现)的流。下列demo则为筛选出,重量大于10kg,且重量不重复的苹果。
List<Apple> apples = mapps.stream()
.filter(a -> a.getWeight() > 10)
.distinct()
.collect(toList());
2
3
4
# 截断-指定数量
流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递给limit。如果流是有序的,则最多会返回前n个元素。比如,你可以建立一个List,选出重量大于10kg,前3个苹果。
List<Apple> apples = mapps.stream()
.filter(a -> a.getWeight() > 10)
.limit(3)
.collect(toList());
2
3
4
# 跳过元素
此外,流还支持skip(n)
方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!例如,下面的代码将跳过重量超过10kg的前两个苹果,并返回剩下的。
List<Apple> apples = mapps.stream()
.filter(a -> a.getWeight() > 10)
.skip(2)
.collect(toList());
2
3
4
# 映射
前一个操作是筛选元素,但实际开发中每个元素都有许多属性,我们在筛选出其中元素后,最终目的其实是使用其中的某些属性。这一步设计者也考虑到了,于是stream有了映射操作。比如,Stream API中的map和flatMap。
# 应用函数map
map方法,它会接受一个函数作为参数。这个函数会被应用到每个元素上,并将其映射成一个新的元素。下面的例子就是获取流中的每个苹果的重量。
List<Integer> appleNames = mapps.stream()
.map(a -> a.getWeight())
.collect(toList());
2
3
当然,在一个流中map是可以重复使用的。如下例子,找出每个人名字的长度。
List<Integer> names = mapps.stream()
.map(Person::getName)
.map(String::length)
.collect(toList());
2
3
4
# 流的扁平化
flatMap方法的效果是,各个数组并不是分别映射成一个流,而是映射成流的内容。有点抽象对吧,不急,举个例子你就懂了。
对于一张单词表,如何返回一张列表,列出里面各不相同的字符呢?例如,给定单词列表["Hello","World"],你想要返回列表["H","e","l", "o","W","r","d"]。
版本一:
words.stream()
.map(word -> word.split(""))
.distinct()
.collect(toList());
2
3
4
似乎解决了问题。你想要的列表类型应该是List< String >,可版本一给你的却是List< String[] >。我们想要的是字符流,而不是数字流。这个时候就要靠我们的flatmap方法了,它可以把一个流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。
List<String> uniqueCharacters =
words.stream()
.map(w -> w.split("")) // ←─将每个单词转换为由其字母构成的数组
.flatMap(Arrays::stream) // ←─将各个生成流扁平化为单个流
.distinct()
.collect(Collectors.toList());
2
3
4
5
6
最后总结下:flatMap
它能将流中的元素转换为新的流,并且扁平化这些流的结果。flatMap
通常用于处理嵌套的集合,即将多个内部集合扁平化为一个单一的流。
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class FlatMapExample {
public static void main(String[] args) {
// 创建一个包含多个列表的列表
List<List<String>> nestedLists = Arrays.asList(
Arrays.asList("Alice", "Bob"),
Arrays.asList("Charlie", "David"),
Arrays.asList("Eve", "Frank")
);
// 使用 flatMap 扁平化嵌套的列表
List<String> flatList = nestedLists.stream()
.flatMap(list -> list.stream())
.collect(Collectors.toList());
// 输出结果
flatList.forEach(System.out::println);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 查找与匹配
Stream
API 中,allMatch
、anyMatch
、noneMatch
、findFirst
和 findAny
是一些常用的终端操作(terminal operations),用于对流中的元素进行特定的检查或查找。这些方法可以帮助你以简洁的方式实现一些常见的逻辑判断和搜索功能。
allMatch:检查流中的所有元素是否都符合给定的条件。 返回值:boolean
anyMatch:检查流中是否有至少一个元素符合给定的条件。 返回值:boolean
noneMatch:检查流中是否有任何元素不符合给定的条件。 返回值:boolean
findFirst:返回流中的第一个元素(如果有),通常用于有序的流。 返回值:Optional<T> findFirst()
findAny:返回流中的任意一个元素(如果有),通常用于无序的流或并行流。 返回值:Optional<T> findAny()
2
3
4
5
optional也是Java8 的一个新特性,这里就不展开介绍了。
if(apples.stream().anyMatch(a -> a.getWeight()>10)){
System.out.println("there is a big one!");
}
2
3
# 规约
规约就是将流中所有元素反复结合起来,得到一个值,比如一个Integer。用函数式编程语言的术语来说,这称为折叠(fold),因为你可以将这个操作看成把一张长长的纸(你的流)反复折叠成一个小方块,而这就是折叠操作的结果。
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
// 以上是用流的方式 进行求和
2
reduce
方法是Java 8引入的流(Stream
)API中的一个重要方法,它用于对流中的元素执行累积操作,并返回一个单一的结果。reduce
方法有多个重载版本,其中最常用的是以下两种形式:
// 无初始值
Optional<U> reduce(BinaryOperator<U> accumulator);
// 带初始值
U reduce(U identity, BinaryOperator<U> accumulator);
2
3
4
5
上述求和用的就是带初始值的方法,那BinaryOperator
又是什么呢?
BiFunction
接口是一个函数式接口,它接受两个输入参数,并返回一个结果。
@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
}
2
3
4
而BinaryOperator
是一个特例,专门用于处理两个相同类型的输入参数,并返回相同类型的结果。
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
default <U extends T> U apply(U x, U y);
}
2
3
4
在使用 reduce
方法时,如果需要处理相同类型的输入参数并返回相同类型的结果,可以选择 BinaryOperator
;如果需要处理不同类型的输入参数或返回不同类型的输出,可以选择 BiFunction
。
# 构建流
上面说了这么多,都是中间的操作,那它的起点是怎样的呢?或者说流它的构建方式有几种?
上面的都是直接调用方法stream()
,且对象只有集合(List)。此外,它还有哪些方法呢?
# 由值创建
你可以使用 Stream.of()
静态方法,通过显式值创建一个流。它可以接受任意数量的参数,一个或者多个。
// 从单个值创建流
Stream<String> singleValueStream = Stream.of("Hello");
// 从多个值创建流
Stream<String> multiValuesStream = Stream.of("Hello", "World");
2
3
4
5
当然,你也可以创建一个没有任何元素的流,空流。
Stream<String> emptyStream = Stream.empty();
虽然空流本身不包含任何元素,但它在编程中有一定的用途和应用场景。比如:当你需要编写一个返回流的方法,无论是否有数据可用,都可以返回一个空流来保持接口的一致性。
# 通过数组创建
当然,你还可以使用 Arrays.stream()
方法来创建流。
Integer[] numbers = {1, 2, 3, 4, 5};
// 使用 Arrays.stream() 方法创建流
Stream<Integer> streamFromArray = Arrays.stream(numbers);
2
3
4
# 由文件生成流
try {
// 从文件创建字符流
Stream<String> lines = Files.lines(Paths.get("example.txt"));
// 处理每一行
lines.forEach(System.out::println);
} catch (Exception e) {
e.printStackTrace();
}
2
3
4
5
6
7
8
9
java.nio.file.Files中的很多静态方法都会返回一个流。例如,一个很有用的方法是Files.lines
,它会返回一个由指定文件中的各行构成的字符串流。
# 通过函数生成流
通过函数生成流,或者叫通过迭代器生成流。
比如:Stream.iterate
和Stream.generate
。这两个操作可以创建所谓的无限流:不像从固定集合创建的流那样有固定大小的流。由iterate和generate产生的流会用给定的函数按需创建值,因此可以无穷无尽地计算下去!一般来说,应该使用limit(n)来对这种流加以限制,以避免打印无穷多个值。
它们都是从给定的种子值开始,并通过指定的函数来生成后续的值。此方法特别适合用于生成数学上的序列,如斐波那契数列、自然数序列等。
扩展:无限流(infinite stream)是一种特殊的流,它没有明确的终点,可以不断地生成元素。无限流通常用于生成一系列无限的数字序列、定时任务等场景。
public static <T> Stream<T> iterate(final T seed, final UnaryOperator<T> f)
- seed:作为序列的第一个值。
- f:一个
UnaryOperator<T>
函数,用于计算下一个值。
import java.util.stream.Stream;
import java.util.function.UnaryOperator;
public class IterateExample {
public static void main(String[] args) {
// 创建一个从 1 开始的无限自然数序列
Stream<Long> naturalNumbers = Stream.iterate(1L, n -> n + 1);
// 打印前 10 个自然数
naturalNumbers.limit(10).forEach(System.out::println);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
UnaryOperator<T>
是 Java 中的一个函数式接口,它定义了一个方法 apply(T t)
,该方法接受一个类型为 T
的参数,并返回一个类型也为 T
的结果。这个接口通常用于实现某种运算或操作,其中输入和输出的类型相同。
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T,T> {
// 除了继承自 Function<T,T> 的 apply 方法外,
// UnaryOperator 还定义了默认方法 andThen
default <V> UnaryOperator<V> compose(Function<? super V, ? extends T> before) { ... }
default <V> UnaryOperator<T> andThen(Function<? super T, ? extends V> after) { ... }
}
2
3
4
5
6
7
关于上面那个例子,还有一个关于创建斐波那契数列的经典例子。
import java.util.stream.Stream;
import java.util.function.UnaryOperator;
public class FibonacciExample {
public static void main(String[] args) {
// 创建一个无限的斐波那契数列
Stream<Long> fibonacci = Stream.iterate(new Long[]{0L, 1L},
pair -> new Long[]{pair[1], pair[0] + pair[1]})
.map(pair -> pair[0]);
// 打印前 10 个斐波那契数
fibonacci.limit(10).forEach(System.out::println);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
至于另一个generate的创建,也很简单。
public static <T> Stream<T> generate(Supplier<T> s)
下面是一个简单的生成随机数的无限流例子。
import java.util.stream.Stream;
import java.util.concurrent.ThreadLocalRandom;
public class GenerateExample {
public static void main(String[] args) {
// 创建一个无限的随机数序列
Stream<Double> randomNumbers = Stream.generate(ThreadLocalRandom::nextDouble);
// 打印前 10 个随机数
randomNumbers.limit(10).forEach(System.out::println);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
注意:
- Stream.iterate:适合用于生成基于前一个值计算出的序列,如自然数序列、斐波那契数列等。
- Stream.generate:适合用于生成每次调用生成器函数时生成的新值,如随机数序列、固定值序列等。
由于
iterate
和generate
方法生成的是无限流,因此必须配合终端操作(如limit
、takeWhile
、findFirst
等)来限制流的大小,否则会导致无限循环。