Java 8函数式编程
2023-08-09 14:53:19 # Language # Java

内容来自《Java 8函数式编程》

1. Lambda表达式

1.1 匿名内部类与Lambda表达式

1
2
3
4
5
6
7
8
// 匿名内部类
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
System.out.println("button clicked");
}
});
// Lambda表达式
button.addActionListener(event -> System.out.println("button clicked"));

匿名内部类:

  • 实现了 ActionListener 接口。这个接口只有一个方法 actionPerformed。匿名内部类实现了该方法。

  • 但是匿名内部类不够简便,样板代码,可读性很差,我们不想传入对象,只想传入行为

Lambda 表达式:

  • 上述代码也可以改写为 Lambda 表达式

  • event 是参数名,和上面匿名内部类示例中的是同一个参数。-> 将参数和 Lambda 表达式的主体分开,而主体是用户点击按钮时会运行的一些代码

  • Lambda 表达式中无需指定类型,程序依然可以编译。这是因为 javac 根据程序的上下文(addActionListener 方法的签名)在后台推断出了参数 event 的类型。这意味着如果参数类型不言而明,则无需显式指定。

为了增加可读性并迁就我们的习惯,声明参数时也可以包括类型信息,而且有时编译器不一定能根据上下文推断出参数的类型!

1.2 几种Lambda表达式的变体

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1
Runnable noArguments = () -> System.out.println("Hello World");
// 2
ActionListener oneArgument = event -> System.out.println("button clicked");
// 3
Runnable multiStatement = () -> {
System.out.print("Hello");
System.out.println("World");
};
// 4
BinaryOperator<Long> add = (x, y) -> x + y;
// 5
BinaryOperator<Long> addExplicit = (Long x, Long y) -> x + y;
  1. 使用空括号 () 表示没有参数,实现了 Runnable 接口,该接口也只有一个 run 方法,返回类型为 void
  2. 只包含一个参数,可省略参数的括号
  3. 主体可以是一段代码块,可以用返回或抛出异常来退出,只有一行代码的 Lambda 表达式也可使用大括号
  4. Lambda 表达式也可以表示包含多个参数的方法,这行代码创建了一个函数,用来计算两个数字相加的结果。变量 add 的类型是 BinaryOperator,它不是两个数字的和,而是将两个数字相加的那行代码
  5. 可以显式声明参数类型,需要使用小括号将参数括起来

目标类型是指 Lambda 表达式所在上下文环境的类型。比如,将 Lambda 表达式赋值给一个局部变量,或传递给一个方法作为参数,局部变量或方法参数的类型就是 Lambda 表达式的目标类型。

1.3 引用值,而不是变量

在使用匿名内部类时,当你需要引用它所在方法里的变量,需要将变量声明为 final

Java 8 虽然放松了这一限制,可以引用非 final 变量,但是该变量在既成事实上必须是 final。如果坚持用作非终态变量,编译器就会报错。

  • 既成事实上的 final 是指只能给该变量赋值一次
  • 换句话说,Lambda 表达式引用的是值,而不是变量。
1
2
String name = getUserName();
button.addActionListener(event -> System.out.println("hi " + name));

如果你试图给该变量多次赋值,然后在 Lambda 表达式中引用它,编译器就会报错:

local variables referenced from a Lambda expression must be final or effectively final

1
2
3
String name = getUserName();
name = formatUserName(name);
button.addActionListener(event -> System.out.println("hi " + name));

1.4 函数接口

函数接口是只有一个抽象方法的接口,用作 Lambda 表达式的类型

例如 Swing 中的 ActionListener, 只有一个抽象方法:actionPerformed,被用来表示行为。该接口也继承自一个不具有任何方法的父接口:EventListener

1
2
3
public interface ActionListener extends EventListener {
public void actionPerformed(ActionEvent event);
}

接口中单一方法的命名并不重要,只要方法签名和 Lambda 表达式的类型匹配即可。

  • 这里的函数接口接受一个 ActionEvent 类型的参数,返回空(void)

JDK 提供了一组核心函数接口会频繁出现,以下罗列一部分

接口 参数 返回类型 示例
Predicate<T> T boolean 这张唱片已经发行了吗
Consumer<T> T void 输出一个值
Function<T, R> T R 获得 Artist 对象的名字
Supplier<T> None T 工厂方法
UnaryOperator<T> T T 逻辑非(!)
BinaryOperator<T> (T, T) T 求两个数的乘积(*)

1.5 类型推断

Lambda 表达式中的类型推断,实际上是 Java 7 就引入的目标类型推断的扩展,例如 Java 7 中的菱形操作符

