本文代码实践基于 JDK-21:
java 21.0.4 2024-07-16 LTS
Java 8 中一项重要更新就是对函数式编程的支持,这是 Java 语言史上的一项重磅更新:函数可以按需创建,可以赋值给变量,可以作为参数到处传递,也可以作为返回值。在面向对象的语言中,类是一等公民,但在 Java 8 中,函数的地位得到了提升。当然,它还没有达到像 LISP,GO 语言中那样——函数是一等公民。本章讨论的就是 Java 函数式编程中的两大应用:Lambda 和 Stream。
42 Lambda 优先于匿名类
这一条说的是用 Lambda 表达式替代匿名类,而在之前的《Effective Java:03 类和接口》一文中,我们就总结过:
在 Java 引入 Lambda 之前,匿名类是动态创建小型函数对象和过程对象的最佳方式。
那啥是函数对象(Function Object)?简单来说,拥有单个抽象方法的接口作为函数类型(Function Type),其实例就被称为函数对象,表示函数或者要采取的动作。下面的示例是我从之前的文章中贴过来的,第 7 行的 greeting 变量便是用匿名类实现的一个函数对象:
interface Greeting {
void greet();
}
public class OuterClass {
public Greeting createAnonymousClass() {
Greeting greeting = new Greeting() {
@Override
public void greet() {
System.out.println("Hello from Anonymous Class");
}
};
return greeting;
}
}
再比如下面排序示例中的 sort 方法:
private static void sort(List<String> words) {
Collections.sort(words, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return Integer.compare(o1.length(), o2.length());
}
});
}
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("hello", "world", "java"));
sort(words);
System.out.println(words);
}
在 Java 8 之前,上面便是我们最常用的写法,但有了 Lambda 语法糖,一切就变得简洁了:
private static void sort(List<String> words) {
Collections.sort(words, (o1, o2) -> Integer.compare(o1.length(), o2.length()));
}
不过语法糖的背后,是编译器和虚拟机在替我们负重前行,下面是两种写法下部分字节码(常量池)的对比,可以明显看到 lambda 写法的字节码要多几十行:
Lambda 类似于匿名类的函数,但要简洁的多,并且会自动进行类型推导,像上面的 (o1, o2) -> Integer.compare(o1.length(), o2.length())
压根都没出现 String 类型(入参)以及 int 类型(出参)。当然,不是所有时候都能推导出来,当编译器无法推导时(编译会失败),我们就得补充类型信息了。
在 Java 8 中把带有单个抽象方法的接口进行了特别优待,这便是函数接口(Function Interface),用 @FunctionalInterface
注解进行修饰:
@FunctionalInterface
public interface Comparator<T> {
// 唯一的抽象方法,其他方法都不是抽象方法
int compare(T o1, T o2);
// ...
// 构造器方法
public static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) {
Objects.requireNonNull(keyExtractor);
return (Comparator<T> & Serializable)
(c1, c2) -> Integer.compare(keyExtractor.applyAsInt(c1), keyExtractor.applyAsInt(c2));
}
// ...
}
如果我们使用 Comparator 中提供的构造器方法对前面的代码进行简化,看到的代码会更简洁:
private static void sort(List<String> words) {
Collections.sort(words, Comparator.comparingInt(String::length));
}
那还能更简洁吗?查看 Collections#sort 源码会发现,它调用的其实是 List#sort,于是便有了:
private static void sort(List<String> words) {
words.sort(Comparator.comparingInt(String::length));
}
一路下来,我们开始感觉到 Lambda 的强大,而匿名类则像上个世纪遗留下来的东西,落伍太多了。那是不是都用 Lambda 就完事了?也不完全是:
-
如果是实现一个抽象类或是拥有多个抽象方法的接口,此时还得使用匿名类;
-
Lambda 没有名字,也没有文档,即缺乏自描述性。所以,一旦 Lambda 超过 2-3 行,理解起来就复杂了,也就是可读性变差了,此时需要考虑方法引用(method reference)。
43 方法引用优先于 Lambda
用 Lambda 创建小型函数对象,多小是小呢?一行代码是理想情况,三行则是容忍的上限,下面是一个代码示例:
private int score;
private void process(List<String> words) {
// 其他业务逻辑
words.forEach(word -> {
System.out.println(word);
if (word.startsWith("hello")) {
score++;
}
});
// 其他业务逻辑
}
像上面的 Lambda 表达式一眼望去,大概率是看不出其意图的,但如果我们把这段 Lambda 表达式调整为一个独立的方法:
private int score;
private void process(List<String> words) {
// 其他业务逻辑
words.forEach(word -> calculateScore(word));
// 其他业务逻辑
}
private void calculateScore(String word) {
System.out.println(word);
if (word.startsWith("hello")) {
score++;
}
}
通过 calculateScore 这个方法名,我们顿时就懂了:计算分数值,这便是方法比 Lambda 表达式厉害的地方,能表达意图,天生的自描述性。不过,这里仍然存在一些样本代码,比如方法的参数 word,可以消除吗?可以。Java 8 中引入了方法引用(method reference),这是比 Lambda 更简洁创建函数对象的方式:
private int score;
private void process(List<String> words) {
// 其他业务逻辑
words.forEach(this::calculateScore);
// 其他业务逻辑
}
private void calculateScore(String word) {
System.out.println(word);
if (word.startsWith("hello")) {
score++;
}
}
this::calculateScore
是如此简单,前面的样板代码突然就不见了,而且方法带的参数越多,用方法引用消除的样板代码也就越多 。如果我们对这套体系不熟悉,其实完全不用担心,因为 IDE 会自动提醒我们简化:
但大多数事情都不是绝对的,像下面的两种写法,它们的结果是一样的,但第二种显然更能表达意图:
// 第一种写法
Function::identity
service.execute(Xyz::action);
// 第二种写法
x -> x
service.execute(() -> action());
大多数情况下,方法引用都优于 Lambda,但如果方法引用本身不够简洁,那就继续使用 Lambda 表达式。下面是书中总结的五种方法引用类型:
方法引用类型 | 范例 | Lambda 等式 |
---|---|---|
静态 | Integer::parseInt | str -> Integer.parseInt(str) |
有限制 | Instant.now()::isAfter | Instant now = Instant.now(); t -> now.isAfter(t) |
无限制 | String::toLowerCase | str -> str.toLowerCase() |
类构造器 | TreeMap<K,V>::new | () -> new TreeMap<K,V> |
数组构造器 | int[]::new | len -> new int[len] |
这里的有限制与无限制都是实例方法引用,其区别是:前者绑定了某个具体的对象实例,后者则没有。
44 坚持使用标准的函数接口
什么是标准的函数接口?如上图所示,java.util.function
包下定义的这些就是了。这一条规则想要表达的就是,如果标准的函数式接口已经支持你想实现的能力,那就不要重复造轮子。但这里有 43 个接口,很难记住,所以我们对它们分个类:
接口 | 函数签名 | 示例 | 功能 |
---|---|---|---|
Predicate<T> | boolean test(T t) | Collection::isEmpty | 谓词,判断是否符合条件,输入一个参数,输出 boolean 类型 |
Consumer<T> | void accept(T t) | System.out::println | 消费者,输入一个参数,但无返回值 |
Supplier<T> | T get() | LocalDateTime::now | 生产者,无入参,但返回一个参数 |
Function<T, R> | R apply(T t) | Arrays::asList | 函数,一个入参,一个出参 |
UnaryOperator<T> | T apply(T t) | String::toLowerCase | 函数,同 Function,但输入输出的类型一致 |
BinaryOperator<T> | T apply(T t, T u) | BigInteger::add | 二元函数,输入两个参数,输出一个参数,且它们的类型相同 |
有了这几个基础接口,笔者简单梳理了一下它们的变体:
1)首先是它们在 int
,long
和 double
三种类型上的变体,各自有 6 个:
-
IntUnaryOperator,IntFunction,IntPredicate,IntConsumer,IntBinaryOperator,IntSupplier
-
LongUnaryOperator,LongSupplier,LongPredicate,LongFunction,LongConsumer,LongBinaryOperator
-
DoubleBinaryOperator,DoubleConsumer,DoubleFunction,DoublePredicate,DoubleSupplier,DoubleUnaryOperator
显然,都是套路,明白了一个,其他的自然可以举一反三。以 int
为例,这六种变体的参数类型就都是 int
,比如 IntFunction 是输入一个 int 参数,然后返回一个对象:
@FunctionalInterface
public interface IntFunction<R> {
R apply(int value);
}
2)Function 在基础类型上的其他变体,一共 9 个:IntToDoubleFunction,IntToLongFunction,LongToDoubleFunction,LongToIntFunction,DoubleToIntFunction,DoubleToLongFunction,ToDoubleFunction,ToIntFunction,ToLongFunction。
这些变体中的 To 表示转换前后的两种类型,比如 IntToDoubleFunction 表示从 int 转到 double:
@FunctionalInterface
public interface IntToDoubleFunction {
double applyAsDouble(int value);
}
而 ToLongFunction 则表示从某个对象转到 long:
@FunctionalInterface
public interface ToLongFunction<T> {
long applyAsLong(T value);
}
3)Function 在引用类型上的变体,一共 4 个:BiFunction,ToIntBiFunction,ToDoubleBiFunction,ToLongBiFunction。
这里的 BiFunction 指的是输入两个对象参数,输出一个对象:
@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
}
4)Consumer 在二元的变体——BiConsumer,也有 4个:BiConsumer,ObjDoubleConsumer,ObjIntConsumer,ObjLongConsumer。
BiConsumer 即输入两个对象参数,没有输出,而 ObjDoubleConsumer 等 3 个变体,其两个入参中会有一个 double 类型的参数:
@FunctionalInterface
public interface BiConsumer<T, U> {
void accept(T t, U u);
}
@FunctionalInterface
public interface ObjDoubleConsumer<T> {
void accept(T t, double value);
}
5)最后剩下两个是:BiPredicate 和 BooleanSupplier,基于前面的介绍,这里就不再额外赘述了。
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
}
@FunctionalInterface
public interface BooleanSupplier {
boolean getAsBoolean();
}
可以看到,标准的函数接口把主要的基本类型都覆盖到了,所以我们不要使用带包装类型的基础函数接口来代替基本函数接口,因为基本类型的性能更好,特别是进行批量处理时。那什么时候需要自定义函数接口呢?主要看看是不是满足下面三个条件:
- 通用,并且业务会受益于一个描述性的名称;
- 该业务有与其关联的严格的契约;
- 可以编写很多默认(缺省)方法,这些方法将很好地服务于业务。
最后要说的是 @FunctionalInterface
注解,当我们使用这个注解时,就是在传达这样一个信号:这是一个针对 Lambda 设计的函数式接口,它只有一个抽象方法(如果再增加抽象方法,编译器会报错)。
An informative annotation type used to indicate that an interface type declaration is intended to be a functional interface as defined by the Java Language Specification. Conceptually, a functional interface has exactly one abstract method. Since default methods have an implementation, they are not abstract. If an interface declares an abstract method overriding one of the public methods of java. lang. Object, that also does not count toward the interface’s abstract method count since any implementation of the interface will have an implementation from java. lang. Object or elsewhere.
45 谨慎使用 Stream
Stream 中元素的来源有很多,比如集合、数组 、文件、正则表达式模式匹配器 、伪随机数生成器,以及其他 Stream 等。Stream 代表数据元素有限或无限的顺序, 而 Stream pipeline 则代表这些元素的一个多级计算。在 Stream pipeline 中包含一个源 Stream,接着是 0 个或者多个中间操作(intermediate operation)和一个终止操作(terminal operation)。所有的中间操作都是将一个 Stream 转成另一个 Stream,而终止操作则是在最后一个中间操作的 Stream 上执行一个最终计算,比如汇总数据,求和或打印等。
Stream pipeline 通常是 lazy 的:直到调用终止操作时才会开始计算,对于完成终止操作不需要的数据元素,将永远都不会被计算。
Stream 是一个不错的工具,但不要把啥逻辑(比如循环)都用流(Stream)来撸一遍,比如下面是来自书中的一个代码示例:
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(Collectors.groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append)
.toString()))
.values()
.stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
很显然,这段代码是非常晦涩难懂的,一个折中的方案是,将 groupingBy 中的逻辑单独封装成一个函数:
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(Collectors.groupingBy(Anagrams::alphabetize))
.values()
.stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
private static String alphabetize(String word) {
return word.chars()
.sorted()
.collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append)
.toString();
}
}
通过 alphabetize 这个函数名的自描述性,我们很快就明白了这段逻辑在表达什么,所以 Stream 加上一个具有良好抽象的 helper 方法是使用 Stream 的好配方。前面我们也提到过,一个 Lambda 的代码行数最好只有一行。
那什么场景适合用 Stream,哪些又不适合呢?适合的有:
- 转换元素的序列
- 过滤元素的序列
- 利用单个操作(如添加、连接或者计算其最小值)合并元素的顺序
- 将元素的序列存放到一个集合中,比如根据某些公共属性进行分组
- 搜索满足某些条件的元素的序列
不适合的有:
- Lambda 只能读取不可变(final)变量,所以一旦需要修改代码块中的局部变量,就无法使用 Stream;
- 代码块中需要执行 return,continue,break 等操作或是抛出 checked 异常,这些也是 Stream 目前无能为力的。
另外,Stream 中很难完成的一件事是,在一个 pipeline 中的多个阶段去访问相应地元素。因为一旦某个中间操作完成了转换,流中原来的值就被替换了,也就是原来的值丢失了。针对这个问题,一个较优雅的办法是重新组织流水线操作顺序,避免在不同阶段丢失原始值。
最后,如果考虑了可读性以及上面这些客观条件,还是不确定是否需要使用 Stream 时,可以将两种方案都拿来用用,然后根据个人喜好,编程环境等进行抉择。
46 优先选择 Stream 中无副作用的函数
正如上一条所提到的,Lambda 中不适合的场景包括修改局部变量的情况,因为一旦修改局部变量,对应的逻辑就不是无状态的,即有副作用。 Stream 并不只是一个 API,它是一种基于函数编程的模型。 既然是函数式编程,那无副作用的函数必然是基础条件:
函数结果只取决于输入,函数不依赖于任何可变的状态,也不更新任何状态。
书中花大量篇幅介绍了 Collectors 中的函数,针对常用的方法,笔者在截图中做了一个简单的标注,而具体内容就不一一赘述了。当我们开始使用 Stream 时,结合 AI 代码插件,这些方法的使用自然会是水到渠成,如果确实有特殊需求,再仔细询问 AI 就可以了,不存在掌握不了的情况。
最后有两个小提示:
- forEach 操作应该只用于报告 Stream 计算的结果,而不是执行计算;
- 静态导入要在 Stream 中常用,因为这样可以提升代码可读性,比如
words.collect(Collectors.groupingBy(Anagrams::alphabetize))
就没有words.collect(groupingBy(Anagrams::alphabetize))
好。
47 Stream 要优先用 Collection 作为返回类型
这一条规则所涉及的问题,简单概括一下就是:当需要编写一个返回元素序列的方法时,用什么返回类型比较合适?一般来说,返回 Collection 比较合适,但我们也要具体问题具体分析。
- 能返回集合,就返回集合;
- 如果返回的序列其元素个数有限,则可以返回一个标准集合,比如 ArrayList;
- 如果元素个数很多,则可能需要考虑定制一个集合,比如继承 AbstractList,然后重写某些方法;
- 如果无法返回集合,此时就得考虑返回 Stream 或 Iterable。如果客户端想使用 Stream 或 Iterable,那我们就按需返回,否则就自行抉择。
之所以 Stream 或 Iterable 二选一,是因为 Stream 不能直接转成 Iterable(迭代)的形式,不过我们可以稍微灵活的改一下,比如下面的 iterableOf 方法:
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
public void test() {
for (ProcessHandle ph : iterableOf(ProcessHandle.allProcesses())) {
// ...
}
}
48 谨慎使用 Stream 并行
一旦涉及到多线程并发,问题就变得复杂了起来。关于 Stream 中的并行,笔者决定单独写一篇总结(涉及 Fork/Join 框架),所以这里只做简单地概括:
-
大多数情况下都没必要执行并行;
-
并行 Stream 不仅可能降低性能,包括活性失败,还可能导致结果出错,以及难以预计的行为(如安全性失败)。
-
……
使用并行流很简单(调用 parallel() 方法),下面的两个代码示例,一个正面,一个反面,我们可以借此感受下:
// 反面示例,使用并行流打印梅森素数,程序可能会出现死循环
public class MersennePrimes {
private static final BigInteger TWO = BigInteger.valueOf(2);
private static final BigInteger ONE = BigInteger.ONE;
public static void main(String[] args) {
primes().parallel()
.map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(10)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
}
// 正面示例,使用并行流计算小于给定数字下的素数数量
public class PrimeCounting {
public static void main(String[] args) {
long start = System.currentTimeMillis();
long count = pi(10000000L);
long end = System.currentTimeMillis();
System.out.println("count: " + count + ", time: " + (end - start) + "ms");
}
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
}
好,本期内容就总结到这里。