《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函数式编程》读书笔记的主要内容,如果未能解决你的问题,请参考以下文章

《Java 8函数式编程》读书笔记

《Java8实战》读书笔记12:函数式编程

《Java8实战》读书笔记12:函数式编程

Java核心技术读书笔记12-Java SE 8引入的流

函数式编程 读书笔记

Java编程思想读书笔记_第8章