1
2
Map<String, Integer> diamondWordCounts = new HashMap<String, Integer>();
Map<String, Integer> diamondWordCounts = new HashMap<>();

如果将构造函数直接传递给一个方法,也可根据方法签名来推断类型

1
2
3
// 需要java8
useHashmap(new HashMap<>());
private void useHashmap(Map<String, String> values);

Java 8 更进一步,可省略 Lambda 表达式中的所有参数类型, javac 根据 Lambda 表达式上下文信息就能推断出参数的正确类型。

程序依然要经过类型检查来保证运行的安全性,但不用再显式声明类型罢了。这就是所谓的类型推断

一些示例

使用 Lambda 表达式检测一个 Integer 是否大于 5。这实际上是一个 Predicate (用来判断真假的函数接口)

1
Predicate<Integer> atLeast5 = x -> x > 5;
  • Predicate 只有一个泛型类型的参数,Integer 用于其中。
  • Lambda 表达式实现了 Predicate 接口,因此它的单一参数被推断为 Integer 类型。
  • javac 还可检查 Lambda 表达式的返回值是不是 boolean,这正是 Predicate 方法的返回类型

Predicate 接口的源码,接受一个对象,返回一个布尔值

1
2
3
public interface Predicate<T> {
boolean test(T t);
}

BinaryOperator:该接口接受两个参数,返回一个值,参数和值的类型均相同。实例中所用的类型是 Long

1
BinaryOperator<Long> addLongs = (x, y) -> x + y;

没有泛型,代码则通不过编译

BinaryOperator add = (x, y) -> x + y;

编译器给出的报错信息如下:

Operator '& #x002B;' cannot be applied to java.lang.Object, java.lang.Object.

上面的例子中并没有给出变量 add 的任何泛型信息,给出的正是原始类型的定义。因此,编译器认为参数和返回值都是 java.lang.Object 实例。

1.6 练习

练习答案可在 GitHub 上本书所对应的代码仓库中找到

  1. Java 有一个 ThreadLocal 类,作为容器保存了当前线程里局部变量的值。Java 8 为该类新加了一个工厂方法,接受一个 Lambda 表达式,并产生一个新的 ThreadLocal 对象,而不用使用继承,语法上更加简洁

  2. 以如下方式重载 check 方法后,还能正确推断出 check(x -> x > 5) 的类型吗?

1
2
3
4
5
6
interface IntPred {
boolean test(Integer value);
}

boolean check(Predicate<Integer> predicate);
boolean check(IntPred predicate);
  • 不能,只能二选一,或者更改 Predicate 泛型类型,并且声明 x 的类型
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
System.out.println(check((Integer x) -> x > 5));
}

public static boolean check(IntPred predicate) {
return predicate.test(8);
}

public static boolean check(Predicate<Long> predicate) {
return predicate.test(3L);
}

2. 流

Java 8 对核心类库的改进主要包括集合类的 API 和新引入的流(Stream)。流使程序员得以站在更高的抽象层次上对集合进行操作。

2.1 从外部迭代到内部迭代

外部迭代

在使用集合类时,一个通用的模式是在集合上进行迭代,然后处理返回的每一个元素,一个常用的方式是使用 for 循环,但样板代码模糊了代码的本意,无法流畅传达意图

就原理来看,for 循环其实是一个封装了迭代的语法糖:

  • 首先调用 iterator() 方法,产生一个新的 Iterator 对象,进而控制整个迭代过程,这就是外部迭代
  • 迭代过程通过显式调用 Iterator 对象的 hasNext()next() 方法完成迭代
  • 然而,外部迭代也有问题,首先,它很难抽象出后面提及的不同操作;此外,它从本质上来讲是一种串行化操作。总体来看,使用 for 循环会将行为和方法混为一谈。
1
2
3
4
5
6
7
8
9
// 使用迭代器计算来自伦敦的艺术家人数
int count = 0;
Iterator<Artist> iterator = allArtists.iterator();
while(iterator.hasNext()) {
Artist artist = iterator.next();
if (artist.isFrom("London")) {
count++;
}
}

image-20230528171811358

内部迭代

另一种方法就是内部迭代。首先要注意 stream() 方法的调用,它和 iterator() 的作用一样, 返回内部迭代中的相应接口:Stream。

Stream 是用函数式编程方式在集合类上进行复杂操作的工具

1
2
3
long count = allArtists.stream()
.filter(artist -> artist.isFrom("London"))
.count()
  • filter: 过滤在这里是指“只保留通过某项测试的对象”。测试由一个函数完成,该函数返回 true 或者 false。
    • 由于 Stream API 的函数式编程风格,我们并没有改变集合的内容,而是描述出 Stream 里的内容。
  • count(): 计算给定 Stream 里包含多少个对象。

