《Java 8函数式编程》读书笔记
Posted 可持续化发展
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《Java 8函数式编程》读书笔记相关的知识,希望对你有一定的参考价值。
Lambda 表达式
Lambda表达式样例
例2-3 编写Lambda 表达式的不同形式
Runnable noArguments = () -> System.out.println("Hello World");
ActionListener oneArgument = event -> System.out.println("button clicked");
Runnable multiStatement = () ->
System.out.print("Hello");
System.out.println(" World");
;
BinaryOperator<Long> add = (x, y) -> x + y;
BinaryOperator<Long> addExplicit = (Long x, Long y) -> x + y;
在Lambda 表达式中,也无法用作非终态变量。如果坚持用作非终态变量,编译器就会报错。既成事实上的final 是指只能给该变量赋值一次。换句话说,Lambda 表达式引用的是值,而不是变量。在例2-6 中,name 就是一个既成事实上的final 变量。
例2-6 Lambda 表达式中引用既成事实上的final 变量
String name = getUserName();
button.addActionListener(event -> System.out.println("hi " + name));
如果你试图给该变量多次赋值,然后在Lambda 表达式中引用它,编译器就会报错。比如,例2-7 无法通过编译,并显示出错信息:local variables referenced from a Lambda expression must be final or effectively final。
例2-7 未使用既成事实上的final 变量,导致无法通过编译
String name = getUserName();
name = formatUserName(name);
button.addActionListener(event -> System.out.println("hi " + name));
这种行为也解释了为什么Lambda 表达式也被称为闭包。Lambda 表达式都是静态类型。Lambda 表达式中引用的局部变量必须是 final 或既成事实上的 final 变量。
Lambda 表达式都是静态类型的。 Lambda 表达式本身的类型:函数接口。
函数接口
函数接口是只有一个抽象方法的接口,用作 Lambda 表达式的类型。
表2-1 Java中重要的函数接口
接口 | 参数 | 返回类型 | 示例 |
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 | 求两个数的乘积 (*) |
要点回顾
Lambda 表达式是一个匿名方法,将行为像数据一样进行传递。
Lambda 表达式的常见结构: BinaryOperator<Integer> add = (x, y) → x + y。
函数接口指仅具有单个抽象方法的接口,用来表示Lambda 表达式的类型。
Predicate<Integer> atLeast5 = x -> x > 5;
Predicate 也是一个Lambda 表达式,和前文中ActionListener 不同的是,它还返回一个值。在例2-11 中,表达式x > 5 是Lambda 表达式的主体。这样的情况下,返回值就是Lambda 表达式主体的值。
例2-13 略显复杂的类型推断
BinaryOperator<Long> addLongs = (x, y) -> x + y;
略显复杂的函数接口:BinaryOperator。该接口接受两个参数,返回一个值,参数和值的类型均相同。实例中所用的类型是Long。
例2-14 没有泛型,代码则通不过编译
BinaryOperator add = (x, y) -> x + y;
编译器给出的报错信息如下:
Operator '& #x002B;' cannot be applied to java.lang.Object, java.lang.Object.
流
从外部迭代到内部迭代
例3-1 使用for 循环计算来自伦敦的艺术家人数
int count = 0;
for (Artist artist : allArtists)
if (artist.isFrom("London"))
count++;
尽管这样的操作可行,但存在几个问题。每次迭代集合类时,都需要写很多样板代码。将for 循环改造成并行方式运行也很麻烦,需要修改每个 for 循环才能实现。
此外,上述代码无法流畅传达程序员的意图。for 循环的样板代码模糊了代码的本意,程序员必须阅读整个循环体才能理解。若是单一的 for 循环,倒也问题不大,但面对一个满是循环(尤其是嵌套循环)的庞大代码库时,负担就重了。
for 循环其实是一个封装了迭代的语法糖。看看它的工作原理。首先调用 iterator 方法,产生一个新的 Iterator 对象,进而控制整 个迭代过程,这就是外部迭代。迭代过程通过显式调用 Iterator 对象的 hasNext 和 next 方法完成迭代。
例3-2 使用迭代器计算来自伦敦的艺术家人数
int count = 0;
Iterator<Artist> iterator = allArtists.iterator();
while(iterator.hasNext())
Artist artist = iterator.next();
if (artist.isFrom("London"))
count++;
外部迭代从本质上来讲是一种串行化操作。总体来看,使用 for 循环会将行为和方法混为一谈。
内部迭代,如例 3-3 所示。首先要注意 stream() 方法的调用,它和例 3-2 中调用iterator() 的作用一样。该方法不是返回一个控制迭代的 Iterator 对象,而是返回内部迭代中的相应接口:Stream。
例3-3 使用内部迭代计算来自伦敦的艺术家人数
long count = allArtists.stream()
.filter(artist -> artist.isFrom("London"))
.count();
Stream 是用函数式编程方式在集合类上进行复杂操作的工具。
例 3-3 可被分解为两步更简单的操作:
1、找出所有来自伦敦的艺术家;2、计算他们的人数。
每种操作都对应 Stream 接口的一个方法。为了找出来自伦敦的艺术家,需要对 Stream 对象进行过滤:filter。过滤在这里是指“只保留通过某项测试的对象”。测试由一个函数完成,根据艺术家是否来自伦敦,该函数返回 true 或者 false。由于Stream API 的函数式编程风格,我们并没有改变集合的内容,而是描述出 Stream 里的内容。count() 方法计算给定 Stream 里包含多少个对象。
实现机制
Stream 里的一些方法却略有不同,它们虽是 普通的 Java 方法,但返回的 Stream 对象却不是一个新集合,而是创建新集合的配方。
例 3-4 只过滤,不计数
allArtists.stream().filter(artist -> artist.isFrom("London"));
filter 只刻画出了 Stream,但没有产生新的集合。像 filter 这样只描述 Stream,最终不产生新集合的方法叫作惰性求值方法;而像 count 这样 最终会从 Stream 产生值的方法叫作及早求值方法。如果在过滤器中加入一条 println 语句,来输出艺术家的名字,就能轻而易举地看出其中的不同。运行这段代码,程序不会输出任何信息!
例3-5 由于使用了惰性求值,没有输出艺术家的名字
allArtists.stream()
.filter(artist ->
System.out.println(artist.getName());
return artist.isFrom("London");
);
如果将同样的输出语句加入一个拥有终止操作的流,如例 3-3 中的计数操作,艺术家的名字就会被输出。
例3-6 输出艺术家的名字
long count = allArtists.stream()
.filter(artist ->
System.out.println(artist.getName());
return artist.isFrom("London");
)
.count();
例3-7 显示披头士乐队成员名单的示例输出
John Lennon
Paul McCartney
George Harrison
Ringo Starr
判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。如果返回值是Stream,那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果。
整个过程和建造者模式有共通之处。建造者模式使用一系列操作设置属性和配置,最后调 用一个 build 方法,这时,对象才被真正创建。
为什么要区分惰性求值和及早求值?只有在对需要什么样的结果和操作有了更多了解之后,才能更有效率地进行计算。例如,如果要找出大于 10 的第一个数 字,那么并不需要和所有元素去做比较,只要找出第一个匹配的元素就够了。这也意味着 可以在集合类上级联多种操作,但迭代只需一次。
常用的流操作
掌握一些常用的 Stream 操作。
collect(toList())
collect(toList()) 方法由 Stream 里的值生成一个列表,是一个及早求值操作。
Stream 的 of 方法使用一组初始值生成新的 Stream。
下面是使用collect 方法的一个例子:
List<String> collected = Stream.of("a", "b", "c") ①
.collect(Collectors.toList()); ②
assertEquals(Arrays.asList("a", "b", "c"), collected); ③
使用 collect(toList()) 方法从 Stream 中生成一个列表。
这个例子也展示了本节中所有示例代码的通用格式。首先由列表生成一个 Stream ➊,然后进行一些 Stream 上的操作,继而是 collect 操作,由 Stream 生成列表➋,最后使用断言判断结果是否和预期一致➌。
map
如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以 使用该函数,将一个流中的值转换成一个新的流。
例3-8 使用for 循环将字符串转换为大写
List<String> collected = new ArrayList<>();
for (String string : asList("a", "b", "hello"))
String uppercaseString = string.toUpperCase();
collected.add(uppercaseString);
assertEquals(asList("A", "B", "HELLO"), collected);
例3-9 使用map 操作将字符串转换为大写形式
List<String> collected = Stream.of("a", "b", "hello")
.map(string -> string.toUpperCase()) ①
.collect(toList());
assertEquals(asList("A", "B", "HELLO"), collected);
传给 map ➊ 的 Lambda 表达式只接受一个 String 类型的参数,返回一个新的 String。参数 和返回值不必属于同一种类型,但是 Lambda 表达式必须是 Function 接口的一个实例(如 图 3-4 所示),Function 接口是只包含一个参数的普通函数接口。
filter
遍历数据并检查其中的元素时,可尝试使用Stream 中提供的新方法filter。
假设要找出一组字符串中以数字开头的字符串,比如字符串" 1abc" 和" abc",其中" 1abc" 就是符合条件的字符串。可以使用一个for 循环,内部用if 条件语句判断字符串的第一个字符来解决这个问题。
例3-10 使用循环遍历列表,使用条件语句做判断
List<String> beginningWithNumbers = new ArrayList<>();
for(String value : asList("a", "1abc", "abc1"))
if (isDigit(value.charAt(0)))
beginningWithNumbers.add(value);
assertEquals(asList("1abc"), beginningWithNumbers);
filter 模式。该模式的核心思想是保留Stream中的一些元素,而过滤掉其他的。
例3-11 函数式风格
List<String> beginningWithNumbers
= Stream.of("a", "1abc", "abc1")
.filter(value -> isDigit(value.charAt(0)))
.collect(toList());
assertEquals(asList("1abc"), beginningWithNumbers);
和map 很像,filter 接受一个函数作为参数,该函数用Lambda 表达式表示。该函数和前面示例中if 条件判断语句的功能一样,如果字符串首字母为数字,则返回true。若要重构遗留代码,for 循环中的if 条件语句就是一个很强的信号,可用filter 方法替代。
此方法和if 条件语句的功能相同,因此其返回值肯定是true 或者false。经过过滤,Stream 中符合条件的,即Lambda 表达式值为true 的元素被保留下来。
flatMap
flatMap 方法可用Stream 替换值,然后将多个Stream 连接成一个Stream。
map 操作,它可用一个新的值代替Stream 中的值。但有时,用户希望让map操作有点变化,生成一个新的Stream 对象取而代之。用户通常不希望结果是一连串的流,此时flatMap 最能派上用场。
假设有一个包含多个列表的流,现在希望得到所有数字的序列。
例3-12 包含多个列表的Stream
List<Integer> together = Stream.of(asList(1, 2), asList(3, 4))
.flatMap(numbers -> numbers.stream())
.collect(toList());
assertEquals(asList(1, 2, 3, 4), together);
调用stream 方法,将每个列表转换成Stream 对象,其余部分由flatMap 方法处理。flatMap 方法的相关函数接口和map 方法的一样,都是Function 接口,只是方法的返回值限定为Stream 类型罢了。
max和min
Stream 上常用的操作之一是求最大值和最小值。
例3-13 使用Stream 查找最短曲目
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 中的最大或最小元素,首先要考虑的是用什么作为排序的指标。以查找专辑中的最短曲目为例,排序的指标就是曲目的长度。
为了让Stream 对象按照曲目长度进行排序,需要传给它一个Comparator 对象。Java 8 提供了一个新的静态方法comparing,使用它可以方便地实现一个比较器。以前,我们需要比较两个对象的某项属性的值,现在只需要提供一个存取方法就够了。本例中使用getLength 方法。
通过调用get 方法可以取出Optional 对象中的值。Optional 对象代表一个可能存在也可能不存在的值。
reduce
reduce 操作可以实现从一组值中生成一个值。在上述例子中用到的count、min 和max 方法,因为常用而被纳入标准库中。事实上,这些方法都是reduce 操作。
如何通过reduce 操作对Stream 中的数字求和。以0 作起点——一个空Stream 的求和结果,每一步都将Stream 中的元素累加至accumulator,遍历至Stream 中的最后一个元素时,accumulator 的值就是所有元素的和。
Lambda 表达式就是reducer,它执行求和操作,有两个参数:传入Stream 中的当前元素和acc。将两个参数相加,acc 是累加器,保存着当前的累加结果。
例3-16 使用reduce 求和
int count = Stream.of(1, 2, 3)
.reduce(0, (acc, element) -> acc + element);
assertEquals(6, count);
Lambda 表达式的返回值是最新的acc,是上一轮acc 的值和当前元素相加的结果。reducer的类型是第2 章已介绍过的BinaryOperator。
后面将介绍另外一种标准类库内置的求和方法,在实际生产环境中,应该使用那种方式,而不是使用像上面这个例子中的代码。
展开reduce 操作
BinaryOperator<Integer> accumulator = (acc, element) -> acc + element;
int count = accumulator.apply(
accumulator.apply(
accumulator.apply(0, 1),
2),
3);
例3-18 使用命令式编程方式求和
int acc = 0;
for (Integer element : asList(1, 2, 3))
acc = acc + element;
assertEquals(6, acc);
整合操作
如何将问题分解为简单的Stream 操作。
找出某张专辑上所有乐队的国籍。艺术家列表里既有个人,也有乐队。利用一点领域知识,假定一般乐队名以定冠词The 开头。当然这不是绝对的,但也差不多。
1. 找出专辑上的所有表演者。
2. 分辨出哪些表演者是乐队。
3. 找出每个乐队的国籍。
4. 将找出的国籍放入一个集合。
找出每一步对应的Stream API 就相对容易了:
1. Album 类有个getMusicians 方法,该方法返回一个Stream 对象,包含整张专辑中所有的表演者;
2. 使用filter 方法对表演者进行过滤,只保留乐队;
3. 使用map 方法将乐队映射为其所属国家;
4. 使用collect(Collectors.toList()) 方法将国籍放入一个列表。
Set<String> origins = album.getMusicians()
.filter(artist -> artist.getName().startsWith("The"))
.map(artist -> artist.getNationality())
.collect(toSet());
这个例子将Stream 的链式操作展现得淋漓尽致,调用getMusicians、filter 和map 方法都返回Stream 对象,因此都属于惰性求值,而collect 方法属于及早求值。map 方法接受一个Lambda 表达式,使用该Lambda 表达式对Stream 上的每个元素做映射,形成一个新的Stream。
通过Stream 暴露集合的最大优点在于,它很好地封装了内部实现的数据结构。仅暴露一个Stream 接口,用户在实际操作中无论如何使用,都不会影响内部的List 或Set。
重构遗留代码
举例说明如何将一段使用循环进行集合操作的代码,重构成基于Stream 的操作。重构过程中的每一步都能确保代码通过单元测试。
假定选定一组专辑,找出其中所有长度大于1 分钟的曲目名称。例3-19 是遗留代码,首先初始化一个Set 对象,用来保存找到的曲目名称。然后使用for 循环遍历所有专辑,每次循环中再使用一个for 循环遍历每张专辑上的每首曲目,检查其长度是否大于60 秒,如果是,则将该曲目名称加入Set 对象。
例3-19 遗留代码:找出长度大于1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums)
Set<String> trackNames = new HashSet<>();
for(Album album : albums)
for (Track track : album.getTrackList())
if (track.getLength() > 60)
String name = track.getName();
trackNames.add(name);
return trackNames;
使用流来重构该段代码的方式很多,下面介绍的只是其中一种。
第一步要修改的是for 循环。首先使用Stream 的forEach 方法替换掉for 循环,但还是暂时保留原来循环体中的代码,这是在重构时非常方便的一个技巧。调用stream 方法从专辑列表中生成第一个Stream,getTracks 方法本身就返回一个Stream 对象。
例3-20 重构的第一步:找出长度大于1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums)
Set<String> trackNames = new HashSet<>();
albums.stream()
.forEach(album ->
album.getTracks()
.forEach(track ->
if (track.getLength() > 60)
String name = track.getName();
trackNames.add(name);
);
);
return trackNames;
最内层的forEach 方法正是主要突破口。
最内层的forEach 方法有三个功用:找出长度大于1 分钟的曲目,得到符合条件的曲目名称,将曲目名称加入集合Set。这就意味着需要三项Stream 操作:找出满足某种条件的曲目是filter 的功能,得到曲目名称则可用map 达成,终结操作可使用forEach 方法将曲目名称加入一个集合。用以上三项Stream 操作将内部的forEach 方法拆分。
例3-21 重构的第二步:找出长度大于1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums)
Set<String> trackNames = new HashSet<>();
albums.stream()
.forEach(album ->
album.getTracks()
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.forEach(name -> trackNames.add(name));
);
return trackNames;
将各种流嵌套起来并不理想,最好还是用干净整洁的顺序调用一些方法。理想的操作莫过于找到一种方法,将专辑转化成一个曲目的Stream。众所周知,任何时候想转化或替代代码,都该使用map 操作。这里将使用比map 更复杂的flatMap 操作,把多个Stream 合并成一个Stream 并返回。将forEach 方法替换成flatMap。
例3-22 重构的第三步:找出长度大于1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums)
Set<String> trackNames = new HashSet<>();
albums.stream()
.flatMap(album -> album.getTracks())
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.forEach(name -> trackNames.add(name));
return trackNames;
然而至此并未结束,仍需手动创建一个Set 对象并将元素加入其中,但我们希望看到的是整个计算任务由一连串的Stream 操作完成。
使用collect(Collectors.toList()) 可以将Stream 中的值转换成一个列表,使用collect(Collectors.toSet()) 可以将Stream 中的值转换成一个集合。因此,将最后的forEach 方法替换为collect,并删掉变量trackNames。
例3-23 重构的第四步:找出长度大于1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums)
return albums.stream()
.flatMap(album -> album.getTracks())
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.collect(toSet());
选取一段遗留代码进行重构,转换成使用流风格的代码。最初只是简单地使用流,但没有引入任何有用的流操作。随后通过一系列重构,最终使代码更符合使用流的风格。
多次调用流操作
例3-24 误用Stream 的例子
List<Artist> musicians = album.getMusicians()
.collect(toList());
List<Artist> bands = musicians.stream()
.filter(artist -> artist.getName().startsWith("The"))
.collect(toList());
Set<String> origins = bands.stream()
.map(artist -> artist.getNationality())
.collect(toSet());
例3-25 符合Stream 使用习惯的链式调用
Set<String> origins = album.getMusicians()
.filter(artist -> artist.getName().startsWith("The"))
.map(artist -> artist.getNationality())
.collect(toSet());
例3-24 所示代码和流的链式调用相比有如下缺点:
1、代码可读性差,样板代码太多,隐藏了真正的业务逻辑;
2、效率差,每一步都要对流及早求值,生成新的集合;
3、代码充斥一堆垃圾变量,它们只用来保存中间结果,除此之外毫无用处;
4、难于自动并行化处理。
如果此时还不习惯Stream API 中大量的链式操作,也很正常。随着练习时间增加,经验也会越来越丰富,这些概念理解起来也更加自然。
高阶函数
高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数。高阶函数不难辨认:看函数签名就够了。如果函数的参数列表里包含函数接口,或该函数返回一个函数接口,那么该函数就是高阶函数。
map 是一个高阶函数,因为它的mapper 参数是一个函数。事实上,本章介绍的Stream 接口中几乎所有的函数都是高阶函数。之前的排序例子中还用到了comparing 函数,它接受一个函数作为参数,获取相应的值,同时返回一个Comparator。Comparator 可能会被误认为是一个对象,但它有且只有一个抽象方法,所以实际上是一个函数接口。
正确使用Lambda 表达式
本章介绍的概念能够帮助用户写出更简单的代码,因为这些概念描述了数据上的操作,明确了要达成什么转化,而不是说明如何转化。这种方式写出的代码,潜在的缺陷更少,更直接地表达了程序员的意图。
明确要达成什么转化,而不是说明如何转化的另外一层含义在于写出的函数没有副作用。
这一点非常重要,这样只通过函数的返回值就能充分理解函数的全部作用。没有副作用的函数不会改变程序或外界的状态。
给变量赋值也是一种副作用,但是它的确更改了程序的状态。
ActionEvent localEvent = null;
button.addActionListener(event ->
localEvent = event;
);
这段代码试图将event 赋给一个局部变量,它无法通过编译,但绝非编写错误。这实际上是语言的设计者有意为之,用以鼓励用户使用Lambda 表达式获取值而不是变量。
在Lambda 表达式中使用局部变量,可以不使用final 关键字,但局部变量在既成事实上必须是final 的。
内部迭代将更多控制权交给了集合类。
和 Iterator 类似,Stream 是一种内部迭代方式。
将Lambda 表达式和 Stream 上的方法结合起来,可以完成很多常见的集合操作。
类库
二进制接口的兼容性
Java 8 中对API 最大的改变在于集合类。虽然Java 在持续演进,但它一直在保持着向后二进制兼容。具体来说,使用Java 1 到Java 7 编译的类库或应用,可以直接在Java 8 上运行。
错误也难免会时有发生,但和其他编程平台相比,二进制兼容性一直被视为Java 的关键优势所在。
Java 8 中为Collection 接口增加了stream 方法,这意味着所有实现了Collection 接口的类都必须增加这个新方法。对核心类库里的类来说,实现这个新方法(比如为ArrayList 增加新的stream 方法)就能就能使问题迎刃而解。
缺憾在于,这个修改依然打破了二进制兼容性,在JDK 之外实现Collection 接口的类,例如MyCustomList,也仍然需要实现新增的stream 方法。这个MyCustomList 在Java 8 中无法通过编译,即使已有一个编译好的版本,在JVM 加载MyCustomList 类时,类加载器仍然会引发异常。
要避免这个糟糕情况,则需要在Java 8 中添加新的语言特性:默认方法
默认方法
Collection 接口中增加了新的stream 方法,如何能让MyCustomList 类在不知道该方法的情况下通过编译?Java 8 通过如下方法解决该问题:Collection 接口告诉它所有的子类:“如果你没有实现stream 方法,就使用我的吧。”接口中这样的方法叫作默认方法,在任何接口中,无论函数接口还是非函数接口,都可以使用该方法。
Iterable 接口中也新增了一个默认方法:forEach,该方法功能和for 循环类似,但是允许用户使用一个Lambda 表达式作为循环体。例4-10 展示了JDK 中forEach 的实现方式:
default void forEach(Consumer<? super T> action)
for (T t : this)
action.accept(t);
它使用一个常规的for 循环遍历Iterable 对象,然后对每个值调用accept方法。
关键字default。这个关键字告诉javac 用户真正需要的是为接口添加一个新方法。除了添加了一个新的关键字,默认方法在继承规则上和普通方法也略有区别。
和类不同,接口没有成员变量,因此默认方法只能通过调用子类的方法来修改子类本身,避免了对子类的实现做出各种假设。
默认方法和子类
默认方法的重写规则也有一些微妙之处。从最简单的情况开始来看:没有重写。在例4-11中,Parent 接口定义了一个默认方法welcome,调用该方法时,发送一条信息。ParentImpl类没有实现welcome 方法,因此它自然继承了该默认方法。
例4-11 Parent 接口,其中的welcome 是一个默认方法
public interface Parent
public void message(String body);
public default void welcome()
message("Parent: Hi!");
public String getLastMessage();
在例4-12 中调用代码,我们调用默认方法,可以看到断言正确。
例4-12 在客户代码中使用默认方法
@Test
public void parentDefaultUsed()
Parent parent = new ParentImpl();
parent.welcome();
assertEquals("Parent: Hi!", parent.getLastMessage());
这时可新建一个接口Child,继承自Parent 接口,代码如例4-13 所示。Child 接口实现了自己的默认welcome 方法,凭直觉判断可知,该方法重写了Parent 的方法。同样在这个例子中,ChildImpl 类不会实现welcome 方法,因此它自然也继承了接口的默认方法。
例4-13 继承了Parent 接口的Child 接口
public interface Child extends Parent
@Override
public default void welcome()
message("Child: Hi!");
例4-14 调用了该接口,最后输出的字符串自然是"Child: Hi!"。
例4-14 调用Child 接口的客户代码
@Test
public void childOverrideDefault()
Child child = new ChildImpl();
child.welcome();
assertEquals("Child: Hi!", child.getLastMessage());
现在默认方法成了虚方法——和静态方法刚好相反。任何时候,一旦与类中定义的方法产生冲突,都要优先选择类中定义的方法。例4-15 和例4-16 展示了这种情况,最终调用的是OverridingParent 的,而不是Parent 的welcome 方法。
例4-15 重写welcome 默认实现的父类
public class OverridingParent extends ParentImpl
@Override
public void welcome()
message("Class Parent: Hi!");
例4-16 调用的是类中的具体方法,而不是默认方法
@Test
public void concreteBeatsDefault()
Parent parent = new OverridingParent();
parent.welcome();
assertEquals("Class Parent: Hi!", parent.getLastMessage());
例4-18 展示了另一种情况,或许不认为类中重写的方法能够覆盖默认方法。OverridingChild本身并没有任何操作,只是继承了Child 和OverridingParent 中的welcome 方法。最后,调用的是OverridingParent 中的welcome 方法,而不是Child 接口中定义的默认方法(代码如例4-17 所示),原因在于,与接口中定义的默认方法相比,类中重写的方法更具体(参见图4-5)。
例4-17 子接口重写了父接口中的默认方法
public class OverridingChild extends OverridingParent implements Child
例4-18 类中重写的方法优先级高于接口中定义的默认方法
@Test
public void concreteBeatsCloserDefault()
Child child = new OverridingChild();
child.welcome();
assertEquals("Class Parent: Hi!", child.getLastMessage());
简言之,类中重写的方法胜出。这样的设计主要是由增加默认方法的目的决定的,增加默认方法主要是为了在接口上向后兼容。让类中重写方法的优先级高于默认方法能简化很多继承问题。
多重继承
接口允许多重继承,因此有可能碰到两个接口包含签名相同的默认方法的情况。比如例4-19 中,接口Carriage 和Jukebox 都有一个默认方法rock,虽然各有各的用途。类MusicalCarriage 同时实现了接口Jukebox(例4-19)和Carriage(例4-20),它到底继承了哪个接口的rock 方法呢?
例4-19 Jukebox
public interface Jukebox
public default String rock()
return "... all over the world!";
例4-20 Carriage
public interface Carriage
public default String rock()
return "... from side to side";
public class MusicalCarriage implements Carriage, Jukebox
此时,javac 并不明确应该继承哪个接口中的方法,因此编译器会报错:class Musical Carriage inherits unrelated defaults for rock() from types Carriage and Jukebox。当然,在类中实现rock 方法就能解决这个问题,如例4-21 所示。
例4-21 实现rock 方法
public class MusicalCarriage implements Carriage, Jukebox
@Override
public String rock()
return Carriage.super.rock();
该例中使用了增强的super 语法,用来指明使用接口Carriage 中定义的默认方法。使用super 关键字是指向父类,现在使用类似InterfaceName.super 这样的语法指的是继承自父接口的方法。
三定律
如果对默认方法的工作原理,特别是在多重继承下的行为还没有把握,如下三条简单的定
律可以帮助大家。
1. 类优先于接口。如果在继承链中有方法体或抽象的方法声明,那么就可以忽略接口中定义的方法。
2. 子类优先于父类。如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法,那么子类中定义的方法胜出。
3. 没有规则三。如果上面两条规则不适用,子类要么需要实现该方法,要么将该方法声明为抽象方法。
其中第一条规则是为了让代码向后兼容。
接口允许多重继承,却没有成员变量;抽象类可以继承成员变量,却不能多重继承。
接口的静态方法
Stream 是个接口,Stream.of 是接口的静态方法。这也是Java 8 中添加的一个新的语言特性,旨在帮助编写类库的开发人员,但对于日常应用程序的开发人员也同样适用。
Optional
Optional 是为核心类库新设计的一个数据类型,用来替换null 值。
人们常常使用null 值表示值不存在,Optional 对象能更好地表达这个概念。使用null 代表值不存在的最大问题在于NullPointerException。一旦引用一个存储null 值的变量,程序会立即崩溃。
使用Optional 对象有两个目的:①Optional 对象鼓励程序员适时检查变量是否为空,以避免代码缺陷;②它将一个类的API 中可能为空的值文档化,这比阅读实现代码要简单很多。
使用工厂方法of,可以从某个值创建出一个Optional 对象。Optional 对象相当于值的容器,而该值可以通过get 方法提取。
例4-22 创建某个值的Optional 对象
Optional<String> a = Optional.of("a");
assertEquals("a", a.get());
Optional 对象也可能为空,因此还有一个对应的工厂方法empty,另外一个工厂方法ofNullable 则可将一个空值转换成Optional 对象。例4-23 展示了这两个方法,同时展示了第三个方法isPresent 的用法(该方法表示一个Optional 对象里是否有值)。
例4-23 创建一个空的Optional 对象,并检查其是否有值
Optional emptyOptional = Optional.empty();
Optional alsoEmpty = Optional.ofNullable(null);
assertFalse(emptyOptional.isPresent());
// 例4-22 中定义了变量a
assertTrue(a.isPresent());
使用Optional 对象的方式之一是在调用get() 方法前,先使用isPresent 检查Optional对象是否有值。使用orElse 方法则更简洁,当Optional 对象为空时,该方法提供了一个备选值。如果计算备选值在计算上太过繁琐,即可使用orElseGet 方法。该方法接受一个Supplier 对象,只有在Optional 对象真正为空时才会调用。例4-24 展示了这两个方法。
例4-24 使用orElse 和orElseGet 方法
assertEquals("b", emptyOptional.orElse("b"));
assertEquals("c", emptyOptional.orElseGet(() -> "c"));
当试图避免空值相关的缺陷,如未捕获的异常时,可以考虑一下是否可使用Optional对象。
要点回顾
1、使用为基本类型定制的Lambda 表达式和Stream,如 IntStream 可以显著提升系统性能。
2、默认方法是指接口中定义的包含方法体的方法,方法名有 default 关键字做前缀。
3、在一个值可能为空的建模情况下,使用 Optional 对象能替代使用null 值。
高级集合类和收集器
方法引用
Lambda 表达式有一个常见的用法:Lambda 表达式经常调用参数。比如想得到艺术家的姓名,Lambda 的表达式如下:
artist -> artist.getName()
这种用法如此普遍,因此Java 8 为其提供了一个简写语法,叫作方法引用,帮助程序员重用已有方法。用方法引用重写上面的Lambda 表达式,代码如下:
Artist::getName
标准语法为Classname::methodName。需要注意的是,虽然这是一个方法,但不需要在后面加括号,因为这里并不调用该方法。我们只是提供了和Lambda 表达式等价的一种结构,在需要时才会调用。凡是使用Lambda 表达式的地方,就可以使用方法引用。
构造函数也有同样的缩写形式,如果你想使用Lambda 表达式创建一个Artist 对象,可能
会写出如下代码:
(name, nationality) -> new Artist(name, nationality)
使用方法引用,上述代码可写为:
Artist::new
这段代码不仅比原来的代码短,而且更易阅读。Artist::new 立刻告诉程序员这是在创建一个Artist 对象,程序员无需看完整行代码就能弄明白代码的意图。另一个要注意的地方是方法引用自动支持多个参数,前提是选对了正确的函数接口。
还可以用这种方式创建数组,下面的代码创建了一个字符串型的数组:
String[]::new
元素顺序
一些集合类型中的元素是按顺序排列的,比如List;而另一些则是无序的,比如HashSet。增加了流操作后,顺序问题变得更加复杂。
直观上看,流是有序的,因为流中的元素都是按顺序处理的。这种顺序称为出现顺序。出现顺序的定义依赖于数据源和对流的操作。在一个有序集合中创建一个流时,流中的元素就按出现顺序排列,因此,例5-1 中的代码总是可以通过。
例5-1 顺序测试永远通过
List<Integer> numbers = asList(1, 2, 3, 4);
List<Integer> sameOrder = numbers.stream()
.collect(toList());
assertEquals(numbers, sameOrder);
如果集合本身就是无序的,由此生成的流也是无序的。HashSet 就是一种无序的集合,因此不能保证例5-2 所示的程序每次都通过。
例5-2 顺序测试不能保证每次通过
Set<Integer> numbers = new HashSet<>(asList(4, 3, 2, 1));
List<Integer> sameOrder = numbers.stream()
.collect(toList());
// 该断言有时会失败
assertEquals(asList(4, 3, 2, 1), sameOrder);
流的目的不仅是在集合类之间做转换,而且同时提供了一组处理数据的通用操作。有些集合本身是无序的,但这些操作有时会产生顺序,试看例5-3 中的代码。
例5-3 生成出现顺序
Set<Integer> numbers = new HashSet<>(asList(4, 3, 2, 1));
List<Integer> sameOrder = numbers.stream()
.sorted()
.collect(toList());
assertEquals(asList(1, 2, 3, 4), sameOrder);
如果需要保证按顺序处理,应该使用forEachOrdered 方法。
如果进来的流是无序的,出去的流也是无序的。看一下例5-4 所示代码,我们只能断言HashSet 中含有某元素,但对其顺序不能作出任何假设,因为HashSet 是无序的,使用了映射操作后,得到的集合仍然是无序的。
例5-4 本例中关于顺序的假设永远是正确的
List<Integer> numbers = asList(1, 2, 3, 4);
List<Integer> stillOrdered = numbers.stream()
.map(x -> x + 1)
.collect(toList());
// 顺
以上是关于《Java 8函数式编程》读书笔记的主要内容,如果未能解决你的问题,请参考以下文章