image-20230528172558102

2.2 实现机制

即使代码被分解为两步操作,但实际上只对列表迭代了一次

通常,在 Java 中调用一个方法,计算机会随即执行操作。但 Stream 里的一些方法却略有不同,它们虽是普通的 Java 方法,但返回的 Stream 对象却不是一个新集合,而是创建新集合的配方

  • 对于像 filter 这种只描述 Stream,最终不产生新集合的方法叫作惰性求值方法
    • 返回值是 Stream
  • count 这样最终会从 Stream 产生值的方法叫作及早求值方法
    • 返回值是另一个值或为空
1
2
3
4
5
6
allArtists.stream()
.filter(artist -> {
System.out.println(artist.getName());
return artist.isFrom("London");
});
//.count() 取消注释就会输出艺术家的名字
  • 这段代码并未做什么实际性的工作,filter 只刻画出了 Stream,但没有产生新的集合。

    • 由于使用了惰性求值,没有输出艺术家的名字
    • 加入一个拥有终止操作的流,如 count() ,艺术家的名字就会被输出
  • 使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果

整个过程和建造者模式有共通之处。建造者模式使用一系列操作设置属性和配置,最后调用一个 build 方法,这时,对象才被真正创建。

2.3 常用的流操作

collect(toList())

1
2
3
List<String> collected = Stream.of("a", "b", "c")
.collect(Collectors.toList());
assertEquals(Arrays.asList("a", "b", "c"), collected);
  • of方法使用一组初始值生成新的 Stream
  • collect(toList())方法由 Stream 里的值生成一个列表,是一个及早求值操作

这个例子也展示了本节中所有示例代码的通用格式。首先由列表生成一个 Stream,然后进行一些 Stream 上的操作,继而是 collect 操作,由 Stream 生成列表,最后使用断言判断结果是否和预期一致

map

1
2
3
4
List<String> collected = Stream.of("a", "b", "hello")
.map(string -> string.toUpperCase())
.collect(toList());
assertEquals(asList("A", "B", "HELLO"), collected);
  • 如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以使用该函数,将一个流中的值转换成一个新的流
    • 参数和返回值不必属于同一种类型
    • 但是 Lambda 表达式必须是 Function 接口的一个实例,Function 接口是只包含一个参数的普通函数接口

image-20230529204931993

image-20230529202907182

filter

1
2
3
4
5
List<String> beginningWithNumbers
= Stream.of("a", "1abc", "abc1")
.filter(value -> isDigit(value.charAt(0)))
.collect(toList());
assertEquals(asList("1abc"), beginningWithNumbers);
  • filter 接受一个函数作为参数,该函数用 Lambda 表达式表示
  • 经过过滤,Stream 中符合条件的,即 Lambda 表达式值为 true 的元素被保留下来
  • 该 Lambda 表达式的函数接口正是前面章节中介绍过的 Predicate

image-20230529205054570

flatMap

假设有一个包含多个列表的流,现在希望得到所有数字的序列

1
2
3
4
List<Integer> together = Stream.of(asList(1, 2), asList(3, 4))
.flatMap(numbers -> numbers.stream())
.collect(toList());
assertEquals(asList(1, 2, 3, 4), together);
  • flatMap 方法可用 Stream 替换值,然后将多个 Stream 连接成一个 Stream
  • 调用 stream 方法,将每个列表转换成 Stream 对象,其余部分由 flatMap 方法处理。
    • flatMap 方法的相关函数接口为 Function 接口,只是方法的返回值限定为 Stream 类型

image-20230529205319762

max和min

1
2
3
4
5
6
7
8
List<Track> tracks = asList(new Track("Bakai", 524),
new Track("Violets for Your Furs", 378),
new Track("Time Was", 451));
Track shortestTrack
= tracks.stream()
.min(Comparator.comparing(track -> track.getLength()))
.get();
assertEquals(tracks.get(1), shortestTrack);
  • 为了让 Stream 对象按照曲目长度进行排序,需要传给它一个 Comparator 对象。Java 8 提 供了一个新的静态方法 comparing,使用它可以方便地实现一个比较器

    • comparing 方法接受一个函数并返回另一个函数
  • 此外,还可以调用空 Stream 的 max 方法,返回 Optional 对象

    • Optional 对象代表一个可能存在也可能不存在的值。如果 Stream 为空,那么该值不存在,如果不为空,则该值存在
    • 通过调用 get 方法可以取出 Optional 对象中的值

max 和 min 方法都属于更通用的一种编程模式: reduce 模式

1
2
3
4
Object accumulator = initialValue;
for(Object element : collection) {
accumulator = combine(accumulator, element);
}

reduce

reduce 操作可以实现从一组值中生成一个值。在上述例子中用到的 count、min 和 max 方 法,因为常用而被纳入标准库中。事实上,这些方法都是 reduce 操作。

1
2
3
int count = Stream.of(1, 2, 3, 4)
.reduce(0, (acc, element) -> acc + element);
assertEquals(10, count);
  • 以 0 作为起点: 一个空 Stream 的求和结果,每一步都将 Stream 中的元素累加至 accumulator,遍历至 Stream 中的最后一个元素时,accumulator 的值就是所有元素的和。
  • Lambda 表达式就是 reducer,它执行求和操作
    • 有两个参数:传入 Stream 中的当前元素和 acc,acc 是累加器,保存着当前的累加结果。
    • 返回值是最新的 acc
    • reducer 的类型是 BinaryOperator

image-20230530215003705

也可以将 reduce 操作展开

1
2
3
4
5
6
BinaryOperator<Integer> accumulator = (acc, element) -> acc + element;
int count = accumulator.apply(
accumulator.apply(
accumulator.apply(0, 1),
2),
3);

reduce方法还有一种形式,它接受三个参数:初始值identity、累加器accumulator和组合器combiner。这种形式的reduce方法用于并行处理流时,可以在多个部分上并行累积结果,然后再将这些部分的结果合并为一个最终结果。

2.4 高阶函数

高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数

  • 可以通过函数签名辨认:函数的参数列表里包含函数接口或者该函数返回一个函数接口

map 是一个高阶函数,因为它的 mapper 参数是一个函数。事实上,本章介绍的 Stream 接口中几乎所有的函数都是高阶函数。

之前的排序例子中还用到了 comparing 函数,它接受一个函数作为参数,获取相应的值,同时返回一个 Comparator。Comparator 可能会被误认为是一个对象,但它有且只有一个抽象方法,所以实际上是一个函数接口

2.5 正确使用Lambda表达式

本章介绍的概念能够帮助用户写出更简单的代码,因为这些概念描述了数据上的操作,明确了要达成什么转化,而不是说明如何转化。这种方式写出的代码,潜在的缺陷更少,更直接地表达了程序员的意图。

明确要达成什么转化,而不是说明如何转化的另外一层含义在于写出的函数没有副作用。这一点非常重要,这样只通过函数的返回值就能充分理解函数的全部作用

没有副作用的函数不会改变程序或外界的状态

  • 向控制台输出信息、给变量赋值都是副作用

鼓励用户使用 Lambda 表达式获取值而不是变量。获取值使用户更容易写出没有副作用的代码。

无论何时,将 Lambda 表达式传给 Stream 上的高阶函数,都应该尽量避免副作用。唯一的例外是 forEach 方法,它是一个终结方法。

2.6 要点回顾

  • 内部迭代将更多控制权交给了集合类。
  • 和 Iterator 类似,Stream 是一种内部迭代方式。
  • 将 Lambda 表达式和 Stream 上的方法结合起来,可以完成很多常见的集合操作。

2.7 练习

  1. 编写一个函数,接受艺术家列表作为参数,返回一个字符串列表,其中包含艺术家的姓名和国籍;
1
2
3
4
5
public static List<String> getNamesAndOrigins(List<Artist> artists) {
return artists.stream()
.flatMap(artist -> Stream.of(artist.getName(), artist.getNationality()))
.collect(toList());
}
  1. 在一个字符串列表中,找出包含最多小写字母的字符串。对于空列表,返回 Optional<String> 对象
1
2
3
4
5
6
7
8
9
10
11
12
public static int countLowercaseLetters(String string) {
return (int) string
.chars()
.filter(Character::isLowerCase)
.count();
}

public static Optional<String> mostLowercaseString(List<String> strings) {
return strings
.stream()
.max(Comparator.comparing(StringExercises::countLowercaseLetters));
}
  1. 只用 reduce 和 Lambda 表达式写出实现 Stream 上的 map 操作的代码,如果不想返回 Stream,可以返回一个 List。
1
2
3
4
5
6
7
8
9
10
11
public static <I, O> List<O> map(Stream<I> stream, Function<I, O> mapper) {
return stream.reduce(new ArrayList<O>(), (acc, x) -> {
List<O> newAcc = new ArrayList<>(acc);
newAcc.add(mapper.apply(x));
return newAcc;
}, (List<O> left, List<O> right) -> {
List<O> newLeft = new ArrayList<>(left);
newLeft.addAll(right);
return newLeft;
});
}
  1. 只用 reduce 和 Lambda 表达式写出实现 Stream 上的 filter 操作的代码,如果不想返回 Stream,可以返回一个 List。
1
2
3
4
5
6
7
8
9
10
11
12
13
public static <I> List<I> filter(Stream<I> stream, Predicate<I> predicate) {
return stream.reduce(new ArrayList<I>(), (acc, x) -> {
List<I> newAcc = new ArrayList<>(acc);
if (predicate.test(x)) {
newAcc.add(x);
}
return newAcc;
}, (List<I> left, List<I> right) -> {
List<I> newLeft = new ArrayList<>(left);
newLeft.addAll(right);
return newLeft;
});
}

3. 类库

3.1 在代码中使用Lambda表达式

在 slf4j 和 log4j 等几种常用的日志系统中,有一些记录日志的方法,当日志级别不低于某个固定级别时就会开始记录日志。

  • 例如void debug(String message),当级别为 debug 时,就开始记录日志消息

但频繁计算消息是否应该记录日志会对系统性能产生影响:可以通过 if 语句预先判断

  • if (logger.isDebugEnabled()) {...}

但使用 Lambda 表达式可以进一步简化日志代码

1
2
3
4
5
6
7
8
Logger logger = new Logger();
logger.debug(() -> "Look at this: " + expensiveOperation());

public void debug(Supplier<String> message) {
if (isDebugEnabled()) {
debug(message.get()); // 调用 get() 方法,相当于调用传入的 Lambda 表达式
}
}

3.2 基本类型

装箱类型是对象,在内存中存在额外开销。

  • 比如整型在内存中占用 4 字节,整型对象却要占用 16 字节,这一情况在数组上更加严重。
  • 将基本类型转换为装箱类型,称为装箱,反之则称为拆箱,两者都需要额外的计算开销。

为了减小这些性能开销,Stream 类的某些方法对基本类型和装箱类型做了区分,在 Java 8 中,仅对整型、长整型和双浮点型做了特殊处理

对基本类型做特殊处理的方法在命名上有明确的规范

  1. 如果方法返回类型为基本类型,则在基本类型前加 To,如ToLongFunction
  2. 如果参数是基本类型,则不加前缀只需类型名即可,如LongFunction
  3. 如果高阶函数使用基本类型,则在操作后加后缀 To 再加基本类型,如mapToLong

这些基本类型都有与之对应的 Stream,以基本类型名为前缀,如 LongStream

事实上,mapToLong方法返回的不是一个一般的 Stream,而是一个特殊处理的 Stream。

在这个特殊的 Stream 中,map 方法的实现方式也不同,它接受一个 LongUnaryOperator 函数,将一个长整型值映射成另一个长整型值。通过一些高阶函数装箱方法,如mapToObj,也可以从一个基本类型的 Stream 得到一个装箱后的 Stream,如Stream<Long>

1
2
3
4
5
6
7
8
9
10
11
12
public static void printTrackLengthStatistics(Album album) {
IntSummaryStatistics trackLengthStats
= album.getTracks()
.mapToInt(track -> track.getLength())
.summaryStatistics();

System.out.printf("Max: %d, Min: %d, Ave: %f, Sum: %d",
trackLengthStats.getMax(),
trackLengthStats.getMin(),
trackLengthStats.getAverage(),
trackLengthStats.getSum());
}

3.3 重载解析

Lambda 表达式作为参数时,其类型由它的目标类型推导得出,推导过程遵循如下规则:

  • 如果只有一个可能的目标类型,由相应函数接口里的参数类型推导得出
  • 如果有多个可能的目标类型,由最具体的类型推导得出
  • 如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型
    • 可以对 Lambda 表达式进行强转

3.4 @FunctionalInterface

Java 中有一些接口,虽然只含一个方法,但并不是为了使用 Lambda 表达式来实现的。有些对象内部可能保存着某种状态,使用带有一个方法的接口纯属巧合。例如 java.lang.Comparable 和 java.io.Closeable。

CloseableComparable接口不同,为了提高 Stream 对象可操作性而引入的各种新接口,都需要有 Lambda 表达式可以实现它。它们存在的意义在于将代码块作为数据打包起来。因此,它们都添加了@FunctionalInterface注解。

该注释会强制 javac 检查一个接口是否符合函数接口的标准。如果该注释添加给一个枚举类型、类或另一个注释,或者接口包含不止一个抽象方法,javac 就会报错。重构代码时,使用它能很容易发现问题。

3.5 二进制接口的兼容性